Merge branch 'main' into CST-5668

This commit is contained in:
Luca Giamminonni
2022-05-20 17:18:44 +02:00
89 changed files with 1864 additions and 1097 deletions

2
.gitignore vendored
View File

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

View File

@@ -330,7 +330,10 @@ 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.
* 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. * 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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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';
@@ -194,7 +193,6 @@ const DECLARATIONS = [];
const EXPORTS = []; const EXPORTS = [];
const PROVIDERS = [ const PROVIDERS = [
ApiService,
AuthenticatedGuard, AuthenticatedGuard,
CommunityDataService, CommunityDataService,
CollectionDataService, CollectionDataService,

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

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

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

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

@@ -22,6 +22,7 @@ import { getTestScheduler } from 'jasmine-marbles';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
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';
import { EmptySpecialGroupDataMock$, SpecialGroupDataMock$ } from '../shared/testing/special-group.mock';
describe('ProfilePageComponent', () => { describe('ProfilePageComponent', () => {
let component: ProfilePageComponent; let component: ProfilePageComponent;
@@ -56,7 +57,8 @@ describe('ProfilePageComponent', () => {
}; };
authService = jasmine.createSpyObj('authService', { authService = jasmine.createSpyObj('authService', {
getAuthenticatedUserFromStore: observableOf(user) getAuthenticatedUserFromStore: observableOf(user),
getSpecialGroupsFromAuthStatus: SpecialGroupDataMock$
}); });
epersonService = jasmine.createSpyObj('epersonService', { epersonService = jasmine.createSpyObj('epersonService', {
findById: createSuccessfulRemoteDataObject$(user), findById: createSuccessfulRemoteDataObject$(user),
@@ -245,4 +247,25 @@ describe('ProfilePageComponent', () => {
}); });
}); });
}); });
describe('check for specialGroups', () => {
it('should contains specialGroups list', () => {
const specialGroupsEle = fixture.debugElement.query(By.css('[data-test="specialGroups"]'));
expect(specialGroupsEle).toBeTruthy();
});
it('should not contains specialGroups list', () => {
component.specialGroupsRD$ = null;
fixture.detectChanges();
const specialGroupsEle = fixture.debugElement.query(By.css('[data-test="specialGroups"]'));
expect(specialGroupsEle).toBeFalsy();
});
it('should not contains specialGroups list', () => {
component.specialGroupsRD$ = EmptySpecialGroupDataMock$;
fixture.detectChanges();
const specialGroupsEle = fixture.debugElement.query(By.css('[data-test="specialGroups"]'));
expect(specialGroupsEle).toBeFalsy();
});
});
}); });

View File

@@ -46,6 +46,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
*/ */
@@ -92,10 +97,10 @@ 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.configurationService.findByPropertyName('researcher-profile.entity-type').pipe( this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe(
getFirstSucceededRemoteDataPayload() getFirstSucceededRemoteDataPayload()
).subscribe(() => this.isResearcherProfileEnabled$.next(true)); ).subscribe(() => this.isResearcherProfileEnabled$.next(true));
this.specialGroupsRD$ = this.authService.getSpecialGroupsFromAuthStatus();
} }
/** /**

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -4,10 +4,10 @@
<div *ngIf="showScopeSelector" class="input-group-prepend"> <div *ngIf="showScopeSelector" class="input-group-prepend">
<button class="scope-button btn btn-outline-secondary text-truncate" [ngbTooltip]="(selectedScope | async)?.name" type="button" (click)="openScopeModal()">{{(selectedScope | async)?.name || ('search.form.scope.all' | translate)}}</button> <button class="scope-button btn btn-outline-secondary text-truncate" [ngbTooltip]="(selectedScope | async)?.name" type="button" (click)="openScopeModal()">{{(selectedScope | async)?.name || ('search.form.scope.all' | translate)}}</button>
</div> </div>
<input type="text" [(ngModel)]="query" name="query" class="form-control" attr.aria-label="{{ searchPlaceholder }}" data-test="search-box" <input type="text" [(ngModel)]="query" name="query" class="form-control" attr.aria-label="{{ searchPlaceholder }}" [attr.data-test]="'search-box' | dsBrowserOnly"
[placeholder]="searchPlaceholder"> [placeholder]="searchPlaceholder">
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-{{brandColor}}" data-test="search-button"><i class="fas fa-search"></i> {{ ('search.form.search' | translate) }}</button> <button type="submit" class="search-button btn btn-{{brandColor}}" [attr.data-test]="'search-button' | dsBrowserOnly"><i class="fas fa-search"></i> {{ ('search.form.search' | translate) }}</button>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -13,6 +13,7 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf
import { PaginationServiceStub } from '../testing/pagination-service.stub'; import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe';
import { SearchServiceStub } from '../testing/search-service.stub'; import { SearchServiceStub } from '../testing/search-service.stub';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RouterStub } from '../testing/router.stub'; import { RouterStub } from '../testing/router.stub';
@@ -41,7 +42,10 @@ describe('SearchFormComponent', () => {
{ provide: SearchConfigurationService, useValue: searchConfigService }, { provide: SearchConfigurationService, useValue: searchConfigService },
{ provide: DSpaceObjectDataService, useValue: dspaceObjectService }, { provide: DSpaceObjectDataService, useValue: dspaceObjectService },
], ],
declarations: [SearchFormComponent] declarations: [
SearchFormComponent,
BrowserOnlyMockPipe,
]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -4,6 +4,7 @@
class="filter-name d-flex" [attr.aria-controls]="regionId" [id]="toggleId" class="filter-name d-flex" [attr.aria-controls]="regionId" [id]="toggleId"
[attr.aria-expanded]="false" [attr.aria-expanded]="false"
[attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate" [attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate"
[attr.data-test]="'filter-toggle' | dsBrowserOnly"
> >
<h5 class="d-inline-block mb-0"> <h5 class="d-inline-block mb-0">
{{'search.filters.filter.' + filter.name + '.head'| translate}} {{'search.filters.filter.' + filter.name + '.head'| translate}}

View File

@@ -13,6 +13,7 @@ import { FilterType } from '../../models/filter-type.model';
import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub'; import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub';
import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
import { SequenceService } from '../../../../core/shared/sequence.service'; import { SequenceService } from '../../../../core/shared/sequence.service';
import { BrowserOnlyMockPipe } from '../../../testing/browser-only-mock.pipe';
describe('SearchFilterComponent', () => { describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent; let comp: SearchFilterComponent;
@@ -62,7 +63,10 @@ describe('SearchFilterComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFilterComponent], declarations: [
SearchFilterComponent,
BrowserOnlyMockPipe,
],
providers: [ providers: [
{ provide: SearchService, useValue: searchServiceStub }, { provide: SearchService, useValue: searchServiceStub },
{ {

View File

@@ -178,6 +178,7 @@ import { RSSComponent } from './rss-feed/rss.component';
import { ExternalLinkMenuItemComponent } from './menu/menu-item/external-link-menu-item.component'; import { ExternalLinkMenuItemComponent } from './menu/menu-item/external-link-menu-item.component';
import { DsoPageOrcidButtonComponent } from './dso-page/dso-page-orcid-button/dso-page-orcid-button.component'; import { DsoPageOrcidButtonComponent } from './dso-page/dso-page-orcid-button/dso-page-orcid-button.component';
import { LogInOrcidComponent } from './log-in/methods/orcid/log-in-orcid.component'; import { LogInOrcidComponent } from './log-in/methods/orcid/log-in-orcid.component';
import { BrowserOnlyPipe } from './utils/browser-only.pipe';
const MODULES = [ const MODULES = [
CommonModule, CommonModule,
@@ -219,7 +220,8 @@ const PIPES = [
ObjectKeysPipe, ObjectKeysPipe,
ObjectValuesPipe, ObjectValuesPipe,
ConsolePipe, ConsolePipe,
ObjNgFor ObjNgFor,
BrowserOnlyPipe,
]; ];
const COMPONENTS = [ const COMPONENTS = [

View File

@@ -1,5 +1,5 @@
<div class="facet-filter d-block mb-3 p-3"> <div class="facet-filter d-block mb-3 p-3">
<div (click)="toggle()" class="filter-name"> <div (click)="toggle()" class="filter-name" [attr.data-test]="'filter-toggle' | dsBrowserOnly">
<h5 class="d-inline-block mb-0"> <h5 class="d-inline-block mb-0">
{{ label | translate }} {{ label | translate }}
</h5> </h5>

View File

@@ -34,6 +34,9 @@ export class AuthRequestServiceStub {
}, },
eperson: { eperson: {
href: this.mockUser._links.self.href href: this.mockUser._links.self.href
},
specialGroups: {
href: this.mockUser._links.self.href
} }
}; };
} else { } else {
@@ -62,6 +65,9 @@ export class AuthRequestServiceStub {
}, },
eperson: { eperson: {
href: this.mockUser._links.self.href href: this.mockUser._links.self.href
},
specialGroups: {
href: this.mockUser._links.self.href
} }
}; };
} else { } else {

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from '@angular/core';
/**
* Support dsBrowserOnly in unit tests.
*/
@Pipe({
name: 'dsBrowserOnly'
})
export class BrowserOnlyMockPipe implements PipeTransform {
transform(value: string): string | undefined {
return value;
}
}

View File

@@ -1,5 +1,6 @@
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { MenuSection } from '../menu/menu-section.model'; import { MenuSection } from '../menu/menu-section.model';
import { MenuState } from '../menu/menu-state.model';
import { MenuID } from '../menu/menu-id.model'; import { MenuID } from '../menu/menu-id.model';
export class MenuServiceStub { export class MenuServiceStub {
@@ -77,6 +78,10 @@ export class MenuServiceStub {
return observableOf(true); return observableOf(true);
} }
getMenu(id: MenuID): Observable<MenuState> { // todo: resolve import
return observableOf({} as MenuState);
}
getMenuTopSections(id: MenuID): Observable<MenuSection[]> { getMenuTopSections(id: MenuID): Observable<MenuSection[]> {
return observableOf([this.visibleSection1, this.visibleSection2]); return observableOf([this.visibleSection1, this.visibleSection2]);
} }

View File

@@ -0,0 +1,56 @@
import { Observable } from 'rxjs';
import { EPersonMock } from './eperson.mock';
import { PageInfo } from '../../core/shared/page-info.model';
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
import { Group } from '../../core/eperson/models/group.model';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { RemoteData } from '../../core/data/remote-data';
export const SpecialGroupMock2: Group = Object.assign(new Group(), {
handle: null,
subgroups: [],
epersons: [],
permanent: true,
selfRegistered: false,
_links: {
self: {
href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2',
},
subgroups: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/subgroups' },
object: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/object' },
epersons: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/epersons' }
},
_name: 'testgroupname2',
id: 'testgroupid2',
uuid: 'testgroupid2',
type: 'specialGroups',
// object: createSuccessfulRemoteDataObject$({ name: 'testspecialGroupsid2objectName'})
});
export const SpecialGroupMock: Group = Object.assign(new Group(), {
handle: null,
subgroups: [SpecialGroupMock2],
epersons: [EPersonMock],
selfRegistered: false,
permanent: false,
_links: {
self: {
href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid',
},
subgroups: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid/subgroups' },
object: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/object' },
epersons: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid/epersons' }
},
_name: 'testgroupname',
id: 'testgroupid',
uuid: 'testgroupid',
type: 'specialGroups',
});
export const SpecialGroupDataMock: RemoteData<PaginatedList<Group>> = createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [SpecialGroupMock2,SpecialGroupMock]));
export const SpecialGroupDataMock$: Observable<RemoteData<PaginatedList<Group>>> = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [SpecialGroupMock2,SpecialGroupMock]));
export const EmptySpecialGroupDataMock$: Observable<RemoteData<PaginatedList<Group>>> = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));

View File

@@ -5,6 +5,7 @@ import { SharedModule } from '../shared.module';
import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive.stub'; import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive.stub';
import { QueryParamsDirectiveStub } from './query-params-directive.stub'; import { QueryParamsDirectiveStub } from './query-params-directive.stub';
import { RouterLinkDirectiveStub } from './router-link-directive.stub'; import { RouterLinkDirectiveStub } from './router-link-directive.stub';
import { BrowserOnlyMockPipe } from './browser-only-mock.pipe';
/** /**
* This module isn't used. It serves to prevent the AoT compiler * This module isn't used. It serves to prevent the AoT compiler
@@ -21,7 +22,8 @@ import { RouterLinkDirectiveStub } from './router-link-directive.stub';
QueryParamsDirectiveStub, QueryParamsDirectiveStub,
MySimpleItemActionComponent, MySimpleItemActionComponent,
RouterLinkDirectiveStub, RouterLinkDirectiveStub,
NgComponentOutletDirectiveStub NgComponentOutletDirectiveStub,
BrowserOnlyMockPipe,
], ],
exports: [ exports: [
QueryParamsDirectiveStub QueryParamsDirectiveStub

View File

@@ -0,0 +1,35 @@
import { Inject, Pipe, PipeTransform, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
/**
* A pipe that only returns its input when run in the browser.
* Used to distinguish client-side rendered content from server-side rendered content.
*
* When used with attributes as in
* ```
* [attr.data-test]="'something' | dsBrowserOnly"
* ```
* the server-side rendered HTML will not contain the `data-test` attribute.
* When rendered client-side, the HTML will contain `data-test="something"`
*
* This can be useful for end-to-end testing elements that need JS (that isn't included in SSR HTML) to function:
* By depending on `dsBrowserOnly` attributes in tests we can make sure we wait
* until such components are fully interactive before trying to interact with them.
*/
@Pipe({
name: 'dsBrowserOnly'
})
export class BrowserOnlyPipe implements PipeTransform {
constructor(
@Inject(PLATFORM_ID) private platformID: any,
) {
}
transform(value: string): string | undefined {
if (isPlatformBrowser((this.platformID))) {
return value;
} else {
return undefined;
}
}
}

View File

@@ -7,7 +7,7 @@
routerLinkActive="active" routerLinkActive="active"
[class.active]="currentMode === viewModeEnum.ListElement" [class.active]="currentMode === viewModeEnum.ListElement"
class="btn btn-secondary" class="btn btn-secondary"
data-test="list-view"> [attr.data-test]="'list-view' | dsBrowserOnly">
<i class="fas fa-list" title="{{'search.view-switch.show-list' | translate}}"></i> <i class="fas fa-list" title="{{'search.view-switch.show-list' | translate}}"></i>
</a> </a>
<a *ngIf="isToShow(viewModeEnum.GridElement)" <a *ngIf="isToShow(viewModeEnum.GridElement)"
@@ -18,7 +18,7 @@
routerLinkActive="active" routerLinkActive="active"
[class.active]="currentMode === viewModeEnum.GridElement" [class.active]="currentMode === viewModeEnum.GridElement"
class="btn btn-secondary" class="btn btn-secondary"
data-test="grid-view"> [attr.data-test]="'grid-view' | dsBrowserOnly">
<i class="fas fa-th-large" title="{{'search.view-switch.show-grid' | translate}}"></i> <i class="fas fa-th-large" title="{{'search.view-switch.show-grid' | translate}}"></i>
</a> </a>
<a *ngIf="isToShow(viewModeEnum.DetailedListElement)" <a *ngIf="isToShow(viewModeEnum.DetailedListElement)"
@@ -29,7 +29,7 @@
routerLinkActive="active" routerLinkActive="active"
[class.active]="currentMode === viewModeEnum.DetailedListElement" [class.active]="currentMode === viewModeEnum.DetailedListElement"
class="btn btn-secondary" class="btn btn-secondary"
data-test="detail-view"> [attr.data-test]="'detail-view' | dsBrowserOnly">
<i class="far fa-square" title="{{'search.view-switch.show-detail' | translate}}"></i> <i class="far fa-square" title="{{'search.view-switch.show-detail' | translate}}"></i>
</a> </a>
</div> </div>

View File

@@ -9,6 +9,7 @@ import { SearchService } from '../../core/shared/search/search.service';
import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { ViewModeSwitchComponent } from './view-mode-switch.component';
import { SearchServiceStub } from '../testing/search-service.stub'; import { SearchServiceStub } from '../testing/search-service.stub';
import { ViewMode } from '../../core/shared/view-mode.model'; import { ViewMode } from '../../core/shared/view-mode.model';
import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe';
@Component({ template: '' }) @Component({ template: '' })
class DummyComponent { class DummyComponent {
@@ -36,7 +37,8 @@ describe('ViewModeSwitchComponent', () => {
], ],
declarations: [ declarations: [
ViewModeSwitchComponent, ViewModeSwitchComponent,
DummyComponent DummyComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: SearchService, useValue: searchService }, { provide: SearchService, useValue: searchService },

View File

@@ -3,6 +3,7 @@
<button *ngIf="(showDepositAndDiscard | async)" <button *ngIf="(showDepositAndDiscard | async)"
type="button" type="button"
id="discard" id="discard"
[attr.data-test]="'discard' | dsBrowserOnly"
class="btn btn-danger" class="btn btn-danger"
[disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)" [disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)"
(click)="$event.preventDefault();confirmDiscard(content)"> (click)="$event.preventDefault();confirmDiscard(content)">
@@ -26,6 +27,7 @@
<button type="button" <button type="button"
class="btn btn-secondary" class="btn btn-secondary"
id="save" id="save"
[attr.data-test]="'save' | dsBrowserOnly"
[disabled]="(processingSaveStatus | async) || !(hasUnsavedModification | async)" [disabled]="(processingSaveStatus | async) || !(hasUnsavedModification | async)"
(click)="save($event)"> (click)="save($event)">
<span><i class="fas fa-save"></i> {{'submission.general.save' | translate}}</span> <span><i class="fas fa-save"></i> {{'submission.general.save' | translate}}</span>
@@ -35,6 +37,7 @@
[class.btn-secondary]="(showDepositAndDiscard | async)" [class.btn-secondary]="(showDepositAndDiscard | async)"
class="btn" class="btn"
id="saveForLater" id="saveForLater"
[attr.data-test]="'save-for-later' | dsBrowserOnly"
[disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)" [disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)"
(click)="saveLater($event)"> (click)="saveLater($event)">
<span><i class="fas fa-save"></i> {{'submission.general.save-later' | translate}}</span> <span><i class="fas fa-save"></i> {{'submission.general.save-later' | translate}}</span>
@@ -42,6 +45,7 @@
<button *ngIf="(showDepositAndDiscard | async)" <button *ngIf="(showDepositAndDiscard | async)"
type="button" type="button"
id="deposit" id="deposit"
[attr.data-test]="'deposit' | dsBrowserOnly"
class="btn btn-success" class="btn btn-success"
[disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)" [disabled]="(processingSaveStatus | async) || (processingDepositStatus | async)"
(click)="deposit($event)"> (click)="deposit($event)">

View File

@@ -15,12 +15,13 @@ import { SubmissionRestServiceStub } from '../../../shared/testing/submission-re
import { SubmissionFormFooterComponent } from './submission-form-footer.component'; import { SubmissionFormFooterComponent } from './submission-form-footer.component';
import { SubmissionRestService } from '../../../core/submission/submission-rest.service'; import { SubmissionRestService } from '../../../core/submission/submission-rest.service';
import { createTestComponent } from '../../../shared/testing/utils.test'; import { createTestComponent } from '../../../shared/testing/utils.test';
import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe';
const submissionServiceStub: SubmissionServiceStub = new SubmissionServiceStub(); const submissionServiceStub: SubmissionServiceStub = new SubmissionServiceStub();
const submissionId = mockSubmissionId; const submissionId = mockSubmissionId;
describe('SubmissionFormFooterComponent Component', () => { describe('SubmissionFormFooterComponent', () => {
let comp: SubmissionFormFooterComponent; let comp: SubmissionFormFooterComponent;
let compAsAny: any; let compAsAny: any;
@@ -36,7 +37,8 @@ describe('SubmissionFormFooterComponent Component', () => {
], ],
declarations: [ declarations: [
SubmissionFormFooterComponent, SubmissionFormFooterComponent,
TestComponent TestComponent,
BrowserOnlyMockPipe,
], ],
providers: [ providers: [
{ provide: SubmissionService, useValue: submissionServiceStub }, { provide: SubmissionService, useValue: submissionServiceStub },

View File

@@ -2897,6 +2897,8 @@
"profile.groups.head": "Authorization groups you belong to", "profile.groups.head": "Authorization groups you belong to",
"profile.special.groups.head": "Authorization special groups you belong to",
"profile.head": "Update Profile", "profile.head": "Update Profile",
"profile.metadata.form.error.firstname.required": "First Name is required", "profile.metadata.form.error.firstname.required": "First Name is required",

View File

@@ -2,11 +2,10 @@ import { HttpClient, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { APP_INITIALIZER, NgModule } from '@angular/core';
import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule, NoPreloading } from '@angular/router';
import { REQUEST } from '@nguniversal/express-engine/tokens'; import { REQUEST } from '@nguniversal/express-engine/tokens';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateJson5HttpLoader } from '../../ngx-translate-loaders/translate-json5-http.loader'; import { TranslateBrowserLoader } from '../../ngx-translate-loaders/translate-browser.loader';
import { IdlePreloadModule } from 'angular-idle-preload'; import { IdlePreloadModule } from 'angular-idle-preload';
@@ -42,8 +41,8 @@ import { environment } from '../../environments/environment';
export const REQ_KEY = makeStateKey<string>('req'); export const REQ_KEY = makeStateKey<string>('req');
export function createTranslateLoader(http: HttpClient) { export function createTranslateLoader(transferState: TransferState, http: HttpClient) {
return new TranslateJson5HttpLoader(http, 'assets/i18n/', '.json5'); return new TranslateBrowserLoader(transferState, http, 'assets/i18n/', '.json5');
} }
export function getRequest(transferState: TransferState): any { export function getRequest(transferState: TransferState): any {
@@ -59,13 +58,6 @@ export function getRequest(transferState: TransferState): any {
HttpClientModule, HttpClientModule,
// forRoot ensures the providers are only created once // forRoot ensures the providers are only created once
IdlePreloadModule.forRoot(), IdlePreloadModule.forRoot(),
RouterModule.forRoot([], {
// enableTracing: true,
useHash: false,
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
preloadingStrategy: NoPreloading
}),
StatisticsModule.forRoot(), StatisticsModule.forRoot(),
Angulartics2RouterlessModule.forRoot(), Angulartics2RouterlessModule.forRoot(),
BrowserAnimationsModule, BrowserAnimationsModule,
@@ -74,7 +66,7 @@ export function getRequest(transferState: TransferState): any {
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useFactory: (createTranslateLoader), useFactory: (createTranslateLoader),
deps: [HttpClient] deps: [TransferState, HttpClient]
} }
}), }),
AppModule AppModule
@@ -92,9 +84,11 @@ export function getRequest(transferState: TransferState): any {
// extend environment with app config for browser // extend environment with app config for browser
extendEnvironmentWithAppConfig(environment, appConfig); extendEnvironmentWithAppConfig(environment, appConfig);
} }
dspaceTransferState.transfer(); return () =>
dspaceTransferState.transfer().then((b: boolean) => {
correlationIdService.initCorrelationId(); correlationIdService.initCorrelationId();
return () => true; return b;
});
}, },
deps: [TransferState, DSpaceTransferState, CorrelationIdService], deps: [TransferState, DSpaceTransferState, CorrelationIdService],
multi: true multi: true

View File

@@ -3,7 +3,6 @@ import { APP_INITIALIZER, NgModule } from '@angular/core';
import { BrowserModule, TransferState } from '@angular/platform-browser'; import { BrowserModule, TransferState } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ServerModule } from '@angular/platform-server'; import { ServerModule } from '@angular/platform-server';
import { RouterModule } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
@@ -15,7 +14,7 @@ import { AppComponent } from '../../app/app.component';
import { AppModule } from '../../app/app.module'; import { AppModule } from '../../app/app.module';
import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module'; import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module';
import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service';
import { TranslateJson5UniversalLoader } from '../../ngx-translate-loaders/translate-json5-universal.loader'; import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader';
import { CookieService } from '../../app/core/services/cookie.service'; import { CookieService } from '../../app/core/services/cookie.service';
import { ServerCookieService } from '../../app/core/services/server-cookie.service'; import { ServerCookieService } from '../../app/core/services/server-cookie.service';
import { AuthService } from '../../app/core/auth/auth.service'; import { AuthService } from '../../app/core/auth/auth.service';
@@ -37,8 +36,8 @@ import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
export function createTranslateLoader() { export function createTranslateLoader(transferState: TransferState) {
return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json5');
} }
@NgModule({ @NgModule({
@@ -47,16 +46,13 @@ export function createTranslateLoader() {
BrowserModule.withServerTransition({ BrowserModule.withServerTransition({
appId: 'dspace-angular' appId: 'dspace-angular'
}), }),
RouterModule.forRoot([], {
useHash: false
}),
NoopAnimationsModule, NoopAnimationsModule,
DSpaceServerTransferStateModule, DSpaceServerTransferStateModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useFactory: (createTranslateLoader), useFactory: (createTranslateLoader),
deps: [] deps: [TransferState]
} }
}), }),
AppModule, AppModule,

View File

@@ -1,12 +1,19 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { coreSelector } from 'src/app/core/core.selectors';
import { StoreAction, StoreActionTypes } from '../../app/store.actions'; import { StoreAction, StoreActionTypes } from '../../app/store.actions';
import { DSpaceTransferState } from './dspace-transfer-state.service'; import { DSpaceTransferState } from './dspace-transfer-state.service';
import { find, map } from 'rxjs/operators';
import { isNotEmpty } from '../../app/shared/empty.util';
@Injectable() @Injectable()
export class DSpaceBrowserTransferState extends DSpaceTransferState { export class DSpaceBrowserTransferState extends DSpaceTransferState {
transfer() { transfer(): Promise<boolean> {
const state = this.transferState.get<any>(DSpaceTransferState.NGRX_STATE, null); const state = this.transferState.get<any>(DSpaceTransferState.NGRX_STATE, null);
this.transferState.remove(DSpaceTransferState.NGRX_STATE); this.transferState.remove(DSpaceTransferState.NGRX_STATE);
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
return this.store.select(coreSelector).pipe(
find((core: any) => isNotEmpty(core)),
map(() => true)
).toPromise();
} }
} }

View File

@@ -5,7 +5,7 @@ import { DSpaceTransferState } from './dspace-transfer-state.service';
@Injectable() @Injectable()
export class DSpaceServerTransferState extends DSpaceTransferState { export class DSpaceServerTransferState extends DSpaceTransferState {
transfer() { transfer(): Promise<boolean> {
this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => { this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => {
let state; let state;
this.store.pipe(take(1)).subscribe((saveState: any) => { this.store.pipe(take(1)).subscribe((saveState: any) => {
@@ -14,5 +14,7 @@ export class DSpaceServerTransferState extends DSpaceTransferState {
return state; return state;
}); });
return new Promise<boolean>(() => true);
} }
} }

View File

@@ -14,5 +14,5 @@ export abstract class DSpaceTransferState {
) { ) {
} }
abstract transfer(): void; abstract transfer(): Promise<boolean>;
} }

View File

@@ -0,0 +1,15 @@
import { makeStateKey } from '@angular/platform-browser';
/**
* Represents ngx-translate messages in different languages in the TransferState
*/
export class NgxTranslateState {
[lang: string]: {
[key: string]: string
}
}
/**
* The key to store the NgxTranslateState as part of the TransferState
*/
export const NGX_TRANSLATE_STATE = makeStateKey<NgxTranslateState>('NGX_TRANSLATE_STATE');

View File

@@ -0,0 +1,44 @@
import { TranslateLoader } from '@ngx-translate/core';
import { HttpClient } from '@angular/common/http';
import { TransferState } from '@angular/platform-browser';
import { NGX_TRANSLATE_STATE, NgxTranslateState } from './ngx-translate-state';
import { hasValue } from '../app/shared/empty.util';
import { map } from 'rxjs/operators';
import { of as observableOf, Observable } from 'rxjs';
import * as JSON5 from 'json5';
/**
* A TranslateLoader for ngx-translate to retrieve i18n messages from the TransferState, or download
* them if they're not available there
*/
export class TranslateBrowserLoader implements TranslateLoader {
constructor(
protected transferState: TransferState,
protected http: HttpClient,
protected prefix?: string,
protected suffix?: string
) {
}
/**
* Return the i18n messages for a given language, first try to find them in the TransferState
* retrieve them using HttpClient if they're not available there
*
* @param lang the language code
*/
getTranslation(lang: string): Observable<any> {
// Get the ngx-translate messages from the transfer state, to speed up the initial page load
// client side
const state = this.transferState.get<NgxTranslateState>(NGX_TRANSLATE_STATE, {});
const messages = state[lang];
if (hasValue(messages)) {
return observableOf(messages);
} else {
// If they're not available on the transfer state (e.g. when running in dev mode), retrieve
// them using HttpClient
return this.http.get('' + this.prefix + lang + this.suffix, { responseType: 'text' }).pipe(
map((json: any) => JSON5.parse(json))
);
}
}
}

View File

@@ -1,15 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { TranslateLoader } from '@ngx-translate/core';
import { map } from 'rxjs/operators';
import * as JSON5 from 'json5';
export class TranslateJson5HttpLoader implements TranslateLoader {
constructor(private http: HttpClient, public prefix?: string, public suffix?: string) {
}
getTranslation(lang: string): any {
return this.http.get('' + this.prefix + lang + this.suffix, {responseType: 'text'}).pipe(
map((json: any) => JSON5.parse(json))
);
}
}

View File

@@ -1,18 +0,0 @@
import { TranslateLoader } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import * as fs from 'fs';
const JSON5 = require('json5').default;
export class TranslateJson5UniversalLoader implements TranslateLoader {
constructor(private prefix: string = 'dist/assets/i18n/', private suffix: string = '.json') { }
public getTranslation(lang: string): Observable<any> {
return Observable.create((observer: any) => {
observer.next(JSON5.parse(fs.readFileSync(`${this.prefix}${lang}${this.suffix}`, 'utf8')));
observer.complete();
});
}
}

View File

@@ -0,0 +1,52 @@
import { TranslateLoader } from '@ngx-translate/core';
import { Observable, of as observableOf } from 'rxjs';
import * as fs from 'fs';
import { TransferState } from '@angular/platform-browser';
import { NGX_TRANSLATE_STATE, NgxTranslateState } from './ngx-translate-state';
const JSON5 = require('json5').default;
/**
* A TranslateLoader for ngx-translate to parse json5 files server-side, and store them in the
* TransferState
*/
export class TranslateServerLoader implements TranslateLoader {
constructor(
protected transferState: TransferState,
protected prefix: string = 'dist/assets/i18n/',
protected suffix: string = '.json'
) {
}
/**
* Return the i18n messages for a given language, and store them in the TransferState
*
* @param lang the language code
*/
public getTranslation(lang: string): Observable<any> {
// Retrieve the file for the given language, and parse it
const messages = JSON5.parse(fs.readFileSync(`${this.prefix}${lang}${this.suffix}`, 'utf8'));
// Store the parsed messages in the transfer state so they'll be available immediately when the
// app loads on the client
this.storeInTransferState(lang, messages);
// Return the parsed messages to translate things server side
return observableOf(messages);
}
/**
* Store the i18n messages for the given language code in the transfer state, so they can be
* retrieved client side
*
* @param lang the language code
* @param messages the i18n messages
* @protected
*/
protected storeInTransferState(lang: string, messages) {
const prevState = this.transferState.get<NgxTranslateState>(NGX_TRANSLATE_STATE, {});
const nextState = Object.assign({}, prevState, {
[lang]: messages
});
this.transferState.set(NGX_TRANSLATE_STATE, nextState);
}
}

View File

@@ -0,0 +1,58 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../app/shared/shared.module';
import { HomeNewsComponent } from './app/home-page/home-news/home-news.component';
import { NavbarComponent } from './app/navbar/navbar.component';
import { HeaderComponent } from './app/header/header.component';
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
import { SearchModule } from '../../app/shared/search/search.module';
import { RootModule } from '../../app/root.module';
import { NavbarModule } from '../../app/navbar/navbar.module';
import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component';
import { ItemPageModule } from '../../app/item-page/item-page.module';
import { FooterComponent } from './app/footer/footer.component';
/**
* Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS.
* This will ensure that decorator gets picked up when the app loads
*/
const ENTRY_COMPONENTS = [
PublicationComponent,
];
const DECLARATIONS = [
...ENTRY_COMPONENTS,
HomeNewsComponent,
HeaderComponent,
HeaderNavbarWrapperComponent,
NavbarComponent,
FooterComponent,
];
@NgModule({
imports: [
CommonModule,
SharedModule,
SearchModule,
FormsModule,
RootModule,
NavbarModule,
ItemPageModule,
],
declarations: DECLARATIONS,
providers: [
...ENTRY_COMPONENTS.map((component) => ({ provide: component }))
],
})
/**
* This module is included in the main bundle that gets downloaded at first page load. So it should
* contain only the themed components that have to be available immediately for the first page load,
* and the minimal set of imports required to make them work. Anything you can cut from it will make
* the initial page load faster, but may cause the page to flicker as components that were already
* rendered server side need to be lazy-loaded again client side
*
* Themed EntryComponents should also be added here
*/
export class EagerThemeModule {
}

View File

@@ -1,5 +0,0 @@
import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component';
export const ENTRY_COMPONENTS = [
PublicationComponent
];

View File

@@ -28,20 +28,28 @@ import { StatisticsModule } from '../../app/statistics/statistics.module';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { StoreRouterConnectingModule } from '@ngrx/router-store'; import { StoreRouterConnectingModule } from '@ngrx/router-store';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { HomeNewsComponent } from './app/home-page/home-news/home-news.component';
import { HomePageComponent } from './app/home-page/home-page.component';
import { HomePageModule } from '../../app/home-page/home-page.module'; import { HomePageModule } from '../../app/home-page/home-page.module';
import { RootComponent } from './app/root/root.component';
import { AppModule } from '../../app/app.module'; import { AppModule } from '../../app/app.module';
import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component';
import { ItemPageModule } from '../../app/item-page/item-page.module'; import { ItemPageModule } from '../../app/item-page/item-page.module';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AccessControlModule } from '../../app/access-control/access-control.module'; import { CommunityListPageModule } from '../../app/community-list-page/community-list-page.module';
import { InfoModule } from '../../app/info/info.module';
import { StatisticsPageModule } from '../../app/statistics-page/statistics-page.module';
import { CommunityPageModule } from '../../app/community-page/community-page.module';
import { CollectionPageModule } from '../../app/collection-page/collection-page.module';
import { SubmissionModule } from '../../app/submission/submission.module';
import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module';
import { SearchModule } from '../../app/shared/search/search.module';
import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module';
import { ComcolModule } from '../../app/shared/comcol/comcol.module';
import { RootModule } from '../../app/root.module';
import { FileSectionComponent } from './app/item-page/simple/field-components/file-section/file-section.component';
import { HomePageComponent } from './app/home-page/home-page.component';
import { RootComponent } from './app/root/root.component';
import { BrowseBySwitcherComponent } from './app/browse-by/browse-by-switcher/browse-by-switcher.component'; import { BrowseBySwitcherComponent } from './app/browse-by/browse-by-switcher/browse-by-switcher.component';
import { CommunityListPageComponent } from './app/community-list-page/community-list-page.component'; import { CommunityListPageComponent } from './app/community-list-page/community-list-page.component';
import { CommunityListPageModule } from '../../app/community-list-page/community-list-page.module';
import { SearchPageComponent } from './app/search-page/search-page.component'; import { SearchPageComponent } from './app/search-page/search-page.component';
import { InfoModule } from '../../app/info/info.module'; import { ConfigurationSearchPageComponent } from './app/search-page/configuration-search-page.component';
import { EndUserAgreementComponent } from './app/info/end-user-agreement/end-user-agreement.component'; import { EndUserAgreementComponent } from './app/info/end-user-agreement/end-user-agreement.component';
import { PageNotFoundComponent } from './app/pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './app/pagenotfound/pagenotfound.component';
import { ObjectNotFoundComponent } from './app/lookup-by-id/objectnotfound/objectnotfound.component'; import { ObjectNotFoundComponent } from './app/lookup-by-id/objectnotfound/objectnotfound.component';
@@ -49,14 +57,10 @@ import { ForbiddenComponent } from './app/forbidden/forbidden.component';
import { PrivacyComponent } from './app/info/privacy/privacy.component'; import { PrivacyComponent } from './app/info/privacy/privacy.component';
import { CollectionStatisticsPageComponent } from './app/statistics-page/collection-statistics-page/collection-statistics-page.component'; import { CollectionStatisticsPageComponent } from './app/statistics-page/collection-statistics-page/collection-statistics-page.component';
import { CommunityStatisticsPageComponent } from './app/statistics-page/community-statistics-page/community-statistics-page.component'; import { CommunityStatisticsPageComponent } from './app/statistics-page/community-statistics-page/community-statistics-page.component';
import { StatisticsPageModule } from '../../app/statistics-page/statistics-page.module';
import { ItemStatisticsPageComponent } from './app/statistics-page/item-statistics-page/item-statistics-page.component'; import { ItemStatisticsPageComponent } from './app/statistics-page/item-statistics-page/item-statistics-page.component';
import { SiteStatisticsPageComponent } from './app/statistics-page/site-statistics-page/site-statistics-page.component'; import { SiteStatisticsPageComponent } from './app/statistics-page/site-statistics-page/site-statistics-page.component';
import { CommunityPageComponent } from './app/community-page/community-page.component'; import { CommunityPageComponent } from './app/community-page/community-page.component';
import { CollectionPageComponent } from './app/collection-page/collection-page.component'; import { CollectionPageComponent } from './app/collection-page/collection-page.component';
import { CommunityPageModule } from '../../app/community-page/community-page.module';
import { CollectionPageModule } from '../../app/collection-page/collection-page.module';
import { ConfigurationSearchPageComponent } from './app/search-page/configuration-search-page.component';
import { ItemPageComponent } from './app/item-page/simple/item-page.component'; import { ItemPageComponent } from './app/item-page/simple/item-page.component';
import { FullItemPageComponent } from './app/item-page/full/full-item-page.component'; import { FullItemPageComponent } from './app/item-page/full/full-item-page.component';
import { LoginPageComponent } from './app/login-page/login-page.component'; import { LoginPageComponent } from './app/login-page/login-page.component';
@@ -66,32 +70,21 @@ import { ForgotEmailComponent } from './app/forgot-password/forgot-password-emai
import { ForgotPasswordFormComponent } from './app/forgot-password/forgot-password-form/forgot-password-form.component'; import { ForgotPasswordFormComponent } from './app/forgot-password/forgot-password-form/forgot-password-form.component';
import { ProfilePageComponent } from './app/profile-page/profile-page.component'; import { ProfilePageComponent } from './app/profile-page/profile-page.component';
import { RegisterEmailComponent } from './app/register-page/register-email/register-email.component'; import { RegisterEmailComponent } from './app/register-page/register-email/register-email.component';
import { MyDSpacePageComponent } from './app/my-dspace-page/my-dspace-page.component';
import { SubmissionEditComponent } from './app/submission/edit/submission-edit.component'; import { SubmissionEditComponent } from './app/submission/edit/submission-edit.component';
import { SubmissionImportExternalComponent } from './app/submission/import-external/submission-import-external.component'; import { SubmissionImportExternalComponent } from './app/submission/import-external/submission-import-external.component';
import { SubmissionSubmitComponent } from './app/submission/submit/submission-submit.component'; import { SubmissionSubmitComponent } from './app/submission/submit/submission-submit.component';
import { MyDSpacePageComponent } from './app/my-dspace-page/my-dspace-page.component';
import { WorkflowItemSendBackComponent } from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component';
import { WorkflowItemDeleteComponent } from './app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component'; import { WorkflowItemDeleteComponent } from './app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component';
import { SubmissionModule } from '../../app/submission/submission.module'; import { WorkflowItemSendBackComponent } from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component';
import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module';
import { NavbarComponent } from './app/navbar/navbar.component';
import { HeaderComponent } from './app/header/header.component';
import { FooterComponent } from './app/footer/footer.component';
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component'; import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
import { FileSectionComponent } from './app/item-page/simple/field-components/file-section/file-section.component';
import { SearchModule } from '../../app/shared/search/search.module';
import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module';
import { ComcolModule } from '../../app/shared/comcol/comcol.module';
import { FeedbackComponent } from './app/info/feedback/feedback.component'; import { FeedbackComponent } from './app/info/feedback/feedback.component';
import { CommunityListComponent } from './app/community-list-page/community-list/community-list.component'; import { CommunityListComponent } from './app/community-list-page/community-list/community-list.component';
const DECLARATIONS = [ const DECLARATIONS = [
FileSectionComponent, FileSectionComponent,
HomePageComponent, HomePageComponent,
HomeNewsComponent,
RootComponent, RootComponent,
PublicationComponent,
BrowseBySwitcherComponent, BrowseBySwitcherComponent,
CommunityListPageComponent, CommunityListPageComponent,
SearchPageComponent, SearchPageComponent,
@@ -122,22 +115,18 @@ const DECLARATIONS = [
SubmissionSubmitComponent, SubmissionSubmitComponent,
WorkflowItemDeleteComponent, WorkflowItemDeleteComponent,
WorkflowItemSendBackComponent, WorkflowItemSendBackComponent,
FooterComponent,
HeaderComponent,
NavbarComponent,
HeaderNavbarWrapperComponent,
BreadcrumbsComponent, BreadcrumbsComponent,
FeedbackComponent, FeedbackComponent,
CommunityListComponent CommunityListComponent,
]; ];
@NgModule({ @NgModule({
imports: [ imports: [
AccessControlModule,
AdminRegistriesModule, AdminRegistriesModule,
AdminSearchModule, AdminSearchModule,
AdminWorkflowModuleModule, AdminWorkflowModuleModule,
AppModule, AppModule,
RootModule,
BitstreamFormatsModule, BitstreamFormatsModule,
BrowseByModule, BrowseByModule,
CollectionFormModule, CollectionFormModule,
@@ -178,9 +167,9 @@ const DECLARATIONS = [
SearchModule, SearchModule,
FormsModule, FormsModule,
ResourcePoliciesModule, ResourcePoliciesModule,
ComcolModule ComcolModule,
], ],
declarations: DECLARATIONS declarations: DECLARATIONS,
}) })
/** /**
@@ -190,5 +179,5 @@ const DECLARATIONS = [
* It is purposefully not exported, it should never be imported anywhere else, its only purpose is * It is purposefully not exported, it should never be imported anywhere else, its only purpose is
* to give lazily loaded components a context in which they can be compiled successfully * to give lazily loaded components a context in which they can be compiled successfully
*/ */
class ThemeModule { class LazyThemeModule {
} }

View File

@@ -1,4 +1,4 @@
<div class="background-image"> <div class="background-image-container">
<div class="container"> <div class="container">
<div class="jumbotron jumbotron-fluid"> <div class="jumbotron jumbotron-fluid">
<div class="d-flex flex-wrap"> <div class="d-flex flex-wrap">
@@ -30,5 +30,10 @@
</ul> </ul>
</div> </div>
</div> </div>
<picture class="background-image">
<source type="image/webp" srcset="assets/dspace/images/banner.webp 2000w, assets/dspace/images/banner-half.webp 1200w, assets/dspace/images/banner-tall.webp 768w">
<source type="image/jpg" srcset="assets/dspace/images/banner.jpg 2000w, assets/dspace/images/banner-half.jpg 1200w, assets/dspace/images/banner-tall.jpg 768w">
<img alt="" [src]="'assets/dspace/images/banner.jpg'"/><!-- without the []="''" Firefox downloads both the fallback and the resolved image -->
</picture>
<small class="credits">Photo by <a href="https://www.pexels.com/@inspiredimages">@inspiredimages</a></small> <small class="credits">Photo by <a href="https://www.pexels.com/@inspiredimages">@inspiredimages</a></small>
</div> </div>

View File

@@ -2,12 +2,21 @@
display: block; display: block;
margin-top: calc(var(--ds-content-spacing) * -1); margin-top: calc(var(--ds-content-spacing) * -1);
div.background-image { div.background-image-container {
color: white; color: white;
background-color: var(--bs-info);
position: relative; position: relative;
background-image: url('/assets/dspace/images/banner.jpg');
background-size: cover; .background-image > img {
background-color: var(--bs-info);
position: absolute;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.container { .container {
position: relative; position: relative;

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

View File

@@ -0,0 +1,52 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../app/shared/shared.module';
import { HomeNewsComponent } from './app/home-page/home-news/home-news.component';
import { NavbarComponent } from './app/navbar/navbar.component';
import { HeaderComponent } from './app/header/header.component';
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
import { SearchModule } from '../../app/shared/search/search.module';
import { RootModule } from '../../app/root.module';
import { NavbarModule } from '../../app/navbar/navbar.module';
/**
* Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS.
* This will ensure that decorator gets picked up when the app loads
*/
const ENTRY_COMPONENTS = [
];
const DECLARATIONS = [
...ENTRY_COMPONENTS,
HomeNewsComponent,
HeaderComponent,
HeaderNavbarWrapperComponent,
NavbarComponent,
];
@NgModule({
imports: [
CommonModule,
SharedModule,
SearchModule,
FormsModule,
RootModule,
NavbarModule,
],
declarations: DECLARATIONS,
providers: [
...ENTRY_COMPONENTS.map((component) => ({ provide: component }))
],
})
/**
* This module is included in the main bundle that gets downloaded at first page load. So it should
* contain only the themed components that have to be available immediately for the first page load,
* and the minimal set of imports required to make them work. Anything you can cut from it will make
* the initial page load faster, but may cause the page to flicker as components that were already
* rendered server side need to be lazy-loaded again client side
*
* Themed EntryComponents should also be added here
*/
export class EagerThemeModule {
}

View File

@@ -1,2 +0,0 @@
export const ENTRY_COMPONENTS = [
];

View File

@@ -2,10 +2,16 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { AdminRegistriesModule } from '../../app/admin/admin-registries/admin-registries.module'; import { AdminRegistriesModule } from '../../app/admin/admin-registries/admin-registries.module';
import { AdminSearchModule } from '../../app/admin/admin-search-page/admin-search.module'; import { AdminSearchModule } from '../../app/admin/admin-search-page/admin-search.module';
import { AdminWorkflowModuleModule } from '../../app/admin/admin-workflow-page/admin-workflow.module'; import {
import { BitstreamFormatsModule } from '../../app/admin/admin-registries/bitstream-formats/bitstream-formats.module'; AdminWorkflowModuleModule
} from '../../app/admin/admin-workflow-page/admin-workflow.module';
import {
BitstreamFormatsModule
} from '../../app/admin/admin-registries/bitstream-formats/bitstream-formats.module';
import { BrowseByModule } from '../../app/browse-by/browse-by.module'; import { BrowseByModule } from '../../app/browse-by/browse-by.module';
import { CollectionFormModule } from '../../app/collection-page/collection-form/collection-form.module'; import {
CollectionFormModule
} from '../../app/collection-page/collection-form/collection-form.module';
import { CommunityFormModule } from '../../app/community-page/community-form/community-form.module'; import { CommunityFormModule } from '../../app/community-page/community-form/community-form.module';
import { CoreModule } from '../../app/core/core.module'; import { CoreModule } from '../../app/core/core.module';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
@@ -13,14 +19,18 @@ import { EditItemPageModule } from '../../app/item-page/edit-item-page/edit-item
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { IdlePreloadModule } from 'angular-idle-preload'; import { IdlePreloadModule } from 'angular-idle-preload';
import { JournalEntitiesModule } from '../../app/entity-groups/journal-entities/journal-entities.module'; import {
JournalEntitiesModule
} from '../../app/entity-groups/journal-entities/journal-entities.module';
import { MyDspaceSearchModule } from '../../app/my-dspace-page/my-dspace-search.module'; import { MyDspaceSearchModule } from '../../app/my-dspace-page/my-dspace-search.module';
import { MenuModule } from '../../app/shared/menu/menu.module'; import { MenuModule } from '../../app/shared/menu/menu.module';
import { NavbarModule } from '../../app/navbar/navbar.module'; import { NavbarModule } from '../../app/navbar/navbar.module';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ProfilePageModule } from '../../app/profile-page/profile-page.module'; import { ProfilePageModule } from '../../app/profile-page/profile-page.module';
import { RegisterEmailFormModule } from '../../app/register-email-form/register-email-form.module'; import { RegisterEmailFormModule } from '../../app/register-email-form/register-email-form.module';
import { ResearchEntitiesModule } from '../../app/entity-groups/research-entities/research-entities.module'; import {
ResearchEntitiesModule
} from '../../app/entity-groups/research-entities/research-entities.module';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { SearchPageModule } from '../../app/search-page/search-page.module'; import { SearchPageModule } from '../../app/search-page/search-page.module';
import { SharedModule } from '../../app/shared/shared.module'; import { SharedModule } from '../../app/shared/shared.module';
@@ -28,7 +38,6 @@ import { StatisticsModule } from '../../app/statistics/statistics.module';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { StoreRouterConnectingModule } from '@ngrx/router-store'; import { StoreRouterConnectingModule } from '@ngrx/router-store';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { HomeNewsComponent } from './app/home-page/home-news/home-news.component';
import { HomePageModule } from '../../app/home-page/home-page.module'; import { HomePageModule } from '../../app/home-page/home-page.module';
import { AppModule } from '../../app/app.module'; import { AppModule } from '../../app/app.module';
import { ItemPageModule } from '../../app/item-page/item-page.module'; import { ItemPageModule } from '../../app/item-page/item-page.module';
@@ -40,18 +49,14 @@ import { CommunityPageModule } from '../../app/community-page/community-page.mod
import { CollectionPageModule } from '../../app/collection-page/collection-page.module'; import { CollectionPageModule } from '../../app/collection-page/collection-page.module';
import { SubmissionModule } from '../../app/submission/submission.module'; import { SubmissionModule } from '../../app/submission/submission.module';
import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module'; import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module';
import { NavbarComponent } from './app/navbar/navbar.component';
import { HeaderComponent } from './app/header/header.component';
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
import { SearchModule } from '../../app/shared/search/search.module'; import { SearchModule } from '../../app/shared/search/search.module';
import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module'; import {
ResourcePoliciesModule
} from '../../app/shared/resource-policies/resource-policies.module';
import { ComcolModule } from '../../app/shared/comcol/comcol.module'; import { ComcolModule } from '../../app/shared/comcol/comcol.module';
import { RootModule } from '../../app/root.module';
const DECLARATIONS = [ const DECLARATIONS = [
HomeNewsComponent,
HeaderComponent,
HeaderNavbarWrapperComponent,
NavbarComponent
]; ];
@NgModule({ @NgModule({
@@ -60,6 +65,7 @@ const DECLARATIONS = [
AdminSearchModule, AdminSearchModule,
AdminWorkflowModuleModule, AdminWorkflowModuleModule,
AppModule, AppModule,
RootModule,
BitstreamFormatsModule, BitstreamFormatsModule,
BrowseByModule, BrowseByModule,
CollectionFormModule, CollectionFormModule,
@@ -100,17 +106,17 @@ const DECLARATIONS = [
SearchModule, SearchModule,
FormsModule, FormsModule,
ResourcePoliciesModule, ResourcePoliciesModule,
ComcolModule ComcolModule,
], ],
declarations: DECLARATIONS declarations: DECLARATIONS,
}) })
/** /**
* This module serves as an index for all the components in this theme. * This module serves as an index for all the components in this theme.
* It should import all other modules, so the compiler knows where to find any components referenced * It should import all other modules, so the compiler knows where to find any components referenced
* from a component in this theme * from a component in this theme
* It is purposefully not exported, it should never be imported anywhere else, its only purpose is * It is purposefully not exported, it should never be imported anywhere else, its only purpose is
* to give lazily loaded components a context in which they can be compiled successfully * to give lazily loaded components a context in which they can be compiled successfully
*/ */
class ThemeModule { class LazyThemeModule {
} }

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module';
// import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module';
/**
* This module bundles the eager theme modules for all available themes.
* Eager modules contain components that are present on every page (to speed up initial loading)
* and entry components (to ensure their decorators get picked up).
*
* Themes that aren't in use should not be imported here so they don't take up unnecessary space in the main bundle.
*/
@NgModule({
imports: [
DSpaceEagerThemeModule,
// CustomEagerThemeModule,
],
})
export class EagerThemesModule {
}

View File

@@ -1,23 +0,0 @@
import { NgModule } from '@angular/core';
import { ENTRY_COMPONENTS as CUSTOM } from './custom/entry-components';
const ENTRY_COMPONENTS = [
...CUSTOM,
];
/**
* This module only serves to ensure themed entry components are discoverable. It's kept separate
* from the theme modules to ensure only the minimal number of theme components is loaded ahead of
* time
*/
@NgModule()
export class ThemedEntryComponentModule {
static withEntryComponents() {
return {
ngModule: ThemedEntryComponentModule,
providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
};
}
}

View File

@@ -3,8 +3,33 @@ import { join } from 'path';
import { buildAppConfig } from '../src/config/config.server'; import { buildAppConfig } from '../src/config/config.server';
import { commonExports } from './webpack.common'; import { commonExports } from './webpack.common';
const CompressionPlugin = require('compression-webpack-plugin');
const zlib = require('zlib');
module.exports = Object.assign({}, commonExports, { module.exports = Object.assign({}, commonExports, {
target: 'web', target: 'web',
plugins: [
...commonExports.plugins,
new CompressionPlugin({
filename: '[path][base].gz',
algorithm: 'gzip',
test: /\.(js|css|html|svg|json5)$/,
threshold: 10240,
minRatio: 0.8,
}),
new CompressionPlugin({
filename: '[path][base].br',
algorithm: 'brotliCompress',
test: /\.(js|css|html|svg|json5)$/,
compressionOptions: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
},
},
threshold: 10240,
minRatio: 0.8,
}),
],
devServer: { devServer: {
setupMiddlewares(middlewares, server) { setupMiddlewares(middlewares, server) {
buildAppConfig(join(process.cwd(), 'src/assets/config.json')); buildAppConfig(join(process.cwd(), 'src/assets/config.json'));

View File

@@ -4111,17 +4111,13 @@ compressible@~2.0.16:
dependencies: dependencies:
mime-db ">= 1.43.0 < 2" mime-db ">= 1.43.0 < 2"
compression-webpack-plugin@^3.0.1: compression-webpack-plugin@^9.2.0:
version "3.1.0" version "9.2.0"
resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-3.1.0.tgz#9f510172a7b5fae5aad3b670652e8bd7997aeeca" resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-9.2.0.tgz#57fd539d17c5907eebdeb4e83dcfe2d7eceb9ef6"
integrity sha512-iqTHj3rADN4yHwXMBrQa/xrncex/uEQy8QHlaTKxGchT/hC0SdlJlmL/5eRqffmWq2ep0/Romw6Ld39JjTR/ug== integrity sha512-R/Oi+2+UHotGfu72fJiRoVpuRifZT0tTC6UqFD/DUo+mv8dbOow9rVOuTvDv5nPPm3GZhHL/fKkwxwIHnJ8Nyw==
dependencies: dependencies:
cacache "^13.0.1" schema-utils "^4.0.0"
find-cache-dir "^3.0.0" serialize-javascript "^6.0.0"
neo-async "^2.5.0"
schema-utils "^2.6.1"
serialize-javascript "^2.1.2"
webpack-sources "^1.0.1"
compression@^1.7.4: compression@^1.7.4:
version "1.7.4" version "1.7.4"
@@ -4916,6 +4912,11 @@ dependency-graph@^0.11.0:
resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27"
integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==
destroy@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
destroy@~1.0.4: destroy@~1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
@@ -5819,6 +5820,13 @@ express-rate-limit@^5.1.3:
resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.5.1.tgz#110c23f6a65dfa96ab468eda95e71697bc6987a2" resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.5.1.tgz#110c23f6a65dfa96ab468eda95e71697bc6987a2"
integrity sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg== integrity sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==
express-static-gzip@^2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/express-static-gzip/-/express-static-gzip-2.1.5.tgz#e45b512ef5596a068c45f729d7e0cc0b429b08b4"
integrity sha512-bgiQ1fY7ltuUrSzg0WoN7ycoAd7r2VEw7durn/3k0jCMUC5wydF0K36ouIuJPE+MNDwK5uoSaVgIBVNemwxWgw==
dependencies:
serve-static "^1.14.1"
express@^4.17.1: express@^4.17.1:
version "4.17.3" version "4.17.3"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1"
@@ -6071,7 +6079,7 @@ finalhandler@1.1.2, finalhandler@~1.1.2:
statuses "~1.5.0" statuses "~1.5.0"
unpipe "~1.0.0" unpipe "~1.0.0"
find-cache-dir@^3.0.0, find-cache-dir@^3.3.1: find-cache-dir@^3.3.1:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b"
integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==
@@ -8823,7 +8831,7 @@ negotiator@0.6.3, negotiator@^0.6.2, negotiator@^0.6.3:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
neo-async@^2.5.0, neo-async@^2.6.2: neo-async@^2.6.2:
version "2.6.2" version "2.6.2"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
@@ -9203,6 +9211,13 @@ obuf@^1.0.0, obuf@^1.1.2:
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
on-finished@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
dependencies:
ee-first "1.1.1"
on-finished@~2.3.0: on-finished@~2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -11275,7 +11290,7 @@ schema-utils@2.7.0:
ajv "^6.12.2" ajv "^6.12.2"
ajv-keywords "^3.4.1" ajv-keywords "^3.4.1"
schema-utils@^2.6.1, schema-utils@^2.6.5, schema-utils@^2.6.6: schema-utils@^2.6.5, schema-utils@^2.6.6:
version "2.7.1" version "2.7.1"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7"
integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==
@@ -11399,10 +11414,24 @@ send@0.17.2:
range-parser "~1.2.1" range-parser "~1.2.1"
statuses "~1.5.0" statuses "~1.5.0"
serialize-javascript@^2.1.2: send@0.18.0:
version "2.1.2" version "0.18.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"
integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==
dependencies:
debug "2.6.9"
depd "2.0.0"
destroy "1.2.0"
encodeurl "~1.0.2"
escape-html "~1.0.3"
etag "~1.8.1"
fresh "0.5.2"
http-errors "2.0.0"
mime "1.6.0"
ms "2.1.3"
on-finished "2.4.1"
range-parser "~1.2.1"
statuses "2.0.1"
serialize-javascript@^4.0.0: serialize-javascript@^4.0.0:
version "4.0.0" version "4.0.0"
@@ -11458,6 +11487,16 @@ serve-static@1.14.2:
parseurl "~1.3.3" parseurl "~1.3.3"
send "0.17.2" send "0.17.2"
serve-static@^1.14.1:
version "1.15.0"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540"
integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==
dependencies:
encodeurl "~1.0.2"
escape-html "~1.0.3"
parseurl "~1.3.3"
send "0.18.0"
server-destroy@1.0.1: server-destroy@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd" resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
@@ -13028,7 +13067,7 @@ webpack-merge@5.8.0, webpack-merge@^5.7.3:
clone-deep "^4.0.1" clone-deep "^4.0.1"
wildcard "^2.0.0" wildcard "^2.0.0"
webpack-sources@^1.0.1, webpack-sources@^1.4.3: webpack-sources@^1.4.3:
version "1.4.3" version "1.4.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933"
integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==