Merge remote-tracking branch 'origin/main' into CST-5307

# Conflicts:
#	src/app/core/core.module.ts
#	src/app/profile-page/profile-page.component.spec.ts
#	src/app/profile-page/profile-page.component.ts
#	src/app/shared/shared.module.ts
This commit is contained in:
Giuseppe Digilio
2022-05-26 10:40:48 +02:00
169 changed files with 3698 additions and 1388 deletions

View File

@@ -41,6 +41,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
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 +74,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64
# 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

@@ -68,8 +68,8 @@
"@material-ui/core": "^4.11.0", "@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1", "@material-ui/icons": "^4.9.1",
"@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-dynamic-forms/core": "^14.0.1", "@ng-dynamic-forms/core": "^15.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^14.0.1", "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
"@ngrx/effects": "^13.0.2", "@ngrx/effects": "^13.0.2",
"@ngrx/router-store": "^13.0.2", "@ngrx/router-store": "^13.0.2",
"@ngrx/store": "^13.0.2", "@ngrx/store": "^13.0.2",
@@ -77,7 +77,7 @@
"@ngx-translate/core": "^13.0.0", "@ngx-translate/core": "^13.0.0",
"@nicky-lenaers/ngx-scroll-to": "^9.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0",
"angular-idle-preload": "3.0.0", "angular-idle-preload": "3.0.0",
"angulartics2": "^10.0.0", "angulartics2": "^12.0.0",
"bootstrap": "4.3.1", "bootstrap": "4.3.1",
"caniuse-lite": "^1.0.30001165", "caniuse-lite": "^1.0.30001165",
"cerialize": "0.1.18", "cerialize": "0.1.18",
@@ -119,7 +119,7 @@
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^6.6.3", "rxjs": "^7.5.5",
"sortablejs": "1.13.0", "sortablejs": "1.13.0",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"url-parse": "^1.5.6", "url-parse": "^1.5.6",
@@ -155,14 +155,14 @@
"@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",
"css-minimizer-webpack-plugin": "^3.4.1", "css-minimizer-webpack-plugin": "^3.4.1",
"cssnano": "^5.0.6", "cssnano": "^5.0.6",
"cypress": "9.5.1", "cypress": "9.5.1",
"cypress-axe": "^0.13.0", "cypress-axe": "^0.14.0",
"debug-loader": "^0.0.1", "debug-loader": "^0.0.1",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@@ -171,10 +171,11 @@
"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",
"jasmine-marbles": "0.6.0", "jasmine-marbles": "0.9.2",
"jasmine-spec-reporter": "~5.0.0", "jasmine-spec-reporter": "~5.0.0",
"karma": "^6.3.14", "karma": "^6.3.14",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
@@ -182,7 +183,7 @@
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"ngx-mask": "^12.0.0", "ngx-mask": "^13.1.7",
"nodemon": "^2.0.15", "nodemon": "^2.0.15",
"postcss": "^8.1", "postcss": "^8.1",
"postcss-apply": "0.12.0", "postcss-apply": "0.12.0",
@@ -196,7 +197,7 @@
"react": "^16.14.0", "react": "^16.14.0",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs-spy": "^7.5.3", "rxjs-spy": "^8.0.2",
"sass": "~1.32.6", "sass": "~1.32.6",
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"sass-resources-loader": "^2.1.1", "sass-resources-loader": "^2.1.1",
@@ -210,4 +211,4 @@
"webpack-cli": "^4.2.0", "webpack-cli": "^4.2.0",
"webpack-dev-server": "^4.5.0" "webpack-dev-server": "^4.5.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

@@ -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

@@ -182,176 +182,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';
/** /**
@@ -85,11 +53,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
) { ) {
super(menuService, injector, authorizationService, route); super(menuService, injector, authorizationService, route);
@@ -105,7 +71,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);
} }
}); });
@@ -135,503 +100,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

@@ -70,6 +70,12 @@ export function getWorkflowItemModuleRoute() {
return `/${WORKFLOW_ITEM_MODULE_PATH}`; return `/${WORKFLOW_ITEM_MODULE_PATH}`;
} }
export const WORKSPACE_ITEM_MODULE_PATH = 'workspaceitems';
export function getWorkspaceItemModuleRoute() {
return `/${WORKSPACE_ITEM_MODULE_PATH}`;
}
export function getDSORoute(dso: DSpaceObject): string { export function getDSORoute(dso: DSpaceObject): string {
if (hasValue(dso)) { if (hasValue(dso)) {
switch ((dso as any).type) { switch ((dso as any).type) {

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

@@ -4,7 +4,7 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule, DOCUMENT } from '@angular/common'; import { CommonModule, DOCUMENT } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2';
// Load the implementations that should be tested // Load the implementations that should be tested
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';

View File

@@ -23,7 +23,7 @@ import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { MetadataService } from './core/metadata/metadata.service'; import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowResizeAction } from './shared/host-window.actions';
@@ -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

@@ -1,8 +1,8 @@
<div class="container"> <div class="container">
<div class="collection-page" <div class="collection-page"
*ngVar="(collectionRD$ | async) as collectionRD"> *ngVar="(collectionRD$ | async) as collectionRD">
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut> <div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
<div *ngIf="collectionRD?.payload as collection"> <div *ngIf="collectionRD?.payload as collection">
<ds-view-tracker [object]="collection"></ds-view-tracker> <ds-view-tracker [object]="collection"></ds-view-tracker>
<div class="d-flex flex-row border-bottom mb-4 pb-4"> <div class="d-flex flex-row border-bottom mb-4 pb-4">
<header class="comcol-header mr-auto"> <header class="comcol-header mr-auto">
@@ -13,8 +13,7 @@
<!-- Collection logo --> <!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoRD$" <ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload" [logo]="(logoRD$ | async)?.payload"
[alternateText]="'Collection Logo'" [alternateText]="'Collection Logo'">
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo> </ds-comcol-page-logo>
<!-- Handle --> <!-- Handle -->

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

@@ -6,7 +6,7 @@ import { CollectionPageComponent } from './collection-page.component';
* Themed wrapper for CollectionPageComponent * Themed wrapper for CollectionPageComponent
*/ */
@Component({ @Component({
selector: 'ds-themed-community-page', selector: 'ds-themed-collection-page',
styleUrls: [], styleUrls: [],
templateUrl: '../shared/theme-support/themed.component.html', templateUrl: '../shared/theme-support/themed.component.html',
}) })

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

@@ -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';
import { ResearcherProfileService } from './profile/researcher-profile.service'; import { ResearcherProfileService } from './profile/researcher-profile.service';
import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service';
import { ResearcherProfile } from './profile/model/researcher-profile.model'; import { ResearcherProfile } from './profile/model/researcher-profile.model';
@@ -193,7 +193,6 @@ const DECLARATIONS = [];
const EXPORTS = []; const EXPORTS = [];
const PROVIDERS = [ const PROVIDERS = [
ApiService,
AuthenticatedGuard, AuthenticatedGuard,
CommunityDataService, CommunityDataService,
CollectionDataService, CollectionDataService,
@@ -208,6 +207,7 @@ const PROVIDERS = [
SectionFormOperationsService, SectionFormOperationsService,
FormService, FormService,
EPersonDataService, EPersonDataService,
LinkHeadService,
HALEndpointService, HALEndpointService,
HostWindowService, HostWindowService,
ItemDataService, ItemDataService,

View File

@@ -7,8 +7,8 @@ import { filter, map, take } from 'rxjs/operators';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { difference } from '../../shared/object.util'; import { difference } from '../../shared/object.util';
import { isNumeric } from 'rxjs/internal-compatibility';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { isNumeric } from '../../shared/numeric.util';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',

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

@@ -3,7 +3,7 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from './hal-endpoint.service'; import { HALEndpointService } from './hal-endpoint.service';
import { EndpointMapRequest } from '../data/request.models'; import { EndpointMapRequest } from '../data/request.models';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
@@ -162,9 +162,9 @@ describe('HALEndpointService', () => {
return observableOf(endpointMaps[param]); return observableOf(endpointMaps[param]);
}); });
observableCombineLatest([ observableCombineLatest<string[]>([
(service as any).getEndpointAt(start, 'one'), (service as any).getEndpointAt(start, 'one'),
(service as any).getEndpointAt(start, 'one', 'two') (service as any).getEndpointAt(start, 'one', 'two'),
]).subscribe(([endpoint1, endpoint2]) => { ]).subscribe(([endpoint1, endpoint2]) => {
expect(endpoint1).toEqual(one); expect(endpoint1).toEqual(one);
expect(endpoint2).toEqual(two); expect(endpoint2).toEqual(two);

View File

@@ -1,5 +1,5 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, interval } from 'rxjs';
import { debounceTime, filter, find, map, switchMap, take, takeWhile } from 'rxjs/operators'; import { filter, find, map, switchMap, take, takeWhile, debounce, debounceTime } from 'rxjs/operators';
import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/models/search-result.model'; import { SearchResult } from '../../shared/search/models/search-result.model';
import { PaginatedList } from '../data/paginated-list.model'; import { PaginatedList } from '../data/paginated-list.model';
@@ -9,6 +9,17 @@ import { MetadataSchema } from '../metadata/metadata-schema.model';
import { BrowseDefinition } from './browse-definition.model'; import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model'; import { DSpaceObject } from './dspace-object.model';
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { MonoTypeOperatorFunction, SchedulerLike } from 'rxjs/internal/types';
/**
* Use this method instead of the RxJs debounceTime if you're waiting for debouncing in tests;
* debounceTime doesn't work with fakeAsync/tick anymore as of Angular 13.2.6 & RxJs 7.5.5
* Workaround suggested in https://github.com/angular/angular/issues/44351#issuecomment-1107454054
* todo: remove once the above issue is fixed
*/
export const debounceTimeWorkaround = <T>(dueTime: number, scheduler?: SchedulerLike): MonoTypeOperatorFunction<T> => {
return debounce(() => interval(dueTime, scheduler));
};
export const DEBOUNCE_TIME_OPERATOR = new InjectionToken<<T>(dueTime: number) => (source: Observable<T>) => Observable<T>>('debounceTime', { export const DEBOUNCE_TIME_OPERATOR = new InjectionToken<<T>(dueTime: number) => (source: Observable<T>) => Observable<T>>('debounceTime', {
providedIn: 'root', providedIn: 'root',

View File

@@ -0,0 +1,43 @@
import { DSpaceObject } from './../../shared/dspace-object.model';
import { followLink } from './../../../shared/utils/follow-link-config.model';
import { ChildHALResource } from './../../shared/child-hal-resource.model';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Store } from '@ngrx/store';
import { switchMap } from 'rxjs/operators';
import { DataService } from '../../data/data.service';
import { RemoteData } from '../../data/remote-data';
import { getFirstCompletedRemoteData } from '../../shared/operators';
/**
* This class represents a resolver that requests a specific item before the route is activated
*/
@Injectable()
export class SubmissionObjectResolver<T> implements Resolve<RemoteData<T>> {
constructor(
protected dataService: DataService<any>,
protected store: Store<any>
) {
}
/**
* Method for resolving an item based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns Observable<<RemoteData<Item>> Emits the found item based on the parameters in the current route,
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<T>> {
const itemRD$ = this.dataService.findById(route.params.id,
true,
false,
followLink('item'),
).pipe(
getFirstCompletedRemoteData(),
switchMap((wfiRD: RemoteData<any>) => wfiRD.payload.item as Observable<RemoteData<T>>),
getFirstCompletedRemoteData()
);
return itemRD$;
}
}

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

@@ -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

@@ -12,26 +12,28 @@
[tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button> [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div> </div>
</div> </div>
<div class="simple-view-link my-3" *ngIf="!fromWfi"> <div class="simple-view-link my-3" *ngIf="!fromSubmissionObject">
<a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]"> <a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]">
{{"item.page.link.simple" | translate}} {{"item.page.link.simple" | translate}}
</a> </a>
</div> </div>
<table class="table table-responsive table-striped"> <div class="table-responsive">
<tbody> <table class="table table-striped">
<ng-container *ngFor="let mdEntry of (metadata$ | async) | keyvalue"> <tbody>
<tr *ngFor="let mdValue of mdEntry.value"> <ng-container *ngFor="let mdEntry of (metadata$ | async) | keyvalue">
<td>{{mdEntry.key}}</td> <tr *ngFor="let mdValue of mdEntry.value">
<td>{{mdValue.value}}</td> <td>{{mdEntry.key}}</td>
<td>{{mdValue.language}}</td> <td>{{mdValue.value}}</td>
</tr> <td>{{mdValue.language}}</td>
</ng-container> </tr>
</tbody> </ng-container>
</table> </tbody>
</table>
</div>
<ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section> <ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section>
<ds-item-page-collections [item]="item"></ds-item-page-collections> <ds-item-page-collections [item]="item"></ds-item-page-collections>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions> <ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
<div class="button-row bottom" *ngIf="fromWfi"> <div class="button-row bottom" *ngIf="fromSubmissionObject">
<div class="text-right"> <div class="text-right">
<button class="btn btn-outline-secondary mr-1" (click)="back()"><i <button class="btn btn-outline-secondary mr-1" (click)="back()"><i
class="fas fa-arrow-left"></i> {{'item.page.return' | translate}}</button> class="fas fa-arrow-left"></i> {{'item.page.return' | translate}}</button>

View File

@@ -112,7 +112,7 @@ describe('FullItemPageComponent', () => {
}); });
it('should show simple view button when not originated from workflow item', () => { it('should show simple view button when not originated from workflow item', () => {
expect(comp.fromWfi).toBe(false); expect(comp.fromSubmissionObject).toBe(false);
const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link'));
expect(simpleViewBtn).toBeTruthy(); expect(simpleViewBtn).toBeTruthy();
}); });
@@ -122,7 +122,7 @@ describe('FullItemPageComponent', () => {
comp.ngOnInit(); comp.ngOnInit();
fixture.detectChanges(); fixture.detectChanges();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
expect(comp.fromWfi).toBe(true); expect(comp.fromSubmissionObject).toBe(true);
const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link')); const simpleViewBtn = fixture.debugElement.query(By.css('.simple-view-link'));
expect(simpleViewBtn).toBeFalsy(); expect(simpleViewBtn).toBeFalsy();
}); });

View File

@@ -2,7 +2,7 @@ import { filter, map } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Data, Router } from '@angular/router'; import { ActivatedRoute, Data, Router } from '@angular/router';
import { Observable , BehaviorSubject } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { ItemPageComponent } from '../simple/item-page.component'; import { ItemPageComponent } from '../simple/item-page.component';
import { MetadataMap } from '../../core/shared/metadata.models'; import { MetadataMap } from '../../core/shared/metadata.models';
@@ -37,9 +37,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit,
metadata$: Observable<MetadataMap>; metadata$: Observable<MetadataMap>;
/** /**
* True when the itemRD has been originated from its workflowitem, false otherwise. * True when the itemRD has been originated from its workspaceite/workflowitem, false otherwise.
*/ */
fromWfi = false; fromSubmissionObject = false;
subs = []; subs = [];
@@ -61,7 +61,7 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit,
map((item: Item) => item.metadata),); map((item: Item) => item.metadata),);
this.subs.push(this.route.data.subscribe((data: Data) => { this.subs.push(this.route.data.subscribe((data: Data) => {
this.fromWfi = hasValue(data.wfi); this.fromSubmissionObject = hasValue(data.wfi) || hasValue(data.wsi);
}) })
); );
} }

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

@@ -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';
/** /**
* Component representing the public navbar * Component representing the public navbar
@@ -42,64 +35,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

@@ -5,8 +5,9 @@ import { FormGroup } from '@angular/forms';
import { hasValue, isEmpty } from '../../shared/empty.util'; import { hasValue, isEmpty } from '../../shared/empty.util';
import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { debounceTime, map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { debounceTimeWorkaround as debounceTime } from '../../core/shared/operators';
@Component({ @Component({
selector: 'ds-profile-page-security-form', selector: 'ds-profile-page-security-form',

View File

@@ -32,12 +32,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 { cold, getTestScheduler } from 'jasmine-marbles'; import { cold, 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';
import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { ConfigurationDataService } from '../core/data/configuration-data.service';
import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../core/shared/configuration-property.model';
@@ -68,7 +69,8 @@ describe('ProfilePageComponent', () => {
}; };
authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: canChangePassword }); authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: canChangePassword });
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),
@@ -259,6 +261,27 @@ 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();
});
});
}); });
describe('isResearcherProfileEnabled', () => { describe('isResearcherProfileEnabled', () => {

View File

@@ -43,6 +43,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
*/ */
@@ -89,6 +94,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();
this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe( this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe(
getFirstCompletedRemoteData() getFirstCompletedRemoteData()

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

@@ -9,7 +9,7 @@ import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
import { NativeWindowRef, NativeWindowService } from '../core/services/window.service'; import { NativeWindowRef, NativeWindowService } from '../core/services/window.service';
import { MetadataService } from '../core/metadata/metadata.service'; import { MetadataService } from '../core/metadata/metadata.service';
import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock'; import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock'; import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { AuthService } from '../core/auth/auth.service'; import { AuthService } from '../core/auth/auth.service';

View File

@@ -5,7 +5,7 @@ import { Router } from '@angular/router';
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { MetadataService } from '../core/metadata/metadata.service'; import { MetadataService } from '../core/metadata/metadata.service';
import { HostWindowState } from '../shared/search/host-window.reducer'; import { HostWindowState } from '../shared/search/host-window.reducer';

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

@@ -38,6 +38,13 @@ 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 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';
@listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom') @listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom')
@Component({ @Component({
@@ -73,6 +80,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,
@@ -103,6 +129,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

@@ -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

@@ -143,7 +143,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
this.typesString = this.types.map((type: string) => type.toString().toLowerCase()).join(', '); this.typesString = this.types.map((type: string) => type.toString().toLowerCase()).join(', ');
// Create an observable searching for the current DSO (return empty list if there's no current DSO) // Create an observable searching for the current DSO (return empty list if there's no current DSO)
let currentDSOResult$; let currentDSOResult$: Observable<PaginatedList<SearchResult<DSpaceObject>>>;
if (isNotEmpty(this.currentDSOId)) { if (isNotEmpty(this.currentDSOId)) {
currentDSOResult$ = this.search(this.getCurrentDSOQuery(), 1).pipe(getFirstSucceededRemoteDataPayload()); currentDSOResult$ = this.search(this.getCurrentDSOQuery(), 1).pipe(getFirstSucceededRemoteDataPayload());
} else { } else {

View File

@@ -100,7 +100,9 @@ describe('ItemVersionsComponent', () => {
isAuthenticated: observableOf(true), isAuthenticated: observableOf(true),
setRedirectUrl: {} setRedirectUrl: {}
}); });
const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
const workspaceItemDataServiceSpy = jasmine.createSpyObj('workspaceItemDataService', { const workspaceItemDataServiceSpy = jasmine.createSpyObj('workspaceItemDataService', {
findByItem: EMPTY, findByItem: EMPTY,
}); });

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 },

View File

@@ -39,7 +39,7 @@ const menuByIDSelector = (menuID: MenuID): MemoizedSelector<AppState, MenuState>
return keySelector<MenuState>(menuID, menusStateSelector); return keySelector<MenuState>(menuID, menusStateSelector);
}; };
const menuSectionStateSelector = (state: MenuState) => state.sections; const menuSectionStateSelector = (state: MenuState) => hasValue(state) ? state.sections : {};
const menuSectionByIDSelector = (id: string): MemoizedSelector<MenuState, MenuSection> => { const menuSectionByIDSelector = (id: string): MemoizedSelector<MenuState, MenuSection> => {
return menuKeySelector<MenuSection>(id, menuSectionStateSelector); return menuKeySelector<MenuSection>(id, menuSectionStateSelector);
@@ -166,7 +166,7 @@ export class MenuService {
*/ */
isMenuCollapsed(menuID: MenuID): Observable<boolean> { isMenuCollapsed(menuID: MenuID): Observable<boolean> {
return this.getMenu(menuID).pipe( return this.getMenu(menuID).pipe(
map((state: MenuState) => state.collapsed) map((state: MenuState) => hasValue(state) ? state.collapsed : undefined)
); );
} }
@@ -177,7 +177,7 @@ export class MenuService {
*/ */
isMenuPreviewCollapsed(menuID: MenuID): Observable<boolean> { isMenuPreviewCollapsed(menuID: MenuID): Observable<boolean> {
return this.getMenu(menuID).pipe( return this.getMenu(menuID).pipe(
map((state: MenuState) => state.previewCollapsed) map((state: MenuState) => hasValue(state) ? state.previewCollapsed : undefined)
); );
} }
@@ -188,7 +188,7 @@ export class MenuService {
*/ */
isMenuVisible(menuID: MenuID): Observable<boolean> { isMenuVisible(menuID: MenuID): Observable<boolean> {
return this.getMenu(menuID).pipe( return this.getMenu(menuID).pipe(
map((state: MenuState) => state.visible) map((state: MenuState) => hasValue(state) ? state.visible : undefined)
); );
} }

View File

@@ -1,22 +1,16 @@
<ng-container *ngVar="(actionRD$ | async)?.payload as workflowAction"> <ng-container *ngVar="(actionRD$ | async)?.payload as workflowAction">
<div class="mt-1 mb-3 space-children-mr"> <div class="mt-1 mb-3 space-children-mr">
<ds-claimed-task-actions-loader *ngFor="let option of workflowAction?.options" <ds-claimed-task-actions-loader *ngFor="let option of workflowAction?.options" [option]="option" [object]="object"
[option]="option" (processCompleted)="this.processCompleted.emit($event)">
[object]="object"
(processCompleted)="this.processCompleted.emit($event)">
</ds-claimed-task-actions-loader> </ds-claimed-task-actions-loader>
<ng-container *ngIf="hasViewAction(workflowAction)"> <button class="btn btn-primary workflow-view" ngbTooltip="{{'submission.workflow.generic.view-help' | translate}}"
<button class="btn btn-primary workflow-view" [routerLink]="[getWorkflowItemViewRoute((workflowitem$ | async))]">
ngbTooltip="{{'submission.workflow.generic.view-help' | translate}}" <i class="fa fa-info-circle"></i> {{"submission.workflow.generic.view" | translate}}
[routerLink]="[getWorkflowItemViewRoute((workflowitem$ | async))]"> </button>
<i class="fa fa-info-circle"></i> {{"submission.workflow.generic.view" | translate}}
</button>
</ng-container>
<ds-claimed-task-actions-loader [option]="returnToPoolOption" <ds-claimed-task-actions-loader [option]="returnToPoolOption" [object]="object"
[object]="object" (processCompleted)="this.processCompleted.emit($event)">
(processCompleted)="this.processCompleted.emit($event)">
</ds-claimed-task-actions-loader> </ds-claimed-task-actions-loader>
</div> </div>
</ng-container> </ng-container>

View File

@@ -161,25 +161,22 @@ describe('ClaimedTaskActionsComponent', () => {
}); });
})); }));
describe('when edit options is not available', () => { it('should display a view button', waitForAsync(() => {
it('should display a view button', waitForAsync(() => { component.object = null;
component.object = null; component.initObjects(mockObject);
component.initObjects(mockObject); fixture.detectChanges();
fixture.detectChanges();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
const debugElement = fixture.debugElement.query(By.css('.workflow-view')); const debugElement = fixture.debugElement.query(By.css('.workflow-view'));
expect(debugElement).toBeTruthy(); expect(debugElement).toBeTruthy();
expect(debugElement.nativeElement.innerText.trim()).toBe('submission.workflow.generic.view'); expect(debugElement.nativeElement.innerText.trim()).toBe('submission.workflow.generic.view');
}); });
})); }));
it('getWorkflowItemViewRoute should return the combined uri to show a workspaceitem', waitForAsync(() => {
const href = component.getWorkflowItemViewRoute(workflowitem);
expect(href).toEqual('/workflowitems/333/view');
}));
});
it('getWorkflowItemViewRoute should return the combined uri to show a workspaceitem', waitForAsync(() => {
const href = component.getWorkflowItemViewRoute(workflowitem);
expect(href).toEqual('/workflowitems/333/view');
}));
}); });

View File

@@ -102,14 +102,6 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
this.actionRD$ = object.action; this.actionRD$ = object.action;
} }
/**
* Check if claimed task actions should display a view item button.
* @param workflowAction
*/
hasViewAction(workflowAction: WorkflowAction) {
return !workflowAction?.options.includes('submit_edit_metadata');
}
/** /**
* Get the workflowitem view route. * Get the workflowitem view route.
*/ */

View File

@@ -1,8 +1,13 @@
<button type="button" <button type="button" class="btn btn-info mt-1 mb-3"
class="btn btn-info mt-1 mb-3" ngbTooltip="{{'submission.workflow.tasks.pool.claim_help' | translate}}" [disabled]="(processing$ | async)"
ngbTooltip="{{'submission.workflow.tasks.pool.claim_help' | translate}}" (click)="claim()">
[disabled]="(processing$ | async)" <span *ngIf="(processing$ | async)"><i class='fas fa-circle-notch fa-spin'></i>
(click)="claim()"> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="(processing$ | async)"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> <span *ngIf="!(processing$ | async)"><i class="fas fa-hand-paper"></i> {{'submission.workflow.tasks.pool.claim' |
<span *ngIf="!(processing$ | async)"><i class="fas fa-hand-paper"></i> {{'submission.workflow.tasks.pool.claim' | translate}}</span> translate}}</span>
</button>
<button class="btn btn-primary workflow-view ml-1 mt-1 mb-3" data-test="view-btn"
ngbTooltip="{{'submission.workflow.generic.view-help' | translate}}"
[routerLink]="[getWorkflowItemViewRoute((workflowitem$ | async))]">
<i class="fa fa-info-circle"></i> {{"submission.workflow.generic.view" | translate}}
</button> </button>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
@@ -133,6 +133,12 @@ describe('PoolTaskActionsComponent', () => {
expect(btn).toBeDefined(); expect(btn).toBeDefined();
}); });
it('should display view button', () => {
const btn = fixture.debugElement.query(By.css('button [data-test="view-btn"]'));
expect(btn).toBeDefined();
});
it('should call claim task with href of getPoolTaskEndpointById', ((done) => { it('should call claim task with href of getPoolTaskEndpointById', ((done) => {
const poolTaskHref = 'poolTaskHref'; const poolTaskHref = 'poolTaskHref';

View File

@@ -2,7 +2,7 @@ import { Component, Injector, Input, OnDestroy } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import {filter, map, switchMap, take} from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model';
@@ -19,6 +19,7 @@ import { Item } from '../../../core/shared/item.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { MyDSpaceReloadableActionsComponent } from '../mydspace-reloadable-actions'; import { MyDSpaceReloadableActionsComponent } from '../mydspace-reloadable-actions';
import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response';
import { getWorkflowItemViewRoute } from '../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
/** /**
* This component represents mydspace actions related to PoolTask object. * This component represents mydspace actions related to PoolTask object.
@@ -58,12 +59,12 @@ export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent
* @param {RequestService} requestService * @param {RequestService} requestService
*/ */
constructor(protected injector: Injector, constructor(protected injector: Injector,
protected router: Router, protected router: Router,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected claimedTaskService: ClaimedTaskDataService, protected claimedTaskService: ClaimedTaskDataService,
protected translate: TranslateService, protected translate: TranslateService,
protected searchService: SearchService, protected searchService: SearchService,
protected requestService: RequestService) { protected requestService: RequestService) {
super(PoolTask.type, injector, router, notificationsService, translate, searchService, requestService); super(PoolTask.type, injector, router, notificationsService, translate, searchService, requestService);
} }
@@ -91,7 +92,7 @@ export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent
return this.objectDataService.getPoolTaskEndpointById(this.object.id) return this.objectDataService.getPoolTaskEndpointById(this.object.id)
.pipe(switchMap((poolTaskHref) => { .pipe(switchMap((poolTaskHref) => {
return this.claimedTaskService.claimTask(this.object.id, poolTaskHref); return this.claimedTaskService.claimTask(this.object.id, poolTaskHref);
})); }));
} }
reloadObjectExecution(): Observable<RemoteData<DSpaceObject> | DSpaceObject> { reloadObjectExecution(): Observable<RemoteData<DSpaceObject> | DSpaceObject> {
@@ -107,12 +108,19 @@ export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent
switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload()) switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload())
)) ))
.subscribe((item: Item) => { .subscribe((item: Item) => {
this.itemUuid = item.uuid; this.itemUuid = item.uuid;
}); });
} }
ngOnDestroy() { ngOnDestroy() {
this.subs.forEach((sub) => sub.unsubscribe()); this.subs.forEach((sub) => sub.unsubscribe());
} }
/**
* Get the workflowitem view route.
*/
getWorkflowItemViewRoute(workflowitem: WorkflowItem): string {
return getWorkflowItemViewRoute(workflowitem?.id);
}
} }

View File

@@ -0,0 +1,5 @@
<button class="btn btn-primary workflow-view mt-1 mb-3" data-test="view-btn"
ngbTooltip="{{'submission.workspace.generic.view-help' | translate}}"
[routerLink]="[getWorkflowItemViewRoute(object)]">
<i class="fa fa-info-circle"></i> {{"submission.workspace.generic.view" | translate}}
</button>

View File

@@ -18,6 +18,7 @@ import { getMockRequestService } from '../../mocks/request.service.mock';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { getMockSearchService } from '../../mocks/search-service.mock'; import { getMockSearchService } from '../../mocks/search-service.mock';
import { SearchService } from '../../../core/shared/search/search.service'; import { SearchService } from '../../../core/shared/search/search.service';
import { By } from '@angular/platform-browser';
let component: WorkflowitemActionsComponent; let component: WorkflowitemActionsComponent;
let fixture: ComponentFixture<WorkflowitemActionsComponent>; let fixture: ComponentFixture<WorkflowitemActionsComponent>;
@@ -105,4 +106,10 @@ describe('WorkflowitemActionsComponent', () => {
expect(component.object).toEqual(mockObject); expect(component.object).toEqual(mockObject);
}); });
it('should display view button', () => {
const btn = fixture.debugElement.query(By.css('button [data-test="view-btn"]'));
expect(btn).toBeDefined();
});
}); });

View File

@@ -9,6 +9,7 @@ import { WorkflowItemDataService } from '../../../core/submission/workflowitem-d
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { SearchService } from '../../../core/shared/search/search.service'; import { SearchService } from '../../../core/shared/search/search.service';
import { getWorkflowItemViewRoute } from '../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
/** /**
* This component represents actions related to WorkflowItem object. * This component represents actions related to WorkflowItem object.
@@ -44,6 +45,13 @@ export class WorkflowitemActionsComponent extends MyDSpaceActionsComponent<Workf
super(WorkflowItem.type, injector, router, notificationsService, translate, searchService, requestService); super(WorkflowItem.type, injector, router, notificationsService, translate, searchService, requestService);
} }
/**
* Get the workflowitem view route.
*/
getWorkflowItemViewRoute(workflowitem: WorkflowItem): string {
return getWorkflowItemViewRoute(workflowitem?.id);
}
/** /**
* Init the target object * Init the target object
* *

View File

@@ -1,22 +1,28 @@
<div class="space-children-mr"> <div class="space-children-mr">
<a class="btn btn-primary mt-1 mb-3"
id="{{'edit_' + object.id}}" <button class="btn btn-primary workflow-view mt-1 mb-3" data-test="view-btn"
ngbTooltip="{{'submission.workflow.generic.edit-help' | translate}}" ngbTooltip="{{'submission.workspace.generic.view-help' | translate}}"
[routerLink]="['/workspaceitems/' + object.id + '/edit']" [routerLink]="[getWorkspaceItemViewRoute(object)]">
role="button"> <i class="fa fa-info-circle"></i> {{"submission.workspace.generic.view" | translate}}
</button>
<a class="btn btn-primary mt-1 mb-3" id="{{'edit_' + object.id}}"
ngbTooltip="{{'submission.workflow.generic.edit-help' | translate}}"
[routerLink]="['/workspaceitems/' + object.id + '/edit']" role="button">
<i class="fa fa-edit"></i> {{'submission.workflow.generic.edit' | translate}} <i class="fa fa-edit"></i> {{'submission.workflow.generic.edit' | translate}}
</a> </a>
<button type="button" <button type="button" id="{{'delete_' + object.id}}" class="btn btn-danger mt-1 mb-3"
id="{{'delete_' + object.id}}" ngbTooltip="{{'submission.workflow.generic.delete-help' | translate}}"
class="btn btn-danger mt-1 mb-3" (click)="$event.preventDefault();confirmDiscard(content)">
ngbTooltip="{{'submission.workflow.generic.delete-help' | translate}}" <span *ngIf="(processingDelete$ | async)"><i class='fas fa-circle-notch fa-spin'></i>
(click)="$event.preventDefault();confirmDiscard(content)"> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="(processingDelete$ | async)"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span> <span *ngIf="!(processingDelete$ | async)"><i class="fa fa-trash"></i> {{'submission.workflow.generic.delete' |
<span *ngIf="!(processingDelete$ | async)"><i class="fa fa-trash"></i> {{'submission.workflow.generic.delete' | translate}}</span> translate}}</span>
</button> </button>
</div> </div>
<ng-template #content let-c="close" let-d="dismiss"> <ng-template #content let-c="close" let-d="dismiss">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title text-danger">{{'submission.general.discard.confirm.title' | translate}}</h4> <h4 class="modal-title text-danger">{{'submission.general.discard.confirm.title' | translate}}</h4>
@@ -28,7 +34,9 @@
<p>{{'submission.general.discard.confirm.info' | translate}}</p> <p>{{'submission.general.discard.confirm.info' | translate}}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" id="delete_cancel" class="btn btn-secondary" (click)="c('cancel')">{{'submission.general.discard.confirm.cancel' | translate}}</button> <button type="button" id="delete_cancel" class="btn btn-secondary"
<button type="button" id="delete_confirm"class="btn btn-danger" (click)="c('ok')">{{'submission.general.discard.confirm.submit' | translate}}</button> (click)="c('cancel')">{{'submission.general.discard.confirm.cancel' | translate}}</button>
<button type="button" id="delete_confirm" class="btn btn-danger"
(click)="c('ok')">{{'submission.general.discard.confirm.submit' | translate}}</button>
</div> </div>
</ng-template> </ng-template>

View File

@@ -132,6 +132,12 @@ describe('WorkspaceitemActionsComponent', () => {
expect(btn).toBeDefined(); expect(btn).toBeDefined();
}); });
it('should display view button', () => {
const btn = fixture.debugElement.query(By.css('button [data-test="view-btn"]'));
expect(btn).toBeDefined();
});
describe('on discard confirmation', () => { describe('on discard confirmation', () => {
beforeEach((done) => { beforeEach((done) => {
mockDataService.delete.and.returnValue(observableOf(true)); mockDataService.delete.and.returnValue(observableOf(true));

View File

@@ -14,6 +14,7 @@ import { SearchService } from '../../../core/shared/search/search.service';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { NoContent } from '../../../core/shared/NoContent.model'; import { NoContent } from '../../../core/shared/NoContent.model';
import { getWorkspaceItemViewRoute } from '../../../workspaceitems-edit-page/workspaceitems-edit-page-routing-paths';
/** /**
* This component represents actions related to WorkspaceItem object. * This component represents actions related to WorkspaceItem object.
@@ -48,12 +49,12 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
* @param {RequestService} requestService * @param {RequestService} requestService
*/ */
constructor(protected injector: Injector, constructor(protected injector: Injector,
protected router: Router, protected router: Router,
protected modalService: NgbModal, protected modalService: NgbModal,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected translate: TranslateService, protected translate: TranslateService,
protected searchService: SearchService, protected searchService: SearchService,
protected requestService: RequestService) { protected requestService: RequestService) {
super(WorkspaceItem.type, injector, router, notificationsService, translate, searchService, requestService); super(WorkspaceItem.type, injector, router, notificationsService, translate, searchService, requestService);
} }
@@ -85,4 +86,11 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
this.object = object; this.object = object;
} }
/**
* Get the workflowitem view route.
*/
getWorkspaceItemViewRoute(workspaceItem: WorkspaceItem): string {
return getWorkspaceItemViewRoute(workspaceItem?.id);
}
} }

View File

@@ -0,0 +1,53 @@
import { isNumeric } from './numeric.util';
describe('Numeric Utils', () => {
describe('isNumeric', () => {
it('should return true for Number values', () => {
expect(isNumeric(0)).toBeTrue();
expect(isNumeric(123456)).toBeTrue();
expect(isNumeric(-123456)).toBeTrue();
expect(isNumeric(0.1234)).toBeTrue();
expect(isNumeric(-0.1234)).toBeTrue();
expect(isNumeric(1234e56)).toBeTrue();
expect(isNumeric(-1234e-56)).toBeTrue();
expect(isNumeric(0x123456)).toBeTrue();
expect(isNumeric(-0x123456)).toBeTrue();
});
it('should return true for numeric String values', () => {
expect(isNumeric('0')).toBeTrue();
expect(isNumeric('123456')).toBeTrue();
expect(isNumeric('-123456')).toBeTrue();
expect(isNumeric('0.1234')).toBeTrue();
expect(isNumeric('-0.1234')).toBeTrue();
expect(isNumeric('1234e56')).toBeTrue();
expect(isNumeric('-1234e-56')).toBeTrue();
expect(isNumeric('0x123456')).toBeTrue();
// expect(isNumeric('-0x123456')).toBeTrue(); // not recognized as numeric, known issue
});
it('should return false for non-numeric String values', () => {
expect(isNumeric('just a regular string')).toBeFalse();
expect(isNumeric('')).toBeFalse();
expect(isNumeric(' ')).toBeFalse();
expect(isNumeric('\n')).toBeFalse();
expect(isNumeric('\t')).toBeFalse();
expect(isNumeric('null')).toBeFalse();
expect(isNumeric('undefined')).toBeFalse();
});
it('should return false for any other kind of value', () => {
expect(isNumeric([1,2,3])).toBeFalse();
expect(isNumeric({ a:1, b:2, c:3 })).toBeFalse();
expect(isNumeric(() => { /* empty */ })).toBeFalse();
expect(isNumeric(null)).toBeFalse();
expect(isNumeric(undefined)).toBeFalse();
expect(isNumeric(true)).toBeFalse();
expect(isNumeric(false)).toBeFalse();
expect(isNumeric(NaN)).toBeFalse();
expect(isNumeric(Infinity)).toBeFalse();
expect(isNumeric(-Infinity)).toBeFalse();
});
});
});

View File

@@ -0,0 +1,16 @@
/**
* Whether a value is a Number or numeric string.
*
* Taken from RxJs 6.x (licensed under Apache 2.0)
* This function was removed from RxJs 7.x onwards.
*
* @param val: any value
* @returns whether this value is numeric
*/
export function isNumeric(val: any): val is number | string {
// parseFloat NaNs numeric-cast false positives (null|true|false|"")
// ...but misinterprets leading-number strings, particularly hex literals ("0x...")
// subtraction forces infinities to NaN
// adding 1 corrects loss of precision from parseFloat (#15100)
return !Array.isArray(val) && (val - parseFloat(val) + 1) >= 0;
}

View File

@@ -3,9 +3,12 @@ import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { SearchResultDetailElementComponent } from '../search-result-detail-element.component'; import { SearchResultDetailElementComponent } from '../search-result-detail-element.component';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import {
MyDspaceItemStatusType
} from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator';
import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model';
import { Context } from '../../../../core/shared/context.model';
/** /**
* This component renders item object for the search result in the detail view. * This component renders item object for the search result in the detail view.
@@ -16,7 +19,8 @@ import { ItemSearchResult } from '../../../object-collection/shared/item-search-
templateUrl: './item-search-result-detail-element.component.html' templateUrl: './item-search-result-detail-element.component.html'
}) })
@listableObjectComponent(Item, ViewMode.DetailedListElement) @listableObjectComponent(ItemSearchResult, ViewMode.DetailedListElement, Context.Workspace)
@listableObjectComponent(ItemSearchResult, ViewMode.DetailedListElement, Context.Workflow)
export class ItemSearchResultDetailElementComponent extends SearchResultDetailElementComponent<ItemSearchResult, Item> { export class ItemSearchResultDetailElementComponent extends SearchResultDetailElementComponent<ItemSearchResult, Item> {
/** /**

View File

@@ -18,7 +18,7 @@
> >
<div class="card-columns row" *ngIf="objects?.hasSucceeded"> <div class="card-columns row" *ngIf="objects?.hasSucceeded">
<div class="card-column col col-sm-6 col-lg-4" *ngFor="let column of (columns$ | async)" @fadeIn> <div class="card-column col col-sm-6 col-lg-4" *ngFor="let column of (columns$ | async)" @fadeIn>
<div class="card-element" *ngFor="let object of column" data-test="grid-object"> <div class="card-element" *ngFor="let object of column" [attr.data-test]="'grid-object' | dsBrowserOnly">
<ds-listable-object-component-loader [object]="object" [viewMode]="viewMode" [context]="context" [linkType]="linkType"></ds-listable-object-component-loader> <ds-listable-object-component-loader [object]="object" [viewMode]="viewMode" [context]="context" [linkType]="linkType"></ds-listable-object-component-loader>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,7 @@
(next)="goNext()" (next)="goNext()"
> >
<ul *ngIf="objects?.hasSucceeded" class="list-unstyled" [ngClass]="{'ml-4': selectable}"> <ul *ngIf="objects?.hasSucceeded" class="list-unstyled" [ngClass]="{'ml-4': selectable}">
<li *ngFor="let object of objects?.payload?.page; let i = index; let last = last" class="mt-4 mb-4 d-flex" [class.border-bottom]="hasBorder && !last" data-test="list-object"> <li *ngFor="let object of objects?.payload?.page; let i = index; let last = last" class="mt-4 mb-4 d-flex" [class.border-bottom]="hasBorder && !last" [attr.data-test]="'list-object' | dsBrowserOnly">
<ds-selectable-list-item-control *ngIf="selectable" [index]="i" <ds-selectable-list-item-control *ngIf="selectable" [index]="i"
[object]="object" [object]="object"
[selectionConfig]="selectionConfig" [selectionConfig]="selectionConfig"

View File

@@ -17,6 +17,12 @@ import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../testing/pagination-service.stub'; import { PaginationServiceStub } from '../../testing/pagination-service.stub';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { LinkHeadService } from '../../../core/services/link-head.service';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service.stub';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
describe('CollectionSelectComponent', () => { describe('CollectionSelectComponent', () => {
let comp: CollectionSelectComponent; let comp: CollectionSelectComponent;
@@ -44,6 +50,25 @@ describe('CollectionSelectComponent', () => {
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'
]
}))
});
const paginationService = new PaginationServiceStub(); const paginationService = new PaginationServiceStub();
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -53,7 +78,11 @@ describe('CollectionSelectComponent', () => {
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockCollectionList[1].id]) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockCollectionList[1].id]) },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: AuthorizationDataService, useValue: authorizationDataService } { provide: AuthorizationDataService, useValue: authorizationDataService },
{ 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

@@ -18,6 +18,12 @@ import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../testing/pagination-service.stub'; import { PaginationServiceStub } from '../../testing/pagination-service.stub';
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';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { LinkHeadService } from '../../../core/services/link-head.service';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service.stub';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
describe('ItemSelectComponent', () => { describe('ItemSelectComponent', () => {
let comp: ItemSelectComponent; let comp: ItemSelectComponent;
@@ -70,6 +76,24 @@ describe('ItemSelectComponent', () => {
const authorizationDataService = new AuthorizationDataService(null, null, null, null, null, null, null, null, null, null); const authorizationDataService = new AuthorizationDataService(null, null, null, null, null, null, null, null, null, null);
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({
@@ -79,7 +103,11 @@ describe('ItemSelectComponent', () => {
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockItemList[1].id]) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockItemList[1].id]) },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: AuthorizationDataService, useValue: authorizationDataService } { provide: AuthorizationDataService, useValue: authorizationDataService },
{ 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

@@ -15,6 +15,7 @@
<button class="dropdown-item" *ngFor="let direction of (sortDirections | dsKeys)" (click)="doSortDirectionChange(direction.value)"><i [ngClass]="{'invisible': direction.value !== (sortDirection$ |async)}" class="fas fa-check" aria-hidden="true"></i> {{'sorting.' + direction.key | translate}} </button> <button class="dropdown-item" *ngFor="let direction of (sortDirections | dsKeys)" (click)="doSortDirectionChange(direction.value)"><i [ngClass]="{'invisible': direction.value !== (sortDirection$ |async)}" class="fas fa-check" aria-hidden="true"></i> {{'sorting.' + direction.key | translate}} </button>
</div> </div>
</div> </div>
<ds-rss></ds-rss>
</div> </div>
</div> </div>
</div> </div>

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