diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..5c418704b3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,87 @@ +# DSpace Continuous Integration/Build via GitHub Actions +# Concepts borrowed from +# https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-nodejs +name: Build + +# Run this Build for all pushes / PRs to current branch +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + env: + # The ci step will test the dspace-angular code against DSpace REST. + # Direct that step to utilize a DSpace REST service that has been started in docker. + DSPACE_REST_HOST: localhost + DSPACE_REST_PORT: 8080 + DSPACE_REST_NAMESPACE: '/server' + DSPACE_REST_SSL: false + strategy: + # Create a matrix of Node versions to test against (in parallel) + matrix: + node-version: [10.x, 12.x] + # Do NOT exit immediately if one matrix job fails + fail-fast: false + # These are the actual CI steps to perform per job + steps: + # https://github.com/actions/checkout + - name: Checkout codebase + uses: actions/checkout@v1 + + # https://github.com/actions/setup-node + - name: Install Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Install latest Chrome (for e2e tests) + run: | + sudo apt-get update + sudo apt-get --only-upgrade install google-chrome-stable -y + google-chrome --version + + # https://github.com/actions/cache/blob/main/examples.md#node---yarn + - name: Get Yarn cache directory + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: Cache Yarn dependencies + uses: actions/cache@v2 + with: + # Cache entire Yarn cache directory (see previous step) + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + # Cache key is hash of yarn.lock. Therefore changes to yarn.lock will invalidate cache + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn- + + - name: Install Yarn dependencies + run: yarn install --frozen-lockfile + + - name: Run lint + run: yarn run lint + + - name: Run build + run: yarn run build:prod + + - name: Run specs (unit tests) + run: yarn run test:headless + + # Using docker-compose start backend using CI configuration + # and load assetstore from a cached copy + - name: Start DSpace REST Backend via Docker (for e2e tests) + run: | + docker-compose -f ./docker/docker-compose-ci.yml up -d + docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + docker container ls + + - name: Run e2e tests (integration tests) + run: yarn run e2e:ci + + - name: Shutdown Docker containers + run: docker-compose -f ./docker/docker-compose-ci.yml down + + # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 + # Upload coverage reports to Codecov (for Node v12 only) + # https://github.com/codecov/codecov-action + - name: Upload coverage to Codecov.io + uses: codecov/codecov-action@v1 + if: matrix.node-version == '12.x' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index db3b49ccdf..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,66 +0,0 @@ -os: linux -dist: bionic -language: node_js - -# Enable caching for yarn & node_modules -cache: - yarn: true - -node_js: - - "10" - - "12" - -# Install latest chrome (for e2e headless testing). Run an update if needed. -addons: - apt: - sources: - - google-chrome - packages: - - google-chrome-stable - update: true - -env: - # The ci step will test the dspace-angular code against DSpace REST. - # Direct that step to utilize a DSpace REST service that has been started in docker. - DSPACE_REST_HOST: localhost - DSPACE_REST_PORT: 8080 - DSPACE_REST_NAMESPACE: '/server' - DSPACE_REST_SSL: false - -before_install: - # Check our versions of everything - - echo "Check versions" - - yarn -v - - docker-compose -v - - google-chrome-stable --version - -install: - # Start up a test DSpace 7 REST backend using the entities database dump - - docker-compose -f ./docker/docker-compose-travis.yml up -d - # Use the dspace-cli image to populate the assetstore. Triggers a discovery and oai update - - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli - # Install all local dependencies (retry if initially fails) - - travis_retry yarn install - -before_script: - - echo "Check Docker containers" - - docker container ls - # The following line could be enabled to verify that the rest server is responding. - #- echo "Check REST API available (via Docker)" - #- curl http://localhost:8080/server/ - -script: - # build app and run all tests - - 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 - - docker-compose -f ./docker/docker-compose-travis.yml down - -# After a successful build and test (see 'script'), send code coverage reports to codecov.io -# NOTE: As there's no need to send coverage multiple times, we only run this for one version of node. -after_success: - - if [ "$TRAVIS_NODE_VERSION" = "12" ]; then bash <(curl -s https://codecov.io/bash); fi diff --git a/README.md b/README.md index 8fec7f404e..4b4c7dc6cf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://codecov.io/gh/DSpace/dspace-angular/branch/main/graph/badge.svg)](https://codecov.io/gh/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) +[![Build Status](https://github.com/DSpace/dspace-angular/workflows/Build/badge.svg?branch=main)](https://github.com/DSpace/dspace-angular/actions?query=workflow%3ABuild) [![Coverage Status](https://codecov.io/gh/DSpace/dspace-angular/branch/main/graph/badge.svg)](https://codecov.io/gh/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) dspace-angular ============== diff --git a/docker/docker-compose-travis.yml b/docker/docker-compose-ci.yml similarity index 94% rename from docker/docker-compose-travis.yml rename to docker/docker-compose-ci.yml index f0f5ef70e8..f440454bb6 100644 --- a/docker/docker-compose-travis.yml +++ b/docker/docker-compose-ci.yml @@ -1,3 +1,4 @@ +# Docker Compose for running the DSpace backend for e2e testing in CI networks: dspacenet: services: 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 d2f2233eee..10ac117b0f 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 @@ -13,12 +13,12 @@ import { GROUP_EDIT_PATH } from './admin-access-control-routing-paths'; { path: `${GROUP_EDIT_PATH}/:groupId`, component: GroupFormComponent, - data: {title: 'admin.registries.schema.title'} + data: {title: 'admin.access-control.groups.title.singleGroup'} }, { path: `${GROUP_EDIT_PATH}/newGroup`, component: GroupFormComponent, - data: {title: 'admin.registries.schema.title'} + data: {title: 'admin.access-control.groups.title.addGroup'} }, ]) ] 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 5cb8e77a3e..f478557e00 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 @@ -40,7 +40,7 @@ { activeEPerson: null, allEpeople: mockEPeople, getEPeople(): Observable>> { - return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople)); }, getActiveEPerson(): Observable { return observableOf(this.activeEPerson); @@ -54,18 +54,18 @@ describe('EPeopleRegistryComponent', () => { const result = this.allEpeople.find((ePerson: EPerson) => { return ePerson.email === query }); - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result])); } if (scope === 'metadata') { if (query === '') { - return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople)); } const result = this.allEpeople.find((ePerson: EPerson) => { return (ePerson.name.includes(query) || ePerson.email.includes(query)) }); - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result])); } - return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople)); }, deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts index 2f989490a7..fe0503bcd7 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts @@ -141,7 +141,8 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { currentPage: this.config.currentPage, elementsPerPage: this.config.pageSize }).subscribe((peopleRD) => { - this.ePeople$.next(peopleRD) + this.ePeople$.next(peopleRD); + this.pageInfoState$.next(peopleRD.payload.pageInfo); } )); diff --git a/src/app/+admin/admin-access-control/group-registry/group-form/group-form.component.html b/src/app/+admin/admin-access-control/group-registry/group-form/group-form.component.html index 2f76e45429..a0d8a45255 100644 --- a/src/app/+admin/admin-access-control/group-registry/group-form/group-form.component.html +++ b/src/app/+admin/admin-access-control/group-registry/group-form/group-form.component.html @@ -1,6 +1,11 @@
+ + +
@@ -18,10 +23,18 @@ [formLayout]="formLayout" (cancel)="onCancel()" (submitForm)="onSubmit()"> +
+ +
- - + +
-
@@ -75,7 +75,7 @@
-
- + >
{{"item.edit.metadata.metadatafield.invalid" | translate}} diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index 60419f41b2..4ecdb21e24 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -20,9 +20,9 @@ import { } from '../../../../shared/remote-data.utils'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { EditInPlaceFieldComponent } from './edit-in-place-field.component'; -import { FilterInputSuggestionsComponent } from '../../../../shared/input-suggestions/filter-suggestions/filter-input-suggestions.component'; import { MockComponent, MockDirective } from 'ng-mocks'; import { DebounceDirective } from '../../../../shared/utils/debounce.directive'; +import { ValidationSuggestionsComponent } from '../../../../shared/input-suggestions/validation-suggestions/validation-suggestions.component'; let comp: EditInPlaceFieldComponent; let fixture: ComponentFixture; @@ -88,7 +88,7 @@ describe('EditInPlaceFieldComponent', () => { declarations: [ EditInPlaceFieldComponent, MockDirective(DebounceDirective), - MockComponent(FilterInputSuggestionsComponent) + MockComponent(ValidationSuggestionsComponent) ], providers: [ { provide: RegistryService, useValue: metadataFieldService }, diff --git a/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts b/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts index 061705619a..9bb99feaa2 100644 --- a/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts +++ b/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts @@ -7,6 +7,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro import { Observable } from 'rxjs/internal/Observable'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { of as observableOf } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; @Injectable({ providedIn: 'root' @@ -17,8 +18,9 @@ import { of as observableOf } from 'rxjs'; export class ItemPageReinstateGuard extends DsoPageFeatureGuard { constructor(protected resolver: ItemPageResolver, protected authorizationService: AuthorizationDataService, - protected router: Router) { - super(resolver, authorizationService, router); + protected router: Router, + protected authService: AuthService) { + super(resolver, authorizationService, router, authService); } /** diff --git a/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts b/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts index 60576bcdb8..b0bd1820bb 100644 --- a/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts +++ b/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts @@ -7,6 +7,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro import { Observable } from 'rxjs/internal/Observable'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { of as observableOf } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; @Injectable({ providedIn: 'root' @@ -17,8 +18,9 @@ import { of as observableOf } from 'rxjs'; export class ItemPageWithdrawGuard extends DsoPageFeatureGuard { constructor(protected resolver: ItemPageResolver, protected authorizationService: AuthorizationDataService, - protected router: Router) { - super(resolver, authorizationService, router); + protected router: Router, + protected authService: AuthService) { + super(resolver, authorizationService, router, authService); } /** diff --git a/src/app/+item-page/full/full-item-page.component.spec.ts b/src/app/+item-page/full/full-item-page.component.spec.ts index 2cf897ea61..85a2c897ad 100644 --- a/src/app/+item-page/full/full-item-page.component.spec.ts +++ b/src/app/+item-page/full/full-item-page.component.spec.ts @@ -20,6 +20,7 @@ import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { AuthService } from '../../core/auth/auth.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), @@ -45,7 +46,14 @@ describe('FullItemPageComponent', () => { let comp: FullItemPageComponent; let fixture: ComponentFixture; + let authService: AuthService; + beforeEach(async(() => { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -57,7 +65,8 @@ describe('FullItemPageComponent', () => { providers: [ {provide: ActivatedRoute, useValue: routeStub}, {provide: ItemDataService, useValue: {}}, - {provide: MetadataService, useValue: metadataServiceStub} + {provide: MetadataService, useValue: metadataServiceStub}, + { provide: AuthService, useValue: authService }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index b2a42b7c6f..741f1e76a7 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -15,6 +15,7 @@ import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; import { hasValue } from '../../shared/empty.util'; +import { AuthService } from '../../core/auth/auth.service'; /** * This component renders a simple item page. @@ -35,8 +36,8 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { metadata$: Observable; - constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService) { - super(route, router, items, metadataService); + constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService, authService: AuthService) { + super(route, router, items, metadataService, authService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/+item-page/item-page-administrator.guard.ts b/src/app/+item-page/item-page-administrator.guard.ts index eae76348ad..692300a871 100644 --- a/src/app/+item-page/item-page-administrator.guard.ts +++ b/src/app/+item-page/item-page-administrator.guard.ts @@ -7,6 +7,7 @@ import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature- import { Observable } from 'rxjs/internal/Observable'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { of as observableOf } from 'rxjs'; +import { AuthService } from '../core/auth/auth.service'; @Injectable({ providedIn: 'root' @@ -17,8 +18,9 @@ import { of as observableOf } from 'rxjs'; export class ItemPageAdministratorGuard extends DsoPageFeatureGuard { constructor(protected resolver: ItemPageResolver, protected authorizationService: AuthorizationDataService, - protected router: Router) { - super(resolver, authorizationService, router); + protected router: Router, + protected authService: AuthService) { + super(resolver, authorizationService, router, authService); } /** diff --git a/src/app/+item-page/simple/item-page.component.spec.ts b/src/app/+item-page/simple/item-page.component.spec.ts index 732bbc7d7c..bdd9d35641 100644 --- a/src/app/+item-page/simple/item-page.component.spec.ts +++ b/src/app/+item-page/simple/item-page.component.spec.ts @@ -19,6 +19,7 @@ import { createFailedRemoteDataObject$, createPendingRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { AuthService } from '../../core/auth/auth.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), @@ -29,6 +30,7 @@ const mockItem: Item = Object.assign(new Item(), { describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; + let authService: AuthService; const mockMetadataService = { /* tslint:disable:no-empty */ @@ -40,6 +42,11 @@ describe('ItemPageComponent', () => { }); beforeEach(async(() => { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -52,7 +59,8 @@ describe('ItemPageComponent', () => { {provide: ActivatedRoute, useValue: mockRoute}, {provide: ItemDataService, useValue: {}}, {provide: MetadataService, useValue: mockMetadataService}, - {provide: Router, useValue: {}} + {provide: Router, useValue: {}}, + { provide: AuthService, useValue: authService }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 29dee6de5f..3267cf743a 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -11,8 +11,9 @@ import { Item } from '../../core/shared/item.model'; import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; -import { redirectOn404Or401 } from '../../core/shared/operators'; +import { redirectOn4xx } from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; +import { AuthService } from '../../core/auth/auth.service'; /** * This component renders a simple item page. @@ -48,6 +49,7 @@ export class ItemPageComponent implements OnInit { private router: Router, private items: ItemDataService, private metadataService: MetadataService, + private authService: AuthService, ) { } /** @@ -56,7 +58,7 @@ export class ItemPageComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.data.pipe( map((data) => data.dso as RemoteData), - redirectOn404Or401(this.router) + redirectOn4xx(this.router, this.authService) ); this.metadataService.processRemoteData(this.itemRD$); } diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 2b57a1957c..8db4ba5aa7 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -55,10 +55,10 @@ export function getDSORoute(dso: DSpaceObject): string { } } -export const UNAUTHORIZED_PATH = '401'; +export const FORBIDDEN_PATH = '403'; -export function getUnauthorizedRoute() { - return `/${UNAUTHORIZED_PATH}`; +export function getForbiddenRoute() { + return `/${FORBIDDEN_PATH}`; } export const PAGE_NOT_FOUND_PATH = '404'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ecb27efbb3..00a0e94054 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -5,16 +5,15 @@ import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; 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 + INFO_MODULE_PATH, + FORBIDDEN_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'; @@ -22,6 +21,7 @@ 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'; +import { ForbiddenComponent } from './forbidden/forbidden.component'; @NgModule({ imports: [ @@ -68,7 +68,7 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut }, { 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: FORBIDDEN_PATH, component: ForbiddenComponent }, { path: 'statistics', loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule', diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f1cdd5f2e5..fcb6ef0ebc 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -41,7 +41,7 @@ import { SharedModule } from './shared/shared.module'; import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; import { environment } from '../environments/environment'; import { BrowserModule } from '@angular/platform-browser'; -import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; +import { ForbiddenComponent } from './forbidden/forbidden.component'; export function getBase() { return environment.ui.nameSpace; @@ -116,6 +116,8 @@ const DECLARATIONS = [ NotificationComponent, NotificationsBoardComponent, SearchNavbarComponent, + BreadcrumbsComponent, + ForbiddenComponent, ]; const EXPORTS = [ @@ -133,8 +135,6 @@ const EXPORTS = [ ], declarations: [ ...DECLARATIONS, - BreadcrumbsComponent, - UnauthorizedComponent, ], exports: [ ...EXPORTS diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 06906346ed..8eea9f8938 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -453,7 +453,7 @@ export class AuthService { * Clear redirect url */ clearRedirectUrl() { - this.store.dispatch(new SetRedirectUrlAction('')); + this.store.dispatch(new SetRedirectUrlAction(undefined)); this.storage.remove(REDIRECT_COOKIE); } diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 61cc98281e..ca67ca2e85 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -58,4 +58,8 @@ export class DSpaceObjectDataService { findById(uuid: string): Observable> { return this.dataService.findById(uuid); } + + findByHref(href: string): Observable> { + return this.dataService.findByHref(href); + } } diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index 4dfa89cde6..ad9b724040 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -20,7 +20,7 @@ import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link- import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../remote-data'; import { PaginatedList } from '../paginated-list'; -import { find, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, find, map, switchMap, tap } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; import { AuthorizationSearchParams } from './authorization-search-params'; @@ -71,6 +71,7 @@ export class AuthorizationDataService extends DataService { return []; } }), + catchError(() => observableOf(false)), oneAuthorizationMatchesFeature(featureId) ); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts index 1f5efd1329..d08fdec39f 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts @@ -7,6 +7,7 @@ import { DSpaceObject } from '../../../shared/dspace-object.model'; import { DsoPageFeatureGuard } from './dso-page-feature.guard'; import { FeatureID } from '../feature-id'; import { Observable } from 'rxjs/internal/Observable'; +import { AuthService } from '../../../auth/auth.service'; /** * Test implementation of abstract class DsoPageAdministratorGuard @@ -15,8 +16,9 @@ class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard { constructor(protected resolver: Resolve>, protected authorizationService: AuthorizationDataService, protected router: Router, + protected authService: AuthService, protected featureID: FeatureID) { - super(resolver, authorizationService, router); + super(resolver, authorizationService, router, authService); } getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { @@ -28,6 +30,7 @@ describe('DsoPageAdministratorGuard', () => { let guard: DsoPageFeatureGuard; let authorizationService: AuthorizationDataService; let router: Router; + let authService: AuthService; let resolver: Resolve>; let object: DSpaceObject; @@ -45,7 +48,10 @@ describe('DsoPageAdministratorGuard', () => { resolver = jasmine.createSpyObj('resolver', { resolve: createSuccessfulRemoteDataObject$(object) }); - guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true) + }); + guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, authService, undefined); } beforeEach(() => { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts index ed2590b521..0c40b6f016 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts @@ -6,6 +6,7 @@ import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; import { map } from 'rxjs/operators'; import { DSpaceObject } from '../../../shared/dspace-object.model'; import { FeatureAuthorizationGuard } from './feature-authorization.guard'; +import { AuthService } from '../../../auth/auth.service'; /** * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature @@ -14,8 +15,9 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard'; export abstract class DsoPageFeatureGuard extends FeatureAuthorizationGuard { constructor(protected resolver: Resolve>, protected authorizationService: AuthorizationDataService, - protected router: Router) { - super(authorizationService, router); + protected router: Router, + protected authService: AuthService) { + super(authorizationService, router, authService); } /** diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts index 829a246dcc..1bfc8ffa2e 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts @@ -4,6 +4,7 @@ import { FeatureID } from '../feature-id'; import { of as observableOf } from 'rxjs'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs/internal/Observable'; +import { AuthService } from '../../../auth/auth.service'; /** * Test implementation of abstract class FeatureAuthorizationGuard @@ -12,10 +13,11 @@ import { Observable } from 'rxjs/internal/Observable'; class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, + protected authService: AuthService, protected featureId: FeatureID, protected objectUrl: string, protected ePersonUuid: string) { - super(authorizationService, router); + super(authorizationService, router, authService); } getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { @@ -35,6 +37,7 @@ describe('FeatureAuthorizationGuard', () => { let guard: FeatureAuthorizationGuard; let authorizationService: AuthorizationDataService; let router: Router; + let authService: AuthService; let featureId: FeatureID; let objectUrl: string; @@ -51,7 +54,10 @@ describe('FeatureAuthorizationGuard', () => { router = jasmine.createSpyObj('router', { parseUrl: {} }); - guard = new FeatureAuthorizationGuardImpl(authorizationService, router, featureId, objectUrl, ePersonUuid); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true) + }); + guard = new FeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid); } beforeEach(() => { @@ -60,7 +66,7 @@ describe('FeatureAuthorizationGuard', () => { describe('canActivate', () => { it('should call authorizationService.isAuthenticated with the appropriate arguments', () => { - guard.canActivate(undefined, undefined).subscribe(); + guard.canActivate(undefined, { url: 'current-url' } as any).subscribe(); expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, objectUrl, ePersonUuid); }); }); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts index d53e71e289..851c4127d8 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts @@ -8,9 +8,10 @@ import { import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { Observable } from 'rxjs/internal/Observable'; -import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators'; +import { returnForbiddenUrlTreeOrLoginOnFalse } from '../../../shared/operators'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { switchMap } from 'rxjs/operators'; +import { AuthService } from '../../../auth/auth.service'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user @@ -19,7 +20,8 @@ import { switchMap } from 'rxjs/operators'; */ export abstract class FeatureAuthorizationGuard implements CanActivate { constructor(protected authorizationService: AuthorizationDataService, - protected router: Router) { + protected router: Router, + protected authService: AuthService) { } /** @@ -29,7 +31,7 @@ export abstract class FeatureAuthorizationGuard implements CanActivate { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)), - returnUnauthorizedUrlTreeOnFalse(this.router) + returnForbiddenUrlTreeOrLoginOnFalse(this.router, this.authService, state.url) ); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts index a45049645a..41b4f06b54 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts @@ -5,6 +5,7 @@ import { AuthorizationDataService } from '../authorization-data.service'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { of as observableOf } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; +import { AuthService } from '../../../auth/auth.service'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator @@ -14,8 +15,8 @@ import { Observable } from 'rxjs/internal/Observable'; providedIn: 'root' }) export class SiteAdministratorGuard extends FeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router) { - super(authorizationService, router); + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { + super(authorizationService, router, authService); } /** diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts index 18397cf71e..147e0617ca 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts @@ -5,6 +5,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro import { Observable } from 'rxjs/internal/Observable'; import { FeatureID } from '../feature-id'; import { of as observableOf } from 'rxjs'; +import { AuthService } from '../../../auth/auth.service'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration @@ -14,8 +15,8 @@ import { of as observableOf } from 'rxjs'; providedIn: 'root' }) export class SiteRegisterGuard extends FeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router) { - super(authorizationService, router); + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { + super(authorizationService, router, authService); } /** diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index a1a6951545..6fde34b9a5 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -303,7 +303,7 @@ describe('EPersonDataService', () => { it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => { service.patchPasswordWithToken('test-uuid', 'test-token','test-password'); - const operation = Object.assign({ op: 'replace', path: '/password', value: 'test-password' }); + const operation = Object.assign({ op: 'add', path: '/password', value: 'test-password' }); const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]); expect(requestService.configure).toHaveBeenCalledWith(expected); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index a1428aee73..5fc4c6497f 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -280,7 +280,7 @@ export class EPersonDataService extends DataService { patchPasswordWithToken(uuid: string, token: string, password: string): Observable { const requestId = this.requestService.generateRequestId(); - const operation = Object.assign({ op: 'replace', path: '/password', value: password }); + const operation = Object.assign({ op: 'add', path: '/password', value: password }); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, uuid)), diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index d42ba392f3..be89afb7cc 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; +import { Operation } from 'fast-json-patch/lib/core'; import { Observable } from 'rxjs'; import { filter, map, take, tap } from 'rxjs/operators'; import { @@ -16,17 +17,24 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { RestResponse } from '../cache/response.models'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { CreateRequest, DeleteRequest, FindListOptions, FindListRequest, PostRequest } from '../data/request.models'; +import { + CreateRequest, + DeleteRequest, + FindListOptions, + FindListRequest, + PatchRequest, + PostRequest +} from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getResponseFromEntry } from '../shared/operators'; +import { getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { Group } from './models/group.model'; import { dataService } from '../cache/builders/build-decorators'; @@ -125,33 +133,51 @@ export class GroupDataService extends DataService { /** * Method to delete a group - * @param id The group id to delete + * @param group The group to delete */ - public deleteGroup(group: Group): Observable { - return this.delete(group.id).pipe(map((response: RestResponse) => response.isSuccessful)); + public deleteGroup(group: Group): Observable<[boolean, string]> { + return this.delete(group.id).pipe(map((response: RestResponse) => { + const errorMessage = response.isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined; + return [response.isSuccessful, errorMessage]; + })); } /** - * Create or Update a group - * If the group contains an id, it is assumed the eperson already exists and is updated instead - * @param group The group to create or update + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param group The group with changes */ - public createOrUpdateGroup(group: Group): Observable> { - const isUpdate = hasValue(group.id); - if (isUpdate) { - return this.updateGroup(group); - } else { - return this.create(group, null); + updateGroup(group: Group): Observable { + const requestId = this.requestService.generateRequestId(); + const oldVersion$ = this.findByHref(group._links.self.href); + oldVersion$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((oldGroup: Group) => { + const operations = this.generateOperations(oldGroup, group); + const patchRequest = new PatchRequest(requestId, group._links.self.href, operations); + return this.requestService.configure(patchRequest); + }), + take(1) + ).subscribe(); + + return this.fetchResponse(requestId); + } + + /** + * Metadata operations are generated by the difference between old and new Group + * Custom replace operation for the other group Name value + * @param oldGroup + * @param newGroup + */ + private generateOperations(oldGroup: Group, newGroup: Group): Operation[] { + let operations = this.comparator.diff(oldGroup, newGroup).filter((operation: Operation) => operation.op === 'replace'); + if (hasValue(oldGroup.name) && oldGroup.name !== newGroup.name) { + operations = [...operations, { + op: 'replace', path: '/name', value: newGroup.name + }]; } - } - - /** - * // TODO - * @param {DSpaceObject} ePerson The given object - */ - updateGroup(group: Group): Observable> { - // TODO - return null; + return operations; } /** diff --git a/src/app/core/eperson/models/group-dto.model.ts b/src/app/core/eperson/models/group-dto.model.ts new file mode 100644 index 0000000000..db167dc6b2 --- /dev/null +++ b/src/app/core/eperson/models/group-dto.model.ts @@ -0,0 +1,17 @@ +import { Group } from './group.model'; + +/** + * This class serves as a Data Transfer Model that contains the Group and whether or not it's able to be deleted + */ +export class GroupDtoModel { + + /** + * The Group linked to this object + */ + public group: Group; + /** + * Whether or not the linked Group is able to be deleted + */ + public ableToDelete: boolean; + +} diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts index e496babddc..f85a252a7b 100644 --- a/src/app/core/eperson/models/group.model.ts +++ b/src/app/core/eperson/models/group.model.ts @@ -5,6 +5,7 @@ import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; import { DSpaceObject } from '../../shared/dspace-object.model'; +import { DSPACE_OBJECT } from '../../shared/dspace-object.resource-type'; import { HALLink } from '../../shared/hal-link.model'; import { EPerson } from './eperson.model'; import { EPERSON } from './eperson.resource-type'; @@ -41,6 +42,7 @@ export class Group extends DSpaceObject { self: HALLink; subgroups: HALLink; epersons: HALLink; + object: HALLink; }; /** @@ -57,4 +59,11 @@ export class Group extends DSpaceObject { @link(EPERSON, true) public epersons?: Observable>>; + /** + * Connected dspace object, the community or collection connected to a workflow group (204 no content for non-workflow groups) + * Will be undefined unless the object {@link HALLink} has been resolved (can only be resolved for workflow groups) + */ + @link(DSPACE_OBJECT) + public object?: Observable>; + } diff --git a/src/app/core/services/server-response.service.ts b/src/app/core/services/server-response.service.ts index 10da2a3379..adf2ecf4d2 100644 --- a/src/app/core/services/server-response.service.ts +++ b/src/app/core/services/server-response.service.ts @@ -24,6 +24,10 @@ export class ServerResponseService { return this.setStatus(401, message) } + setForbidden(message = 'Forbidden'): this { + return this.setStatus(403, message) + } + setNotFound(message = 'Not found'): this { return this.setStatus(404, message) } diff --git a/src/app/core/shared/collection.model.spec.ts b/src/app/core/shared/collection.model.spec.ts new file mode 100644 index 0000000000..b35fa7415b --- /dev/null +++ b/src/app/core/shared/collection.model.spec.ts @@ -0,0 +1,24 @@ +import {Collection} from './collection.model'; + +describe('Collection', () => { + + describe('Collection handle value', () => { + + let metadataValue; + + beforeEach(() => { + metadataValue = {'dc.identifier.uri': [ { value: '123456789/1'}]}; + }) + + it('should return the handle value from metadata', () => { + const community = Object.assign(new Collection(), { metadata: metadataValue }); + expect(community.handle).toEqual('123456789/1'); + }); + + it('should return undefined if the handle value from metadata is not present', () => { + const community = Object.assign(new Collection(), { }); + expect(community.handle).toEqual(undefined); + }); + }); + +}); diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index c1464d7d39..a82f0646c5 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; import { link, typedObject } from '../cache/builders/build-decorators'; import { PaginatedList } from '../data/paginated-list'; @@ -21,12 +21,6 @@ import { ChildHALResource } from './child-hal-resource.model'; export class Collection extends DSpaceObject implements ChildHALResource { static type = COLLECTION; - /** - * A string representing the unique handle of this Collection - */ - @autoserialize - handle: string; - /** * The {@link HALLink}s for this Collection */ @@ -75,6 +69,13 @@ export class Collection extends DSpaceObject implements ChildHALResource { @link(COMMUNITY, false) parentCommunity?: Observable>; + /** + * A string representing the unique handle of this Collection + */ + get handle(): string { + return this.firstMetadataValue('dc.identifier.uri'); + } + /** * The introductory text of this Collection * Corresponds to the metadata field dc.description diff --git a/src/app/core/shared/community.model.spec.ts b/src/app/core/shared/community.model.spec.ts new file mode 100644 index 0000000000..5697686853 --- /dev/null +++ b/src/app/core/shared/community.model.spec.ts @@ -0,0 +1,24 @@ +import {Community} from './community.model'; + +describe('Community', () => { + + describe('Community handle value', () => { + + let metadataValue; + + beforeEach(() => { + metadataValue = {'dc.identifier.uri': [ { value: '123456789/1'}]}; + }) + + it('should return the handle value from metadata', () => { + const community = Object.assign(new Community(), { metadata: metadataValue }); + expect(community.handle).toEqual('123456789/1'); + }); + + it('should return undefined if the handle value from metadata is not present', () => { + const community = Object.assign(new Community(), { }); + expect(community.handle).toEqual(undefined); + }); + }); + +}); diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index 796aaa8ece..778696486b 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; import { link, typedObject } from '../cache/builders/build-decorators'; import { PaginatedList } from '../data/paginated-list'; @@ -17,12 +17,6 @@ import { ChildHALResource } from './child-hal-resource.model'; export class Community extends DSpaceObject implements ChildHALResource { static type = COMMUNITY; - /** - * A string representing the unique handle of this Community - */ - @autoserialize - handle: string; - /** * The {@link HALLink}s for this Community */ @@ -64,6 +58,13 @@ export class Community extends DSpaceObject implements ChildHALResource { @link(COMMUNITY, false) parentCommunity?: Observable>; + /** + * A string representing the unique handle of this Community + */ + get handle(): string { + return this.firstMetadataValue('dc.identifier.uri'); + } + /** * The introductory text of this Community * Corresponds to the metadata field dc.description diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 8acf5ea860..91a8029617 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -14,7 +14,7 @@ import { getResourceLinksFromResponse, getResponseFromEntry, getSucceededRemoteData, - redirectOn404Or401 + redirectOn4xx } from './operators'; import { RemoteData } from '../data/remote-data'; import { RemoteDataError } from '../data/remote-data-error'; @@ -200,39 +200,67 @@ describe('Core Module - RxJS Operators', () => { }); }); - describe('redirectOn404Or401', () => { + describe('redirectOn4xx', () => { let router; + let authService; + beforeEach(() => { router = jasmine.createSpyObj('router', ['navigateByUrl']); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); }); it('should call navigateByUrl to a 404 page, when the remote data contains a 404 error', () => { const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(404, 'Not Found', 'Object was not found')); - observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); + observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe(); expect(router.navigateByUrl).toHaveBeenCalledWith('/404', { skipLocationChange: true }); }); - it('should call navigateByUrl to a 401 page, when the remote data contains a 401 error', () => { - const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(401, 'Unauthorized', 'The current user is unauthorized')); + it('should call navigateByUrl to a 403 page, when the remote data contains a 403 error', () => { + const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(403, 'Forbidden', 'Forbidden access')); - observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/401', { skipLocationChange: true }); + observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/403', { skipLocationChange: true }); }); - it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains another error than a 404 or 401', () => { + it('should not call navigateByUrl to a 404, 403 or 401 page, when the remote data contains another error than a 404, 403 or 401', () => { const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(500, 'Server Error', 'Something went wrong')); - observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); + observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe(); expect(router.navigateByUrl).not.toHaveBeenCalled(); }); - it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains no error', () => { + it('should not call navigateByUrl to a 404, 403 or 401 page, when the remote data contains no error', () => { const testRD = createSuccessfulRemoteDataObject(undefined); - observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); + observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe(); expect(router.navigateByUrl).not.toHaveBeenCalled(); }); + + describe('when the user is not authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false)); + }); + + it('should set the redirect url and navigate to login when the remote data contains a 401 error', () => { + const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(401, 'Unauthorized', 'The current user is unauthorized')); + + observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe(); + expect(authService.setRedirectUrl).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith('login'); + }); + + it('should set the redirect url and navigate to login when the remote data contains a 403 error', () => { + const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(403, 'Forbidden', 'Forbidden access')); + + observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe(); + expect(authService.setRedirectUrl).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith('login'); + }); + }); }); describe('getResponseFromEntry', () => { diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 4ff6666a81..e98a0cee7b 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -13,8 +13,9 @@ import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; -import { getPageNotFoundRoute, getUnauthorizedRoute } from '../../app-routing-paths'; +import { getForbiddenRoute, getPageNotFoundRoute } from '../../app-routing-paths'; import { getEndUserAgreementPath } from '../../info/info-routing-paths'; +import { AuthService } from '../auth/auth.service'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -178,29 +179,47 @@ export const getAllSucceededRemoteListPayload = () => * Operator that checks if a remote data object returned a 401 or 404 error * When it does contain such an error, it will redirect the user to the related error page, without altering the current URL * @param router The router used to navigate to a new page + * @param authService Service to check if the user is authenticated */ -export const redirectOn404Or401 = (router: Router) => +export const redirectOn4xx = (router: Router, authService: AuthService) => (source: Observable>): Observable> => - source.pipe( - tap((rd: RemoteData) => { + observableCombineLatest(source, authService.isAuthenticated()).pipe( + map(([rd, isAuthenticated]: [RemoteData, boolean]) => { if (rd.hasFailed) { if (rd.error.statusCode === 404) { router.navigateByUrl(getPageNotFoundRoute(), {skipLocationChange: true}); - } else if (rd.error.statusCode === 401) { - router.navigateByUrl(getUnauthorizedRoute(), {skipLocationChange: true}); + } else if (rd.error.statusCode === 403 || rd.error.statusCode === 401) { + if (isAuthenticated) { + router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); + } else { + authService.setRedirectUrl(router.url); + router.navigateByUrl('login'); + } } } + return rd; })); /** - * Operator that returns a UrlTree to the unauthorized page when the boolean received is false - * @param router + * Operator that returns a UrlTree to a forbidden page or the login page when the boolean received is false + * @param router The router used to navigate to a forbidden page + * @param authService The AuthService used to determine whether or not the user is logged in + * @param redirectUrl The URL to redirect back to after logging in */ -export const returnUnauthorizedUrlTreeOnFalse = (router: Router) => +export const returnForbiddenUrlTreeOrLoginOnFalse = (router: Router, authService: AuthService, redirectUrl: string) => (source: Observable): Observable => - source.pipe( - map((authorized: boolean) => { - return authorized ? authorized : router.parseUrl(getUnauthorizedRoute()) + observableCombineLatest(source, authService.isAuthenticated()).pipe( + map(([authorized, authenticated]: [boolean, boolean]) => { + if (authorized) { + return authorized; + } else { + if (authenticated) { + return router.parseUrl(getForbiddenRoute()); + } else { + authService.setRedirectUrl(redirectUrl); + return router.parseUrl('login'); + } + } })); /** diff --git a/src/app/core/shared/process-output.resource-type.ts b/src/app/core/shared/process-output.resource-type.ts new file mode 100644 index 0000000000..2e707d0bda --- /dev/null +++ b/src/app/core/shared/process-output.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ProcessOutput + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const PROCESS_OUTPUT_TYPE = new ResourceType('processOutput'); diff --git a/src/app/forbidden/forbidden.component.html b/src/app/forbidden/forbidden.component.html new file mode 100644 index 0000000000..067aad0048 --- /dev/null +++ b/src/app/forbidden/forbidden.component.html @@ -0,0 +1,10 @@ +
+

403

+

{{"403.forbidden" | translate}}

+
+

{{"403.help" | translate}}

+
+

+ {{"403.link.home-page" | translate}} +

+
diff --git a/src/app/unauthorized/unauthorized.component.scss b/src/app/forbidden/forbidden.component.scss similarity index 100% rename from src/app/unauthorized/unauthorized.component.scss rename to src/app/forbidden/forbidden.component.scss diff --git a/src/app/unauthorized/unauthorized.component.ts b/src/app/forbidden/forbidden.component.ts similarity index 50% rename from src/app/unauthorized/unauthorized.component.ts rename to src/app/forbidden/forbidden.component.ts index 280a1ae947..b622a0f066 100644 --- a/src/app/unauthorized/unauthorized.component.ts +++ b/src/app/forbidden/forbidden.component.ts @@ -3,30 +3,30 @@ import { AuthService } from '../core/auth/auth.service'; import { ServerResponseService } from '../core/services/server-response.service'; /** - * This component representing the `Unauthorized` DSpace page. + * This component representing the `Forbidden` DSpace page. */ @Component({ - selector: 'ds-unauthorized', - templateUrl: './unauthorized.component.html', - styleUrls: ['./unauthorized.component.scss'] + selector: 'ds-forbidden', + templateUrl: './forbidden.component.html', + styleUrls: ['./forbidden.component.scss'] }) -export class UnauthorizedComponent implements OnInit { +export class ForbiddenComponent implements OnInit { /** * Initialize instance variables * - * @param {AuthService} authservice + * @param {AuthService} authService * @param {ServerResponseService} responseService */ - constructor(private authservice: AuthService, private responseService: ServerResponseService) { - this.responseService.setUnauthorized(); + constructor(private authService: AuthService, private responseService: ServerResponseService) { + this.responseService.setForbidden(); } /** * Remove redirect url from the state */ ngOnInit(): void { - this.authservice.clearRedirectUrl(); + this.authService.clearRedirectUrl(); } } diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 9cb1f1e6af..d59f93254e 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -17,7 +17,7 @@ {{getFileName(file)}} - ({{(file?.sizeBytes) | dsFileSize }}) + ({{(file?.sizeBytes) | dsFileSize }})
@@ -34,9 +34,20 @@
{{ process.processStatus }}
- - - + + + +
{{ (outputLogs$ | async) }}
+

+ {{ 'process.detail.logs.none' | translate }} +

+
- {{'process.detail.back' | translate}} +
diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index dff481fdc6..b81eedabad 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -1,9 +1,22 @@ +import { HttpClient } from '@angular/common/http'; +import { AuthService } from '../../core/auth/auth.service'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; import { ProcessDetailComponent } from './process-detail.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { + async, + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + flush, + flushMicrotasks, + TestBed, + tick +} from '@angular/core/testing'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ProcessDetailFieldComponent } from './process-detail-field/process-detail-field.component'; import { Process } from '../processes/process.model'; import { ActivatedRoute } from '@angular/router'; @@ -22,15 +35,21 @@ describe('ProcessDetailComponent', () => { let processService: ProcessDataService; let nameService: DSONameService; + let bitstreamDataService: BitstreamDataService; + let httpClient: HttpClient; let process: Process; let fileName: string; let files: Bitstream[]; + let processOutput; + function init() { + processOutput = 'Process Started' process = Object.assign(new Process(), { processId: 1, scriptName: 'script-name', + processStatus: 'COMPLETED', parameters: [ { name: '-f', @@ -40,7 +59,15 @@ describe('ProcessDetailComponent', () => { name: '-i', value: 'identifier' } - ] + ], + _links: { + self: { + href: 'https://rest.api/processes/1' + }, + output: { + href: 'https://rest.api/processes/1/output' + } + } }); fileName = 'fake-file-name'; files = [ @@ -59,12 +86,24 @@ describe('ProcessDetailComponent', () => { } }) ]; + const logBitstream = Object.assign(new Bitstream(), { + id: 'output.log', + _links: { + content: { href: 'log-selflink' } + } + }); processService = jasmine.createSpyObj('processService', { getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)) }); + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findByHref: createSuccessfulRemoteDataObject$(logBitstream) + }); nameService = jasmine.createSpyObj('nameService', { getName: fileName }); + httpClient = jasmine.createSpyObj('httpClient', { + get: observableOf(processOutput) + }); } beforeEach(async(() => { @@ -73,26 +112,41 @@ describe('ProcessDetailComponent', () => { declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: ActivatedRoute, useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } }, + { + provide: ActivatedRoute, + useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } + }, { provide: ProcessDataService, useValue: processService }, - { provide: DSONameService, useValue: nameService } + { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: HttpClient, useValue: httpClient }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ProcessDetailComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); + afterEach(fakeAsync(() => { + TestBed.resetTestingModule(); + fixture.destroy(); + flush(); + flushMicrotasks(); + discardPeriodicTasks(); + component = null; + })); it('should display the script\'s name', () => { + fixture.detectChanges(); const name = fixture.debugElement.query(By.css('#process-name')).nativeElement; expect(name.textContent).toContain(process.scriptName); }); it('should display the process\'s parameters', () => { + fixture.detectChanges(); const args = fixture.debugElement.query(By.css('#process-arguments')).nativeElement; process.parameters.forEach((param) => { expect(args.textContent).toContain(`${param.name} ${param.value}`) @@ -100,8 +154,57 @@ describe('ProcessDetailComponent', () => { }); it('should display the process\'s output files', () => { + fixture.detectChanges(); const processFiles = fixture.debugElement.query(By.css('#process-files')).nativeElement; expect(processFiles.textContent).toContain(fileName); }); + describe('if press show output logs', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'showProcessOutputLogs').and.callThrough(); + fixture.detectChanges(); + + const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton')); + showOutputButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + })); + it('should trigger showProcessOutputLogs', () => { + expect(component.showProcessOutputLogs).toHaveBeenCalled(); + }); + it('should display the process\'s output logs', () => { + fixture.detectChanges(); + const outputProcess = fixture.debugElement.query(By.css('#process-output pre')); + expect(outputProcess.nativeElement.textContent).toContain(processOutput); + }); + }); + + describe('if press show output logs and process has no output logs', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(httpClient, 'get').and.returnValue(observableOf(null)); + fixture = TestBed.createComponent(ProcessDetailComponent); + component = fixture.componentInstance; + spyOn(component, 'showProcessOutputLogs').and.callThrough(); + fixture.detectChanges(); + const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton')); + showOutputButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should not display the process\'s output logs', () => { + const outputProcess = fixture.debugElement.query(By.css('#process-output pre')); + expect(outputProcess).toBeNull(); + }); + it('should display message saying there are no output logs', () => { + const noOutputProcess = fixture.debugElement.query(By.css('#no-output-logs-message')).nativeElement; + expect(noOutputProcess).toBeDefined(); + }); + }); + }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index c4610b70e9..53dd6dd921 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -1,15 +1,23 @@ -import { Component, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Component, NgZone, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { Observable } from 'rxjs/internal/Observable'; -import { RemoteData } from '../../core/data/remote-data'; -import { Process } from '../processes/process.model'; -import { map, switchMap } from 'rxjs/operators'; -import { getFirstSucceededRemoteDataPayload, redirectOn404Or401 } from '../../core/shared/operators'; -import { AlertType } from '../../shared/alert/aletr-type'; -import { ProcessDataService } from '../../core/data/processes/process-data.service'; -import { PaginatedList } from '../../core/data/paginated-list'; -import { Bitstream } from '../../core/shared/bitstream.model'; +import { finalize, map, mergeMap, switchMap, take, tap } from 'rxjs/operators'; +import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ProcessDataService } from '../../core/data/processes/process-data.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { AlertType } from '../../shared/alert/aletr-type'; +import { hasValue } from '../../shared/empty.util'; +import { ProcessStatus } from '../processes/process-status.model'; +import { Process } from '../processes/process.model'; @Component({ selector: 'ds-process-detail', @@ -36,10 +44,33 @@ export class ProcessDetailComponent implements OnInit { */ filesRD$: Observable>>; + /** + * File link that contain the output logs with auth token + */ + outputLogFileUrl$: Observable; + + /** + * The Process's Output logs + */ + outputLogs$: Observable; + + /** + * Boolean on whether or not to show the output logs + */ + showOutputLogs; + /** + * When it's retrieving the output logs from backend, to show loading component + */ + retrievingOutputLogs$: BehaviorSubject; + constructor(protected route: ActivatedRoute, protected router: Router, protected processService: ProcessDataService, - protected nameService: DSONameService) { + protected bitstreamDataService: BitstreamDataService, + protected nameService: DSONameService, + private zone: NgZone, + protected authService: AuthService, + protected http: HttpClient) { } /** @@ -47,9 +78,13 @@ export class ProcessDetailComponent implements OnInit { * Display a 404 if the process doesn't exist */ ngOnInit(): void { + this.showOutputLogs = false; + this.retrievingOutputLogs$ = new BehaviorSubject(false); this.processRD$ = this.route.data.pipe( - map((data) => data.process as RemoteData), - redirectOn404Or401(this.router) + map((data) => { + return data.process as RemoteData + }), + redirectOn4xx(this.router, this.authService) ); this.filesRD$ = this.processRD$.pipe( @@ -63,7 +98,68 @@ export class ProcessDetailComponent implements OnInit { * @param bitstream */ getFileName(bitstream: Bitstream) { - return this.nameService.getName(bitstream); + return bitstream instanceof DSpaceObject ? this.nameService.getName(bitstream) : 'unknown'; + } + + /** + * Retrieves the process logs, while setting the loading subject to true. + * Sets the outputLogs when retrieved and sets the showOutputLogs boolean to show them and hide the button. + */ + showProcessOutputLogs() { + this.retrievingOutputLogs$.next(true); + this.zone.runOutsideAngular(() => { + const processOutputRD$: Observable> = this.processRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((process: Process) => { + return this.bitstreamDataService.findByHref(process._links.output.href); + }) + ); + this.outputLogFileUrl$ = processOutputRD$.pipe( + tap((processOutputFileRD: RemoteData) => { + if (processOutputFileRD.statusCode === 204) { + this.zone.run(() => this.retrievingOutputLogs$.next(false)); + this.showOutputLogs = true; + } + }), + getFirstSucceededRemoteDataPayload(), + mergeMap((processOutput: Bitstream) => { + const url = processOutput._links.content.href; + return this.authService.getShortlivedToken().pipe(take(1), + map((token: string) => { + return hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url; + })); + }) + ) + }); + this.outputLogs$ = this.outputLogFileUrl$.pipe(take(1), + mergeMap((url: string) => { + return this.getTextFile(url); + }), + finalize(() => this.zone.run(() => this.retrievingOutputLogs$.next(false))), + ); + this.outputLogs$.pipe(take(1)).subscribe(); + } + + getTextFile(filename: string): Observable { + // The Observable returned by get() is of type Observable + // because a text response was specified. + // There's no need to pass a type parameter to get(). + return this.http.get(filename, { responseType: 'text' }) + .pipe( + finalize(() => { + this.showOutputLogs = true; + }), + ); + } + + /** + * Whether or not the given process has Completed or Failed status + * @param process Process to check if completed or failed + */ + isProcessFinished(process: Process): boolean { + return (hasValue(process) && hasValue(process.processStatus) && + (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString() + || process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString())); } } diff --git a/src/app/process-page/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index 85de5337e7..74bb82b890 100644 --- a/src/app/process-page/processes/process.model.ts +++ b/src/app/process-page/processes/process.model.ts @@ -1,3 +1,5 @@ +import { Bitstream } from '../../core/shared/bitstream.model'; +import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-type'; import { ProcessStatus } from './process-status.model'; import { ProcessParameter } from './process-parameter.model'; import { CacheableObject } from '../../core/cache/object-cache.reducer'; @@ -85,4 +87,11 @@ export class Process implements CacheableObject { */ @link(SCRIPT) script?: Observable>; + + /** + * The output logs created by this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(PROCESS_OUTPUT_TYPE) + output?: Observable>; } diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 8d78539bab..418626e4d1 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -177,7 +177,7 @@ describe('ProfilePageComponent', () => { component.setPasswordValue('testest'); component.setInvalid(false); - operations = [{op: 'replace', path: '/password', value: 'testest'}]; + operations = [{op: 'add', path: '/password', value: 'testest'}]; result = component.updateSecurity(); }); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index bc06c49f81..4ae644a633 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -120,7 +120,7 @@ export class ProfilePageComponent implements OnInit { this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general')); } if (!this.invalidSecurity && passEntered) { - const operation = Object.assign({op: 'replace', path: '/password', value: this.password}); + const operation = Object.assign({op: 'add', path: '/password', value: this.password}); this.epersonService.patch(this.currentUser, [operation]).subscribe((response: RestResponse) => { if (response.isSuccessful) { this.notificationsService.success( diff --git a/src/app/shared/comcol-page-handle/comcol-page-handle.component.spec.ts b/src/app/shared/comcol-page-handle/comcol-page-handle.component.spec.ts new file mode 100644 index 0000000000..e3e0c65920 --- /dev/null +++ b/src/app/shared/comcol-page-handle/comcol-page-handle.component.spec.ts @@ -0,0 +1,48 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { ComcolPageHandleComponent } from './comcol-page-handle.component'; + +const handle = 'http://localhost:4000/handle/123456789/2'; + +describe('ComcolPageHandleComponent', () => { + let component: ComcolPageHandleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ ComcolPageHandleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ComcolPageHandleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should be empty if no content is passed', () => { + component.content = undefined; + fixture.detectChanges(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).toBeNull(); + }); + + it('should create a link pointing the handle when present', () => { + + component.content = handle; + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.getAttribute('href')).toBe(handle); + expect(link.nativeElement.innerHTML).toBe(handle); + + }); + +}); diff --git a/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts b/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts index bf403e9e88..2ce49ebea2 100644 --- a/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts +++ b/src/app/shared/comcol-page-handle/comcol-page-handle.component.ts @@ -1,5 +1,4 @@ import { Component, Injectable, Input } from '@angular/core'; -import { UIURLCombiner } from '../../core/url-combiner/ui-url-combiner'; /** * This component builds a URL from the value of "handle" @@ -21,6 +20,6 @@ export class ComcolPageHandleComponent { @Input() content: string; public getHandle(): string { - return new UIURLCombiner('/handle/', this.content).toString(); + return this.content; } } diff --git a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts index 40aab2fad5..ef9abbb5ee 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts @@ -53,12 +53,26 @@ describe('ExportMetadataSelectorComponent', () => { const mockCollection: Collection = Object.assign(new Collection(), { id: 'test-collection-1-1', name: 'test-collection-1', - handle: 'fake/test-collection-1', + metadata: { + 'dc.identifier.uri': [ + { + language: null, + value: 'fake/test-collection-1' + } + ] + } }); const mockCommunity = Object.assign(new Community(), { id: 'test-uuid', - handle: 'fake/test-community-1', + metadata: { + 'dc.identifier.uri': [ + { + language: null, + value: 'fake/test-community-1' + } + ] + } }); const itemRD = createSuccessfulRemoteDataObject(mockItem); diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html index 91d8217ade..7a9481f2f1 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html @@ -1,15 +1,14 @@ -
- + + [ngModelOptions]="{standalone: true}" autocomplete="off"/> -
- + \ No newline at end of file diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts index cb36071c28..51664039f7 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts @@ -3,11 +3,9 @@ import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angula import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; -import { MetadataFieldDataService } from '../../../core/data/metadata-field-data.service'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { FilterInputSuggestionsComponent } from './filter-input-suggestions.component'; describe('FilterInputSuggestionsComponent', () => { @@ -23,13 +21,9 @@ describe('FilterInputSuggestionsComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule, ReactiveFormsModule], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule], declarations: [FilterInputSuggestionsComponent], - providers: [FormsModule, - ReactiveFormsModule, - { provide: MetadataFieldDataService, useValue: {} }, - { provide: ObjectUpdatesService, useValue: {} }, - ], + providers: [], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(FilterInputSuggestionsComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts index 49aa46b757..9e7d84d9ed 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.ts @@ -1,8 +1,5 @@ -import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { FormControl, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { MetadatumViewModel } from '../../../core/shared/metadata.models'; -import { MetadataFieldValidator } from '../../utils/metadatafield-validator.directive'; +import { Component, forwardRef, Input } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { InputSuggestionsComponent } from '../input-suggestions.component'; import { InputSuggestion } from '../input-suggestions.model'; @@ -24,39 +21,12 @@ import { InputSuggestion } from '../input-suggestions.model'; /** * Component representing a form with a autocomplete functionality */ -export class FilterInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit { - - form: FormGroup; - - /** - * The current url of this page - */ - @Input() url: string; - - /** - * The metadatum of this field - */ - @Input() metadata: MetadatumViewModel; - +export class FilterInputSuggestionsComponent extends InputSuggestionsComponent { /** * The suggestions that should be shown */ @Input() suggestions: InputSuggestion[] = []; - constructor(private metadataFieldValidator: MetadataFieldValidator, - private objectUpdatesService: ObjectUpdatesService) { - super(); - } - - ngOnInit() { - this.form = new FormGroup({ - metadataNameField: new FormControl(this._value, { - asyncValidators: [this.metadataFieldValidator.validate.bind(this.metadataFieldValidator)], - validators: [Validators.required] - }) - }); - } - onSubmit(data) { this.value = data; this.submitSuggestion.emit(data); @@ -70,15 +40,4 @@ export class FilterInputSuggestionsComponent extends InputSuggestionsComponent i this.queryInput.nativeElement.focus(); return false; } - - /** - * Check if the input is valid according to validator and send (in)valid state to store - * @param form Form with input - */ - checkIfValidInput(form) { - this.valid = !(form.get('metadataNameField').status === 'INVALID' && (form.get('metadataNameField').dirty || form.get('metadataNameField').touched)); - this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, this.valid); - return this.valid; - } - } diff --git a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html new file mode 100644 index 0000000000..91d8217ade --- /dev/null +++ b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html @@ -0,0 +1,24 @@ +
+ + + +
+ diff --git a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.spec.ts b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.spec.ts new file mode 100644 index 0000000000..82e838effc --- /dev/null +++ b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.spec.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { MetadataFieldDataService } from '../../../core/data/metadata-field-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ValidationSuggestionsComponent } from './validation-suggestions.component'; + +describe('ValidationSuggestionsComponent', () => { + + let comp: ValidationSuggestionsComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let el: HTMLElement; + const suggestions = [{ displayValue: 'suggestion uno', value: 'suggestion uno' }, { + displayValue: 'suggestion dos', + value: 'suggestion dos' + }, { displayValue: 'suggestion tres', value: 'suggestion tres' }]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule, ReactiveFormsModule], + declarations: [ValidationSuggestionsComponent], + providers: [FormsModule, + ReactiveFormsModule, + { provide: MetadataFieldDataService, useValue: {} }, + { provide: ObjectUpdatesService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ValidationSuggestionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ValidationSuggestionsComponent); + + comp = fixture.componentInstance; // LoadingComponent test instance + comp.suggestions = suggestions; + // query for the message