Merge branch 'main-upstream' into w2p-91272_Add-themed-components-to-upstream-branch

This commit is contained in:
Alexandre Vryghem
2022-06-02 12:09:51 +02:00
164 changed files with 3748 additions and 1404 deletions

View File

@@ -31,6 +31,10 @@ jobs:
# We turn off 'latest' tag by default. # We turn off 'latest' tag by default.
TAGS_FLAVOR: | TAGS_FLAVOR: |
latest=false latest=false
# Architectures / Platforms for which we will build Docker images
# If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work.
# If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64.
PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }}
steps: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
@@ -41,6 +45,10 @@ jobs:
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU emulation to build for multiple architectures
uses: docker/setup-qemu-action@v2
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Login to DockerHub - name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push # Only login if not a PR, as PRs only trigger a Docker build and not a push
@@ -70,6 +78,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build), # For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR # but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

2
.gitignore vendored
View File

@@ -37,3 +37,5 @@ package-lock.json
.env .env
/nbproject/ /nbproject/
junit.xml

View File

@@ -330,8 +330,11 @@ All E2E tests must be created under the `./cypress/integration/` folder, and mus
* In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_. * In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_.
* From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page. * From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page.
* Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector * Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector
* It's generally best not to rely on attributes like `class` and `id` in tests, as those are likely to change later on. Instead, you can add a `data-test` attribute to makes it clear that it's required for a test.
* Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc. * Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc.
* Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. * When running with server-side rendering enabled, the client first receives HTML without the JS; only once the page is rendered client-side do some elements (e.g. a button that toggles a Bootstrap dropdown) become fully interactive. This can trip up Cypress in some cases as it may try to `click` or `type` in an element that's not fully loaded yet, causing tests to fail.
* To work around this issue, define the attributes you use for Cypress selectors as `[attr.data-test]="'button' | ngBrowserOnly"`. This will only show the attribute in CSR HTML, forcing Cypress to wait until CSR is complete before interacting with the element.
* Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions.
* Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly. * Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly.
* Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests. * Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests.

View File

@@ -65,7 +65,7 @@ describe('My DSpace page', () => {
cy.visit('/mydspace'); cy.visit('/mydspace');
// Open the New Submission dropdown // Open the New Submission dropdown
cy.get('#dropdownSubmission').click(); cy.get('button[data-test="submission-dropdown"]').click();
// Click on the "Item" type in that dropdown // Click on the "Item" type in that dropdown
cy.get('#entityControlsDropdownMenu button[title="none"]').click(); cy.get('#entityControlsDropdownMenu button[title="none"]').click();
@@ -98,7 +98,7 @@ describe('My DSpace page', () => {
const id = subpaths[2]; const id = subpaths[2];
// Click the "Save for Later" button to save this submission // Click the "Save for Later" button to save this submission
cy.get('button#saveForLater').click(); cy.get('ds-submission-form-footer [data-test="save-for-later"]').click();
// "Save for Later" should send us to MyDSpace // "Save for Later" should send us to MyDSpace
cy.url().should('include', '/mydspace'); cy.url().should('include', '/mydspace');
@@ -122,7 +122,7 @@ describe('My DSpace page', () => {
cy.url().should('include', '/workspaceitems/' + id + '/edit'); cy.url().should('include', '/workspaceitems/' + id + '/edit');
// Discard our new submission by clicking Discard in Submission form & confirming // Discard our new submission by clicking Discard in Submission form & confirming
cy.get('button#discard').click(); cy.get('ds-submission-form-footer [data-test="discard"]').click();
cy.get('button#discard_submit').click(); cy.get('button#discard_submit').click();
// Discarding should send us back to MyDSpace // Discarding should send us back to MyDSpace
@@ -135,7 +135,7 @@ describe('My DSpace page', () => {
cy.visit('/mydspace'); cy.visit('/mydspace');
// Open the New Import dropdown // Open the New Import dropdown
cy.get('#dropdownImport').click(); cy.get('button[data-test="import-dropdown"]').click();
// Click on the "Item" type in that dropdown // Click on the "Item" type in that dropdown
cy.get('#importControlsDropdownMenu button[title="none"]').click(); cy.get('#importControlsDropdownMenu button[title="none"]').click();

View File

@@ -24,7 +24,7 @@ describe('Search Page', () => {
// Click each filter toggle to open *every* filter // Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well) // (As we want to scan filter section for accessibility issues as well)
cy.get('.filter-toggle').click({ multiple: true }); cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-search-page> for accessibility issues // Analyze <ds-search-page> for accessibility issues
testA11y( testA11y(

View File

@@ -21,7 +21,7 @@ import './commands';
import 'cypress-axe'; import 'cypress-axe';
// Runs once before the first test in each "block" // Runs once before the first test in each "block"
before(() => { beforeEach(() => {
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
// This just ensures it doesn't get in the way of matching other objects in the page. // This just ensures it doesn't get in the way of matching other objects in the page.
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true}'); cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true}');

View File

@@ -154,7 +154,7 @@
"@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/eslint-plugin": "5.11.0",
"@typescript-eslint/parser": "5.11.0", "@typescript-eslint/parser": "5.11.0",
"axe-core": "^4.3.3", "axe-core": "^4.3.3",
"compression-webpack-plugin": "^3.0.1", "compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "^6.2.0", "css-loader": "^6.2.0",
@@ -170,6 +170,7 @@
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsdoc": "^38.0.6", "eslint-plugin-jsdoc": "^38.0.6",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.5",
"fork-ts-checker-webpack-plugin": "^6.0.3", "fork-ts-checker-webpack-plugin": "^6.0.3",
"html-loader": "^1.3.2", "html-loader": "^1.3.2",
"jasmine-core": "^3.8.0", "jasmine-core": "^3.8.0",

View File

@@ -25,6 +25,7 @@ import * as morgan from 'morgan';
import * as express from 'express'; import * as express from 'express';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import * as compression from 'compression'; import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -74,11 +75,15 @@ export function app() {
/* /*
* If production mode is enabled in the environment file: * If production mode is enabled in the environment file:
* - Enable Angular's production mode * - Enable Angular's production mode
* - Enable compression for response bodies. See [compression](https://github.com/expressjs/compression) * - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
*/ */
if (environment.production) { if (environment.production) {
enableProdMode(); enableProdMode();
server.use(compression()); server.use(compression({
// only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
filter: (_, res) => res.locals.ssr,
}));
} }
/* /*
@@ -150,8 +155,14 @@ export function app() {
/* /*
* Serve static resources (images, i18n messages, …) * Serve static resources (images, i18n messages, …)
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
*/ */
server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); server.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
index: false,
enableBrotli: true,
orderPreference: ['br', 'gzip'],
}));
/* /*
* Fallthrough to the IIIF viewer (must be included in the build). * Fallthrough to the IIIF viewer (must be included in the build).
*/ */
@@ -180,6 +191,7 @@ function ngApp(req, res) {
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
}, (err, data) => { }, (err, data) => {
if (hasNoValue(err) && hasValue(data)) { if (hasNoValue(err) && hasValue(data)) {
res.locals.ssr = true; // mark response as SSR
res.send(data); res.send(data);
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been // When this error occurs we can't fall back to CSR because the response has already been

View File

@@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -35,6 +35,7 @@ import { RouterMock } from '../../../shared/mocks/router.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator'; import { ValidateGroupExists } from './validators/group-exists.validator';
import { NoContent } from '../../../core/shared/NoContent.model';
describe('GroupFormComponent', () => { describe('GroupFormComponent', () => {
let component: GroupFormComponent; let component: GroupFormComponent;
@@ -87,6 +88,9 @@ describe('GroupFormComponent', () => {
patch(group: Group, operations: Operation[]) { patch(group: Group, operations: Operation[]) {
return null; return null;
}, },
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
},
cancelEditGroup(): void { cancelEditGroup(): void {
this.activeGroup = null; this.activeGroup = null;
}, },
@@ -348,4 +352,46 @@ describe('GroupFormComponent', () => {
}); });
}); });
describe('delete', () => {
let deleteButton;
beforeEach(() => {
component.initialisePage();
component.canEdit$ = observableOf(true);
component.groupBeingEdited = {
permanent: false
} as Group;
fixture.detectChanges();
deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement;
spyOn(groupsDataServiceStub, 'delete').and.callThrough();
spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' }));
});
describe('if confirmed via modal', () => {
beforeEach(waitForAsync(() => {
deleteButton.click();
fixture.detectChanges();
(document as any).querySelector('.modal-footer .confirm').click();
}));
it('should call GroupDataService.delete', () => {
expect(groupsDataServiceStub.delete).toHaveBeenCalledWith('active-group');
});
});
describe('if canceled via modal', () => {
beforeEach(waitForAsync(() => {
deleteButton.click();
fixture.detectChanges();
(document as any).querySelector('.modal-footer .cancel').click();
}));
it('should not call GroupDataService.delete', () => {
expect(groupsDataServiceStub.delete).not.toHaveBeenCalled();
});
});
});
}); });

View File

@@ -426,7 +426,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
.subscribe((rd: RemoteData<NoContent>) => { .subscribe((rd: RemoteData<NoContent>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name }));
this.reset(); this.onCancel();
} else { } else {
this.notificationsService.error( this.notificationsService.error(
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }), this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }),
@@ -439,16 +439,6 @@ export class GroupFormComponent implements OnInit, OnDestroy {
}); });
} }
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.groupDataService.getBrowseEndpoint().pipe(take(1)).subscribe((href: string) => {
this.requestService.removeByHrefSubstring(href);
});
this.onCancel();
}
/** /**
* Cancel the current edit when component is destroyed & unsub all subscriptions * Cancel the current edit when component is destroyed & unsub all subscriptions
*/ */

View File

@@ -79,7 +79,7 @@
</button> </button>
</ng-container> </ng-container>
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete" <button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm" (click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}"> title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>

View File

@@ -31,6 +31,7 @@ import { RouterMock } from '../../shared/mocks/router.mock';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { NoContent } from '../../core/shared/NoContent.model';
describe('GroupRegistryComponent', () => { describe('GroupRegistryComponent', () => {
let component: GroupsRegistryComponent; let component: GroupsRegistryComponent;
@@ -145,7 +146,10 @@ describe('GroupRegistryComponent', () => {
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1
}), [result])); }), [result]));
} },
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
},
}; };
dsoDataServiceStub = { dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> { findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
@@ -301,4 +305,29 @@ describe('GroupRegistryComponent', () => {
}); });
}); });
}); });
describe('delete', () => {
let deleteButton;
beforeEach(fakeAsync(() => {
spyOn(groupsDataServiceStub, 'delete').and.callThrough();
setIsAuthorized(true, true);
// force rerender after setup changes
component.search({ query: '' });
tick();
fixture.detectChanges();
// only mockGroup[0] is deletable, so we should only get one button
deleteButton = fixture.debugElement.query(By.css('.btn-delete')).nativeElement;
}));
it('should call GroupDataService.delete', () => {
deleteButton.click();
fixture.detectChanges();
expect(groupsDataServiceStub.delete).toHaveBeenCalledWith(mockGroups[0].id);
});
});
}); });

View File

@@ -9,7 +9,7 @@ import {
of as observableOf, of as observableOf,
Subscription Subscription
} from 'rxjs'; } from 'rxjs';
import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
@@ -199,7 +199,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id]; this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id];
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name })); this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name }));
this.reset();
} else { } else {
this.notificationsService.error( this.notificationsService.error(
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }), this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }),
@@ -209,17 +208,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
} }
} }
/**
* This method will set everything to stale, which will cause the lists on this page to update.
*/
reset() {
this.groupService.getBrowseEndpoint().pipe(
take(1)
).subscribe((href: string) => {
this.requestService.setStaleByHrefSubstring(href);
});
}
/** /**
* Get the members (epersons embedded value of a group) * Get the members (epersons embedded value of a group)
* @param group * @param group

View File

@@ -1,6 +1,17 @@
<div class="container"> <div class="container">
<h2 id="header">{{'admin.metadata-import.page.header' | translate}}</h2> <h2 id="header">{{'admin.metadata-import.page.header' | translate}}</h2>
<p>{{'admin.metadata-import.page.help' | translate}}</p> <p>{{'admin.metadata-import.page.help' | translate}}</p>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly">
{{'admin.metadata-import.page.validateOnly' | translate}}
</label>
</div>
<small id="validateOnlyHelpBlock" class="form-text text-muted">
{{'admin.metadata-import.page.validateOnly.hint' | translate}}
</small>
</div>
<ds-file-dropzone-no-uploader <ds-file-dropzone-no-uploader
(onFileAdded)="setFile($event)" (onFileAdded)="setFile($event)"
@@ -8,8 +19,10 @@
[dropMessageLabelReplacement]="'admin.metadata-import.page.dropMsgReplace'"> [dropMessageLabelReplacement]="'admin.metadata-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader> </ds-file-dropzone-no-uploader>
<button class="btn btn-secondary" id="backButton" <div class="space-children-mr">
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button> <button class="btn btn-secondary" id="backButton"
<button class="btn btn-primary" id="proceedButton" (click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button> <button class="btn btn-primary" id="proceedButton"
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
</div>
</div> </div>

View File

@@ -87,8 +87,9 @@ describe('MetadataImportPageComponent', () => {
comp.setFile(fileMock); comp.setFile(fileMock);
}); });
describe('if proceed button is pressed', () => { describe('if proceed button is pressed without validate only', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
comp.validateOnly = false;
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
proceed.click(); proceed.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -107,6 +108,28 @@ describe('MetadataImportPageComponent', () => {
}); });
}); });
describe('if proceed button is pressed with validate only', () => {
beforeEach(fakeAsync(() => {
comp.validateOnly = true;
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
proceed.click();
fixture.detectChanges();
}));
it('metadata-import script is invoked with -f fileName and the mockFile and -v validate-only', () => {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '-f', value: 'filename.txt' }),
Object.assign(new ProcessParameter(), { name: '-v', value: true }),
];
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', () => { describe('if proceed is pressed; but script invoke fails', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
jasmine.getEnv().allowRespy(true); jasmine.getEnv().allowRespy(true);

View File

@@ -30,6 +30,11 @@ export class MetadataImportPageComponent {
*/ */
fileObject: File; fileObject: File;
/**
* The validate only flag
*/
validateOnly = true;
public constructor(private location: Location, public constructor(private location: Location,
protected translate: TranslateService, protected translate: TranslateService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
@@ -62,6 +67,9 @@ export class MetadataImportPageComponent {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }), Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }),
]; ];
if (this.validateOnly) {
parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true }));
}
this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),

View File

@@ -128,7 +128,6 @@ export class MetadataRegistryComponent {
* Delete all the selected metadata schemas * Delete all the selected metadata schemas
*/ */
deleteSchemas() { deleteSchemas() {
this.registryService.clearMetadataSchemaRequests().subscribe();
this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe(
(schemas) => { (schemas) => {
const tasks$ = []; const tasks$ = [];
@@ -148,7 +147,6 @@ export class MetadataRegistryComponent {
} }
this.registryService.deselectAllMetadataSchema(); this.registryService.deselectAllMetadataSchema();
this.registryService.cancelEditMetadataSchema(); this.registryService.cancelEditMetadataSchema();
this.forceUpdateSchemas();
}); });
} }
); );

View File

@@ -174,15 +174,12 @@ export class MetadataSchemaComponent implements OnInit {
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed); const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
if (successResponses.length > 0) { if (successResponses.length > 0) {
this.showNotification(true, successResponses.length); this.showNotification(true, successResponses.length);
this.registryService.clearMetadataFieldRequests();
} }
if (failedResponses.length > 0) { if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length); this.showNotification(false, failedResponses.length);
} }
this.registryService.deselectAllMetadataField(); this.registryService.deselectAllMetadataField();
this.registryService.cancelEditMetadataField(); this.registryService.cancelEditMetadataField();
this.forceUpdateFields();
}); });
} }
); );

View File

@@ -185,176 +185,4 @@ describe('AdminSidebarComponent', () => {
expect(menuService.collapseMenuPreview).toHaveBeenCalled(); expect(menuService.collapseMenuPreview).toHaveBeenCalled();
})); }));
}); });
describe('menu', () => {
beforeEach(() => {
spyOn(menuService, 'addSection');
});
describe('for regular user', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => {
return observableOf(false);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should not show site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'admin_search', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'curation_tasks', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: false,
}));
});
it('should not show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
});
it('should not show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
});
it('should not show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'access_control', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'access_control', visible: false,
}));
});
// We check that the menu section has not been called with visible set to true
// The reason why we don't check if it has been called with visible set to false
// Is because the function does not get called unless a user is authorised
it('should not show the import section', () => {
expect(menuService.addSection).not.toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'import', visible: true,
}));
});
// We check that the menu section has not been called with visible set to true
// The reason why we don't check if it has been called with visible set to false
// Is because the function does not get called unless a user is authorised
it('should not show the export section', () => {
expect(menuService.addSection).not.toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'export', visible: true,
}));
});
});
describe('for site admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.AdministratorOf);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should contain site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'admin_search', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'curation_tasks', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'import', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'export', visible: true,
}));
});
});
describe('for community admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCommunityAdmin);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_community', visible: true,
}));
});
});
describe('for collection admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCollectionAdmin);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_collection', visible: true,
}));
});
});
describe('for group admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanManageGroups);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'access_control', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'access_control', visible: true,
}));
});
});
});
}); });

View File

@@ -1,45 +1,13 @@
import { Component, HostListener, Injector, OnInit } from '@angular/core'; import { Component, HostListener, Injector, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { BehaviorSubject, combineLatest as observableCombineLatest, combineLatest, Observable } from 'rxjs'; import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, filter, first, map, take, withLatestFrom } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service'; 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 { 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';
import {
CreateItemParentSelectorComponent
} from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
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 { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model';
import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model';
import { MenuComponent } from '../../shared/menu/menu.component'; import { MenuComponent } from '../../shared/menu/menu.component';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { MenuID } from '../../shared/menu/menu-id.model'; import { MenuID } from '../../shared/menu/menu-id.model';
import { MenuItemType } from '../../shared/menu/menu-item-type.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ThemeService } from '../../shared/theme-support/theme.service'; import { ThemeService } from '../../shared/theme-support/theme.service';
@@ -86,11 +54,9 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
constructor( constructor(
protected menuService: MenuService, protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
protected variableService: CSSVariableService, private variableService: CSSVariableService,
protected authService: AuthService, private authService: AuthService,
protected modalService: NgbModal,
public authorizationService: AuthorizationDataService, public authorizationService: AuthorizationDataService,
protected scriptDataService: ScriptDataService,
public route: ActivatedRoute, public route: ActivatedRoute,
protected themeService: ThemeService protected themeService: ThemeService
) { ) {
@@ -107,7 +73,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
this.authService.isAuthenticated() this.authService.isAuthenticated()
.subscribe((loggedIn: boolean) => { .subscribe((loggedIn: boolean) => {
if (loggedIn) { if (loggedIn) {
this.createMenu();
this.menuService.showMenu(this.menuID); this.menuService.showMenu(this.menuID);
} }
}); });
@@ -137,503 +102,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
}); });
} }
/**
* Initialize all menu sections and items for this menu
*/
createMenu() {
this.createMainMenuSections();
this.createSiteAdministratorMenuSections();
this.createExportMenuSections();
this.createImportMenuSections();
this.createAccessControlMenuSections();
}
/**
* Initialize the main menu sections.
* edit_community / edit_collection is only included if the current user is a Community or Collection admin
*/
createMainMenuSections() {
combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf)
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => {
const menuList = [
/* News */
{
id: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
index: 0
},
{
id: 'new_community',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_process',
parentID: 'new',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new'
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'new_item_version',
// parentID: 'new',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.new_item_version',
// link: ''
// } as LinkMenuItemModel,
// },
/* Edit */
{
id: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit'
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1
},
{
id: 'edit_community',
parentID: 'edit',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/* Statistics */
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'statistics_task',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.statistics_task',
// link: ''
// } as LinkMenuItemModel,
// icon: 'chart-bar',
// index: 8
// },
/* Control Panel */
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'control_panel',
// active: false,
// visible: isSiteAdmin,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.control_panel',
// link: ''
// } as LinkMenuItemModel,
// icon: 'cogs',
// index: 9
// },
/* Processes */
{
id: 'processes',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.processes',
link: '/processes'
} as LinkMenuItemModel,
icon: 'terminal',
index: 10
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
* 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 = [
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_community',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_community',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_collection',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_collection',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// 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(
filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists),
take(1)
).subscribe(() => {
// Hides the export menu for unauthorised people
// If in the future more sub-menus are added,
// it should be reviewed if they need to be in this subscribe
this.menuService.addSection(this.menuID, {
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'file-export',
index: 3,
shouldPersistOnRouteChange: true
});
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 = [
// TODO: enable this menu item once the feature has been implemented
// {
// 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(
filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists),
take(1)
).subscribe(() => {
// Hides the import menu for unauthorised people
// If in the future more sub-menus are added,
// it should be reviewed if they need to be in this subscribe
this.menuService.addSection(this.menuID, {
id: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import'
} as TextMenuItemModel,
icon: 'file-import',
index: 2
});
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
*/
createSiteAdministratorMenuSections() {
this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => {
const menuList = [
/* Admin Search */
{
id: 'admin_search',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search'
} as LinkMenuItemModel,
icon: 'search',
index: 5
},
/* Registries */
{
id: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries'
} as TextMenuItemModel,
icon: 'list',
index: 6
},
{
id: 'registries_metadata',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata'
} as LinkMenuItemModel,
},
{
id: 'registries_format',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats'
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: 'admin/curation-tasks'
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Workflow */
{
id: 'workflow',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.workflow',
link: '/admin/workflow'
} as LinkMenuItemModel,
icon: 'user-check',
index: 11
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
* Create menu sections dependent on whether or not the current user can manage access control groups
*/
createAccessControlMenuSections() {
observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanManageGroups)
]).subscribe(([isSiteAdmin, canManageGroups]) => {
const menuList = [
/* Access Control */
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: canManageGroups,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/access-control/groups'
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'access_control_authorizations',
// parentID: 'access_control',
// active: false,
// visible: authorized,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.access_control_authorizations',
// link: ''
// } as LinkMenuItemModel,
// },
{
id: 'access_control',
active: false,
visible: canManageGroups || isSiteAdmin,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true,
})));
});
}
@HostListener('focusin') @HostListener('focusin')
public handleFocusIn() { public handleFocusIn() {
this.inFocus$.next(true); this.inFocus$.next(true);

View File

@@ -1,5 +1,5 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule, NoPreloading } from '@angular/router';
import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { AuthenticatedGuard } from './core/auth/authenticated.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard';
@@ -30,6 +30,7 @@ import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component
import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
import { ServerCheckGuard } from './core/server-check/server-check.guard'; import { ServerCheckGuard } from './core/server-check/server-check.guard';
import { MenuResolver } from './menu.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -39,6 +40,7 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard';
path: '', path: '',
canActivate: [AuthBlockingGuard], canActivate: [AuthBlockingGuard],
canActivateChild: [ServerCheckGuard], canActivateChild: [ServerCheckGuard],
resolve: [MenuResolver],
children: [ children: [
{ path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ {
@@ -217,6 +219,12 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard';
] ]
} }
], { ], {
// enableTracing: true,
useHash: false,
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
initialNavigation: 'enabledBlocking',
preloadingStrategy: NoPreloading,
onSameUrlNavigation: 'reload', onSameUrlNavigation: 'reload',
}) })
], ],

View File

@@ -72,7 +72,7 @@ export class AppComponent implements OnInit, AfterViewInit {
/** /**
* Whether or not the app is in the process of rerouting * Whether or not the app is in the process of rerouting
*/ */
isRouteLoading$: BehaviorSubject<boolean> = new BehaviorSubject(true); isRouteLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
/** /**
* Whether or not the theme is in the process of being swapped * Whether or not the theme is in the process of being swapped
@@ -121,7 +121,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.themeService.getThemeName$().subscribe((themeName: string) => { this.themeService.getThemeName$().subscribe((themeName: string) => {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
// the theme css will never download server side, so this should only happen on the browser // the theme css will never download server side, so this should only happen on the browser
this.isThemeCSSLoading$.next(true); this.distinctNext(this.isThemeCSSLoading$, true);
} }
if (hasValue(themeName)) { if (hasValue(themeName)) {
this.loadGlobalThemeConfig(themeName); this.loadGlobalThemeConfig(themeName);
@@ -200,7 +200,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.router.events.subscribe((event) => { this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) { if (event instanceof NavigationStart) {
resolveEndFound = false; resolveEndFound = false;
this.isRouteLoading$.next(true); this.distinctNext(this.isRouteLoading$, true);
} else if (event instanceof ResolveEnd) { } else if (event instanceof ResolveEnd) {
resolveEndFound = true; resolveEndFound = true;
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root; const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
@@ -213,16 +213,16 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
}) })
).subscribe((changed) => { ).subscribe((changed) => {
this.isThemeLoading$.next(changed); this.distinctNext(this.isThemeLoading$, changed);
}); });
} else if ( } else if (
event instanceof NavigationEnd || event instanceof NavigationEnd ||
event instanceof NavigationCancel event instanceof NavigationCancel
) { ) {
if (!resolveEndFound) { if (!resolveEndFound) {
this.isThemeLoading$.next(false); this.distinctNext(this.isThemeLoading$, false);
} }
this.isRouteLoading$.next(false); this.distinctNext(this.isRouteLoading$, false);
} }
}); });
} }
@@ -280,7 +280,7 @@ export class AppComponent implements OnInit, AfterViewInit {
}); });
} }
// the fact that this callback is used, proves we're on the browser. // the fact that this callback is used, proves we're on the browser.
this.isThemeCSSLoading$.next(false); this.distinctNext(this.isThemeCSSLoading$, false);
}; };
head.appendChild(link); head.appendChild(link);
} }
@@ -375,4 +375,17 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
}); });
} }
/**
* Use nextValue to update a given BehaviorSubject, only if it differs from its current value
*
* @param bs a BehaviorSubject
* @param nextValue the next value for that BehaviorSubject
* @protected
*/
protected distinctNext<T>(bs: BehaviorSubject<T>, nextValue: T): void {
if (bs.getValue() !== nextValue) {
bs.next(nextValue);
}
}
} }

View File

@@ -15,10 +15,6 @@ import {
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { AdminSidebarSectionComponent } from './admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component';
import { ExpandableAdminSidebarSectionComponent } from './admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { appEffects } from './app.effects'; import { appEffects } from './app.effects';
@@ -27,40 +23,20 @@ import { appReducers, AppState, storeModuleConfig } from './app.reducer';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
import { ClientCookieService } from './core/services/client-cookie.service'; import { ClientCookieService } from './core/services/client-cookie.service';
import { FooterComponent } from './footer/footer.component';
import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component';
import { HeaderComponent } from './header/header.component';
import { NavbarModule } from './navbar/navbar.module'; import { NavbarModule } from './navbar/navbar.module';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer';
import { NotificationComponent } from './shared/notifications/notification/notification.component';
import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component';
import { SharedModule } from './shared/shared.module'; import { SharedModule } from './shared/shared.module';
import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { ForbiddenComponent } from './forbidden/forbidden.component';
import { AuthInterceptor } from './core/auth/auth.interceptor'; import { AuthInterceptor } from './core/auth/auth.interceptor';
import { LocaleInterceptor } from './core/locale/locale.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
import { LogInterceptor } from './core/log/log.interceptor'; import { LogInterceptor } from './core/log/log.interceptor';
import { RootComponent } from './root/root.component'; import { EagerThemesModule } from '../themes/eager-themes.module';
import { ThemedRootComponent } from './root/themed-root.component';
import { ThemedEntryComponentModule } from '../themes/themed-entry-component.module';
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
import { ThemedHeaderComponent } from './header/themed-header.component';
import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
import { PageInternalServerErrorComponent } from './page-internal-server-error/page-internal-server-error.component';
import { ThemedAdminSidebarComponent } from './admin/admin-sidebar/themed-admin-sidebar.component';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { NgxMaskModule } from 'ngx-mask'; import { NgxMaskModule } from 'ngx-mask';
import { StoreDevModules } from '../config/store/devtools'; import { StoreDevModules } from '../config/store/devtools';
import { RootModule } from './root.module';
export function getConfig() { export function getConfig() {
return environment; return environment;
@@ -96,8 +72,9 @@ const IMPORTS = [
EffectsModule.forRoot(appEffects), EffectsModule.forRoot(appEffects),
StoreModule.forRoot(appReducers, storeModuleConfig), StoreModule.forRoot(appReducers, storeModuleConfig),
StoreRouterConnectingModule.forRoot(), StoreRouterConnectingModule.forRoot(),
ThemedEntryComponentModule.withEntryComponents(),
StoreDevModules, StoreDevModules,
EagerThemesModule,
RootModule,
]; ];
const PROVIDERS = [ const PROVIDERS = [
@@ -162,29 +139,6 @@ const PROVIDERS = [
const DECLARATIONS = [ const DECLARATIONS = [
AppComponent, AppComponent,
RootComponent,
ThemedRootComponent,
HeaderComponent,
ThemedHeaderComponent,
HeaderNavbarWrapperComponent,
ThemedHeaderNavbarWrapperComponent,
AdminSidebarComponent,
ThemedAdminSidebarComponent,
AdminSidebarSectionComponent,
ExpandableAdminSidebarSectionComponent,
FooterComponent,
ThemedFooterComponent,
PageNotFoundComponent,
ThemedPageNotFoundComponent,
NotificationComponent,
NotificationsBoardComponent,
BreadcrumbsComponent,
ThemedBreadcrumbsComponent,
ForbiddenComponent,
ThemedForbiddenComponent,
IdleModalComponent,
ThemedPageInternalServerErrorComponent,
PageInternalServerErrorComponent
]; ];
const EXPORTS = [ const EXPORTS = [

View File

@@ -43,6 +43,10 @@ import { createPaginatedList } from '../../shared/testing/utils.test';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component';
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub';
import { GroupDataService } from '../../core/eperson/group-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
describe('CollectionItemMapperComponent', () => { describe('CollectionItemMapperComponent', () => {
let comp: CollectionItemMapperComponent; let comp: CollectionItemMapperComponent;
@@ -143,6 +147,25 @@ describe('CollectionItemMapperComponent', () => {
isAuthorized: observableOf(true) isAuthorized: observableOf(true)
}); });
const linkHeadService = jasmine.createSpyObj('linkHeadService', {
addTag: ''
});
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
getUUIDFromString: '',
});
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'test',
values: [
'org.dspace.ctask.general.ProfileFormats = test'
]
}))
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
@@ -159,7 +182,10 @@ describe('CollectionItemMapperComponent', () => {
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() },
{ provide: RouteService, useValue: routeServiceStub }, { provide: RouteService, useValue: routeServiceStub },
{ provide: AuthorizationDataService, useValue: authorizationDataService } { provide: AuthorizationDataService, useValue: authorizationDataService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
] ]
}).overrideComponent(CollectionItemMapperComponent, { }).overrideComponent(CollectionItemMapperComponent, {
set: { set: {

View File

@@ -5,7 +5,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { CollectionDataService } from '../../core/data/collection-data.service'; import { CollectionDataService } from '../../core/data/collection-data.service';
import { Collection } from '../../core/shared/collection.model'; import { Collection } from '../../core/shared/collection.model';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { RequestService } from '../../core/data/request.service';
/** /**
* Component that represents the page where a user can delete an existing Collection * Component that represents the page where a user can delete an existing Collection
@@ -24,8 +23,7 @@ export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Col
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected notifications: NotificationsService, protected notifications: NotificationsService,
protected translate: TranslateService, protected translate: TranslateService,
protected requestService: RequestService
) { ) {
super(dsoDataService, router, route, notifications, translate, requestService); super(dsoDataService, router, route, notifications, translate);
} }
} }

View File

@@ -12,7 +12,6 @@ import { NotificationsService } from '../../../shared/notifications/notification
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths';
@@ -49,9 +48,6 @@ describe('CollectionMetadataComponent', () => {
success: {}, success: {},
error: {} error: {}
}); });
const objectCache = jasmine.createSpyObj('objectCache', {
remove: {}
});
const requestService = jasmine.createSpyObj('requestService', { const requestService = jasmine.createSpyObj('requestService', {
setStaleByHrefSubstring: {} setStaleByHrefSubstring: {}
}); });
@@ -65,8 +61,7 @@ describe('CollectionMetadataComponent', () => {
{ provide: ItemTemplateDataService, useValue: itemTemplateServiceStub }, { provide: ItemTemplateDataService, useValue: itemTemplateServiceStub },
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } },
{ provide: NotificationsService, useValue: notificationsService }, { provide: NotificationsService, useValue: notificationsService },
{ provide: ObjectCacheService, useValue: objectCache }, { provide: RequestService, useValue: requestService },
{ provide: RequestService, useValue: requestService }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -95,21 +90,19 @@ describe('CollectionMetadataComponent', () => {
}); });
describe('deleteItemTemplate', () => { describe('deleteItemTemplate', () => {
describe('when delete returns a success', () => { beforeEach(() => {
beforeEach(() => { (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true));
(itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true)); comp.deleteItemTemplate();
comp.deleteItemTemplate(); });
});
it('should call ItemTemplateService.deleteByCollectionID', () => {
expect(itemTemplateService.deleteByCollectionID).toHaveBeenCalledWith(template, 'collection-id');
});
describe('when delete returns a success', () => {
it('should display a success notification', () => { it('should display a success notification', () => {
expect(notificationsService.success).toHaveBeenCalled(); expect(notificationsService.success).toHaveBeenCalled();
}); });
it('should reset related object and request cache', () => {
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(collectionTemplateHref);
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(template.self);
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(collection.self);
});
}); });
describe('when delete returns a failure', () => { describe('when delete returns a failure', () => {

View File

@@ -8,10 +8,9 @@ import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { switchMap, tap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths';
@@ -38,8 +37,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected translate: TranslateService, protected translate: TranslateService,
protected objectCache: ObjectCacheService, protected requestService: RequestService,
protected requestService: RequestService
) { ) {
super(collectionDataService, router, route, notificationsService, translate); super(collectionDataService, router, route, notificationsService, translate);
} }
@@ -93,23 +91,9 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
)), )),
); );
const templateHref$ = collection$.pipe( combineLatestObservable(collection$, template$).pipe(
switchMap((collection) => this.itemTemplateService.getCollectionEndpoint(collection.id)), switchMap(([collection, template]) => {
); return this.itemTemplateService.deleteByCollectionID(template, collection.uuid);
combineLatestObservable(collection$, template$, templateHref$).pipe(
switchMap(([collection, template, templateHref]) => {
return this.itemTemplateService.deleteByCollectionID(template, collection.uuid).pipe(
tap((success: boolean) => {
if (success) {
this.objectCache.remove(templateHref);
this.objectCache.remove(template.self);
this.requestService.setStaleByHrefSubstring(template.self);
this.requestService.setStaleByHrefSubstring(templateHref);
this.requestService.setStaleByHrefSubstring(collection.self);
}
})
);
}) })
).subscribe((success: boolean) => { ).subscribe((success: boolean) => {
if (success) { if (success) {

View File

@@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ComcolModule } from '../../../shared/comcol/comcol.module'; import { ComcolModule } from '../../../shared/comcol/comcol.module';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
describe('CollectionRolesComponent', () => { describe('CollectionRolesComponent', () => {
@@ -79,6 +81,7 @@ describe('CollectionRolesComponent', () => {
{ provide: ActivatedRoute, useValue: route }, { provide: ActivatedRoute, useValue: route },
{ provide: RequestService, useValue: requestService }, { provide: RequestService, useValue: requestService },
{ provide: GroupDataService, useValue: groupDataService }, { provide: GroupDataService, useValue: groupDataService },
{ provide: NotificationsService, useClass: NotificationsServiceStub }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -5,7 +5,6 @@ import { ActivatedRoute, Router } from '@angular/router';
import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { RequestService } from '../../core/data/request.service';
/** /**
* Component that represents the page where a user can delete an existing Community * Component that represents the page where a user can delete an existing Community
@@ -24,9 +23,8 @@ export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Comm
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected notifications: NotificationsService, protected notifications: NotificationsService,
protected translate: TranslateService, protected translate: TranslateService,
protected requestService: RequestService
) { ) {
super(dsoDataService, router, route, notifications, translate, requestService); super(dsoDataService, router, route, notifications, translate);
} }
} }

View File

@@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ComcolModule } from '../../../shared/comcol/comcol.module'; import { ComcolModule } from '../../../shared/comcol/comcol.module';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
describe('CommunityRolesComponent', () => { describe('CommunityRolesComponent', () => {
@@ -64,6 +66,7 @@ describe('CommunityRolesComponent', () => {
{ provide: ActivatedRoute, useValue: route }, { provide: ActivatedRoute, useValue: route },
{ provide: RequestService, useValue: requestService }, { provide: RequestService, useValue: requestService },
{ provide: GroupDataService, useValue: groupDataService }, { provide: GroupDataService, useValue: groupDataService },
{ provide: NotificationsService, useClass: NotificationsServiceStub }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -25,6 +25,14 @@ import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../shared/theme-support/theme.service'; import { ThemeService } from '../../shared/theme-support/theme.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model'; import { FindListOptions } from '../../core/data/find-list-options.model';
import { GroupDataService } from '../../core/eperson/group-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { SearchServiceStub } from '../../shared/testing/search-service.stub';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub';
describe('CommunityPageSubCollectionList Component', () => { describe('CommunityPageSubCollectionList Component', () => {
let comp: CommunityPageSubCollectionListComponent; let comp: CommunityPageSubCollectionListComponent;
@@ -122,6 +130,25 @@ describe('CommunityPageSubCollectionList Component', () => {
themeService = getMockThemeService(); themeService = getMockThemeService();
const linkHeadService = jasmine.createSpyObj('linkHeadService', {
addTag: ''
});
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
getUUIDFromString: '',
});
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'test',
values: [
'org.dspace.ctask.general.ProfileFormats = test'
]
}))
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
@@ -138,6 +165,10 @@ describe('CommunityPageSubCollectionList Component', () => {
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: SelectableListService, useValue: {} }, { provide: SelectableListService, useValue: {} },
{ provide: ThemeService, useValue: themeService }, { provide: ThemeService, useValue: themeService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -25,6 +25,13 @@ import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../shared/theme-support/theme.service'; import { ThemeService } from '../../shared/theme-support/theme.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model'; import { FindListOptions } from '../../core/data/find-list-options.model';
import { GroupDataService } from '../../core/eperson/group-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { createPaginatedList } from '../../shared/testing/utils.test';
describe('CommunityPageSubCommunityListComponent Component', () => { describe('CommunityPageSubCommunityListComponent Component', () => {
let comp: CommunityPageSubCommunityListComponent; let comp: CommunityPageSubCommunityListComponent;
@@ -119,6 +126,25 @@ describe('CommunityPageSubCommunityListComponent Component', () => {
} }
}; };
const linkHeadService = jasmine.createSpyObj('linkHeadService', {
addTag: ''
});
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
getUUIDFromString: '',
});
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'test',
values: [
'org.dspace.ctask.general.ProfileFormats = test'
]
}))
});
const paginationService = new PaginationServiceStub(); const paginationService = new PaginationServiceStub();
themeService = getMockThemeService(); themeService = getMockThemeService();
@@ -139,6 +165,10 @@ describe('CommunityPageSubCommunityListComponent Component', () => {
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: SelectableListService, useValue: {} }, { provide: SelectableListService, useValue: {} },
{ provide: ThemeService, useValue: themeService }, { provide: ThemeService, useValue: themeService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -12,13 +12,13 @@ import { AuthStatus } from './models/auth-status.model';
import { ShortLivedToken } from './models/short-lived-token.model'; import { ShortLivedToken } from './models/short-lived-token.model';
import { URLCombiner } from '../url-combiner/url-combiner'; import { URLCombiner } from '../url-combiner/url-combiner';
import { RestRequest } from '../data/rest-request.model'; import { RestRequest } from '../data/rest-request.model';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/** /**
* Abstract service to send authentication requests * Abstract service to send authentication requests
*/ */
export abstract class AuthRequestService { export abstract class AuthRequestService {
protected linkName = 'authn'; protected linkName = 'authn';
protected browseEndpoint = '';
protected shortlivedtokensEndpoint = 'shortlivedtokens'; protected shortlivedtokensEndpoint = 'shortlivedtokens';
constructor(protected halService: HALEndpointService, constructor(protected halService: HALEndpointService,
@@ -27,14 +27,21 @@ export abstract class AuthRequestService {
) { ) {
} }
protected fetchRequest(request: RestRequest): Observable<RemoteData<AuthStatus>> { protected fetchRequest(request: RestRequest, ...linksToFollow: FollowLinkConfig<AuthStatus>[]): Observable<RemoteData<AuthStatus>> {
return this.rdbService.buildFromRequestUUID<AuthStatus>(request.uuid).pipe( return this.rdbService.buildFromRequestUUID<AuthStatus>(request.uuid, ...linksToFollow).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
); );
} }
protected getEndpointByMethod(endpoint: string, method: string): string { protected getEndpointByMethod(endpoint: string, method: string, ...linksToFollow: FollowLinkConfig<AuthStatus>[]): string {
return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; let url = isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`;
if (linksToFollow?.length > 0) {
linksToFollow.forEach((link: FollowLinkConfig<AuthStatus>, index: number) => {
url += ((index === 0) ? '?' : '&') + `embed=${link.name}`;
});
}
return url;
} }
public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<RemoteData<AuthStatus>> { public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<RemoteData<AuthStatus>> {
@@ -48,14 +55,14 @@ export abstract class AuthRequestService {
distinctUntilChanged()); distinctUntilChanged());
} }
public getRequest(method: string, options?: HttpOptions): Observable<RemoteData<AuthStatus>> { public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig<any>[]): Observable<RemoteData<AuthStatus>> {
return this.halService.getEndpoint(this.linkName).pipe( return this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)),
distinctUntilChanged(), distinctUntilChanged(),
map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)),
tap((request: GetRequest) => this.requestService.send(request)), tap((request: GetRequest) => this.requestService.send(request)),
mergeMap((request: GetRequest) => this.fetchRequest(request)), mergeMap((request: GetRequest) => this.fetchRequest(request, ...linksToFollow)),
distinctUntilChanged()); distinctUntilChanged());
} }

View File

@@ -192,7 +192,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true, blocking: false,
loading: true, loading: true,
idle: false idle: false
}; };
@@ -212,7 +212,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true, blocking: false,
loading: true, loading: true,
idle: false idle: false
}; };
@@ -558,7 +558,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true, blocking: false,
loading: true, loading: true,
authMethods: [], authMethods: [],
idle: false idle: false

View File

@@ -92,11 +92,15 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
}); });
case AuthActionTypes.AUTHENTICATED: case AuthActionTypes.AUTHENTICATED:
return Object.assign({}, state, {
loading: true,
blocking: true
});
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: true, loading: true,
blocking: true
}); });
case AuthActionTypes.AUTHENTICATED_ERROR: case AuthActionTypes.AUTHENTICATED_ERROR:
@@ -210,7 +214,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.RETRIEVE_AUTH_METHODS: case AuthActionTypes.RETRIEVE_AUTH_METHODS:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: true, loading: true,
blocking: true
}); });
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:

View File

@@ -32,6 +32,8 @@ import { TranslateService } from '@ngx-translate/core';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions';
import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock';
import { cold } from 'jasmine-marbles';
describe('AuthService test', () => { describe('AuthService test', () => {
@@ -56,6 +58,13 @@ describe('AuthService test', () => {
let linkService; let linkService;
let hardRedirectService; let hardRedirectService;
const AuthStatusWithSpecialGroups = Object.assign(new AuthStatus(), {
uuid: 'test',
authenticated: true,
okay: true,
specialGroups: SpecialGroupDataMock$
});
function init() { function init() {
mockStore = jasmine.createSpyObj('store', { mockStore = jasmine.createSpyObj('store', {
dispatch: {}, dispatch: {},
@@ -511,6 +520,19 @@ describe('AuthService test', () => {
expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled();
}); });
}); });
describe('getSpecialGroupsFromAuthStatus', () => {
beforeEach(() => {
spyOn(authRequest, 'getRequest').and.returnValue(createSuccessfulRemoteDataObject$(AuthStatusWithSpecialGroups));
});
it('should call navigateToRedirectUrl with no url', () => {
const expectRes = cold('(a|)', {
a: SpecialGroupDataMock
});
expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes);
});
});
}); });
describe('when user is not logged in', () => { describe('when user is not logged in', () => {

View File

@@ -44,13 +44,18 @@ import {
import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { RouteService } from '../services/route.service'; import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service'; import { EPersonDataService } from '../eperson/eperson-data.service';
import { getAllSucceededRemoteDataPayload } from '../shared/operators'; import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model';
import { Group } from '../eperson/models/group.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PageInfo } from '../shared/page-info.model';
import { followLink } from '../../shared/utils/follow-link-config.model';
export const LOGIN_ROUTE = '/login'; export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout'; export const LOGOUT_ROUTE = '/logout';
@@ -211,6 +216,22 @@ export class AuthService {
this.store.dispatch(new CheckAuthenticationTokenAction()); this.store.dispatch(new CheckAuthenticationTokenAction());
} }
/**
* Return the special groups list embedded in the AuthStatus model
*/
public getSpecialGroupsFromAuthStatus(): Observable<RemoteData<PaginatedList<Group>>> {
return this.authRequestService.getRequest('status', null, followLink('specialGroups')).pipe(
getFirstCompletedRemoteData(),
switchMap((status: RemoteData<AuthStatus>) => {
if (status.hasSucceeded) {
return status.payload.specialGroups;
} else {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[]));
}
})
);
}
/** /**
* Checks if token is present into storage and is not expired * Checks if token is present into storage and is not expired
*/ */

View File

@@ -5,6 +5,8 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer';
import { RemoteData } from '../../data/remote-data'; import { RemoteData } from '../../data/remote-data';
import { EPerson } from '../../eperson/models/eperson.model'; import { EPerson } from '../../eperson/models/eperson.model';
import { EPERSON } from '../../eperson/models/eperson.resource-type'; import { EPERSON } from '../../eperson/models/eperson.resource-type';
import { Group } from '../../eperson/models/group.model';
import { GROUP } from '../../eperson/models/group.resource-type';
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import { excludeFromEquals } from '../../utilities/equals.decorators'; import { excludeFromEquals } from '../../utilities/equals.decorators';
@@ -13,6 +15,7 @@ import { AUTH_STATUS } from './auth-status.resource-type';
import { AuthTokenInfo } from './auth-token-info.model'; import { AuthTokenInfo } from './auth-token-info.model';
import { AuthMethod } from './auth.method'; import { AuthMethod } from './auth.method';
import { CacheableObject } from '../../cache/cacheable-object.model'; import { CacheableObject } from '../../cache/cacheable-object.model';
import { PaginatedList } from '../../data/paginated-list.model';
/** /**
* Object that represents the authenticated status of a user * Object that represents the authenticated status of a user
@@ -61,6 +64,7 @@ export class AuthStatus implements CacheableObject {
_links: { _links: {
self: HALLink; self: HALLink;
eperson: HALLink; eperson: HALLink;
specialGroups: HALLink;
}; };
/** /**
@@ -70,6 +74,13 @@ export class AuthStatus implements CacheableObject {
@link(EPERSON) @link(EPERSON)
eperson?: Observable<RemoteData<EPerson>>; eperson?: Observable<RemoteData<EPerson>>;
/**
* The SpecialGroup of this auth status
* Will be undefined unless the SpecialGroup {@link HALLink} has been resolved.
*/
@link(GROUP, true)
specialGroups?: Observable<RemoteData<PaginatedList<Group>>>;
/** /**
* True if the token is valid, false if there was no token or the token wasn't valid * True if the token is valid, false if there was no token or the token wasn't valid
*/ */

View File

@@ -41,7 +41,7 @@ describe('objectCacheReducer', () => {
alternativeLinks: [altLink1, altLink2], alternativeLinks: [altLink1, altLink2],
timeCompleted: new Date().getTime(), timeCompleted: new Date().getTime(),
msToLive: 900000, msToLive: 900000,
requestUUID: requestUUID1, requestUUIDs: [requestUUID1],
patches: [], patches: [],
isDirty: false, isDirty: false,
}, },
@@ -55,7 +55,7 @@ describe('objectCacheReducer', () => {
alternativeLinks: [altLink3, altLink4], alternativeLinks: [altLink3, altLink4],
timeCompleted: new Date().getTime(), timeCompleted: new Date().getTime(),
msToLive: 900000, msToLive: 900000,
requestUUID: selfLink2, requestUUIDs: [selfLink2],
patches: [], patches: [],
isDirty: false isDirty: false
} }

View File

@@ -63,9 +63,11 @@ export class ObjectCacheEntry implements CacheEntry {
msToLive: number; msToLive: number;
/** /**
* The UUID of the request that caused this entry to be added * The UUIDs of the requests that caused this entry to be added
* New UUIDs should be added to the front of the array
* to make retrieving the latest UUID easier.
*/ */
requestUUID: string; requestUUIDs: string[];
/** /**
* An array of patches that were made on the client side to this entry, but haven't been sent to the server yet * An array of patches that were made on the client side to this entry, but haven't been sent to the server yet
@@ -156,11 +158,11 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio
data: action.payload.objectToCache, data: action.payload.objectToCache,
timeCompleted: action.payload.timeCompleted, timeCompleted: action.payload.timeCompleted,
msToLive: action.payload.msToLive, msToLive: action.payload.msToLive,
requestUUID: action.payload.requestUUID, requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])],
isDirty: isNotEmpty(existing.patches), isDirty: isNotEmpty(existing.patches),
patches: existing.patches || [], patches: existing.patches || [],
alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks]
} } as ObjectCacheEntry
}); });
} }

View File

@@ -211,25 +211,69 @@ describe('ObjectCacheService', () => {
}); });
}); });
describe('has', () => { describe('hasByHref', () => {
describe('with requestUUID not specified', () => {
describe('getByHref emits an object', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(cacheEntry));
});
describe('getByHref emits an object', () => { it('should return true', () => {
beforeEach(() => { expect(service.hasByHref(selfLink)).toBe(true);
spyOn(service, 'getByHref').and.returnValue(observableOf(cacheEntry)); });
}); });
it('should return true', () => { describe('getByHref emits nothing', () => {
expect(service.hasByHref(selfLink)).toBe(true); beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(empty());
});
it('should return false', () => {
expect(service.hasByHref(selfLink)).toBe(false);
});
}); });
}); });
describe('getByHref emits nothing', () => { describe('with requestUUID specified', () => {
beforeEach(() => { describe('getByHref emits an object that includes the specified requestUUID', () => {
spyOn(service, 'getByHref').and.returnValue(empty()); beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(Object.assign(cacheEntry, {
requestUUIDs: [
'something',
'something-else',
'specific-request',
]
})));
});
it('should return true', () => {
expect(service.hasByHref(selfLink, 'specific-request')).toBe(true);
});
}); });
it('should return false', () => { describe('getByHref emits an object that doesn\'t include the specified requestUUID', () => {
expect(service.hasByHref(selfLink)).toBe(false); beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(Object.assign(cacheEntry, {
requestUUIDs: [
'something',
'something-else',
]
})));
});
it('should return true', () => {
expect(service.hasByHref(selfLink, 'specific-request')).toBe(false);
});
});
describe('getByHref emits nothing', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(empty());
});
it('should return false', () => {
expect(service.hasByHref(selfLink, 'specific-request')).toBe(false);
});
}); });
}); });
}); });

View File

@@ -197,7 +197,7 @@ export class ObjectCacheService {
*/ */
getRequestUUIDBySelfLink(selfLink: string): Observable<string> { getRequestUUIDBySelfLink(selfLink: string): Observable<string> {
return this.getByHref(selfLink).pipe( return this.getByHref(selfLink).pipe(
map((entry: ObjectCacheEntry) => entry.requestUUID), map((entry: ObjectCacheEntry) => entry.requestUUIDs[0]),
distinctUntilChanged()); distinctUntilChanged());
} }
@@ -282,7 +282,7 @@ export class ObjectCacheService {
let result = false; let result = false;
this.getByHref(href).subscribe((entry: ObjectCacheEntry) => { this.getByHref(href).subscribe((entry: ObjectCacheEntry) => {
if (isNotEmpty(requestUUID)) { if (isNotEmpty(requestUUID)) {
result = entry.requestUUID === requestUUID; result = entry.requestUUIDs.includes(requestUUID);
} else { } else {
result = true; result = true;
} }

View File

@@ -75,7 +75,6 @@ import { RegistryService } from './registry/registry.service';
import { RoleService } from './roles/role.service'; import { RoleService } from './roles/role.service';
import { FeedbackDataService } from './feedback/feedback-data.service'; import { FeedbackDataService } from './feedback/feedback-data.service';
import { ApiService } from './services/api.service';
import { ServerResponseService } from './services/server-response.service'; import { ServerResponseService } from './services/server-response.service';
import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service';
import { BitstreamFormat } from './shared/bitstream-format.model'; import { BitstreamFormat } from './shared/bitstream-format.model';
@@ -166,6 +165,7 @@ import { GroupDataService } from './eperson/group-data.service';
import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model';
import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model';
import { AccessStatusDataService } from './data/access-status-data.service'; import { AccessStatusDataService } from './data/access-status-data.service';
import { LinkHeadService } from './services/link-head.service';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -190,7 +190,6 @@ const DECLARATIONS = [];
const EXPORTS = []; const EXPORTS = [];
const PROVIDERS = [ const PROVIDERS = [
ApiService,
AuthenticatedGuard, AuthenticatedGuard,
CommunityDataService, CommunityDataService,
CollectionDataService, CollectionDataService,
@@ -205,6 +204,7 @@ const PROVIDERS = [
SectionFormOperationsService, SectionFormOperationsService,
FormService, FormService,
EPersonDataService, EPersonDataService,
LinkHeadService,
HALEndpointService, HALEndpointService,
HostWindowService, HostWindowService,
ItemDataService, ItemDataService,

View File

@@ -37,7 +37,12 @@ describe('BitstreamFormatDataService', () => {
} }
} as Store<CoreState>; } as Store<CoreState>;
const objectCache = {} as ObjectCacheService; const requestUUIDs = ['some', 'uuid'];
const objectCache = jasmine.createSpyObj('objectCache', {
getByHref: observableOf({ requestUUIDs })
}) as ObjectCacheService;
const halEndpointService = { const halEndpointService = {
getEndpoint(linkPath: string): Observable<string> { getEndpoint(linkPath: string): Observable<string> {
return cold('a', { a: bitstreamFormatsEndpoint }); return cold('a', { a: bitstreamFormatsEndpoint });
@@ -76,6 +81,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -96,6 +102,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -118,6 +125,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -139,6 +147,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -163,6 +172,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -186,6 +196,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -209,6 +220,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -231,6 +243,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -253,6 +266,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', { a: responseCacheEntry }), getByUUID: cold('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });
@@ -273,6 +287,7 @@ describe('BitstreamFormatDataService', () => {
send: {}, send: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: hot('a', { a: responseCacheEntry }), getByUUID: hot('a', { a: responseCacheEntry }),
setStaleByUUID: observableOf(true),
generateRequestId: 'request-id', generateRequestId: 'request-id',
removeByHrefSubstring: {} removeByHrefSubstring: {}
}); });

View File

@@ -22,6 +22,7 @@ import {
import { BitstreamDataService } from './bitstream-data.service'; import { BitstreamDataService } from './bitstream-data.service';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { Bitstream } from '../shared/bitstream.model';
const LINK_NAME = 'test'; const LINK_NAME = 'test';
@@ -244,4 +245,75 @@ describe('ComColDataService', () => {
}); });
}); });
}); });
describe('deleteLogo', () => {
let dso;
beforeEach(() => {
dso = {
_links: {
logo: {
href: 'logo-href'
}
}
};
});
describe('when DSO has no logo', () => {
beforeEach(() => {
dso.logo = undefined;
});
it('should return a failed RD', (done) => {
service.deleteLogo(dso).subscribe(rd => {
expect(rd.hasFailed).toBeTrue();
expect(bitstreamDataService.deleteByHref).not.toHaveBeenCalled();
done();
});
});
});
describe('when DSO has a logo', () => {
let logo;
beforeEach(() => {
logo = Object.assign(new Bitstream, {
id: 'logo-id',
_links: {
self: {
href: 'logo-href',
}
}
});
});
describe('that can be retrieved', () => {
beforeEach(() => {
dso.logo = createSuccessfulRemoteDataObject$(logo);
});
it('should call BitstreamDataService.deleteByHref', (done) => {
service.deleteLogo(dso).subscribe(rd => {
expect(rd.hasSucceeded).toBeTrue();
expect(bitstreamDataService.deleteByHref).toHaveBeenCalledWith('logo-href');
done();
});
});
});
describe('that cannot be retrieved', () => {
beforeEach(() => {
dso.logo = createFailedRemoteDataObject$(logo);
});
it('should not call BitstreamDataService.deleteByHref', (done) => {
service.deleteLogo(dso).subscribe(rd => {
expect(rd.hasFailed).toBeTrue();
expect(bitstreamDataService.deleteByHref).not.toHaveBeenCalled();
done();
});
});
});
});
});
}); });

View File

@@ -11,7 +11,11 @@ import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import {
createFailedRemoteDataObject,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$,
} from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer'; import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { PatchRequest } from './request.models'; import { PatchRequest } from './request.models';
@@ -25,9 +29,12 @@ import { RemoteData } from './remote-data';
import { RequestEntryState } from './request-entry-state.model'; import { RequestEntryState } from './request-entry-state.model';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { fakeAsync, tick } from '@angular/core/testing';
const endpoint = 'https://rest.api/core'; const endpoint = 'https://rest.api/core';
const BOOLEAN = { f: false, t: true };
class TestService extends DataService<any> { class TestService extends DataService<any> {
constructor( constructor(
@@ -86,6 +93,9 @@ describe('DataService', () => {
}, },
getObjectBySelfLink: () => { getObjectBySelfLink: () => {
/* empty */ /* empty */
},
getByHref: () => {
/* empty */
} }
} as any; } as any;
store = {} as Store<CoreState>; store = {} as Store<CoreState>;
@@ -833,4 +843,149 @@ describe('DataService', () => {
}); });
}); });
describe('invalidateByHref', () => {
let getByHrefSpy: jasmine.Spy;
beforeEach(() => {
getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
requestUUIDs: ['request1', 'request2', 'request3']
}));
});
it('should call setStaleByUUID for every request associated with this DSO', (done) => {
service.invalidateByHref('some-href').subscribe((ok) => {
expect(ok).toBeTrue();
expect(getByHrefSpy).toHaveBeenCalledWith('some-href');
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2');
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3');
done();
});
});
it('should call setStaleByUUID even if not subscribing to returned Observable', fakeAsync(() => {
service.invalidateByHref('some-href');
tick();
expect(getByHrefSpy).toHaveBeenCalledWith('some-href');
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2');
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3');
}));
it('should return an Observable that only emits true once all requests are stale', () => {
testScheduler.run(({ cold, expectObservable }) => {
requestService.setStaleByUUID.and.callFake((uuid) => {
switch (uuid) { // fake requests becoming stale at different times
case 'request1':
return cold('--(t|)', BOOLEAN);
case 'request2':
return cold('----(t|)', BOOLEAN);
case 'request3':
return cold('------(t|)', BOOLEAN);
}
});
const done$ = service.invalidateByHref('some-href');
// emit true as soon as the final request is stale
expectObservable(done$).toBe('------(t|)', BOOLEAN);
});
});
});
describe('delete', () => {
let MOCK_SUCCEEDED_RD;
let MOCK_FAILED_RD;
let invalidateByHrefSpy: jasmine.Spy;
let buildFromRequestUUIDSpy: jasmine.Spy;
let getIDHrefObsSpy: jasmine.Spy;
let deleteByHrefSpy: jasmine.Spy;
beforeEach(() => {
invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true));
buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough();
getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough();
deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough();
MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({});
MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong');
});
it('should retrieve href by ID and call deleteByHref', () => {
getIDHrefObsSpy.and.returnValue(observableOf('some-href'));
buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => {
expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id');
expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']);
});
});
describe('deleteByHref', () => {
it('should call invalidateByHref if the DELETE request succeeds', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD));
service.deleteByHref('some-href').subscribe(rd => {
expect(rd).toBe(MOCK_SUCCEEDED_RD);
expect(invalidateByHrefSpy).toHaveBeenCalled();
done();
});
});
it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD));
service.deleteByHref('some-href');
tick();
expect(invalidateByHrefSpy).toHaveBeenCalled();
}));
it('should not call invalidateByHref if the DELETE request fails', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD));
service.deleteByHref('some-href').subscribe(rd => {
expect(rd).toBe(MOCK_FAILED_RD);
expect(invalidateByHrefSpy).not.toHaveBeenCalled();
done();
});
});
it('should wait for invalidateByHref before emitting', () => {
testScheduler.run(({ cold, expectObservable }) => {
buildFromRequestUUIDSpy.and.returnValue(
cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away
);
invalidateByHrefSpy.and.returnValue(
cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer
);
const done$ = service.deleteByHref('some-href');
expectObservable(done$).toBe(
'----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done
);
});
});
it('should wait for the DELETE request to resolve before emitting', () => {
testScheduler.run(({ cold, expectObservable }) => {
buildFromRequestUUIDSpy.and.returnValue(
cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while
);
invalidateByHrefSpy.and.returnValue(
cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner
); // e.g.: maybe already stale before this call?
const done$ = service.deleteByHref('some-href');
expectObservable(done$).toBe(
'----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request
);
});
});
});
});
}); });

View File

@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { Observable, of as observableOf } from 'rxjs'; import { AsyncSubject, combineLatest, from as observableFrom, Observable, of as observableOf } from 'rxjs';
import { import {
distinctUntilChanged, distinctUntilChanged,
filter, filter,
@@ -12,7 +12,7 @@ import {
takeWhile, takeWhile,
switchMap, switchMap,
tap, tap,
skipWhile, skipWhile, toArray
} from 'rxjs/operators'; } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
@@ -21,11 +21,12 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { getClassForType } from '../cache/builders/build-decorators'; import { getClassForType } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestParam } from '../cache/models/request-param.model'; import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getRemoteDataPayload, getFirstSucceededRemoteData, } from '../shared/operators'; import { getRemoteDataPayload, getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner'; import { URLCombiner } from '../url-combiner/url-combiner';
import { ChangeAnalyzer } from './change-analyzer'; import { ChangeAnalyzer } from './change-analyzer';
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
@@ -579,6 +580,38 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
return result$; return result$;
} }
/**
* Invalidate an existing DSpaceObject by marking all requests it is included in as stale
* @param objectId The id of the object to be invalidated
* @return An Observable that will emit `true` once all requests are stale
*/
invalidate(objectId: string): Observable<boolean> {
return this.getIDHrefObs(objectId).pipe(
switchMap((href: string) => this.invalidateByHref(href))
);
}
/**
* Invalidate an existing DSpaceObject by marking all requests it is included in as stale
* @param href The self link of the object to be invalidated
* @return An Observable that will emit `true` once all requests are stale
*/
invalidateByHref(href: string): Observable<boolean> {
const done$ = new AsyncSubject<boolean>();
this.objectCache.getByHref(href).pipe(
switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe(
mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
toArray(),
)),
).subscribe(() => {
done$.next(true);
done$.complete();
});
return done$;
}
/** /**
* Delete an existing DSpace Object on the server * Delete an existing DSpace Object on the server
* @param objectId The id of the object to be removed * @param objectId The id of the object to be removed
@@ -600,6 +633,7 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* metadata should be saved as real metadata * metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc * errorMessage, timeCompleted, etc
* Only emits once all request related to the DSO has been invalidated.
*/ */
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> { deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
@@ -618,7 +652,27 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
} }
this.requestService.send(request); this.requestService.send(request);
return this.rdbService.buildFromRequestUUID(requestId); const response$ = this.rdbService.buildFromRequestUUID(requestId);
const invalidated$ = new AsyncSubject<boolean>();
response$.pipe(
getFirstCompletedRemoteData(),
switchMap((rd: RemoteData<NoContent>) => {
if (rd.hasSucceeded) {
return this.invalidateByHref(href);
} else {
return [true];
}
})
).subscribe(() => {
invalidated$.next(true);
invalidated$.complete();
});
return combineLatest([response$, invalidated$]).pipe(
filter(([_, invalidated]) => invalidated),
map(([response, _]) => response),
);
} }
/** /**

View File

@@ -8,7 +8,7 @@ import { defaultUUID, getMockUUIDService } from '../../shared/mocks/uuid.service
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { coreReducers} from '../core.reducers'; import { coreReducers} from '../core.reducers';
import { UUIDService } from '../shared/uuid.service'; import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; import { RequestConfigureAction, RequestExecuteAction, RequestStaleAction } from './request.actions';
import { import {
DeleteRequest, DeleteRequest,
GetRequest, GetRequest,
@@ -19,7 +19,7 @@ import {
PutRequest PutRequest
} from './request.models'; } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { TestBed, waitForAsync } from '@angular/core/testing'; import { fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { storeModuleConfig } from '../../app.reducer'; import { storeModuleConfig } from '../../app.reducer';
import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { RequestEntryState } from './request-entry-state.model'; import { RequestEntryState } from './request-entry-state.model';
@@ -426,7 +426,7 @@ describe('RequestService', () => {
describe('and it is cached', () => { describe('and it is cached', () => {
describe('in the ObjectCache', () => { describe('in the ObjectCache', () => {
beforeEach(() => { beforeEach(() => {
(objectCache.getByHref as any).and.returnValue(observableOf({ requestUUID: 'some-uuid' })); (objectCache.getByHref as any).and.returnValue(observableOf({ requestUUIDs: ['some-uuid'] }));
spyOn(serviceAsAny, 'hasByHref').and.returnValue(false); spyOn(serviceAsAny, 'hasByHref').and.returnValue(false);
spyOn(serviceAsAny, 'hasByUUID').and.returnValue(true); spyOn(serviceAsAny, 'hasByUUID').and.returnValue(true);
}); });
@@ -596,4 +596,33 @@ describe('RequestService', () => {
}); });
}); });
describe('setStaleByUUID', () => {
let dispatchSpy: jasmine.Spy;
let getByUUIDSpy: jasmine.Spy;
beforeEach(() => {
dispatchSpy = spyOn(store, 'dispatch');
getByUUIDSpy = spyOn(service, 'getByUUID').and.callThrough();
});
it('should dispatch a RequestStaleAction', () => {
service.setStaleByUUID('something');
const firstAction = dispatchSpy.calls.argsFor(0)[0];
expect(firstAction).toBeInstanceOf(RequestStaleAction);
expect(firstAction.payload).toEqual({ uuid: 'something' });
});
it('should return an Observable that emits true as soon as the request is stale', fakeAsync(() => {
dispatchSpy.and.callFake(() => { /* empty */ }); // don't actually set as stale
getByUUIDSpy.and.returnValue(cold('a-b--c--d-', { // but fake the state in the cache
a: { state: RequestEntryState.ResponsePending },
b: { state: RequestEntryState.Success },
c: { state: RequestEntryState.SuccessStale },
d: { state: RequestEntryState.Error },
}));
const done$ = service.setStaleByUUID('something');
expect(done$).toBeObservable(cold('-----(t|)', { t: true }));
}));
});
}); });

View File

@@ -311,6 +311,21 @@ export class RequestService {
); );
} }
/**
* Mark a request as stale
* @param uuid the UUID of the request
* @return an Observable that will emit true once the Request becomes stale
*/
setStaleByUUID(uuid: string): Observable<boolean> {
this.store.dispatch(new RequestStaleAction(uuid));
return this.getByUUID(uuid).pipe(
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1),
);
}
/** /**
* Check if a GET request is in the cache or if it's still pending * Check if a GET request is in the cache or if it's still pending
* @param {GetRequest} request The request to check * @param {GetRequest} request The request to check
@@ -339,7 +354,7 @@ export class RequestService {
.subscribe((entry: ObjectCacheEntry) => { .subscribe((entry: ObjectCacheEntry) => {
// if the object cache has a match, check if the request that the object came with is // if the object cache has a match, check if the request that the object came with is
// still valid // still valid
inObjCache = this.hasByUUID(entry.requestUUID); inObjCache = this.hasByUUID(entry.requestUUIDs[0]);
}).unsubscribe(); }).unsubscribe();
// we should send the request if it isn't cached // we should send the request if it isn't cached

View File

@@ -21,7 +21,7 @@ import { EPersonDataService } from './eperson-data.service';
import { EPerson } from './models/eperson.model'; import { EPerson } from './models/eperson.model';
import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock';
@@ -287,13 +287,12 @@ describe('EPersonDataService', () => {
describe('deleteEPerson', () => { describe('deleteEPerson', () => {
beforeEach(() => { beforeEach(() => {
spyOn(service, 'findById').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); spyOn(service, 'delete').and.returnValue(createNoContentRemoteDataObject$());
service.deleteEPerson(EPersonMock).subscribe(); service.deleteEPerson(EPersonMock).subscribe();
}); });
it('should send DeleteRequest', () => { it('should call DataService.delete with the EPerson\'s UUID', () => {
const expected = new DeleteRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid); expect(service.delete).toHaveBeenCalledWith(EPersonMock.id);
expect(requestService.send).toHaveBeenCalledWith(expected);
}); });
}); });

View File

@@ -386,6 +386,10 @@ describe('RegistryService', () => {
result = registryService.deleteMetadataSchema(mockSchemasList[0].id); result = registryService.deleteMetadataSchema(mockSchemasList[0].id);
}); });
it('should defer to MetadataSchemaDataService.delete', () => {
expect(metadataSchemaService.delete).toHaveBeenCalledWith(`${mockSchemasList[0].id}`);
});
it('should return a successful response', () => { it('should return a successful response', () => {
result.subscribe((response: RemoteData<NoContent>) => { result.subscribe((response: RemoteData<NoContent>) => {
expect(response.hasSucceeded).toBe(true); expect(response.hasSucceeded).toBe(true);
@@ -400,6 +404,10 @@ describe('RegistryService', () => {
result = registryService.deleteMetadataField(mockFieldsList[0].id); result = registryService.deleteMetadataField(mockFieldsList[0].id);
}); });
it('should defer to MetadataFieldDataService.delete', () => {
expect(metadataFieldService.delete).toHaveBeenCalledWith(`${mockFieldsList[0].id}`);
});
it('should return a successful response', () => { it('should return a successful response', () => {
result.subscribe((response: RemoteData<NoContent>) => { result.subscribe((response: RemoteData<NoContent>) => {
expect(response.hasSucceeded).toBe(true); expect(response.hasSucceeded).toBe(true);

View File

@@ -19,6 +19,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils
import { RestResponse } from '../cache/response.models'; import { RestResponse } from '../cache/response.models';
import { RequestEntry } from '../data/request-entry.model'; import { RequestEntry } from '../data/request-entry.model';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { GroupDataService } from '../eperson/group-data.service';
describe('ResourcePolicyService', () => { describe('ResourcePolicyService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -28,6 +30,8 @@ describe('ResourcePolicyService', () => {
let objectCache: ObjectCacheService; let objectCache: ObjectCacheService;
let halService: HALEndpointService; let halService: HALEndpointService;
let responseCacheEntry: RequestEntry; let responseCacheEntry: RequestEntry;
let ePersonService: EPersonDataService;
let groupService: GroupDataService;
const resourcePolicy: any = { const resourcePolicy: any = {
id: '1', id: '1',
@@ -88,6 +92,8 @@ describe('ResourcePolicyService', () => {
const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy); const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy);
const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
const ePersonEndpoint = 'EPERSON_EP';
beforeEach(() => { beforeEach(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
@@ -105,6 +111,7 @@ describe('ResourcePolicyService', () => {
removeByHrefSubstring: {}, removeByHrefSubstring: {},
getByHref: observableOf(responseCacheEntry), getByHref: observableOf(responseCacheEntry),
getByUUID: observableOf(responseCacheEntry), getByUUID: observableOf(responseCacheEntry),
setStaleByHrefSubstring: {},
}); });
rdbService = jasmine.createSpyObj('rdbService', { rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: hot('a|', { buildSingle: hot('a|', {
@@ -117,6 +124,11 @@ describe('ResourcePolicyService', () => {
a: resourcePolicyRD a: resourcePolicyRD
}) })
}); });
ePersonService = jasmine.createSpyObj('ePersonService', {
getBrowseEndpoint: hot('a', {
a: ePersonEndpoint
}),
});
objectCache = {} as ObjectCacheService; objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService; const notificationsService = {} as NotificationsService;
const http = {} as HttpClient; const http = {} as HttpClient;
@@ -129,7 +141,9 @@ describe('ResourcePolicyService', () => {
halService, halService,
notificationsService, notificationsService,
http, http,
comparator comparator,
ePersonService,
groupService
); );
spyOn((service as any).dataService, 'create').and.callThrough(); spyOn((service as any).dataService, 'create').and.callThrough();
@@ -320,4 +334,17 @@ describe('ResourcePolicyService', () => {
expect(result).toBeObservable(expected); expect(result).toBeObservable(expected);
}); });
}); });
describe('updateTarget', () => {
it('should create a new PUT request for eperson', () => {
const targetType = 'eperson';
const result = service.updateTarget(resourcePolicyId, requestURL, epersonUUID, targetType);
const expected = cold('a|', {
a: resourcePolicyRD
});
expect(result).toBeObservable(expected);
});
});
}); });

View File

@@ -1,6 +1,6 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -23,11 +23,19 @@ import { PaginatedList } from '../data/paginated-list.model';
import { ActionType } from './models/action-type.model'; import { ActionType } from './models/action-type.model';
import { RequestParam } from '../cache/models/request-param.model'; import { RequestParam } from '../cache/models/request-param.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { map } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { NoContent } from '../shared/NoContent.model'; import { NoContent } from '../shared/NoContent.model';
import { getFirstCompletedRemoteData } from '../shared/operators'; import { getFirstCompletedRemoteData } from '../shared/operators';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { PutRequest } from '../data/request.models';
import { GenericConstructor } from '../shared/generic-constructor';
import { ResponseParsingService } from '../data/parsing.service';
import { StatusCodeOnlyResponseParsingService } from '../data/status-code-only-response-parsing.service';
import { HALLink } from '../shared/hal-link.model';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { GroupDataService } from '../eperson/group-data.service';
/** /**
@@ -44,7 +52,8 @@ class DataServiceImpl extends DataService<ResourcePolicy> {
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected http: HttpClient, protected http: HttpClient,
protected comparator: ChangeAnalyzer<ResourcePolicy>) { protected comparator: ChangeAnalyzer<ResourcePolicy>,
) {
super(); super();
} }
@@ -68,7 +77,10 @@ export class ResourcePolicyService {
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected http: HttpClient, protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ResourcePolicy>) { protected comparator: DefaultChangeAnalyzer<ResourcePolicy>,
protected ePersonService: EPersonDataService,
protected groupService: GroupDataService,
) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator);
} }
@@ -221,4 +233,44 @@ export class ResourcePolicyService {
return this.dataService.searchBy(this.searchByResourceMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.dataService.searchBy(this.searchByResourceMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Update the target of the resource policy
* @param resourcePolicyId the ID of the resource policy
* @param resourcePolicyHref the link to the resource policy
* @param targetUUID the UUID of the target to which the permission is being granted
* @param targetType the type of the target (eperson or group) to which the permission is being granted
*/
updateTarget(resourcePolicyId: string, resourcePolicyHref: string, targetUUID: string, targetType: string): Observable<RemoteData<any>> {
const targetService = targetType === 'eperson' ? this.ePersonService : this.groupService;
const targetEndpoint$ = targetService.getBrowseEndpoint().pipe(
take(1),
map((endpoint: string) =>`${endpoint}/${targetUUID}`),
);
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
options.headers = headers;
const requestId = this.requestService.generateRequestId();
this.requestService.setStaleByHrefSubstring(`${this.dataService.getLinkPath()}/${resourcePolicyId}/${targetType}`);
targetEndpoint$.subscribe((targetEndpoint) => {
const resourceEndpoint = resourcePolicyHref + '/' + targetType;
const request = new PutRequest(requestId, resourceEndpoint, targetEndpoint, options);
Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return StatusCodeOnlyResponseParsingService;
}
});
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUID(requestId);
}
} }

View File

@@ -1,24 +0,0 @@
import { throwError as observableThrowError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class ApiService {
constructor(public _http: HttpClient) {
}
/**
* whatever domain/feature method name
*/
get(url: string, options?: any) {
return this._http.get(url, options).pipe(
catchError((err) => {
console.log('Error: ', err);
return observableThrowError(err);
}));
}
}

View File

@@ -0,0 +1,45 @@
import { DOCUMENT } from '@angular/common';
import { Renderer2, RendererFactory2 } from '@angular/core';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { MockProvider } from 'ng-mocks';
import { LinkHeadService } from './link-head.service';
describe('LinkHeadService', () => {
let service: LinkHeadService;
const renderer2: Renderer2 = {
createRenderer: jasmine.createSpy('createRenderer'),
createElement: jasmine.createSpy('createElement'),
setAttribute: jasmine.createSpy('setAttribute'),
appendChild: jasmine.createSpy('appendChild')
} as unknown as Renderer2;
beforeEach(waitForAsync(() => {
return TestBed.configureTestingModule({
providers: [
MockProvider(RendererFactory2, {
createRenderer: () => renderer2
}),
{ provide: Document, useExisting: DOCUMENT },
]
});
}));
beforeEach(() => {
service = new LinkHeadService(TestBed.inject(RendererFactory2), TestBed.inject(DOCUMENT));
});
describe('link', () => {
it('should create a link tag', () => {
const link = service.addTag({
href: 'test',
type: 'application/atom+xml',
rel: 'alternate',
title: 'Sitewide Atom feed'
});
expect(link).not.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,90 @@
import { Injectable, RendererFactory2, ViewEncapsulation, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
/**
* LinkHead Service injects <link> tag into the head element during runtime.
*/
@Injectable()
export class LinkHeadService {
constructor(
private rendererFactory: RendererFactory2,
@Inject(DOCUMENT) private document
) {
}
/**
* Method to create a Link tag in the HEAD of the html.
* @param tag LinkDefition is the paramaters to define a link tag.
* @returns Link tag that was created
*/
addTag(tag: LinkDefinition) {
try {
const renderer = this.rendererFactory.createRenderer(this.document, {
id: '-1',
encapsulation: ViewEncapsulation.None,
styles: [],
data: {}
});
const link = renderer.createElement('link');
const head = this.document.head;
if (head === null) {
throw new Error('<head> not found within DOCUMENT.');
}
Object.keys(tag).forEach((prop: string) => {
return renderer.setAttribute(link, prop, tag[prop]);
});
renderer.appendChild(head, link);
return renderer;
} catch (e) {
console.error('Error within linkService : ', e);
}
}
/**
* Removes a link tag in header based on the given attrSelector.
* @param attrSelector The attr assigned to a link tag which will be used to determine what link to remove.
*/
removeTag(attrSelector: string) {
if (attrSelector) {
try {
const renderer = this.rendererFactory.createRenderer(this.document, {
id: '-1',
encapsulation: ViewEncapsulation.None,
styles: [],
data: {}
});
const head = this.document.head;
if (head === null) {
throw new Error('<head> not found within DOCUMENT.');
}
const linkTags = this.document.querySelectorAll('link[' + attrSelector + ']');
for (const link of linkTags) {
renderer.removeChild(head, link);
}
} catch (e) {
console.log('Error while removing tag ' + e.message);
}
}
}
}
export declare type LinkDefinition = {
charset?: string;
crossorigin?: string;
href?: string;
hreflang?: string;
media?: string;
rel?: string;
rev?: string;
sizes?: string;
target?: string;
type?: string;
} & {
[prop: string]: string;
};

View File

@@ -25,6 +25,13 @@ import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../shared/theme-support/theme.service'; import { ThemeService } from '../../shared/theme-support/theme.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model'; import { FindListOptions } from '../../core/data/find-list-options.model';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { GroupDataService } from '../../core/eperson/group-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub';
describe('TopLevelCommunityList Component', () => { describe('TopLevelCommunityList Component', () => {
let comp: TopLevelCommunityListComponent; let comp: TopLevelCommunityListComponent;
@@ -114,6 +121,25 @@ describe('TopLevelCommunityList Component', () => {
themeService = getMockThemeService(); themeService = getMockThemeService();
const linkHeadService = jasmine.createSpyObj('linkHeadService', {
addTag: ''
});
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
getUUIDFromString: '',
});
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'test',
values: [
'org.dspace.ctask.general.ProfileFormats = test'
]
}))
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
@@ -130,6 +156,10 @@ describe('TopLevelCommunityList Component', () => {
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: SelectableListService, useValue: {} }, { provide: SelectableListService, useValue: {} },
{ provide: ThemeService, useValue: themeService }, { provide: ThemeService, useValue: themeService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -147,7 +147,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme
// Perform the setup actions from above in order and display notifications // Perform the setup actions from above in order and display notifications
removedResponses$.pipe(take(1)).subscribe((responses: RemoteData<NoContent>[]) => { removedResponses$.pipe(take(1)).subscribe((responses: RemoteData<NoContent>[]) => {
this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); this.displayNotifications('item.edit.bitstreams.notifications.remove', responses);
this.reset();
this.submitting = false; this.submitting = false;
}); });
} }
@@ -242,27 +241,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme
); );
} }
/**
* De-cache the current item (it should automatically reload due to itemUpdateSubscription)
*/
reset() {
this.refreshItemCache();
}
/**
* Remove the current item's cache from object- and request-cache
*/
refreshItemCache() {
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
bundles.forEach((bundle: Bundle) => {
this.objectCache.remove(bundle.self);
this.requestService.removeByHrefSubstring(bundle.self);
});
this.objectCache.remove(this.item.self);
this.requestService.removeByHrefSubstring(this.item.self);
});
}
/** /**
* Unsubscribe from open subscriptions whenever the component gets destroyed * Unsubscribe from open subscriptions whenever the component gets destroyed
*/ */

View File

@@ -27,7 +27,7 @@
<a *ngIf="bitstreamDownloadUrl != null" [href]="bitstreamDownloadUrl" <a *ngIf="bitstreamDownloadUrl != null" [href]="bitstreamDownloadUrl"
class="btn btn-outline-primary btn-sm" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}" title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}"
data-test="download-button"> [attr.data-test]="'download-button' | dsBrowserOnly">
<i class="fas fa-download fa-fw"></i> <i class="fas fa-download fa-fw"></i>
</a> </a>
<button [routerLink]="['/bitstreams/', bitstream.id, 'edit']" class="btn btn-outline-primary btn-sm" <button [routerLink]="['/bitstreams/', bitstream.id, 'edit']" class="btn btn-outline-primary btn-sm"

View File

@@ -12,6 +12,7 @@ import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { getBitstreamDownloadRoute } from '../../../../app-routing-paths'; import { getBitstreamDownloadRoute } from '../../../../app-routing-paths';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { BrowserOnlyMockPipe } from '../../../../shared/testing/browser-only-mock.pipe';
let comp: ItemEditBitstreamComponent; let comp: ItemEditBitstreamComponent;
let fixture: ComponentFixture<ItemEditBitstreamComponent>; let fixture: ComponentFixture<ItemEditBitstreamComponent>;
@@ -72,7 +73,11 @@ describe('ItemEditBitstreamComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [ItemEditBitstreamComponent, VarDirective], declarations: [
ItemEditBitstreamComponent,
VarDirective,
BrowserOnlyMockPipe,
],
providers: [ providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService } { provide: ObjectUpdatesService, useValue: objectUpdatesService }
], schemas: [ ], schemas: [

View File

@@ -23,6 +23,14 @@ import { PaginationComponent } from '../../../../shared/pagination/pagination.co
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { RelationshipTypeService } from '../../../../core/data/relationship-type.service'; import { RelationshipTypeService } from '../../../../core/data/relationship-type.service';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
import { LinkHeadService } from '../../../../core/services/link-head.service';
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub';
import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model';
import { Router } from '@angular/router';
import { RouterMock } from '../../../../shared/mocks/router.mock';
let comp: EditRelationshipListComponent; let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>; let fixture: ComponentFixture<EditRelationshipListComponent>;
@@ -174,6 +182,25 @@ describe('EditRelationshipListComponent', () => {
hostWindowService = new HostWindowServiceStub(1200); hostWindowService = new HostWindowServiceStub(1200);
const linkHeadService = jasmine.createSpyObj('linkHeadService', {
addTag: ''
});
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
getUUIDFromString: '',
});
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'test',
values: [
'org.dspace.ctask.general.ProfileFormats = test'
]
}))
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()], imports: [SharedModule, TranslateModule.forRoot()],
declarations: [EditRelationshipListComponent], declarations: [EditRelationshipListComponent],
@@ -185,6 +212,11 @@ describe('EditRelationshipListComponent', () => {
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: HostWindowService, useValue: hostWindowService }, { provide: HostWindowService, useValue: hostWindowService },
{ provide: RelationshipTypeService, useValue: relationshipTypeService }, { provide: RelationshipTypeService, useValue: relationshipTypeService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: Router, useValue: new RouterMock() },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]

View File

@@ -0,0 +1,87 @@
import { ItemPageResolver } from './item-page.resolver';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { DSpaceObject } from '../core/shared/dspace-object.model';
import { MetadataValueFilter } from '../core/shared/metadata.models';
import { first } from 'rxjs/operators';
import { Router } from '@angular/router';
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
describe('ItemPageResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{
path: 'entities/:entity-type/:id',
component: {} as any
}])]
});
});
describe('resolve', () => {
let resolver: ItemPageResolver;
let itemService: any;
let store: any;
let router: any;
const uuid = '1234-65487-12354-1235';
let item: DSpaceObject;
function runTestsWithEntityType(entityType: string) {
beforeEach(() => {
router = TestBed.inject(Router);
item = Object.assign(new DSpaceObject(), {
uuid: uuid,
firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string {
return entityType;
}
});
itemService = {
findById: (_id: string) => createSuccessfulRemoteDataObject$(item)
};
store = jasmine.createSpyObj('store', {
dispatch: {},
});
resolver = new ItemPageResolver(itemService, store, router);
});
it('should redirect to the correct route for the entity type', (done) => {
spyOn(item, 'firstMetadataValue').and.returnValue(entityType);
spyOn(router, 'navigateByUrl').and.callThrough();
resolver.resolve({ params: { id: uuid } } as any, { url: router.parseUrl(`/items/${uuid}`).toString() } as any)
.pipe(first())
.subscribe(
() => {
expect(router.navigateByUrl).toHaveBeenCalledWith(router.parseUrl(`/entities/${entityType}/${uuid}`).toString());
done();
}
);
});
it('should not redirect if were already on the correct route', (done) => {
spyOn(item, 'firstMetadataValue').and.returnValue(entityType);
spyOn(router, 'navigateByUrl').and.callThrough();
resolver.resolve({ params: { id: uuid } } as any, { url: router.parseUrl(`/entities/${entityType}/${uuid}`).toString() } as any)
.pipe(first())
.subscribe(
() => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
done();
}
);
});
}
describe('when normal entity type is provided', () => {
runTestsWithEntityType('publication');
});
describe('when entity type contains a special character', () => {
runTestsWithEntityType('alligator,loki');
runTestsWithEntityType('🐊');
runTestsWithEntityType(' ');
});
});
});

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { ItemDataService } from '../core/data/item-data.service'; import { ItemDataService } from '../core/data/item-data.service';
@@ -35,8 +35,14 @@ export class ItemPageResolver extends ItemResolver {
return super.resolve(route, state).pipe( return super.resolve(route, state).pipe(
map((rd: RemoteData<Item>) => { map((rd: RemoteData<Item>) => {
if (rd.hasSucceeded && hasValue(rd.payload)) { if (rd.hasSucceeded && hasValue(rd.payload)) {
const itemRoute = getItemPageRoute(rd.payload);
const thisRoute = state.url; const thisRoute = state.url;
// Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas
// or semicolons) and thisRoute has been encoded with that function. If we want to compare
// it with itemRoute, we have to run itemRoute through Angular's version as well to ensure
// the same characters are encoded the same way.
const itemRoute = this.router.parseUrl(getItemPageRoute(rd.payload)).toString();
if (!thisRoute.startsWith(itemRoute)) { if (!thisRoute.startsWith(itemRoute)) {
const itemId = rd.payload.uuid; const itemId = rd.payload.uuid;
const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length); const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length);

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VersionedItemComponent } from './versioned-item.component'; import { VersionedItemComponent } from './versioned-item.component';
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { VersionDataService } from '../../../../core/data/version-data.service'; import { VersionDataService } from '../../../../core/data/version-data.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
@@ -19,6 +19,7 @@ import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemDataService } from '../../../../core/data/item-data.service'; import { ItemDataService } from '../../../../core/data/item-data.service';
import { Version } from '../../../../core/shared/version.model'; import { Version } from '../../../../core/shared/version.model';
import { RouteService } from '../../../../core/services/route.service'; import { RouteService } from '../../../../core/services/route.service';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
const mockItem: Item = Object.assign(new Item(), { const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
@@ -57,10 +58,17 @@ describe('VersionedItemComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [VersionedItemComponent, DummyComponent], declarations: [VersionedItemComponent, DummyComponent],
imports: [RouterTestingModule], imports: [
RouterTestingModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
}
}),
],
providers: [ providers: [
{ provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy },
{ provide: TranslateService, useValue: {} },
{ provide: VersionDataService, useValue: versionServiceSpy }, { provide: VersionDataService, useValue: versionServiceSpy },
{ provide: NotificationsService, useValue: {} }, { provide: NotificationsService, useValue: {} },
{ provide: ItemVersionsSharedService, useValue: {} }, { provide: ItemVersionsSharedService, useValue: {} },

View File

@@ -0,0 +1,331 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { MenuResolver } from './menu.resolver';
import { of as observableOf } from 'rxjs';
import { FeatureID } from './core/data/feature-authorization/feature-id';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MenuService } from './shared/menu/menu.service';
import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service';
import { ScriptDataService } from './core/data/processes/script-data.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { MenuID } from './shared/menu/menu-id.model';
import { BrowseService } from './core/browse/browse.service';
import { cold } from 'jasmine-marbles';
import createSpy = jasmine.createSpy;
import { createSuccessfulRemoteDataObject$ } from './shared/remote-data.utils';
import { createPaginatedList } from './shared/testing/utils.test';
const BOOLEAN = { t: true, f: false };
const MENU_STATE = {
id: 'some menu'
};
const BROWSE_DEFINITIONS = [
{ id: 'definition1' },
{ id: 'definition2' },
{ id: 'definition3' },
];
describe('MenuResolver', () => {
let resolver: MenuResolver;
let menuService;
let browseService;
let authorizationService;
let scriptService;
beforeEach(waitForAsync(() => {
menuService = new MenuServiceStub();
spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE));
browseService = jasmine.createSpyObj('browseService', {
getBrowseDefinitions: createSuccessfulRemoteDataObject$(createPaginatedList(BROWSE_DEFINITIONS))
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
scriptService = jasmine.createSpyObj('scriptService', {
scriptWithNameExistsAndCanExecute: observableOf(true)
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule],
declarations: [AdminSidebarComponent],
providers: [
{ provide: MenuService, useValue: menuService },
{ provide: BrowseService, useValue: browseService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ScriptDataService, useValue: scriptService },
{
provide: NgbModal, useValue: {
open: () => {/*comment*/
}
}
}
],
schemas: [NO_ERRORS_SCHEMA]
});
resolver = TestBed.inject(MenuResolver);
spyOn(menuService, 'addSection');
}));
it('should be created', () => {
expect(resolver).toBeTruthy();
});
describe('resolve', () => {
it('should create all menus', (done) => {
spyOn(resolver, 'createPublicMenu$').and.returnValue(observableOf(true));
spyOn(resolver, 'createAdminMenu$').and.returnValue(observableOf(true));
resolver.resolve(null, null).subscribe(resolved => {
expect(resolved).toBeTrue();
expect(resolver.createPublicMenu$).toHaveBeenCalled();
expect(resolver.createAdminMenu$).toHaveBeenCalled();
done();
});
});
it('should return an Observable that emits true as soon as all menus are created', () => {
spyOn(resolver, 'createPublicMenu$').and.returnValue(cold('--(t|)', BOOLEAN));
spyOn(resolver, 'createAdminMenu$').and.returnValue(cold('----(t|)', BOOLEAN));
expect(resolver.resolve(null, null)).toBeObservable(cold('----(t|)', BOOLEAN));
});
});
describe('createPublicMenu$', () => {
it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => {
(menuService as any).getMenu.and.returnValue(cold('--u--m--', {
u: undefined,
m: MENU_STATE,
}));
expect(resolver.createPublicMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN));
expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.PUBLIC);
});
describe('contents', () => {
beforeEach((done) => {
resolver.createPublicMenu$().subscribe((_) => {
done();
});
});
it('should include community list link', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({
id: 'browse_global_communities_and_collections', visible: true,
}));
});
it('should include browse dropdown', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({
id: 'browse_global_by_definition1', parentID: 'browse_global', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({
id: 'browse_global_by_definition2', parentID: 'browse_global', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({
id: 'browse_global_by_definition3', parentID: 'browse_global', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, jasmine.objectContaining({
id: 'browse_global', visible: true,
}));
});
});
});
describe('createAdminMenu$', () => {
it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => {
(menuService as any).getMenu.and.returnValue(cold('--u--m', {
u: undefined,
m: MENU_STATE,
}));
expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN));
expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN);
});
describe('for regular user', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => {
return observableOf(false);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should not show site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'admin_search', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
parentID: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'curation_tasks', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'workflow', visible: false,
}));
});
it('should not show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
});
it('should not show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
});
it('should not show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'access_control', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
parentID: 'access_control', visible: false,
}));
});
// We check that the menu section has not been called with visible set to true
// The reason why we don't check if it has been called with visible set to false
// Is because the function does not get called unless a user is authorised
it('should not show the import section', () => {
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import', visible: true,
}));
});
// We check that the menu section has not been called with visible set to true
// The reason why we don't check if it has been called with visible set to false
// Is because the function does not get called unless a user is authorised
it('should not show the export section', () => {
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true,
}));
});
});
describe('for site admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.AdministratorOf);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should contain site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'admin_search', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
parentID: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'curation_tasks', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true,
}));
});
});
describe('for community admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCommunityAdmin);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_community', visible: true,
}));
});
});
describe('for collection admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCollectionAdmin);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_collection', visible: true,
}));
});
});
describe('for group admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanManageGroups);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'access_control', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
parentID: 'access_control', visible: true,
}));
});
});
});
});

627
src/app/menu.resolver.ts Normal file
View File

@@ -0,0 +1,627 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { combineLatest as observableCombineLatest, combineLatest, Observable } from 'rxjs';
import { MenuID } from './shared/menu/menu-id.model';
import { MenuState } from './shared/menu/menu-state.model';
import { MenuItemType } from './shared/menu/menu-item-type.model';
import { LinkMenuItemModel } from './shared/menu/menu-item/models/link.model';
import { getFirstCompletedRemoteData } from './core/shared/operators';
import { PaginatedList } from './core/data/paginated-list.model';
import { BrowseDefinition } from './core/shared/browse-definition.model';
import { RemoteData } from './core/data/remote-data';
import { TextMenuItemModel } from './shared/menu/menu-item/models/text.model';
import { BrowseService } from './core/browse/browse.service';
import { MenuService } from './shared/menu/menu.service';
import { filter, find, map, take } from 'rxjs/operators';
import { hasValue } from './shared/empty.util';
import { FeatureID } from './core/data/feature-authorization/feature-id';
import { CreateCommunityParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
import { OnClickMenuItemModel } from './shared/menu/menu-item/models/onclick.model';
import { CreateCollectionParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
import { CreateItemParentSelectorComponent } from './shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
import { EditCommunitySelectorComponent } from './shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
import { EditCollectionSelectorComponent } from './shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-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 { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
METADATA_EXPORT_SCRIPT_NAME, METADATA_IMPORT_SCRIPT_NAME, ScriptDataService
} from './core/data/processes/script-data.service';
/**
* Creates all of the app's menus
*/
@Injectable({
providedIn: 'root'
})
export class MenuResolver implements Resolve<boolean> {
constructor(
protected menuService: MenuService,
protected browseService: BrowseService,
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
protected scriptDataService: ScriptDataService,
) {
}
/**
* Initialize all menus
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
return combineLatest([
this.createPublicMenu$(),
this.createAdminMenu$(),
]).pipe(
map((menusDone: boolean[]) => menusDone.every(Boolean)),
);
}
/**
* Wait for a specific menu to appear
* @param id the ID of the menu to wait for
* @return an Observable that emits true as soon as the menu is created
*/
protected waitForMenu$(id: MenuID): Observable<boolean> {
return this.menuService.getMenu(id).pipe(
find((menu: MenuState) => hasValue(menu)),
map(() => true),
);
}
/**
* Initialize all menu sections and items for {@link MenuID.PUBLIC}
*/
createPublicMenu$(): Observable<boolean> {
const menuList: any[] = [
/* Communities & Collections tree */
{
id: `browse_global_communities_and_collections`,
active: false,
visible: true,
index: 0,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_communities_and_collections`,
link: `/community-list`
} as LinkMenuItemModel
}
];
// Read the different Browse-By types from config and add them to the browse menu
this.browseService.getBrowseDefinitions()
.pipe(getFirstCompletedRemoteData<PaginatedList<BrowseDefinition>>())
.subscribe((browseDefListRD: RemoteData<PaginatedList<BrowseDefinition>>) => {
if (browseDefListRD.hasSucceeded) {
browseDefListRD.payload.page.forEach((browseDef: BrowseDefinition) => {
menuList.push({
id: `browse_global_by_${browseDef.id}`,
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_by_${browseDef.id}`,
link: `/browse/${browseDef.id}`
} as LinkMenuItemModel
});
});
menuList.push(
/* Browse */
{
id: 'browse_global',
active: false,
visible: true,
index: 1,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global'
} as TextMenuItemModel,
}
);
}
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.PUBLIC, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
return this.waitForMenu$(MenuID.PUBLIC);
}
/**
* Initialize all menu sections and items for {@link MenuID.ADMIN}
*/
createAdminMenu$() {
this.createMainMenuSections();
this.createSiteAdministratorMenuSections();
this.createExportMenuSections();
this.createImportMenuSections();
this.createAccessControlMenuSections();
return this.waitForMenu$(MenuID.ADMIN);
}
/**
* Initialize the main menu sections.
* edit_community / edit_collection is only included if the current user is a Community or Collection admin
*/
createMainMenuSections() {
combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf)
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => {
const menuList = [
/* News */
{
id: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
index: 0
},
{
id: 'new_community',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_process',
parentID: 'new',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new'
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'new_item_version',
// parentID: 'new',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.new_item_version',
// link: ''
// } as LinkMenuItemModel,
// },
/* Edit */
{
id: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit'
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1
},
{
id: 'edit_community',
parentID: 'edit',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/* Statistics */
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'statistics_task',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.statistics_task',
// link: ''
// } as LinkMenuItemModel,
// icon: 'chart-bar',
// index: 8
// },
/* Control Panel */
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'control_panel',
// active: false,
// visible: isSiteAdmin,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.control_panel',
// link: ''
// } as LinkMenuItemModel,
// icon: 'cogs',
// index: 9
// },
/* Processes */
{
id: 'processes',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.processes',
link: '/processes'
} as LinkMenuItemModel,
icon: 'terminal',
index: 10
},
];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
* 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 = [
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_community',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_community',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_collection',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_collection',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// 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(MenuID.ADMIN, menuSection));
observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME)
]).pipe(
filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists),
take(1)
).subscribe(() => {
// Hides the export menu for unauthorised people
// If in the future more sub-menus are added,
// it should be reviewed if they need to be in this subscribe
this.menuService.addSection(MenuID.ADMIN, {
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'file-export',
index: 3,
shouldPersistOnRouteChange: true
});
this.menuService.addSection(MenuID.ADMIN, {
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 = [
// TODO: enable this menu item once the feature has been implemented
// {
// 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(MenuID.ADMIN, menuSection));
observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME)
]).pipe(
filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists),
take(1)
).subscribe(() => {
// Hides the import menu for unauthorised people
// If in the future more sub-menus are added,
// it should be reviewed if they need to be in this subscribe
this.menuService.addSection(MenuID.ADMIN, {
id: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import'
} as TextMenuItemModel,
icon: 'file-import',
index: 2,
shouldPersistOnRouteChange: true,
});
this.menuService.addSection(MenuID.ADMIN, {
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
*/
createSiteAdministratorMenuSections() {
this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => {
const menuList = [
/* Admin Search */
{
id: 'admin_search',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search'
} as LinkMenuItemModel,
icon: 'search',
index: 5
},
/* Registries */
{
id: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries'
} as TextMenuItemModel,
icon: 'list',
index: 6
},
{
id: 'registries_metadata',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata'
} as LinkMenuItemModel,
},
{
id: 'registries_format',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats'
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: 'admin/curation-tasks'
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Workflow */
{
id: 'workflow',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.workflow',
link: '/admin/workflow'
} as LinkMenuItemModel,
icon: 'user-check',
index: 11
},
];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
* Create menu sections dependent on whether or not the current user can manage access control groups
*/
createAccessControlMenuSections() {
observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanManageGroups)
]).subscribe(([isSiteAdmin, canManageGroups]) => {
const menuList = [
/* Access Control */
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: canManageGroups,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/access-control/groups'
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'access_control_authorizations',
// parentID: 'access_control',
// active: false,
// visible: authorized,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.access_control_authorizations',
// link: ''
// } as LinkMenuItemModel,
// },
{
id: 'access_control',
active: false,
visible: canManageGroups || isSiteAdmin,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {
shouldPersistOnRouteChange: true,
})));
});
}
}

View File

@@ -11,6 +11,7 @@
<button class="btn btn-lg btn-outline-primary mt-1 ml-2" id="dropdownImport" ngbDropdownToggle <button class="btn btn-lg btn-outline-primary mt-1 ml-2" id="dropdownImport" ngbDropdownToggle
type="button" [disabled]="!(initialized$|async)" type="button" [disabled]="!(initialized$|async)"
attr.aria-label="{{'mydspace.new-submission-external' | translate}}" attr.aria-label="{{'mydspace.new-submission-external' | translate}}"
[attr.data-test]="'import-dropdown' | dsBrowserOnly"
title="{{'mydspace.new-submission-external' | translate}}"> title="{{'mydspace.new-submission-external' | translate}}">
<i class="fa fa-file-import" aria-hidden="true"></i> <i class="fa fa-file-import" aria-hidden="true"></i>
<span class="caret"></span> <span class="caret"></span>

View File

@@ -13,6 +13,7 @@ import { ResourceType } from '../../../core/shared/resource-type';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
import { RouterStub } from '../../../shared/testing/router.stub'; import { RouterStub } from '../../../shared/testing/router.stub';
import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe';
export function getMockEntityTypeService(): EntityTypeService { export function getMockEntityTypeService(): EntityTypeService {
const pageInfo = { elementsPerPage: 20, totalElements: 4, totalPages: 1, currentPage: 0 } as PageInfo; const pageInfo = { elementsPerPage: 20, totalElements: 4, totalPages: 1, currentPage: 0 } as PageInfo;
@@ -83,7 +84,8 @@ describe('MyDSpaceNewExternalDropdownComponent test', () => {
], ],
declarations: [ declarations: [
MyDSpaceNewExternalDropdownComponent, MyDSpaceNewExternalDropdownComponent,
TestComponent TestComponent,
BrowserOnlyMockPipe
], ],
providers: [ providers: [
{ provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() }, { provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() },
@@ -134,7 +136,8 @@ describe('MyDSpaceNewExternalDropdownComponent test', () => {
], ],
declarations: [ declarations: [
MyDSpaceNewExternalDropdownComponent, MyDSpaceNewExternalDropdownComponent,
TestComponent TestComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: EntityTypeService, useValue: getMockEntityTypeService() }, { provide: EntityTypeService, useValue: getMockEntityTypeService() },

View File

@@ -9,6 +9,7 @@
<button class="btn btn-lg btn-primary mt-1 ml-2" id="dropdownSubmission" ngbDropdownToggle <button class="btn btn-lg btn-primary mt-1 ml-2" id="dropdownSubmission" ngbDropdownToggle
type="button" [disabled]="!(initialized$|async)" type="button" [disabled]="!(initialized$|async)"
attr.aria-label="{{'mydspace.new-submission' | translate}}" attr.aria-label="{{'mydspace.new-submission' | translate}}"
[attr.data-test]="'submission-dropdown' | dsBrowserOnly"
title="{{'mydspace.new-submission' | translate}}"> title="{{'mydspace.new-submission' | translate}}">
<i class="fa fa-plus-circle" aria-hidden="true"></i> <i class="fa fa-plus-circle" aria-hidden="true"></i>
<span class="caret"></span> <span class="caret"></span>

View File

@@ -12,6 +12,7 @@ import { ItemType } from '../../../core/shared/item-relationships/item-type.mode
import { ResourceType } from '../../../core/shared/resource-type'; import { ResourceType } from '../../../core/shared/resource-type';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe';
export function getMockEntityTypeService(): EntityTypeService { export function getMockEntityTypeService(): EntityTypeService {
const type1: ItemType = { const type1: ItemType = {
@@ -87,7 +88,8 @@ describe('MyDSpaceNewSubmissionDropdownComponent test', () => {
], ],
declarations: [ declarations: [
MyDSpaceNewSubmissionDropdownComponent, MyDSpaceNewSubmissionDropdownComponent,
TestComponent TestComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() }, { provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() },
@@ -138,7 +140,8 @@ describe('MyDSpaceNewSubmissionDropdownComponent test', () => {
], ],
declarations: [ declarations: [
MyDSpaceNewSubmissionDropdownComponent, MyDSpaceNewSubmissionDropdownComponent,
TestComponent TestComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: EntityTypeService, useValue: getMockEntityTypeService() }, { provide: EntityTypeService, useValue: getMockEntityTypeService() },

View File

@@ -2,18 +2,11 @@ import { Component, Injector } from '@angular/core';
import { slideMobileNav } from '../shared/animations/slide'; import { slideMobileNav } from '../shared/animations/slide';
import { MenuComponent } from '../shared/menu/menu.component'; import { MenuComponent } from '../shared/menu/menu.component';
import { MenuService } from '../shared/menu/menu.service'; import { MenuService } from '../shared/menu/menu.service';
import { TextMenuItemModel } from '../shared/menu/menu-item/models/text.model';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
import { BrowseService } from '../core/browse/browse.service'; import { BrowseService } from '../core/browse/browse.service';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { PaginatedList } from '../core/data/paginated-list.model';
import { BrowseDefinition } from '../core/shared/browse-definition.model';
import { RemoteData } from '../core/data/remote-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { MenuID } from '../shared/menu/menu-id.model'; import { MenuID } from '../shared/menu/menu-id.model';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { ThemeService } from '../shared/theme-support/theme.service'; import { ThemeService } from '../shared/theme-support/theme.service';
/** /**
@@ -44,64 +37,6 @@ export class NavbarComponent extends MenuComponent {
} }
ngOnInit(): void { ngOnInit(): void {
this.createMenu();
super.ngOnInit(); super.ngOnInit();
} }
/**
* Initialize all menu sections and items for this menu
*/
createMenu() {
const menuList: any[] = [
/* Communities & Collections tree */
{
id: `browse_global_communities_and_collections`,
active: false,
visible: true,
index: 0,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_communities_and_collections`,
link: `/community-list`
} as LinkMenuItemModel
}
];
// Read the different Browse-By types from config and add them to the browse menu
this.browseService.getBrowseDefinitions()
.pipe(getFirstCompletedRemoteData<PaginatedList<BrowseDefinition>>())
.subscribe((browseDefListRD: RemoteData<PaginatedList<BrowseDefinition>>) => {
if (browseDefListRD.hasSucceeded) {
browseDefListRD.payload.page.forEach((browseDef: BrowseDefinition) => {
menuList.push({
id: `browse_global_by_${browseDef.id}`,
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_by_${browseDef.id}`,
link: `/browse/${browseDef.id}`
} as LinkMenuItemModel
});
});
menuList.push(
/* Browse */
{
id: 'browse_global',
active: false,
visible: true,
index: 1,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global'
} as TextMenuItemModel,
}
);
}
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
} }

View File

@@ -22,12 +22,21 @@
</div> </div>
<ng-container *ngVar="(groupsRD$ | async)?.payload?.page as groups"> <ng-container *ngVar="(groupsRD$ | async)?.payload?.page as groups">
<div *ngIf="groups"> <div *ngIf="groups?.length > 0">
<h3 class="mt-4">{{'profile.groups.head' | translate}}</h3> <h3 class="mt-4">{{'profile.groups.head' | translate}}</h3>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li *ngFor="let group of groups" class="list-group-item">{{group.name}}</li> <li *ngFor="let group of groups" class="list-group-item">{{group.name}}</li>
</ul> </ul>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngVar="(specialGroupsRD$ | async)?.payload?.page as specialGroups">
<div *ngIf="specialGroups?.length > 0" data-test="specialGroups">
<h3 class="mt-4">{{'profile.special.groups.head' | translate}}</h3>
<ul class="list-group list-group-flush">
<li *ngFor="let specialGroup of specialGroups" class="list-group-item">{{specialGroup.name}}</li>
</ul>
</div>
</ng-container>
</div> </div>
</ng-container> </ng-container>

View File

@@ -20,6 +20,7 @@ import { provideMockStore } from '@ngrx/store/testing';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { getTestScheduler } from 'jasmine-marbles'; import { getTestScheduler } from 'jasmine-marbles';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { EmptySpecialGroupDataMock$, SpecialGroupDataMock$ } from '../shared/testing/special-group.mock';
describe('ProfilePageComponent', () => { describe('ProfilePageComponent', () => {
let component: ProfilePageComponent; let component: ProfilePageComponent;
@@ -54,7 +55,8 @@ describe('ProfilePageComponent', () => {
}; };
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
getAuthenticatedUserFromStore: observableOf(user) getAuthenticatedUserFromStore: observableOf(user),
getSpecialGroupsFromAuthStatus: SpecialGroupDataMock$
}); });
epersonService = jasmine.createSpyObj('epersonService', { epersonService = jasmine.createSpyObj('epersonService', {
findById: createSuccessfulRemoteDataObject$(user), findById: createSuccessfulRemoteDataObject$(user),
@@ -235,4 +237,25 @@ describe('ProfilePageComponent', () => {
}); });
}); });
}); });
describe('check for specialGroups', () => {
it('should contains specialGroups list', () => {
const specialGroupsEle = fixture.debugElement.query(By.css('[data-test="specialGroups"]'));
expect(specialGroupsEle).toBeTruthy();
});
it('should not contains specialGroups list', () => {
component.specialGroupsRD$ = null;
fixture.detectChanges();
const specialGroupsEle = fixture.debugElement.query(By.css('[data-test="specialGroups"]'));
expect(specialGroupsEle).toBeFalsy();
});
it('should not contains specialGroups list', () => {
component.specialGroupsRD$ = EmptySpecialGroupDataMock$;
fixture.detectChanges();
const specialGroupsEle = fixture.debugElement.query(By.css('[data-test="specialGroups"]'));
expect(specialGroupsEle).toBeFalsy();
});
});
}); });

View File

@@ -9,11 +9,7 @@ import { RemoteData } from '../core/data/remote-data';
import { PaginatedList } from '../core/data/paginated-list.model'; import { PaginatedList } from '../core/data/paginated-list.model';
import { filter, switchMap, tap } from 'rxjs/operators'; import { filter, switchMap, tap } from 'rxjs/operators';
import { EPersonDataService } from '../core/eperson/eperson-data.service'; import { EPersonDataService } from '../core/eperson/eperson-data.service';
import { import { getAllSucceededRemoteData, getFirstCompletedRemoteData, getRemoteDataPayload } from '../core/shared/operators';
getAllSucceededRemoteData,
getRemoteDataPayload,
getFirstCompletedRemoteData
} from '../core/shared/operators';
import { hasValue, isNotEmpty } from '../shared/empty.util'; import { hasValue, isNotEmpty } from '../shared/empty.util';
import { followLink } from '../shared/utils/follow-link-config.model'; import { followLink } from '../shared/utils/follow-link-config.model';
import { AuthService } from '../core/auth/auth.service'; import { AuthService } from '../core/auth/auth.service';
@@ -45,6 +41,11 @@ export class ProfilePageComponent implements OnInit {
*/ */
groupsRD$: Observable<RemoteData<PaginatedList<Group>>>; groupsRD$: Observable<RemoteData<PaginatedList<Group>>>;
/**
* The special groups the user belongs to
*/
specialGroupsRD$: Observable<RemoteData<PaginatedList<Group>>>;
/** /**
* Prefix for the notification messages of this component * Prefix for the notification messages of this component
*/ */
@@ -88,6 +89,7 @@ export class ProfilePageComponent implements OnInit {
); );
this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups));
this.canChangePassword$ = this.user$.pipe(switchMap((user: EPerson) => this.authorizationService.isAuthorized(FeatureID.CanChangePassword, user._links.self.href))); this.canChangePassword$ = this.user$.pipe(switchMap((user: EPerson) => this.authorizationService.isAuthorized(FeatureID.CanChangePassword, user._links.self.href)));
this.specialGroupsRD$ = this.authService.getSpecialGroupsFromAuthStatus();
} }
/** /**

100
src/app/root.module.ts Normal file
View File

@@ -0,0 +1,100 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {
AdminSidebarSectionComponent
} from './admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component';
import { ThemedAdminSidebarComponent } from './admin/admin-sidebar/themed-admin-sidebar.component';
import {
ExpandableAdminSidebarSectionComponent
} from './admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { FooterComponent } from './footer/footer.component';
import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component';
import { HeaderComponent } from './header/header.component';
import { NavbarModule } from './navbar/navbar.module';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { NotificationComponent } from './shared/notifications/notification/notification.component';
import {
NotificationsBoardComponent
} from './shared/notifications/notifications-board/notifications-board.component';
import { SharedModule } from './shared/shared.module';
import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
import { ForbiddenComponent } from './forbidden/forbidden.component';
import { RootComponent } from './root/root.component';
import { ThemedRootComponent } from './root/themed-root.component';
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
import { ThemedHeaderComponent } from './header/themed-header.component';
import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import {
ThemedHeaderNavbarWrapperComponent
} from './header-nav-wrapper/themed-header-navbar-wrapper.component';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import {
ThemedPageInternalServerErrorComponent
} from './page-internal-server-error/themed-page-internal-server-error.component';
import {
PageInternalServerErrorComponent
} from './page-internal-server-error/page-internal-server-error.component';
const IMPORTS = [
CommonModule,
SharedModule.withEntryComponents(),
NavbarModule,
NgbModule,
];
const PROVIDERS = [
];
const DECLARATIONS = [
RootComponent,
ThemedRootComponent,
HeaderComponent,
ThemedHeaderComponent,
HeaderNavbarWrapperComponent,
ThemedHeaderNavbarWrapperComponent,
AdminSidebarComponent,
ThemedAdminSidebarComponent,
AdminSidebarSectionComponent,
ExpandableAdminSidebarSectionComponent,
FooterComponent,
ThemedFooterComponent,
PageNotFoundComponent,
ThemedPageNotFoundComponent,
NotificationComponent,
NotificationsBoardComponent,
BreadcrumbsComponent,
ThemedBreadcrumbsComponent,
ForbiddenComponent,
ThemedForbiddenComponent,
IdleModalComponent,
ThemedPageInternalServerErrorComponent,
PageInternalServerErrorComponent
];
const EXPORTS = [
];
@NgModule({
imports: [
...IMPORTS
],
providers: [
...PROVIDERS
],
declarations: [
...DECLARATIONS,
],
exports: [
...EXPORTS,
...DECLARATIONS,
]
})
export class RootModule {
}

View File

@@ -3,8 +3,8 @@
<form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on"> <form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on">
<input #searchInput [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query" <input #searchInput [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query"
formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}" formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}"
class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-right p-1" data-test="header-search-box"> class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-right p-1" [attr.data-test]="'header-search-box' | dsBrowserOnly">
<a class="submit-icon" [routerLink]="" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()" data-test="header-search-icon"> <a class="submit-icon" [routerLink]="" (click)="searchExpanded ? onSubmit(searchForm.value) : expand()" [attr.data-test]="'header-search-icon' | dsBrowserOnly">
<em class="fas fa-search fa-lg fa-fw"></em> <em class="fas fa-search fa-lg fa-fw"></em>
</a> </a>
</form> </form>

View File

@@ -10,6 +10,7 @@ import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
import { SearchNavbarComponent } from './search-navbar.component'; import { SearchNavbarComponent } from './search-navbar.component';
import { PaginationServiceStub } from '../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../shared/testing/pagination-service.stub';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { BrowserOnlyMockPipe } from '../shared/testing/browser-only-mock.pipe';
describe('SearchNavbarComponent', () => { describe('SearchNavbarComponent', () => {
let component: SearchNavbarComponent; let component: SearchNavbarComponent;
@@ -44,7 +45,10 @@ describe('SearchNavbarComponent', () => {
useClass: TranslateLoaderMock useClass: TranslateLoaderMock
} }
})], })],
declarations: [SearchNavbarComponent], declarations: [
SearchNavbarComponent,
BrowserOnlyMockPipe,
],
providers: [ providers: [
{ provide: SearchService, useValue: mockSearchService } { provide: SearchService, useValue: mockSearchService }
] ]

View File

@@ -1,8 +1,8 @@
<ul class="navbar-nav" [ngClass]="{'mr-auto': (isXsOrSm$ | async)}"> <ul class="navbar-nav" [ngClass]="{'mr-auto': (isXsOrSm$ | async)}">
<li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item" <li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"
(click)="$event.stopPropagation();"> (click)="$event.stopPropagation();">
<div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" data-test="login-menu" @fadeInOut> <div ngbDropdown #loginDrop display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);" class="dropdownLogin px-1 " [attr.aria-label]="'nav.login' |translate" (click)="$event.preventDefault()" ngbDropdownToggle> <a href="javascript:void(0);" class="dropdownLogin px-1 " [attr.aria-label]="'nav.login' |translate" (click)="$event.preventDefault()" [attr.data-test]="'login-menu' | dsBrowserOnly" ngbDropdownToggle>
{{ 'nav.login' | translate }} {{ 'nav.login' | translate }}
</a> </a>
<div class="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu <div class="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu
@@ -17,9 +17,9 @@
{{ 'nav.login' | translate }}<span class="sr-only">(current)</span> {{ 'nav.login' | translate }}<span class="sr-only">(current)</span>
</a> </a>
</li> </li>
<li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item" data-test="user-menu"> <li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut> <div ngbDropdown display="dynamic" placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="javascript:void(0);" role="button" [attr.aria-label]="'nav.logout' |translate" (click)="$event.preventDefault()" [title]="'nav.logout' | translate" class="px-1" ngbDropdownToggle> <a href="javascript:void(0);" role="button" [attr.aria-label]="'nav.logout' |translate" (click)="$event.preventDefault()" [title]="'nav.logout' | translate" class="px-1" [attr.data-test]="'user-menu' | dsBrowserOnly" ngbDropdownToggle>
<i class="fas fa-user-circle fa-lg fa-fw"></i></a> <i class="fas fa-user-circle fa-lg fa-fw"></i></a>
<div class="logoutDropdownMenu" ngbDropdownMenu [attr.aria-label]="'nav.logout' |translate"> <div class="logoutDropdownMenu" ngbDropdownMenu [attr.aria-label]="'nav.logout' |translate">
<ds-user-menu></ds-user-menu> <ds-user-menu></ds-user-menu>

View File

@@ -15,6 +15,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe';
describe('AuthNavMenuComponent', () => { describe('AuthNavMenuComponent', () => {
@@ -77,7 +78,8 @@ describe('AuthNavMenuComponent', () => {
TranslateModule.forRoot() TranslateModule.forRoot()
], ],
declarations: [ declarations: [
AuthNavMenuComponent AuthNavMenuComponent,
BrowserOnlyMockPipe
], ],
providers: [ providers: [
{ provide: HostWindowService, useValue: window }, { provide: HostWindowService, useValue: window },

View File

@@ -17,7 +17,6 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../testing/pagination-service.stub'; import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model';
import { import {
ListableObjectComponentLoaderComponent ListableObjectComponentLoaderComponent
} from '../object-collection/shared/listable-object/listable-object-component-loader.component'; } from '../object-collection/shared/listable-object/listable-object-component-loader.component';
@@ -37,7 +36,13 @@ import { HostWindowServiceStub } from '../testing/host-window-service.stub';
import { HostWindowService } from '../host-window.service'; import { HostWindowService } from '../host-window.service';
import { RouteService } from '../../core/services/route.service'; import { RouteService } from '../../core/services/route.service';
import { routeServiceStub } from '../testing/route-service.stub'; import { routeServiceStub } from '../testing/route-service.stub';
import SpyObj = jasmine.SpyObj; import { GroupDataService } from '../../core/eperson/group-data.service';
import { createPaginatedList } from '../testing/utils.test';
import { LinkHeadService } from '../../core/services/link-head.service';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { SearchConfigurationServiceStub } from '../testing/search-configuration-service.stub';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { getMockThemeService } from '../mocks/theme-service.mock'; import { getMockThemeService } from '../mocks/theme-service.mock';
@listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom') @listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom')
@@ -74,6 +79,25 @@ describe('BrowseByComponent', () => {
]; ];
const mockItemsRD$ = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockItems)); const mockItemsRD$ = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockItems));
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
getUUIDFromString: '',
});
const linkHeadService = jasmine.createSpyObj('linkHeadService', {
addTag: ''
});
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'test',
values: [
'org.dspace.ctask.general.ProfileFormats = test'
]
}))
});
const paginationConfig = Object.assign(new PaginationComponentOptions(), { const paginationConfig = Object.assign(new PaginationComponentOptions(), {
id: 'test-pagination', id: 'test-pagination',
currentPage: 1, currentPage: 1,
@@ -101,6 +125,10 @@ describe('BrowseByComponent', () => {
], ],
declarations: [], declarations: [],
providers: [ providers: [
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: MockThemedBrowseEntryListElementComponent }, { provide: MockThemedBrowseEntryListElementComponent },
{ provide: ThemeService, useValue: themeService }, { provide: ThemeService, useValue: themeService },

View File

@@ -55,6 +55,9 @@ describe('ComColFormComponent', () => {
}) })
]; ];
const logo = {
id: 'logo'
};
const logoEndpoint = 'rest/api/logo/endpoint'; const logoEndpoint = 'rest/api/logo/endpoint';
const dsoService = Object.assign({ const dsoService = Object.assign({
getLogoEndpoint: () => observableOf(logoEndpoint), getLogoEndpoint: () => observableOf(logoEndpoint),
@@ -207,7 +210,7 @@ describe('ComColFormComponent', () => {
beforeEach(() => { beforeEach(() => {
initComponent(Object.assign(new Community(), { initComponent(Object.assign(new Community(), {
id: 'community-id', id: 'community-id',
logo: createSuccessfulRemoteDataObject$({}), logo: createSuccessfulRemoteDataObject$(logo),
_links: { _links: {
self: { href: 'community-self' }, self: { href: 'community-self' },
logo: { href: 'community-logo' }, logo: { href: 'community-logo' },
@@ -225,28 +228,31 @@ describe('ComColFormComponent', () => {
describe('submit with logo marked for deletion', () => { describe('submit with logo marked for deletion', () => {
beforeEach(() => { beforeEach(() => {
spyOn(dsoService, 'deleteLogo').and.callThrough();
comp.markLogoForDeletion = true; comp.markLogoForDeletion = true;
}); });
it('should call dsoService.deleteLogo on the DSO', () => {
comp.onSubmit();
fixture.detectChanges();
expect(dsoService.deleteLogo).toHaveBeenCalledWith(comp.dso);
});
describe('when dsoService.deleteLogo returns a successful response', () => { describe('when dsoService.deleteLogo returns a successful response', () => {
beforeEach(() => { beforeEach(() => {
spyOn(dsoService, 'deleteLogo').and.returnValue(createSuccessfulRemoteDataObject$({})); dsoService.deleteLogo.and.returnValue(createSuccessfulRemoteDataObject$({}));
comp.onSubmit(); comp.onSubmit();
}); });
it('should display a success notification', () => { it('should display a success notification', () => {
expect(notificationsService.success).toHaveBeenCalled(); expect(notificationsService.success).toHaveBeenCalled();
}); });
it('should remove the object\'s cache', () => {
expect(requestServiceStub.removeByHrefSubstring).toHaveBeenCalled();
expect(objectCacheStub.remove).toHaveBeenCalled();
});
}); });
describe('when dsoService.deleteLogo returns an error response', () => { describe('when dsoService.deleteLogo returns an error response', () => {
beforeEach(() => { beforeEach(() => {
spyOn(dsoService, 'deleteLogo').and.returnValue(createFailedRemoteDataObject$('Error', 500)); dsoService.deleteLogo.and.returnValue(createFailedRemoteDataObject$('Error', 500));
comp.onSubmit(); comp.onSubmit();
}); });

View File

@@ -184,7 +184,6 @@ export class ComColFormComponent<T extends Collection | Community> implements On
} }
this.dso.logo = undefined; this.dso.logo = undefined;
this.uploadFilesOptions.method = RestRequestMethod.POST; this.uploadFilesOptions.method = RestRequestMethod.POST;
this.refreshCache();
this.finish.emit(); this.finish.emit();
}); });
} }

View File

@@ -68,7 +68,6 @@ describe('DeleteComColPageComponent', () => {
{ {
delete: createNoContentRemoteDataObject$(), delete: createNoContentRemoteDataObject$(),
findByHref: jasmine.createSpy('findByHref'), findByHref: jasmine.createSpy('findByHref'),
refreshCache: jasmine.createSpy('refreshCache')
}); });
routerStub = { routerStub = {
@@ -79,10 +78,6 @@ describe('DeleteComColPageComponent', () => {
data: observableOf(community) data: observableOf(community)
}; };
requestServiceStub = jasmine.createSpyObj('RequestService', {
removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring')
});
translateServiceStub = jasmine.createSpyObj('TranslateService', { translateServiceStub = jasmine.createSpyObj('TranslateService', {
instant: jasmine.createSpy('instant') instant: jasmine.createSpy('instant')
}); });
@@ -99,7 +94,6 @@ describe('DeleteComColPageComponent', () => {
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: TranslateService, useValue: translateServiceStub }, { provide: TranslateService, useValue: translateServiceStub },
{ provide: RequestService, useValue: requestServiceStub }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -159,7 +153,6 @@ describe('DeleteComColPageComponent', () => {
scheduler.flush(); scheduler.flush();
fixture.detectChanges(); fixture.detectChanges();
expect(notificationsService.error).toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled();
expect(dsoDataService.refreshCache).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalled();
}); });
@@ -169,7 +162,6 @@ describe('DeleteComColPageComponent', () => {
scheduler.flush(); scheduler.flush();
fixture.detectChanges(); fixture.detectChanges();
expect(notificationsService.success).toHaveBeenCalled(); expect(notificationsService.success).toHaveBeenCalled();
expect(dsoDataService.refreshCache).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalled();
}); });

View File

@@ -7,7 +7,6 @@ import { NotificationsService } from '../../../notifications/notifications.servi
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
import { NoContent } from '../../../../core/shared/NoContent.model'; import { NoContent } from '../../../../core/shared/NoContent.model';
import { RequestService } from '../../../../core/data/request.service';
import { ComColDataService } from '../../../../core/data/comcol-data.service'; import { ComColDataService } from '../../../../core/data/comcol-data.service';
import { Community } from '../../../../core/shared/community.model'; import { Community } from '../../../../core/shared/community.model';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
@@ -41,7 +40,6 @@ export class DeleteComColPageComponent<TDomain extends Community | Collection> i
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected notifications: NotificationsService, protected notifications: NotificationsService,
protected translate: TranslateService, protected translate: TranslateService,
protected requestService: RequestService
) { ) {
} }
@@ -61,7 +59,6 @@ export class DeleteComColPageComponent<TDomain extends Community | Collection> i
if (response.hasSucceeded) { if (response.hasSucceeded) {
const successMessage = this.translate.instant((dso as any).type + '.delete.notification.success'); const successMessage = this.translate.instant((dso as any).type + '.delete.notification.success');
this.notifications.success(successMessage); this.notifications.success(successMessage);
this.dsoDataService.refreshCache(dso);
} else { } else {
const errorMessage = this.translate.instant((dso as any).type + '.delete.notification.fail'); const errorMessage = this.translate.instant((dso as any).type + '.delete.notification.fail');
this.notifications.error(errorMessage); this.notifications.error(errorMessage);

View File

@@ -4,7 +4,7 @@
*ngVar="group$ | async as group"> *ngVar="group$ | async as group">
<h5 class="w-100"> <h5 class="w-100">
{{'comcol-role.edit.' + (comcolRole$ | async)?.name + '.name' | translate}} {{ roleName$ | async }}
</h5> </h5>
<div class="mt-2 mb-2"> <div class="mt-2 mb-2">

View File

@@ -10,6 +10,8 @@ import { RouterTestingModule } from '@angular/router/testing';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ComcolModule } from '../../../comcol.module'; import { ComcolModule } from '../../../comcol.module';
import { NotificationsService } from '../../../../notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../testing/notifications-service.stub';
describe('ComcolRoleComponent', () => { describe('ComcolRoleComponent', () => {
@@ -20,6 +22,7 @@ describe('ComcolRoleComponent', () => {
let group; let group;
let statusCode; let statusCode;
let comcolRole; let comcolRole;
let notificationsService;
const requestService = { hasByHref$: () => observableOf(true) }; const requestService = { hasByHref$: () => observableOf(true) };
@@ -40,6 +43,7 @@ describe('ComcolRoleComponent', () => {
providers: [ providers: [
{ provide: GroupDataService, useValue: groupService }, { provide: GroupDataService, useValue: groupService },
{ provide: RequestService, useValue: requestService }, { provide: RequestService, useValue: requestService },
{ provide: NotificationsService, useClass: NotificationsServiceStub }
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
@@ -59,12 +63,14 @@ describe('ComcolRoleComponent', () => {
fixture = TestBed.createComponent(ComcolRoleComponent); fixture = TestBed.createComponent(ComcolRoleComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
de = fixture.debugElement; de = fixture.debugElement;
notificationsService = TestBed.inject(NotificationsService);
comcolRole = { comcolRole = {
name: 'test role name', name: 'test role name',
href: 'test role link', href: 'test role link',
}; };
comp.comcolRole = comcolRole; comp.comcolRole = comcolRole;
comp.roleName$ = observableOf(comcolRole.name);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -101,6 +107,18 @@ describe('ComcolRoleComponent', () => {
done(); done();
}); });
}); });
describe('when a group cannot be created', () => {
beforeEach(() => {
groupService.createComcolGroup.and.returnValue(createFailedRemoteDataObject$());
de.query(By.css('.btn.create')).nativeElement.click();
});
it('should show an error notification', (done) => {
expect(notificationsService.error).toHaveBeenCalled();
done();
});
});
}); });
describe('when the related group is the Anonymous group', () => { describe('when the related group is the Anonymous group', () => {
@@ -169,5 +187,17 @@ describe('ComcolRoleComponent', () => {
done(); done();
}); });
}); });
describe('when a group cannot be deleted', () => {
beforeEach(() => {
groupService.deleteComcolGroup.and.returnValue(createFailedRemoteDataObject$());
de.query(By.css('.btn.delete')).nativeElement.click();
});
it('should show an error notification', (done) => {
expect(notificationsService.error).toHaveBeenCalled();
done();
});
});
}); });
}); });

View File

@@ -12,6 +12,8 @@ import { HALLink } from '../../../../../core/shared/hal-link.model';
import { getGroupEditRoute } from '../../../../../access-control/access-control-routing-paths'; import { getGroupEditRoute } from '../../../../../access-control/access-control-routing-paths';
import { hasNoValue, hasValue } from '../../../../empty.util'; import { hasNoValue, hasValue } from '../../../../empty.util';
import { NoContent } from '../../../../../core/shared/NoContent.model'; import { NoContent } from '../../../../../core/shared/NoContent.model';
import { NotificationsService } from '../../../../notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
/** /**
* Component for managing a community or collection role. * Component for managing a community or collection role.
@@ -64,9 +66,16 @@ export class ComcolRoleComponent implements OnInit {
*/ */
hasCustomGroup$: Observable<boolean>; hasCustomGroup$: Observable<boolean>;
/**
* The human-readable name of this role
*/
roleName$: Observable<string>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected groupService: GroupDataService, protected groupService: GroupDataService,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
) { ) {
} }
@@ -101,7 +110,12 @@ export class ComcolRoleComponent implements OnInit {
this.groupService.clearGroupsRequests(); this.groupService.clearGroupsRequests();
this.requestService.setStaleByHrefSubstring(this.comcolRole.href); this.requestService.setStaleByHrefSubstring(this.comcolRole.href);
} else { } else {
// TODO show error notification this.notificationsService.error(
this.roleName$.pipe(
switchMap(role => this.translateService.get('comcol-role.edit.create.error.title', { role }))
),
`${rd.statusCode} ${rd.errorMessage}`
);
} }
}); });
} }
@@ -117,7 +131,12 @@ export class ComcolRoleComponent implements OnInit {
this.groupService.clearGroupsRequests(); this.groupService.clearGroupsRequests();
this.requestService.setStaleByHrefSubstring(this.comcolRole.href); this.requestService.setStaleByHrefSubstring(this.comcolRole.href);
} else { } else {
// TODO show error notification this.notificationsService.error(
this.roleName$.pipe(
switchMap(role => this.translateService.get('comcol-role.edit.delete.error.title', { role }))
),
rd.errorMessage
);
} }
}); });
} }
@@ -154,5 +173,7 @@ export class ComcolRoleComponent implements OnInit {
this.hasCustomGroup$ = this.group$.pipe( this.hasCustomGroup$ = this.group$.pipe(
map((group: Group) => hasValue(group) && group.name !== 'Anonymous'), map((group: Group) => hasValue(group) && group.name !== 'Anonymous'),
); );
this.roleName$ = this.translateService.get(`comcol-role.edit.${this.comcolRole.name}.name`);
} }
} }

View File

@@ -46,27 +46,27 @@ describe('ConfirmationModalComponent', () => {
describe('confirmPressed', () => { describe('confirmPressed', () => {
beforeEach(() => { beforeEach(() => {
spyOn(component.response, 'next'); spyOn(component.response, 'emit');
component.confirmPressed(); component.confirmPressed();
}); });
it('should call the close method on the active modal', () => { it('should call the close method on the active modal', () => {
expect(modalStub.close).toHaveBeenCalled(); expect(modalStub.close).toHaveBeenCalled();
}); });
it('behaviour subject should have true as next', () => { it('behaviour subject should emit true', () => {
expect(component.response.next).toHaveBeenCalledWith(true); expect(component.response.emit).toHaveBeenCalledWith(true);
}); });
}); });
describe('cancelPressed', () => { describe('cancelPressed', () => {
beforeEach(() => { beforeEach(() => {
spyOn(component.response, 'next'); spyOn(component.response, 'emit');
component.cancelPressed(); component.cancelPressed();
}); });
it('should call the close method on the active modal', () => { it('should call the close method on the active modal', () => {
expect(modalStub.close).toHaveBeenCalled(); expect(modalStub.close).toHaveBeenCalled();
}); });
it('behaviour subject should have false as next', () => { it('behaviour subject should emit false', () => {
expect(component.response.next).toHaveBeenCalledWith(false); expect(component.response.emit).toHaveBeenCalledWith(false);
}); });
}); });
@@ -88,7 +88,7 @@ describe('ConfirmationModalComponent', () => {
describe('when the click method emits on cancel button', () => { describe('when the click method emits on cancel button', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component, 'close'); spyOn(component, 'close');
spyOn(component.response, 'next'); spyOn(component.response, 'emit');
debugElement.query(By.css('button.cancel')).triggerEventHandler('click', { debugElement.query(By.css('button.cancel')).triggerEventHandler('click', {
preventDefault: () => {/**/ preventDefault: () => {/**/
} }
@@ -99,15 +99,15 @@ describe('ConfirmationModalComponent', () => {
it('should call the close method on the component', () => { it('should call the close method on the component', () => {
expect(component.close).toHaveBeenCalled(); expect(component.close).toHaveBeenCalled();
}); });
it('behaviour subject should have false as next', () => { it('behaviour subject should emit false', () => {
expect(component.response.next).toHaveBeenCalledWith(false); expect(component.response.emit).toHaveBeenCalledWith(false);
}); });
}); });
describe('when the click method emits on confirm button', () => { describe('when the click method emits on confirm button', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component, 'close'); spyOn(component, 'close');
spyOn(component.response, 'next'); spyOn(component.response, 'emit');
debugElement.query(By.css('button.confirm')).triggerEventHandler('click', { debugElement.query(By.css('button.confirm')).triggerEventHandler('click', {
preventDefault: () => {/**/ preventDefault: () => {/**/
} }
@@ -118,8 +118,8 @@ describe('ConfirmationModalComponent', () => {
it('should call the close method on the component', () => { it('should call the close method on the component', () => {
expect(component.close).toHaveBeenCalled(); expect(component.close).toHaveBeenCalled();
}); });
it('behaviour subject should have true as next', () => { it('behaviour subject should emit false', () => {
expect(component.response.next).toHaveBeenCalledWith(true); expect(component.response.emit).toHaveBeenCalledWith(true);
}); });
}); });

View File

@@ -1,6 +1,5 @@
import { Component, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
@Component({ @Component({
@@ -24,7 +23,7 @@ export class ConfirmationModalComponent {
* An event fired when the cancel or confirm button is clicked, with respectively false or true * An event fired when the cancel or confirm button is clicked, with respectively false or true
*/ */
@Output() @Output()
response: Subject<boolean> = new Subject(); response = new EventEmitter<boolean>();
constructor(protected activeModal: NgbActiveModal) { constructor(protected activeModal: NgbActiveModal) {
} }
@@ -33,7 +32,7 @@ export class ConfirmationModalComponent {
* Confirm the action that led to the modal * Confirm the action that led to the modal
*/ */
confirmPressed() { confirmPressed() {
this.response.next(true); this.response.emit(true);
this.close(); this.close();
} }
@@ -41,7 +40,7 @@ export class ConfirmationModalComponent {
* Cancel the action that led to the modal and close modal * Cancel the action that led to the modal and close modal
*/ */
cancelPressed() { cancelPressed() {
this.response.next(false); this.response.emit(false);
this.close(); this.close();
} }

View File

@@ -46,7 +46,7 @@ describe('IdleModalComponent', () => {
describe('extendSessionPressed', () => { describe('extendSessionPressed', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component.response, 'next'); spyOn(component.response, 'emit');
component.extendSessionPressed(); component.extendSessionPressed();
})); }));
it('should set idle to false', () => { it('should set idle to false', () => {
@@ -55,8 +55,8 @@ describe('IdleModalComponent', () => {
it('should close the modal', () => { it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled(); expect(modalStub.close).toHaveBeenCalled();
}); });
it('response \'closed\' should have true as next', () => { it('response \'closed\' should emit true', () => {
expect(component.response.next).toHaveBeenCalledWith(true); expect(component.response.emit).toHaveBeenCalledWith(true);
}); });
}); });
@@ -74,7 +74,7 @@ describe('IdleModalComponent', () => {
describe('closePressed', () => { describe('closePressed', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component.response, 'next'); spyOn(component.response, 'emit');
component.closePressed(); component.closePressed();
})); }));
it('should set idle to false', () => { it('should set idle to false', () => {
@@ -83,8 +83,8 @@ describe('IdleModalComponent', () => {
it('should close the modal', () => { it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled(); expect(modalStub.close).toHaveBeenCalled();
}); });
it('response \'closed\' should have true as next', () => { it('response \'closed\' should emit true', () => {
expect(component.response.next).toHaveBeenCalledWith(true); expect(component.response.emit).toHaveBeenCalledWith(true);
}); });
}); });

View File

@@ -1,8 +1,7 @@
import { Component, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { Subject } from 'rxjs';
import { hasValue } from '../empty.util'; import { hasValue } from '../empty.util';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
@@ -29,7 +28,7 @@ export class IdleModalComponent implements OnInit {
* An event fired when the modal is closed * An event fired when the modal is closed
*/ */
@Output() @Output()
response: Subject<boolean> = new Subject(); response = new EventEmitter<boolean>();
constructor(private activeModal: NgbActiveModal, constructor(private activeModal: NgbActiveModal,
private authService: AuthService, private authService: AuthService,
@@ -84,6 +83,6 @@ export class IdleModalComponent implements OnInit {
*/ */
closeModal() { closeModal() {
this.activeModal.close(); this.activeModal.close();
this.response.next(true); this.response.emit(true);
} }
} }

View File

@@ -8,12 +8,12 @@
<p class="pb-2">{{ "item.version.delete.modal.text" | translate : {version: versionNumber} }}</p> <p class="pb-2">{{ "item.version.delete.modal.text" | translate : {version: versionNumber} }}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" <button class="btn btn-outline-secondary btn-sm cancel"
(click)="onModalClose()" (click)="onModalClose()"
title="{{'item.version.delete.modal.button.cancel.tooltip' | translate}}"> title="{{'item.version.delete.modal.button.cancel.tooltip' | translate}}">
<i class="fas fa-times fa-fw"></i> {{'item.version.delete.modal.button.cancel' | translate}} <i class="fas fa-times fa-fw"></i> {{'item.version.delete.modal.button.cancel' | translate}}
</button> </button>
<button class="btn btn-danger btn-sm" <button class="btn btn-danger btn-sm confirm"
(click)="onModalSubmit()" (click)="onModalSubmit()"
title="{{'item.version.delete.modal.button.confirm.tooltip' | translate}}"> title="{{'item.version.delete.modal.button.confirm.tooltip' | translate}}">
<i class="fas fa-check fa-fw"></i> {{'item.version.delete.modal.button.confirm' | translate}} <i class="fas fa-check fa-fw"></i> {{'item.version.delete.modal.button.confirm' | translate}}

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, EventEmitter, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@Component({ @Component({
@@ -7,6 +7,11 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
styleUrls: ['./item-versions-delete-modal.component.scss'] styleUrls: ['./item-versions-delete-modal.component.scss']
}) })
export class ItemVersionsDeleteModalComponent { export class ItemVersionsDeleteModalComponent {
/**
* An event fired when the cancel or confirm button is clicked, with respectively false or true
*/
@Output()
response = new EventEmitter<boolean>();
versionNumber: number; versionNumber: number;
@@ -15,10 +20,12 @@ export class ItemVersionsDeleteModalComponent {
} }
onModalClose() { onModalClose() {
this.response.emit(false);
this.activeModal.dismiss(); this.activeModal.dismiss();
} }
onModalSubmit() { onModalSubmit() {
this.response.emit(true);
this.activeModal.close(); this.activeModal.close();
} }

View File

@@ -1,14 +1,15 @@
import { ItemVersionsComponent } from './item-versions.component'; import { ItemVersionsComponent } from './item-versions.component';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import {
ComponentFixture, TestBed, waitForAsync
} from '@angular/core/testing';
import { VarDirective } from '../../utils/var.directive'; import { VarDirective } from '../../utils/var.directive';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { Version } from '../../../core/shared/version.model'; import { Version } from '../../../core/shared/version.model';
import { VersionHistory } from '../../../core/shared/version-history.model'; import { VersionHistory } from '../../../core/shared/version-history.model';
import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
import { By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { createPaginatedList } from '../../testing/utils.test'; import { createPaginatedList } from '../../testing/utils.test';
import { EMPTY, of, of as observableOf } from 'rxjs'; import { EMPTY, of, of as observableOf } from 'rxjs';
@@ -17,7 +18,7 @@ import { PaginationServiceStub } from '../../testing/pagination-service.stub';
import { AuthService } from '../../../core/auth/auth.service'; import { AuthService } from '../../../core/auth/auth.service';
import { VersionDataService } from '../../../core/data/version-data.service'; import { VersionDataService } from '../../../core/data/version-data.service';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import { FormBuilder } from '@angular/forms'; import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
@@ -25,6 +26,9 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service';
import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { CommonModule } from '@angular/common';
describe('ItemVersionsComponent', () => { describe('ItemVersionsComponent', () => {
let component: ItemVersionsComponent; let component: ItemVersionsComponent;
@@ -70,6 +74,7 @@ describe('ItemVersionsComponent', () => {
versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
const item1 = Object.assign(new Item(), { // is a workspace item const item1 = Object.assign(new Item(), { // is a workspace item
id: 'item-identifier-1',
uuid: 'item-identifier-1', uuid: 'item-identifier-1',
handle: '123456789/1', handle: '123456789/1',
version: createSuccessfulRemoteDataObject$(version1), version: createSuccessfulRemoteDataObject$(version1),
@@ -80,6 +85,7 @@ describe('ItemVersionsComponent', () => {
} }
}); });
const item2 = Object.assign(new Item(), { const item2 = Object.assign(new Item(), {
id: 'item-identifier-2',
uuid: 'item-identifier-2', uuid: 'item-identifier-2',
handle: '123456789/2', handle: '123456789/2',
version: createSuccessfulRemoteDataObject$(version2), version: createSuccessfulRemoteDataObject$(version2),
@@ -95,6 +101,8 @@ describe('ItemVersionsComponent', () => {
const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', {
getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)), getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)),
getVersionHistoryFromVersion$: of(versionHistory),
getLatestVersionItemFromHistory$: of(item1), // called when version2 is deleted
}); });
const authenticationServiceSpy = jasmine.createSpyObj('authenticationService', { const authenticationServiceSpy = jasmine.createSpyObj('authenticationService', {
isAuthenticated: observableOf(true), isAuthenticated: observableOf(true),
@@ -117,11 +125,19 @@ describe('ItemVersionsComponent', () => {
findByPropertyName: of(true), findByPropertyName: of(true),
}); });
const itemDataServiceSpy = jasmine.createSpyObj('itemDataService', {
delete: createSuccessfulRemoteDataObject$({}),
});
const routerSpy = jasmine.createSpyObj('router', {
navigateByUrl: null,
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ItemVersionsComponent, VarDirective], declarations: [ItemVersionsComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], imports: [TranslateModule.forRoot(), CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule],
providers: [ providers: [
{provide: PaginationService, useValue: new PaginationServiceStub()}, {provide: PaginationService, useValue: new PaginationServiceStub()},
{provide: FormBuilder, useValue: new FormBuilder()}, {provide: FormBuilder, useValue: new FormBuilder()},
@@ -129,11 +145,12 @@ describe('ItemVersionsComponent', () => {
{provide: AuthService, useValue: authenticationServiceSpy}, {provide: AuthService, useValue: authenticationServiceSpy},
{provide: AuthorizationDataService, useValue: authorizationServiceSpy}, {provide: AuthorizationDataService, useValue: authorizationServiceSpy},
{provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy}, {provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy},
{provide: ItemDataService, useValue: {}}, {provide: ItemDataService, useValue: itemDataServiceSpy},
{provide: VersionDataService, useValue: versionServiceSpy}, {provide: VersionDataService, useValue: versionServiceSpy},
{provide: WorkspaceitemDataService, useValue: workspaceItemDataServiceSpy}, {provide: WorkspaceitemDataService, useValue: workspaceItemDataServiceSpy},
{provide: WorkflowItemDataService, useValue: workflowItemDataServiceSpy}, {provide: WorkflowItemDataService, useValue: workflowItemDataServiceSpy},
{provide: ConfigurationDataService, useValue: configurationServiceSpy}, {provide: ConfigurationDataService, useValue: configurationServiceSpy},
{ provide: Router, useValue: routerSpy },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -277,4 +294,43 @@ describe('ItemVersionsComponent', () => {
}); });
}); });
describe('when deleting a version', () => {
let deleteButton;
beforeEach(() => {
const canDelete = (featureID: FeatureID, url: string ) => of(featureID === FeatureID.CanDeleteVersion);
authorizationServiceSpy.isAuthorized.and.callFake(canDelete);
fixture.detectChanges();
// delete the last version in the table (version2 → item2)
deleteButton = fixture.debugElement.queryAll(By.css('.version-row-element-delete'))[1].nativeElement;
itemDataServiceSpy.delete.calls.reset();
});
describe('if confirmed via modal', () => {
beforeEach(waitForAsync(() => {
deleteButton.click();
fixture.detectChanges();
(document as any).querySelector('.modal-footer .confirm').click();
}));
it('should call ItemService.delete', () => {
expect(itemDataServiceSpy.delete).toHaveBeenCalledWith(item2.id);
});
});
describe('if canceled via modal', () => {
beforeEach(waitForAsync(() => {
deleteButton.click();
fixture.detectChanges();
(document as any).querySelector('.modal-footer .cancel').click();
}));
it('should not call ItemService.delete', () => {
expect(itemDataServiceSpy.delete).not.toHaveBeenCalled();
});
});
});
}); });

View File

@@ -283,44 +283,42 @@ export class ItemVersionsComponent implements OnInit {
activeModal.componentInstance.firstVersion = false; activeModal.componentInstance.firstVersion = false;
// On modal submit/dismiss // On modal submit/dismiss
activeModal.result.then(() => { activeModal.componentInstance.response.pipe(take(1)).subscribe((ok) => {
versionItem$.pipe( if (ok) {
getFirstSucceededRemoteDataPayload<Item>(), versionItem$.pipe(
// Retrieve version history and invalidate cache getFirstSucceededRemoteDataPayload<Item>(),
mergeMap((item: Item) => combineLatest([ // Retrieve version history
of(item), mergeMap((item: Item) => combineLatest([
this.versionHistoryService.getVersionHistoryFromVersion$(version).pipe( of(item),
tap((versionHistory: VersionHistory) => { this.versionHistoryService.getVersionHistoryFromVersion$(version)
this.versionHistoryService.invalidateVersionHistoryCache(versionHistory.id); ])),
}) // Delete item
) mergeMap(([item, versionHistory]: [Item, VersionHistory]) => combineLatest([
])), this.deleteItemAndGetResult$(item),
// Delete item of(versionHistory)
mergeMap(([item, versionHistory]: [Item, VersionHistory]) => combineLatest([ ])),
this.deleteItemAndGetResult$(item), // Retrieve new latest version
of(versionHistory) mergeMap(([deleteItemResult, versionHistory]: [boolean, VersionHistory]) => combineLatest([
])), of(deleteItemResult),
// Retrieve new latest version this.versionHistoryService.getLatestVersionItemFromHistory$(versionHistory).pipe(
mergeMap(([deleteItemResult, versionHistory]: [boolean, VersionHistory]) => combineLatest([ tap(() => {
of(deleteItemResult), this.getAllVersions(of(versionHistory));
this.versionHistoryService.getLatestVersionItemFromHistory$(versionHistory).pipe( }),
tap(() => { )
this.getAllVersions(of(versionHistory)); ])),
}), ).subscribe(([deleteHasSucceeded, newLatestVersionItem]: [boolean, Item]) => {
) // Notify operation result and redirect to latest item
])), if (deleteHasSucceeded) {
).subscribe(([deleteHasSucceeded, newLatestVersionItem]: [boolean, Item]) => { this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': versionNumber}));
// Notify operation result and redirect to latest item } else {
if (deleteHasSucceeded) { this.notificationsService.error(null, this.translateService.get(failureMessageKey, {'version': versionNumber}));
this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': versionNumber})); }
} else { if (redirectToLatest) {
this.notificationsService.error(null, this.translateService.get(failureMessageKey, {'version': versionNumber})); const path = getItemEditVersionhistoryRoute(newLatestVersionItem);
} this.router.navigateByUrl(path);
if (redirectToLatest) { }
const path = getItemEditVersionhistoryRoute(newLatestVersionItem); });
this.router.navigateByUrl(path); }
}
});
}); });
} }

View File

@@ -8,6 +8,6 @@
</ng-container> </ng-container>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" *ngIf="canRegister$ | async" [routerLink]="[getRegisterRoute()]" data-test="register">{{"login.form.new-user" | translate}}</a> <a class="dropdown-item" *ngIf="canRegister$ | async" [routerLink]="[getRegisterRoute()]" [attr.data-test]="'register' | dsBrowserOnly">{{"login.form.new-user" | translate}}</a>
<a class="dropdown-item" [routerLink]="[getForgotRoute()]" data-test="forgot">{{"login.form.forgot-password" | translate}}</a> <a class="dropdown-item" [routerLink]="[getForgotRoute()]" [attr.data-test]="'forgot' | dsBrowserOnly">{{"login.form.forgot-password" | translate}}</a>
</div> </div>

View File

@@ -10,7 +10,7 @@
placeholder="{{'login.form.email' | translate}}" placeholder="{{'login.form.email' | translate}}"
required required
type="email" type="email"
data-test="email"> [attr.data-test]="'email' | dsBrowserOnly">
<label class="sr-only">{{"login.form.password" | translate}}</label> <label class="sr-only">{{"login.form.password" | translate}}</label>
<input [attr.aria-label]="'login.form.password' |translate" <input [attr.aria-label]="'login.form.password' |translate"
autocomplete="off" autocomplete="off"
@@ -19,12 +19,12 @@
formControlName="password" formControlName="password"
required required
type="password" type="password"
data-test="password"> [attr.data-test]="'password' | dsBrowserOnly">
<div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert" <div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert"
@fadeOut>{{ (error | async) | translate }}</div> @fadeOut>{{ (error | async) | translate }}</div>
<div *ngIf="(message | async) && hasMessage" class="alert alert-info" role="alert" <div *ngIf="(message | async) && hasMessage" class="alert alert-info" role="alert"
@fadeOut>{{ (message | async) | translate }}</div> @fadeOut>{{ (message | async) | translate }}</div>
<button class="btn btn-lg btn-primary btn-block mt-3" type="submit" data-test="login-button" <button class="btn btn-lg btn-primary btn-block mt-3" type="submit" [attr.data-test]="'login-button' | dsBrowserOnly"
[disabled]="!form.valid"><i class="fas fa-sign-in-alt"></i> {{"login.form.submit" | translate}}</button> [disabled]="!form.valid"><i class="fas fa-sign-in-alt"></i> {{"login.form.submit" | translate}}</button>
</form> </form>

View File

@@ -17,6 +17,7 @@ import { storeModuleConfig } from '../../../../app.reducer';
import { AuthMethod } from '../../../../core/auth/models/auth.method'; import { AuthMethod } from '../../../../core/auth/models/auth.method';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; import { HardRedirectService } from '../../../../core/services/hard-redirect.service';
import { BrowserOnlyMockPipe } from '../../../testing/browser-only-mock.pipe';
describe('LogInPasswordComponent', () => { describe('LogInPasswordComponent', () => {
@@ -57,7 +58,8 @@ describe('LogInPasswordComponent', () => {
TranslateModule.forRoot() TranslateModule.forRoot()
], ],
declarations: [ declarations: [
LogInPasswordComponent LogInPasswordComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: AuthService, useClass: AuthServiceStub }, { provide: AuthService, useClass: AuthServiceStub },

View File

@@ -2,5 +2,5 @@
<div *ngIf="(error | async)" class="alert alert-danger" role="alert" @fadeOut>{{ error | async }}</div> <div *ngIf="(error | async)" class="alert alert-danger" role="alert" @fadeOut>{{ error | async }}</div>
<button class="btn btn-lg btn-primary btn-block mt-3" (click)="logOut()" data-test="logout-button"><i class="fas fa-sign-out-alt"></i> {{"logout.form.submit" | translate}}</button> <button class="btn btn-lg btn-primary btn-block mt-3" (click)="logOut()" [attr.data-test]="'logout-button' | dsBrowserOnly"><i class="fas fa-sign-out-alt"></i> {{"logout.form.submit" | translate}}</button>
</div> </div>

View File

@@ -12,6 +12,7 @@ import { Router } from '@angular/router';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { LogOutComponent } from './log-out.component'; import { LogOutComponent } from './log-out.component';
import { RouterStub } from '../testing/router.stub'; import { RouterStub } from '../testing/router.stub';
import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe';
describe('LogOutComponent', () => { describe('LogOutComponent', () => {
@@ -46,7 +47,8 @@ describe('LogOutComponent', () => {
TranslateModule.forRoot() TranslateModule.forRoot()
], ],
declarations: [ declarations: [
LogOutComponent LogOutComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: Router, useValue: routerStub }, { provide: Router, useValue: routerStub },

Some files were not shown because too many files have changed in this diff Show More