Merge pull request #338 from atmire/dynamic_menus

Dynamic menus
This commit is contained in:
Art Lowel
2018-12-20 14:46:11 +01:00
committed by GitHub
128 changed files with 5224 additions and 1953 deletions

View File

@@ -130,6 +130,7 @@
"devDependencies": { "devDependencies": {
"@angular/compiler": "^6.1.4", "@angular/compiler": "^6.1.4",
"@angular/compiler-cli": "^6.1.4", "@angular/compiler-cli": "^6.1.4",
"@fortawesome/fontawesome-free": "^5.5.0",
"@ngrx/entity": "^6.1.0", "@ngrx/entity": "^6.1.0",
"@ngrx/schematics": "^6.1.0", "@ngrx/schematics": "^6.1.0",
"@ngrx/store-devtools": "^6.1.0", "@ngrx/store-devtools": "^6.1.0",
@@ -182,7 +183,7 @@
"karma-webdriver-launcher": "1.0.5", "karma-webdriver-launcher": "1.0.5",
"karma-webpack": "3.0.0", "karma-webpack": "3.0.0",
"ngrx-store-freeze": "^0.2.4", "ngrx-store-freeze": "^0.2.4",
"node-sass": "^4.7.2", "node-sass": "^4.11.0",
"nodemon": "^1.15.0", "nodemon": "^1.15.0",
"npm-run-all": "4.1.3", "npm-run-all": "4.1.3",
"postcss": "^7.0.2", "postcss": "^7.0.2",

View File

@@ -49,9 +49,19 @@
} }
}, },
"nav": { "nav": {
"home": "Home", "browse": {
"header": "All of DSpace"
},
"community-browse": {
"header": "By Community"
},
"statistics": {
"header": "Statistics"
},
"login": "Log In", "login": "Log In",
"logout": "Log Out" "logout": "Log Out",
"language": "Language switch",
"search": "Search"
}, },
"pagination": { "pagination": {
"results-per-page": "Results Per Page", "results-per-page": "Results Per Page",
@@ -207,6 +217,85 @@
} }
} }
}, },
"menu": {
"header": {
"admin": "Admin",
"image": {
"logo": "Repository logo"
}
},
"section": {
"pin": "Pin sidebar",
"unpin": "Unpin sidebar",
"new": "New",
"new_community": "Community",
"new_collection": "Collection",
"new_item": "Item",
"new_item_version": "Item Version",
"edit": "Edit",
"edit_community": "Community",
"edit_collection": "Collection",
"edit_item": "Item",
"import": "Import",
"import_metadata": "Metadata",
"import_batch": "Batch Import (ZIP)",
"export": "Export",
"export_community": "Community",
"export_collection": "Collection",
"export_item": "Item",
"export_metadata": "Metadata",
"access_control": "Access Control",
"access_control_people": "People",
"access_control_groups": "Groups",
"access_control_authorizations": "Authorizations",
"find": "Find",
"find_items": "Items",
"find_withdrawn_items": "Withdrawn Items",
"find_private_items": "Private Items",
"registries": "Registries",
"registries_metadata": "Metadata",
"registries_format": "Format",
"curation_task": "Curation Task",
"statistics_task": "Statistics Task",
"control_panel": "Control Panel",
"browse_global": "All of DSpace",
"browse_global_communities_and_collections": "Communities & Collections",
"browse_global_by_issue_date": "By Issue Date",
"browse_global_by_author": "By Author",
"browse_global_by_title": "By Title",
"statistics": "Statistics",
"browse_community": "This Community",
"browse_community_by_issue_date": "By Issue Date",
"browse_community_by_author": "By Author",
"browse_community_by_title": "By Title",
"icon": {
"pin": "Pin sidebar",
"unpin": "Unpin sidebar",
"new": "New menu section",
"edit": "Edit menu section",
"import": "Import menu section",
"export": "Export menu section",
"access_control": "Access Control menu section",
"find": "Find menu section",
"registries": "Registries menu section",
"curation_task": "Curation Task menu section",
"statistics_task": "Statistics Task menu section",
"control_panel": "Control Panel menu section"
},
"toggle": {
"new": "Toggle New section",
"edit": "Toggle Edit section",
"import": "Toggle Import section",
"export": "Toggle Export section",
"access_control": "Toggle Access Control section",
"find": "Toggle Find section",
"registries": "Toggle Registries section",
"curation_task": "Toggle Curation Task section",
"statistics_task": "Toggle Statistics Task section",
"control_panel": "Toggle Control Panel section"
}
}
},
"loading": { "loading": {
"default": "Loading...", "default": "Loading...",
"top-level-communities": "Loading top-level communities...", "top-level-communities": "Loading top-level communities...",
@@ -279,5 +368,8 @@
"errors": { "errors": {
"invalid-user": "Invalid email address or password." "invalid-user": "Invalid email address or password."
} }
},
"chips": {
"remove": "Remove chip"
} }
} }

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="97.562px" height="91.866px" viewBox="67.165 0 97.562 91.866" enable-background="new 67.165 0 97.562 91.866"
xml:space="preserve">
<path fill="#92C642" d="M120.726,58.569l0.109-0.006l0.116-0.01l0.106-0.013l0.11-0.01l0.11-0.023l0.109-0.019l0.106-0.023
l0.106-0.029l0.105-0.023l0.106-0.033l0.103-0.034l0.097-0.035l0.104-0.04l0.101-0.042l0.1-0.042v-0.001l0.096-0.045l0,0
l0.095-0.044l0.097-0.049l0.091-0.056v-0.001l0.094-0.05v-0.002l0.09-0.056v-0.001l0.093-0.06l0.083-0.056v-0.001l0.085-0.063
l0.088-0.065v-0.002l0.087-0.062v-0.001c0.816-0.683,1.393-1.646,1.561-2.738l0.013-0.104V54.72l0.014-0.101v-0.011l0.009-0.098
v-0.012l0.009-0.101V54.38l0.005-0.095v-0.016l0.002-0.105v-16.46l-0.002-0.105v-0.016l-0.005-0.095v-0.013l-0.009-0.101v-0.012
l-0.009-0.098v-0.011l-0.014-0.1v-0.01l-0.013-0.104c-0.167-1.092-0.744-2.057-1.561-2.738V34.3l-0.087-0.063v-0.002l-0.088-0.065
l-0.085-0.063v-0.001l-0.083-0.056l-0.093-0.061l0,0l-0.09-0.056V33.93l-0.094-0.05v-0.001l-0.091-0.056l-0.097-0.049l-0.095-0.043
V33.73l-0.096-0.045v-0.001l-0.1-0.043l-0.101-0.042l-0.104-0.04l-0.097-0.035l-0.103-0.031l-0.106-0.036l-0.105-0.023l-0.106-0.028
l-0.106-0.024l-0.109-0.019l-0.11-0.023l-0.11-0.009l-0.106-0.014l-0.116-0.01l-0.109-0.006l-0.114-0.005h-7.89
c-9.716,0-15.858-7.838-15.858-17.15V6.92c0-3.812-3.102-6.915-6.914-6.915H74.085c-3.813,0-6.92,3.106-6.92,6.915v16.682
c0,3.806,3.104,6.909,6.92,6.909h8.414c9.169,0,16.906,5.95,17.146,15.403v0.04c-0.24,9.453-7.978,15.402-17.146,15.402h-8.414
c-3.815,0-6.92,3.103-6.92,6.909v16.682c0,3.81,3.106,6.915,6.92,6.915H89.95c3.812,0,6.914-3.104,6.914-6.915v-9.223
c0-9.312,6.144-17.149,15.858-17.149h7.89L120.726,58.569z M154.772,9.956C148.631,3.814,140.15,0,130.816,0h-15.024v17.424h15.024
c4.526,0,8.647,1.858,11.64,4.849c2.99,2.99,4.849,7.112,4.849,11.639v24.042c0,4.538-1.853,8.665-4.832,11.655l-0.017-0.016
c-2.991,2.991-7.113,4.849-11.64,4.849h-15.024v17.424h15.024c9.333,0,17.814-3.814,23.956-9.956v-0.033
c6.142-6.143,9.955-14.614,9.955-23.923V33.912C164.727,24.578,160.914,16.097,154.772,9.956z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="231.892px" height="167.458px" viewBox="0 0 231.892 167.458" enable-background="new 0 0 231.892 167.458"
xml:space="preserve">
<path fill="#6D6E71" d="M51.733,143.32c0-1.941,1.651-3.203,4.563-3.203c3.302,0,6.798,1.116,9.419,3.543l3.835-5.146
c-3.203-2.962-7.476-4.516-12.622-4.516c-7.622,0-12.284,4.468-12.284,9.856c0,12.187,18.644,8.254,18.644,13.886
c0,1.894-1.797,3.593-5.632,3.593c-4.466,0-8.011-2.039-10.292-4.419l-3.787,5.39c3.058,3.059,7.525,5.154,13.788,5.154
c8.691,0,12.964-4.474,12.964-10.397C70.329,144.971,51.733,148.418,51.733,143.32z M100.682,134.484H85.534v32.385h6.894v-11.556
h8.254c6.991,0,10.875-4.759,10.875-10.391C111.558,139.242,107.722,134.484,100.682,134.484z M99.71,149.244h-7.283v-8.69h7.283
c2.719,0,4.807,1.651,4.807,4.369C104.518,147.593,102.43,149.244,99.71,149.244z M180.759,140.067c3.301,0,6.214,2.089,7.573,4.71
l5.923-2.913c-2.281-4.078-6.408-7.914-13.496-7.914c-9.759,0-17.284,6.75-17.284,16.75c0,9.954,7.525,16.759,17.284,16.759
c7.088,0,11.215-3.94,13.496-7.97l-5.923-2.865c-1.359,2.622-4.272,4.71-7.573,4.71c-5.924,0-10.195-4.516-10.195-10.634
C170.564,144.583,174.835,140.067,180.759,140.067z M131.958,134.484l-12.486,32.385h7.824l2.04-5.486h13.886l2.039,5.486h7.816
l-12.479-32.385H131.958z M131.228,155.313l5.05-13.935l5.05,13.935H131.228z M231.892,140.553v-6.069h-22.916v32.385h22.916v-6.069
H215.87v-7.38h15.683v-6.069H215.87v-6.797H231.892z"/>
<path fill="#92C642" d="M29.956,150.652c0-9.71-7.04-16.168-17.187-16.168H0v32.385h12.817
C22.916,166.869,29.956,160.458,29.956,150.652z M12.769,160.799H6.894v-20.246h5.924c6.603,0,10.098,4.418,10.098,10.099
C22.916,156.187,19.177,160.799,12.769,160.799z"/>
<path fill="#92C642" d="M120.726,58.569l0.11-0.006l0.116-0.01l0.106-0.013l0.111-0.01l0.11-0.023l0.109-0.019l0.107-0.023
l0.106-0.029l0.106-0.023l0.106-0.033l0.103-0.034l0.096-0.035l0.104-0.04l0.101-0.042l0.099-0.042v-0.001l0.096-0.045v0
l0.095-0.044l0.096-0.049l0.091-0.056v-0.001l0.094-0.05v-0.002l0.09-0.056v-0.001l0.092-0.06l0.083-0.056v-0.001l0.085-0.063
l0.088-0.065v-0.002l0.087-0.063v-0.001c0.817-0.683,1.393-1.646,1.561-2.738l0.012-0.104v-0.009l0.014-0.101v-0.011l0.009-0.098
v-0.012l0.009-0.101V54.38l0.005-0.095v-0.016l0.002-0.105v-16.46l-0.002-0.105v-0.016l-0.005-0.095v-0.013l-0.009-0.101v-0.012
l-0.009-0.098v-0.011l-0.014-0.1v-0.01l-0.012-0.104c-0.167-1.092-0.744-2.057-1.561-2.738v-0.001l-0.087-0.063v-0.002l-0.088-0.065
l-0.085-0.063v-0.001l-0.083-0.056l-0.092-0.061v0l-0.09-0.056v-0.003l-0.094-0.05v-0.001l-0.091-0.056l-0.096-0.049l-0.095-0.043
v-0.001l-0.096-0.045v-0.001l-0.099-0.043l-0.101-0.042l-0.104-0.04l-0.096-0.035l-0.103-0.031l-0.106-0.036l-0.106-0.023
l-0.106-0.028l-0.107-0.024l-0.109-0.019l-0.11-0.023l-0.111-0.009l-0.106-0.014l-0.116-0.01l-0.11-0.006l-0.114-0.005h-7.89
c-9.715,0-15.858-7.838-15.858-17.15V6.92c0-3.812-3.102-6.915-6.914-6.915H74.085c-3.814,0-6.92,3.106-6.92,6.915v16.682
c0,3.806,3.104,6.909,6.92,6.909h8.414c9.169,0,16.906,5.95,17.146,15.403v0.04c-0.24,9.453-7.977,15.402-17.146,15.402h-8.414
c-3.816,0-6.92,3.103-6.92,6.909v16.682c0,3.809,3.106,6.915,6.92,6.915H89.95c3.812,0,6.914-3.104,6.914-6.915v-9.223
c0-9.312,6.144-17.149,15.858-17.149h7.89L120.726,58.569z M154.772,9.956C148.631,3.814,140.15,0,130.816,0h-15.024v17.424h15.024
c4.527,0,8.648,1.858,11.64,4.849c2.99,2.99,4.848,7.112,4.848,11.639v24.042c0,4.538-1.852,8.665-4.832,11.655l-0.016-0.016
c-2.991,2.991-7.113,4.849-11.64,4.849h-15.024v17.424h15.024c9.333,0,17.815-3.814,23.956-9.956v-0.033
c6.142-6.143,9.955-14.614,9.955-23.923V33.912C164.727,24.578,160.914,16.097,154.772,9.956z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -7,7 +7,6 @@ import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataField } from '../../../core/metadata/metadatafield.model'; import { MetadataField } from '../../../core/metadata/metadatafield.model';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortOptions } from '../../../core/cache/models/sort-options.model';
@Component({ @Component({
selector: 'ds-metadata-schema', selector: 'ds-metadata-schema',

View File

@@ -0,0 +1,11 @@
<li class="sidebar-section">
<a class="nav-item nav-link shortcut-icon" [routerLink]="itemModel.link">
<i class="fas fa-{{section.icon}} fa-fw" [title]="('menu.section.icon.' + section.id) | translate"></i>
</a>
<div class="sidebar-collapsible">
<span class="section-header-text">
<ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container>
</span>
</div>
</li>

View File

@@ -0,0 +1 @@
@import '../../../../styles/variables.scss';

View File

@@ -0,0 +1,60 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuServiceStub } from '../../../shared/testing/menu-service-stub';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service-stub';
import { Component } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AdminSidebarSectionComponent } from './admin-sidebar-section.component';
import { RouterTestingModule } from '@angular/router/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
describe('AdminSidebarSectionComponent', () => {
let component: AdminSidebarSectionComponent;
let fixture: ComponentFixture<AdminSidebarSectionComponent>;
const menuService = new MenuServiceStub();
const iconString = 'test';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
declarations: [AdminSidebarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
]
}).overrideComponent(AdminSidebarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
});
// declare a test component
@Component({
selector: 'ds-test-cmp',
template: ``
})
class TestComponent {
}

View File

@@ -0,0 +1,34 @@
import { Component, Inject, Injector, OnInit } from '@angular/core';
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component';
import { MenuID } from '../../../shared/menu/initial-menus-state';
import { MenuService } from '../../../shared/menu/menu.service';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu.reducer';
/**
* Represents a non-expandable section in the admin sidebar
*/
@Component({
selector: 'ds-admin-sidebar-section',
templateUrl: './admin-sidebar-section.component.html',
styleUrls: ['./admin-sidebar-section.component.scss'],
})
@rendersSectionForMenu(MenuID.ADMIN, false)
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit {
/**
* This section resides in the Admin Sidebar
*/
menuID: MenuID = MenuID.ADMIN;
itemModel;
constructor(@Inject('sectionDataProvider') menuSection: MenuSection, protected menuService: MenuService, protected injector: Injector,) {
super(menuSection, menuService, injector);
this.itemModel = menuSection.model as LinkMenuItemModel;
}
ngOnInit(): void {
super.ngOnInit();
}
}

View File

@@ -0,0 +1,52 @@
<nav @slideHorizontal class="navbar navbar-dark bg-dark p-0"
[ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}"
[@slideSidebar]="{
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),
params: {sidebarWidth: (sidebarWidth | async)}
}" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)"
*ngIf="menuVisible | async" (mouseenter)="expandPreview($event)"
(mouseleave)="collapsePreview($event)">
<div class="sidebar-top-level-items">
<ul class="navbar-nav">
<li class="admin-menu-header sidebar-section">
<a class="shortcut-icon navbar-brand mr-0" href="#">
<span class="logo-wrapper">
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
[alt]="('menu.header.image.logo') | translate">
</span>
</a>
<div class="sidebar-collapsible">
<a class="navbar-brand mr-0" href="#">
<h4 class="section-header-text mb-0">{{'menu.header.admin' |
translate}}</h4>
</a>
</div>
</li>
<ng-container *ngFor="let section of (sections | async)">
<ng-container
*ngComponentOutlet="sectionComponents.get(section.id); injector: sectionInjectors.get(section.id);"></ng-container>
</ng-container>
</ul>
</div>
<div class="navbar-nav">
<div class="sidebar-section" id="sidebar-collapse-toggle">
<a class="nav-item nav-link shortcut-icon"
href="#"
(click)="toggle($event)">
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
[title]="'menu.section.icon.pin' | translate"></i>
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
[title]="'menu.section.icon.unpin' | translate"></i>
</a>
<div class="sidebar-collapsible">
<a class="nav-item nav-link sidebar-section"
href="#"
(click)="toggle($event)">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
</a>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,77 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
$icon-z-index: 10;
:host {
left: 0;
top: 0;
height: 100vh;
flex: 1 1 auto;
nav {
height: 100%;
flex-direction: column;
> div {
width: 100%;
&.sidebar-top-level-items {
flex: 1;
overflow: auto;
@include dark-scrollbar;
}
}
&.inactive ::ng-deep .sidebar-collapsible {
margin-left: -#{$sidebar-items-width};
}
.navbar-nav {
.admin-menu-header {
background-color: $admin-sidebar-header-bg;
.logo-wrapper {
img {
height: 20px;
}
}
.section-header-text {
line-height: 1.5;
}
}
}
::ng-deep {
.navbar-nav {
.sidebar-section {
display: flex;
align-content: stretch;
background-color: $dark;
.nav-item {
padding-top: $spacer;
padding-bottom: $spacer;
}
.shortcut-icon {
padding-left: $icon-padding;
padding-right: $icon-padding;
}
.shortcut-icon, .icon-wrapper {
background-color: inherit;
z-index: $icon-z-index;
}
.sidebar-collapsible {
width: $sidebar-items-width;
position: relative;
a {
padding-right: $spacer;
width: 100%;
}
}
&.active > .sidebar-collapsible > .nav-link {
color: $navbar-dark-active-color;
}
}
}
}
}
}

View File

@@ -0,0 +1,142 @@
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { AdminSidebarComponent } from './admin-sidebar.component';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuServiceStub } from '../../shared/testing/menu-service-stub';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { CSSVariableServiceStub } from '../../shared/testing/css-variable-service-stub';
import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { AuthService } from '../../core/auth/auth.service';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent;
let fixture: ComponentFixture<AdminSidebarComponent>;
const menuService = new MenuServiceStub();
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule],
declarations: [AdminSidebarComponent],
providers: [
{ provide: Injector, useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: AuthService, useClass: AuthServiceStub }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(AdminSidebarComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
}
}).compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(AdminSidebarComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
comp.sections = observableOf([]);
fixture.detectChanges();
});
describe('startSlide', () => {
describe('when expanding', () => {
beforeEach(() => {
comp.sidebarClosed = true;
comp.startSlide({ toState: 'expanded' } as any);
});
it('should set the sidebarClosed to false', () => {
expect(comp.sidebarClosed).toBeFalsy();
})
});
describe('when collapsing', () => {
beforeEach(() => {
comp.sidebarClosed = false;
comp.startSlide({ toState: 'collapsed' } as any);
});
it('should set the sidebarOpen to false', () => {
expect(comp.sidebarOpen).toBeFalsy();
})
})
});
describe('finishSlide', () => {
describe('when expanding', () => {
beforeEach(() => {
comp.sidebarClosed = true;
comp.startSlide({ fromState: 'expanded' } as any);
});
it('should set the sidebarClosed to true', () => {
expect(comp.sidebarClosed).toBeTruthy();
})
});
describe('when collapsing', () => {
beforeEach(() => {
comp.sidebarClosed = false;
comp.startSlide({ fromState: 'collapsed' } as any);
});
it('should set the sidebarOpen to true', () => {
expect(comp.sidebarOpen).toBeTruthy();
})
})
});
describe('when the collapse icon is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
});
it('should call toggleMenu on the menuService', () => {
expect(menuService.toggleMenu).toHaveBeenCalled();
});
});
describe('when the collapse link is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
});
it('should call toggleMenu on the menuService', () => {
expect(menuService.toggleMenu).toHaveBeenCalled();
});
});
describe('when the the mouse enters the nav tag', () => {
it('should call expandPreview on the menuService after 100ms', fakeAsync(() => {
spyOn(menuService, 'expandMenuPreview');
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
sidebarToggler.triggerEventHandler('mouseenter', {preventDefault: () => {/**/}});
tick(99);
expect(menuService.expandMenuPreview).not.toHaveBeenCalled();
tick(1);
expect(menuService.expandMenuPreview).toHaveBeenCalled();
}));
});
describe('when the the mouse leaves the nav tag', () => {
it('should call collapseMenuPreview on the menuService after 400ms', fakeAsync(() => {
spyOn(menuService, 'collapseMenuPreview');
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
sidebarToggler.triggerEventHandler('mouseleave', {preventDefault: () => {/**/}});
tick(399);
expect(menuService.collapseMenuPreview).not.toHaveBeenCalled();
tick(1);
expect(menuService.collapseMenuPreview).toHaveBeenCalled();
}));
});
});

View File

@@ -0,0 +1,479 @@
import { Component, Injector, OnInit } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { slide, slideHorizontal, slideSidebar } from '../../shared/animations/slide';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state';
import { MenuComponent } from '../../shared/menu/menu.component';
import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model';
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
import { AuthService } from '../../core/auth/auth.service';
import { first, map } from 'rxjs/operators';
import { combineLatest as combineLatestObservable } from 'rxjs';
/**
* Component representing the admin sidebar
*/
@Component({
selector: 'ds-admin-sidebar',
templateUrl: './admin-sidebar.component.html',
styleUrls: ['./admin-sidebar.component.scss'],
animations: [slideHorizontal, slideSidebar]
})
export class AdminSidebarComponent extends MenuComponent implements OnInit {
/**
* The menu ID of the Navbar is PUBLIC
* @type {MenuID.ADMIN}
*/
menuID = MenuID.ADMIN;
/**
* Observable that emits the width of the collapsible menu sections
*/
sidebarWidth: Observable<string>;
/**
* Is true when the sidebar is open, is false when the sidebar is animating or closed
* @type {boolean}
*/
sidebarOpen = true; // Open in UI, animation finished
/**
* Is true when the sidebar is closed, is false when the sidebar is animating or open
* @type {boolean}
*/
sidebarClosed = !this.sidebarOpen; // Closed in UI, animation finished
/**
* Emits true when either the menu OR the menu's preview is expanded, else emits false
*/
sidebarExpanded: Observable<boolean>;
constructor(protected menuService: MenuService,
protected injector: Injector,
private variableService: CSSVariableService,
private authService: AuthService
) {
super(menuService, injector);
}
/**
* Set and calculate all initial values of the instance variables
*/
ngOnInit(): void {
this.createMenu();
super.ngOnInit();
this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth');
this.authService.isAuthenticated()
.subscribe((loggedIn: boolean) => {
if (loggedIn) {
this.menuService.showMenu(this.menuID);
}
});
this.menuCollapsed.pipe(first())
.subscribe((collapsed: boolean) => {
this.sidebarOpen = !collapsed;
this.sidebarClosed = collapsed;
});
this.sidebarExpanded = combineLatestObservable(this.menuCollapsed, this.menuPreviewCollapsed)
.pipe(
map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed))
);
}
/**
* Initialize all menu sections and items for this menu
*/
private createMenu() {
const menuList = [
/* News */
{
id: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus-circle',
index: 0
},
{
id: 'new_community',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_community',
link: '/communities/submission'
} as LinkMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_collection',
link: '/collections/submission'
} as LinkMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_item',
link: '/items/submission'
} as LinkMenuItemModel,
},
{
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: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.edit_community',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.edit_collection',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.edit_item',
link: '#'
} as LinkMenuItemModel,
},
/* Import */
{
id: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import'
} as TextMenuItemModel,
icon: 'sign-in-alt',
index: 2
},
{
id: 'import_metadata',
parentID: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_metadata',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'import_batch',
parentID: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_batch',
link: '#'
} as LinkMenuItemModel,
},
/* Export */
{
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'sign-out-alt',
index: 3
},
{
id: 'export_community',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_community',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'export_collection',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_collection',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'export_item',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_item',
link: '#'
} as LinkMenuItemModel,
}, {
id: 'export_metadata',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.export_metadata',
link: '#'
} as LinkMenuItemModel,
},
/* Access Control */
{
id: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'access_control_authorizations',
parentID: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_authorizations',
link: '#'
} as LinkMenuItemModel,
},
/* Search */
{
id: 'find',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.find'
} as TextMenuItemModel,
icon: 'search',
index: 5
},
{
id: 'find_items',
parentID: 'find',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.find_items',
link: '/search'
} as LinkMenuItemModel,
},
{
id: 'find_withdrawn_items',
parentID: 'find',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.find_withdrawn_items',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'find_private_items',
parentID: 'find',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.find_private_items',
link: '/admin/items'
} as LinkMenuItemModel,
},
/* Registries */
{
id: 'registries',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries'
} as TextMenuItemModel,
icon: 'list',
index: 6
},
{
id: 'registries_metadata',
parentID: 'registries',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata'
} as LinkMenuItemModel,
},
{
id: 'registries_format',
parentID: 'registries',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats'
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: '/curation'
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Statistics */
{
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 */
{
id: 'control_panel',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.control_panel',
link: '#'
} as LinkMenuItemModel,
icon: 'cogs',
index: 9
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
}
/**
* Method to change this.collapsed to false when the slide animation ends and is sliding open
* @param event The animation event
*/
startSlide(event: any): void {
if (event.toState === 'expanded') {
this.sidebarClosed = false;
} else if (event.toState === 'collapsed') {
this.sidebarOpen = false;
}
}
/**
* Method to change this.collapsed to false when the slide animation ends and is sliding open
* @param event The animation event
*/
finishSlide(event: any): void {
if (event.fromState === 'expanded') {
this.sidebarClosed = true;
} else if (event.fromState === 'collapsed') {
this.sidebarOpen = true;
}
}
}

View File

@@ -0,0 +1,27 @@
<li class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
[@bgColor]="{
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg | async)}}">
<div class="icon-wrapper">
<a class="nav-item nav-link shortcut-icon" (click)="toggleSection($event)" href="#">
<i class="fas fa-{{section.icon}} fa-fw" [title]="('menu.section.icon.' + section.id) | translate"></i>
</a>
</div>
<div class="sidebar-collapsible">
<a class="nav-item nav-link" href="#"
(click)="toggleSection($event)">
<span class="section-header-text">
<ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container>
</span>
<i class="fas fa-chevron-right fa-pull-right"
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i>
</a>
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
<li *ngFor="let subSection of (subSections | async)">
<ng-container
*ngComponentOutlet="itemComponents.get(subSection.id); injector: itemInjectors.get(subSection.id);"></ng-container>
</li>
</ul>
</div>
</li>

View File

@@ -0,0 +1,21 @@
@import '../../../../styles/variables.scss';
::ng-deep {
.fa-chevron-right {
padding-left: $spacer/2;
font-size: 0.5rem;
line-height: 3;
}
.sidebar-sub-level-items {
list-style: disc;
color: $navbar-dark-color;
overflow: hidden;
}
.sidebar-collapsible {
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,84 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ExpandableAdminSidebarSectionComponent } from './expandable-admin-sidebar-section.component';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuServiceStub } from '../../../shared/testing/menu-service-stub';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service-stub';
import { of as observableOf } from 'rxjs';
import { Component } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
describe('ExpandableAdminSidebarSectionComponent', () => {
let component: ExpandableAdminSidebarSectionComponent;
let fixture: ComponentFixture<ExpandableAdminSidebarSectionComponent>;
const menuService = new MenuServiceStub();
const iconString = 'test';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, TranslateModule.forRoot()],
declarations: [ExpandableAdminSidebarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: {icon: iconString} },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
]
}).overrideComponent(ExpandableAdminSidebarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.icon-wrapper')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
describe('when the icon is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('a.shortcut-icon'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
});
it('should call toggleActiveSection on the menuService', () => {
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
describe('when the header text is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-collapsible')).query(By.css('a'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
});
it('should call toggleActiveSection on the menuService', () => {
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
});
// declare a test component
@Component({
selector: 'ds-test-cmp',
template: ``
})
class TestComponent {
}

View File

@@ -0,0 +1,69 @@
import { Component, Inject, Injector, OnInit } from '@angular/core';
import { rotate } from '../../../shared/animations/rotate';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
import { slide } from '../../../shared/animations/slide';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
import { bgColor } from '../../../shared/animations/bgColor';
import { MenuID } from '../../../shared/menu/initial-menus-state';
import { MenuService } from '../../../shared/menu/menu.service';
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
/**
* Represents a expandable section in the sidebar
*/
@Component({
selector: 'ds-expandable-admin-sidebar-section',
templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor]
})
@rendersSectionForMenu(MenuID.ADMIN, true)
export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit {
/**
* This section resides in the Admin Sidebar
*/
menuID = MenuID.ADMIN;
/**
* The background color of the section when it's active
*/
sidebarActiveBg;
/**
* Emits true when the sidebar is currently collapsed, true when it's expanded
*/
sidebarCollapsed: Observable<boolean>;
/**
* Emits true when the sidebar's preview is currently collapsed, true when it's expanded
*/
sidebarPreviewCollapsed: Observable<boolean>;
/**
* Emits true when the menu section is expanded, else emits false
* This is true when the section is active AND either the sidebar or it's preview is open
*/
expanded: Observable<boolean>;
constructor(@Inject('sectionDataProvider') menuSection, protected menuService: MenuService,
private variableService: CSSVariableService, protected injector: Injector) {
super(menuSection, menuService, injector);
}
/**
* Set initial values for instance variables
*/
ngOnInit(): void {
super.ngOnInit();
this.sidebarActiveBg = this.variableService.getVariable('adminSidebarActiveBg');
this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed)
.pipe(
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed)))
);
}
}

View File

@@ -5,7 +5,7 @@ import { AdminRoutingModule } from './admin-routing.module';
@NgModule({ @NgModule({
imports: [ imports: [
AdminRegistriesModule, AdminRegistriesModule,
AdminRoutingModule AdminRoutingModule,
] ]
}) })
export class AdminModule { export class AdminModule {

View File

@@ -8,6 +8,9 @@
.dspace-logo-container { .dspace-logo-container {
margin: 10px 20px 0px 20px; margin: 10px 20px 0px 20px;
.display-3 {
word-break: break-word;
}
} }
.dspace-logo-container img { .dspace-logo-container img {

View File

@@ -1,6 +1,6 @@
<div class="container w-100 h-100"> <div class="container w-100 h-100">
<div class="text-center mt-5 row justify-content-md-center"> <div class="text-center mt-5 row justify-content-md-center">
<div> <div class="mx-auto">
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png"> <img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
<h1 class="h3 mb-0 font-weight-normal">{{"logout.form.header" | translate}}</h1> <h1 class="h3 mb-0 font-weight-normal">{{"logout.form.header" | translate}}</h1>
<ds-log-out></ds-log-out> <ds-log-out></ds-log-out>

View File

@@ -1,5 +1,5 @@
<div> <div>
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right" <div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fas float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div> [ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}"> <div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper> <ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>

View File

@@ -58,7 +58,6 @@ export class SearchFiltersComponent {
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown * @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/ */
isActive(filterConfig: SearchFilterConfig): Observable<boolean> { isActive(filterConfig: SearchFilterConfig): Observable<boolean> {
// console.log(filter.name);
return this.filterService.getSelectedValuesForFilter(filterConfig).pipe( return this.filterService.getSelectedValuesForFilter(filterConfig).pipe(
mergeMap((isActive) => { mergeMap((isActive) => {
if (isNotEmpty(isActive)) { if (isNotEmpty(isActive)) {

View File

@@ -26,7 +26,7 @@
<ds-view-mode-switch></ds-view-mode-switch> <ds-view-mode-switch></ds-view-mode-switch>
<button (click)="openSidebar()" aria-controls="#search-body" <button (click)="openSidebar()" aria-controls="#search-body"
class="btn btn-outline-primary float-right open-sidebar"><i class="btn btn-outline-primary float-right open-sidebar"><i
class="fa fa-sliders"></i> {{"search.sidebar.open" class="fas fa-sliders"></i> {{"search.sidebar.open"
| translate}} | translate}}
</button> </button>
</div> </div>

View File

@@ -4,7 +4,7 @@
<button (click)="toggleSidebar.emit()" <button (click)="toggleSidebar.emit()"
aria-controls="#search-body" aria-controls="#search-body"
class="btn btn-outline-primary float-right close-sidebar"><i class="btn btn-outline-primary float-right close-sidebar"><i
class="fa fa-arrow-right"></i> {{"search.sidebar.close" | translate}} class="fas fa-arrow-right" [title]="'search.sidebar.close' | translate"></i> {{"search.sidebar.close" | translate}}
</button> </button>
</div> </div>
<div id="search-sidebar-content"> <div id="search-sidebar-content">

View File

@@ -1,6 +1,10 @@
<div class="outer-wrapper"> <div class="outer-wrapper">
<div class="inner-wrapper"> <ds-admin-sidebar></ds-admin-sidebar>
<ds-header></ds-header> <div class="inner-wrapper" [@slideSidebarPadding]="{
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
params: {collapsedSidebarWidth: (collapsedSidebarWidth | async), totalSidebarWidth: (totalSidebarWidth | async)}
}">
<ds-header-navbar-wrapper></ds-header-navbar-wrapper>
<ds-notifications-board <ds-notifications-board
[options]="config.notifications"> [options]="config.notifications">
@@ -16,5 +20,3 @@
<ds-footer></ds-footer> <ds-footer></ds-footer>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
@import '../styles/variables.scss'; @import '../styles/variables.scss';
@import '../styles/helpers/font_awesome_imports.scss';
@import '../../node_modules/bootstrap/scss/bootstrap.scss'; @import '../../node_modules/bootstrap/scss/bootstrap.scss';
@import '../../node_modules/nouislider/distribute/nouislider.min.css'; @import '../../node_modules/nouislider/distribute/nouislider.min';
@import "../../node_modules/font-awesome/scss/font-awesome.scss";
html { html {
position: relative; position: relative;
@@ -11,8 +11,8 @@ html {
body { body {
overflow-x: hidden; overflow-x: hidden;
} }
// Sticky Footer
// Sticky Footer
.outer-wrapper { .outer-wrapper {
display: flex; display: flex;
margin: 0; margin: 0;
@@ -25,10 +25,22 @@ body {
min-height: 100vh; min-height: 100vh;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
position: relative;
} }
.main-content { .main-content {
z-index: $main-z-index;
flex: 1 0 auto; flex: 1 0 auto;
margin-top: $content-spacing; margin-top: $content-spacing;
margin-bottom: $content-spacing; margin-bottom: $content-spacing;
} }
ds-header-navbar-wrapper {
z-index: $nav-z-index;
}
ds-admin-sidebar {
position: fixed;
z-index: $sidebar-z-index;
}

View File

@@ -35,11 +35,18 @@ import { AngularticsMock } from './shared/mocks/mock-angulartics.service';
import { AuthServiceMock } from './shared/mocks/mock-auth.service'; import { AuthServiceMock } from './shared/mocks/mock-auth.service';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MenuService } from './shared/menu/menu.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { CSSVariableServiceStub } from './shared/testing/css-variable-service-stub';
import { MenuServiceStub } from './shared/testing/menu-service-stub';
import { HostWindowService } from './shared/host-window.service';
import { HostWindowServiceStub } from './shared/testing/host-window-service-stub';
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
let de: DebugElement; let de: DebugElement;
let el: HTMLElement; let el: HTMLElement;
const menuService = new MenuServiceStub();
describe('App component', () => { describe('App component', () => {
@@ -64,6 +71,9 @@ describe('App component', () => {
{ provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() },
{ provide: AuthService, useValue: new AuthServiceMock() }, { provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: {} }, { provide: Router, useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
AppComponent AppComponent
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
@@ -75,7 +85,6 @@ describe('App component', () => {
fixture = TestBed.createComponent(AppComponent); fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance; // component test instance comp = fixture.componentInstance; // component test instance
// query for the <div class='outer-wrapper'> by CSS element selector // query for the <div class='outer-wrapper'> by CSS element selector
de = fixture.debugElement.query(By.css('div.outer-wrapper')); de = fixture.debugElement.query(By.css('div.outer-wrapper'));
el = de.nativeElement; el = de.nativeElement;

View File

@@ -1,4 +1,4 @@
import { filter, first, take } from 'rxjs/operators'; import { filter, first, map, take } from 'rxjs/operators';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -23,16 +23,29 @@ import { NativeWindowRef, NativeWindowService } from './shared/services/window.s
import { isAuthenticated } from './core/auth/selectors'; import { isAuthenticated } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
import variables from '../styles/_exposed_variables.scss';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service';
import { MenuID } from './shared/menu/initial-menus-state';
import { Observable } from 'rxjs/internal/Observable';
import { slideSidebarPadding } from './shared/animations/slide';
import { combineLatest as combineLatestObservable } from 'rxjs';
import { HostWindowService } from './shared/host-window.service';
@Component({ @Component({
selector: 'ds-app', selector: 'ds-app',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None,
animations: [slideSidebarPadding]
}) })
export class AppComponent implements OnInit, AfterViewInit { export class AppComponent implements OnInit, AfterViewInit {
isLoading = true; isLoading = true;
sidebarVisible: Observable<boolean>;
slideSidebarOver: Observable<boolean>;
collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>;
constructor( constructor(
@Inject(GLOBAL_CONFIG) public config: GlobalConfig, @Inject(GLOBAL_CONFIG) public config: GlobalConfig,
@@ -42,7 +55,10 @@ export class AppComponent implements OnInit, AfterViewInit {
private metadata: MetadataService, private metadata: MetadataService,
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
private authService: AuthService, private authService: AuthService,
private router: Router private router: Router,
private cssService: CSSVariableService,
private menuService: MenuService,
private windowService: HostWindowService
) { ) {
// this language will be used as a fallback when a translation isn't found in the current language // this language will be used as a fallback when a translation isn't found in the current language
translate.setDefaultLang('en'); translate.setDefaultLang('en');
@@ -54,9 +70,12 @@ export class AppComponent implements OnInit, AfterViewInit {
if (config.debug) { if (config.debug) {
console.info(config); console.info(config);
} }
this.storeCSSVariables();
} }
ngOnInit() { ngOnInit() {
const env: string = this.config.production ? 'Production' : 'Development'; const env: string = this.config.production ? 'Production' : 'Development';
const color: string = this.config.production ? 'red' : 'green'; const color: string = this.config.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
@@ -67,7 +86,23 @@ export class AppComponent implements OnInit, AfterViewInit {
first(), first(),
filter((authenticated) => !authenticated) filter((authenticated) => !authenticated)
).subscribe((authenticated) => this.authService.checkAuthenticationToken()); ).subscribe((authenticated) => this.authService.checkAuthenticationToken());
this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN);
this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth');
this.totalSidebarWidth = this.cssService.getVariable('totalSidebarWidth');
const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN);
this.slideSidebarOver = combineLatestObservable(sidebarCollapsed, this.windowService.isXsOrSm())
.pipe(
map(([collapsed, mobile]) => collapsed || mobile)
);
}
private storeCSSVariables() {
const vars = variables.locals || {};
Object.keys(vars).forEach((name: string) => {
this.cssService.addCSSVariable(name, vars[name]);
})
} }
ngAfterViewInit() { ngAfterViewInit() {

View File

@@ -1,10 +1,9 @@
import { HeaderEffects } from './header/header.effects';
import { StoreEffects } from './store.effects'; import { StoreEffects } from './store.effects';
import { NotificationsEffects } from './shared/notifications/notifications.effects'; import { NotificationsEffects } from './shared/notifications/notifications.effects';
import { NavbarEffects } from './navbar/navbar.effects';
export const appEffects = [ export const appEffects = [
StoreEffects, StoreEffects,
HeaderEffects, NavbarEffects,
NotificationsEffects NotificationsEffects,
]; ];

View File

@@ -31,6 +31,11 @@ import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-s
import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component';
import { NotificationComponent } from './shared/notifications/notification/notification.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component';
import { SharedModule } from './shared/shared.module'; import { SharedModule } from './shared/shared.module';
import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component';
import { AdminSidebarComponent } from './+admin/admin-sidebar/admin-sidebar.component';
import { AdminSidebarSectionComponent } from './+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { NavbarModule } from './navbar/navbar.module';
export function getConfig() { export function getConfig() {
return ENV_CONFIG; return ENV_CONFIG;
@@ -48,6 +53,7 @@ export function getMetaReducers(config: GlobalConfig): Array<MetaReducer<AppStat
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
SharedModule, SharedModule,
NavbarModule,
HttpClientModule, HttpClientModule,
AppRoutingModule, AppRoutingModule,
CoreModule.forRoot(), CoreModule.forRoot(),
@@ -88,6 +94,10 @@ const PROVIDERS = [
const DECLARATIONS = [ const DECLARATIONS = [
AppComponent, AppComponent,
HeaderComponent, HeaderComponent,
HeaderNavbarWrapperComponent,
AdminSidebarComponent,
AdminSidebarSectionComponent,
ExpandableAdminSidebarSectionComponent,
FooterComponent, FooterComponent,
PageNotFoundComponent, PageNotFoundComponent,
NotificationComponent, NotificationComponent,
@@ -106,10 +116,14 @@ const EXPORTS = [
...PROVIDERS ...PROVIDERS
], ],
declarations: [ declarations: [
...DECLARATIONS ...DECLARATIONS,
], ],
exports: [ exports: [
...EXPORTS ...EXPORTS
],
entryComponents: [
AdminSidebarSectionComponent,
ExpandableAdminSidebarSectionComponent
] ]
}) })
export class AppModule { export class AppModule {

View File

@@ -1,7 +1,5 @@
import { ActionReducerMap } from '@ngrx/store'; import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store';
import * as fromRouter from '@ngrx/router-store'; import * as fromRouter from '@ngrx/router-store';
import { headerReducer, HeaderState } from './header/header.reducer';
import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer';
import { formReducer, FormState } from './shared/form/form.reducer'; import { formReducer, FormState } from './shared/form/form.reducer';
import { import {
@@ -12,29 +10,47 @@ import {
filterReducer, filterReducer,
SearchFiltersState SearchFiltersState
} from './+search-page/search-filters/search-filter/search-filter.reducer'; } from './+search-page/search-filters/search-filter/search-filter.reducer';
import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; import {
notificationsReducer,
NotificationsState
} from './shared/notifications/notifications.reducers';
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
import { hasValue } from './shared/empty.util';
import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer';
import { menusReducer, MenusState } from './shared/menu/menu.reducer';
export interface AppState { export interface AppState {
router: fromRouter.RouterReducerState; router: fromRouter.RouterReducerState;
hostWindow: HostWindowState; hostWindow: HostWindowState;
header: HeaderState;
forms: FormState; forms: FormState;
notifications: NotificationsState; notifications: NotificationsState;
searchSidebar: SearchSidebarState; searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState; searchFilter: SearchFiltersState;
truncatable: TruncatablesState; truncatable: TruncatablesState;
cssVariables: CSSVariablesState;
menus: MenusState;
} }
export const appReducers: ActionReducerMap<AppState> = { export const appReducers: ActionReducerMap<AppState> = {
router: fromRouter.routerReducer, router: fromRouter.routerReducer,
hostWindow: hostWindowReducer, hostWindow: hostWindowReducer,
header: headerReducer,
forms: formReducer, forms: formReducer,
notifications: notificationsReducer, notifications: notificationsReducer,
searchSidebar: sidebarReducer, searchSidebar: sidebarReducer,
searchFilter: filterReducer, searchFilter: filterReducer,
truncatable: truncatableReducer truncatable: truncatableReducer,
cssVariables: cssVariablesReducer,
menus: menusReducer,
}; };
export const routerStateSelector = (state: AppState) => state.router; export const routerStateSelector = (state: AppState) => state.router;
export function keySelector<T>(key: string, selector): MemoizedSelector<AppState, T> {
return createSelector(selector, (state) => {
if (hasValue(state)) {
return state[key];
} else {
return undefined;
}
});
}

View File

@@ -41,7 +41,6 @@ export class ServerAuthService extends AuthService {
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString()); const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
// person$.subscribe(() => console.log('test'));
return person$.pipe(map((eperson) => eperson.payload)); return person$.pipe(map((eperson) => eperson.payload));
} else { } else {
throw(new Error('Not authenticated')); throw(new Error('Not authenticated'));

View File

@@ -64,6 +64,8 @@ import { NotificationsService } from '../shared/notifications/notifications.serv
import { UploaderService } from '../shared/uploader/uploader.service'; import { UploaderService } from '../shared/uploader/uploader.service';
import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service';
import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service';
import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
import { MenuService } from '../shared/menu/menu.service';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -128,6 +130,8 @@ const PROVIDERS = [
UploaderService, UploaderService,
UUIDService, UUIDService,
DSpaceObjectDataService, DSpaceObjectDataService,
CSSVariableService,
MenuService,
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,

View File

@@ -1,9 +1,10 @@
import { filter, map } from 'rxjs/operators'; import { filter, map, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Effect, Actions, ofType } from '@ngrx/effects'; import { Actions, Effect, ofType } from '@ngrx/effects';
import { import {
ObjectCacheActionTypes, AddToObjectCacheAction, AddToObjectCacheAction,
ObjectCacheActionTypes,
RemoveFromObjectCacheAction RemoveFromObjectCacheAction
} from '../cache/object-cache.actions'; } from '../cache/object-cache.actions';
import { RequestActionTypes, RequestConfigureAction } from '../data/request.actions'; import { RequestActionTypes, RequestConfigureAction } from '../data/request.actions';
@@ -11,6 +12,11 @@ import { RestRequestMethod } from '../data/request.models';
import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'; import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { IndexName } from './index.reducer'; import { IndexName } from './index.reducer';
import {
AddMenuSectionAction,
MenuActionTypes,
RemoveMenuSectionAction
} from '../../shared/menu/menu.actions';
@Injectable() @Injectable()
export class UUIDIndexEffects { export class UUIDIndexEffects {
@@ -52,17 +58,6 @@ export class UUIDIndexEffects {
}) })
); );
// @Effect() removeRequest$ = this.actions$
// .pipe(
// ofType(ObjectCacheActionTypes.REMOVE),
// map((action: RemoveFromObjectCacheAction) => {
// return new RemoveFromIndexByValueAction(
// IndexName.OBJECT,
// action.payload
// );
// })
// )
constructor(private actions$: Actions) { constructor(private actions$: Actions) {
} }

View File

@@ -20,6 +20,8 @@ describe('requestReducer', () => {
const testState: IndexState = { const testState: IndexState = {
[IndexName.OBJECT]: { [IndexName.OBJECT]: {
[key1]: val1 [key1]: val1
},
[IndexName.REQUEST]: {
} }
}; };
deepFreeze(testState); deepFreeze(testState);

View File

@@ -7,13 +7,11 @@ import {
export enum IndexName { export enum IndexName {
OBJECT = 'object/uuid-to-self-link', OBJECT = 'object/uuid-to-self-link',
REQUEST = 'get-request/href-to-uuid' REQUEST = 'get-request/href-to-uuid',
} }
export interface IndexState { export type IndexState = {
// TODO this should be `[name in IndexName]: {` but that's currently broken, [name in IndexName]: {
// see https://github.com/Microsoft/TypeScript/issues/13042
[name: string]: {
[key: string]: string [key: string]: string
} }
} }

View File

@@ -0,0 +1,4 @@
<div [ngClass]="{'open': !(isNavBarCollapsed | async)}">
<ds-header></ds-header>
<ds-navbar></ds-navbar>
</div>

View File

@@ -0,0 +1,9 @@
@import '../../styles/variables.scss';
@media screen and (max-width: map-get($grid-breakpoints, md)) {
:host.open {
background-color: $white;
top: 0;
position: sticky;
}
}

View File

@@ -0,0 +1,40 @@
import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '../app.reducer';
import { hasValue } from '../shared/empty.util';
import { Observable } from 'rxjs/internal/Observable';
import { Subscription } from 'rxjs/internal/Subscription';
import { MenuService } from '../shared/menu/menu.service';
import { MenuID } from '../shared/menu/initial-menus-state';
/**
* This component represents a wrapper for the horizontal navbar and the header
*/
@Component({
selector: 'ds-header-navbar-wrapper',
styleUrls: ['header-navbar-wrapper.component.scss'],
templateUrl: 'header-navbar-wrapper.component.html',
})
export class HeaderNavbarWrapperComponent implements OnInit, OnDestroy {
@HostBinding('class.open') isOpen = false;
private sub: Subscription;
public isNavBarCollapsed: Observable<boolean>;
menuID = MenuID.PUBLIC;
constructor(
private store: Store<AppState>,
private menuService: MenuService
) {
}
ngOnInit(): void {
this.isNavBarCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.sub = this.isNavBarCollapsed.subscribe((isCollapsed) => this.isOpen = !isCollapsed)
}
ngOnDestroy() {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -1,40 +0,0 @@
import { Action } from '@ngrx/store';
import { type } from '../shared/ngrx/type';
/**
* For each action type in an action group, make a simple
* enum object for all of this group's action types.
*
* The 'type' utility function coerces strings into string
* literal types and runs a simple check to guarantee all
* action types in the application are unique.
*/
export const HeaderActionTypes = {
COLLAPSE: type('dspace/header/COLLAPSE'),
EXPAND: type('dspace/header/EXPAND'),
TOGGLE: type('dspace/header/TOGGLE')
};
/* tslint:disable:max-classes-per-file */
export class HeaderCollapseAction implements Action {
type = HeaderActionTypes.COLLAPSE;
}
export class HeaderExpandAction implements Action {
type = HeaderActionTypes.EXPAND;
}
export class HeaderToggleAction implements Action {
type = HeaderActionTypes.TOGGLE;
}
/* tslint:enable:max-classes-per-file */
/**
* Export a type alias of all actions in this action group
* so that reducers can easily compose action types
*/
export type HeaderAction
= HeaderCollapseAction
| HeaderExpandAction
| HeaderToggleAction

View File

@@ -1,18 +1,20 @@
<header> <header>
<nav class="navbar navbar-dark bg-primary navbar-expand-md"> <div class="container">
<div [ngClass]="{'clearfix': !(isNavBarCollapsed | async)}"> <a class="navbar-brand my-2" routerLink="/home">
<a class="navbar-brand" routerLink="/home">{{ 'title' | translate }}</a> <img src="assets/images/dspace-logo.svg"/>
</div> </a>
<button class="navbar-toggler" type="button" (click)="toggle()" aria-controls="collapsingNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon fa fa-bars fa-fw" aria-hidden="true"></span> <nav class="navbar navbar-light navbar-expand-md float-right px-0">
</button> <a href="#" class="px-1"><i class="fas fa-search fa-lg fa-fw" [title]="'nav.search' | translate"></i></a>
<div [ngbCollapse]="(isNavBarCollapsed | async)" class="collapse navbar-collapse" id="collapsingNav"> <a href="#" class="px-1"><i class="fas fa-globe-asia fa-lg fa-fw" [title]="'nav.language' | translate"></i></a>
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" routerLink="/home" routerLinkActive="active"><i class="fa fa-home fa-fw" aria-hidden="true"></i> {{ 'nav.home' | translate }}<span class="sr-only">(current)</span></a>
</li>
</ul>
<ds-auth-nav-menu></ds-auth-nav-menu> <ds-auth-nav-menu></ds-auth-nav-menu>
<div class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()"
aria-controls="collapsingNav"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon fas fa-bars fa-fw" aria-hidden="true"></span>
</button>
</div> </div>
</nav> </nav>
</div>
</header> </header>

View File

@@ -1,14 +1,12 @@
@import '../../styles/variables.scss'; @import '../../styles/variables.scss';
header nav.navbar { .navbar-brand img {
border-radius: 0; height: $header-logo-height;
@media screen and (max-width: map-get($grid-breakpoints, sm)) {
height: $header-logo-height-xs;
} }
header nav.navbar .navbar-toggler:hover {
cursor: pointer;
} }
.navbar-toggler .navbar-toggler-icon {
header nav.navbar .navbar-toggler .navbar-toggler-icon {
background-image: none !important; background-image: none !important;
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -1,46 +1,31 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { Store, StoreModule } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { HeaderComponent } from './header.component';
import { HeaderState } from './header.reducer';
import { HeaderToggleAction } from './header.actions';
import { AuthNavMenuComponent } from '../shared/auth-nav-menu/auth-nav-menu.component';
import { LogInComponent } from '../shared/log-in/log-in.component';
import { LogOutComponent } from '../shared/log-out/log-out.component';
import { LoadingComponent } from '../shared/loading/loading.component';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { HostWindowService } from '../shared/host-window.service';
import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub';
import { RouterStub } from '../shared/testing/router-stub';
import { Router } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import * as ngrx from '@ngrx/store';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MenuService } from '../shared/menu/menu.service';
import { MenuServiceStub } from '../shared/testing/menu-service-stub';
import { HeaderComponent } from './header.component';
let comp: HeaderComponent; let comp: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
let store: Store<HeaderState>;
describe('HeaderComponent', () => { describe('HeaderComponent', () => {
const menuService = new MenuServiceStub();
// async beforeEach // async beforeEach
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
StoreModule.forRoot({}),
TranslateModule.forRoot(), TranslateModule.forRoot(),
NgbCollapseModule.forRoot(),
NoopAnimationsModule, NoopAnimationsModule,
ReactiveFormsModule], ReactiveFormsModule],
declarations: [HeaderComponent], declarations: [HeaderComponent],
providers: [ providers: [
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: MenuService, useValue: menuService }
{ provide: Router, useClass: RouterStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })
@@ -49,63 +34,25 @@ describe('HeaderComponent', () => {
// synchronous beforeEach // synchronous beforeEach
beforeEach(() => { beforeEach(() => {
spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(HeaderComponent); fixture = TestBed.createComponent(HeaderComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
store = fixture.debugElement.injector.get(Store) as Store<HeaderState>;
spyOn(store, 'dispatch');
}); });
describe('when the toggle button is clicked', () => { describe('when the toggle button is clicked', () => {
beforeEach(() => { beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const navbarToggler = fixture.debugElement.query(By.css('.navbar-toggler')); const navbarToggler = fixture.debugElement.query(By.css('.navbar-toggler'));
navbarToggler.triggerEventHandler('click', null); navbarToggler.triggerEventHandler('click', null);
}); });
it('should dispatch a HeaderToggleAction', () => { it('should call toggleMenu on the menuService', () => {
expect(store.dispatch).toHaveBeenCalledWith(new HeaderToggleAction()); expect(menuService.toggleMenu).toHaveBeenCalled();
}); });
}); });
describe('when navCollapsed in the store is true', () => {
let menu: HTMLElement;
beforeEach(() => {
menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement;
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf({ navCollapsed: true })
};
});
fixture.detectChanges();
});
it('should close the menu', () => {
expect(menu.classList).not.toContain('show');
});
});
describe('when navCollapsed in the store is false', () => {
let menu: HTMLElement;
beforeEach(() => {
menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement;
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(false)
};
});
fixture.detectChanges();
});
it('should open the menu', () => {
expect(menu.classList).toContain('show');
});
});
}); });

View File

@@ -1,43 +1,31 @@
import { Component, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable';
import { Observable } from 'rxjs'; import { MenuService } from '../shared/menu/menu.service';
import { RouterReducerState } from '@ngrx/router-store'; import { MenuID } from '../shared/menu/initial-menus-state';
import { HeaderState } from './header.reducer';
import { HeaderToggleAction } from './header.actions';
import { AppState } from '../app.reducer';
import { HostWindowService } from '../shared/host-window.service';
const headerStateSelector = (state: AppState) => state.header;
const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed);
/**
* Represents the header with the logo and simple navigation
*/
@Component({ @Component({
selector: 'ds-header', selector: 'ds-header',
styleUrls: ['header.component.scss'], styleUrls: ['header.component.scss'],
templateUrl: 'header.component.html', templateUrl: 'header.component.html',
}) })
export class HeaderComponent implements OnInit { export class HeaderComponent {
/** /**
* Whether user is authenticated. * Whether user is authenticated.
* @type {Observable<string>} * @type {Observable<string>}
*/ */
public isAuthenticated: Observable<boolean>; public isAuthenticated: Observable<boolean>;
public isNavBarCollapsed: Observable<boolean>;
public showAuth = false; public showAuth = false;
menuID = MenuID.PUBLIC;
constructor( constructor(
private store: Store<AppState>, private menuService: MenuService
private windowService: HostWindowService
) { ) {
} }
ngOnInit(): void { public toggleNavbar(): void {
// set loading this.menuService.toggleMenu(this.menuID);
this.isNavBarCollapsed = this.store.pipe(select(navCollapsedSelector));
} }
public toggle(): void {
this.store.dispatch(new HeaderToggleAction());
}
} }

View File

@@ -1,28 +0,0 @@
import { map } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Effect, Actions, ofType } from '@ngrx/effects'
import * as fromRouter from '@ngrx/router-store';
import { HostWindowActionTypes } from '../shared/host-window.actions';
import { HeaderCollapseAction } from './header.actions';
@Injectable()
export class HeaderEffects {
@Effect() resize$ = this.actions$
.pipe(
ofType(HostWindowActionTypes.RESIZE),
map(() => new HeaderCollapseAction())
);
@Effect() routeChange$ = this.actions$
.pipe(
ofType(fromRouter.ROUTER_NAVIGATION),
map(() => new HeaderCollapseAction())
);
constructor(private actions$: Actions) {
}
}

View File

@@ -1,89 +0,0 @@
import * as deepFreeze from 'deep-freeze';
import { headerReducer } from './header.reducer';
import {
HeaderCollapseAction,
HeaderExpandAction,
HeaderToggleAction
} from './header.actions';
class NullAction extends HeaderCollapseAction {
type = null;
constructor() {
super();
}
}
describe('headerReducer', () => {
it('should return the current state when no valid actions have been made', () => {
const state = { navCollapsed: false };
const action = new NullAction();
const newState = headerReducer(state, action);
expect(newState).toEqual(state);
});
it('should start with navCollapsed = true', () => {
const action = new NullAction();
const initialState = headerReducer(undefined, action);
// The navigation starts collapsed
expect(initialState.navCollapsed).toEqual(true);
});
it('should set navCollapsed to true in response to the COLLAPSE action', () => {
const state = { navCollapsed: false };
const action = new HeaderCollapseAction();
const newState = headerReducer(state, action);
expect(newState.navCollapsed).toEqual(true);
});
it('should perform the COLLAPSE action without affecting the previous state', () => {
const state = { navCollapsed: false };
deepFreeze(state);
const action = new HeaderCollapseAction();
headerReducer(state, action);
// no expect required, deepFreeze will ensure an exception is thrown if the state
// is mutated, and any uncaught exception will cause the test to fail
});
it('should set navCollapsed to false in response to the EXPAND action', () => {
const state = { navCollapsed: true };
const action = new HeaderExpandAction();
const newState = headerReducer(state, action);
expect(newState.navCollapsed).toEqual(false);
});
it('should perform the EXPAND action without affecting the previous state', () => {
const state = { navCollapsed: true };
deepFreeze(state);
const action = new HeaderExpandAction();
headerReducer(state, action);
});
it('should flip the value of navCollapsed in response to the TOGGLE action', () => {
const state1 = { navCollapsed: true };
const action = new HeaderToggleAction();
const state2 = headerReducer(state1, action);
const state3 = headerReducer(state2, action);
expect(state2.navCollapsed).toEqual(false);
expect(state3.navCollapsed).toEqual(true);
});
it('should perform the TOGGLE action without affecting the previous state', () => {
const state = { navCollapsed: true };
deepFreeze(state);
const action = new HeaderToggleAction();
headerReducer(state, action);
});
});

View File

@@ -1,38 +0,0 @@
import { HeaderAction, HeaderActionTypes } from './header.actions';
export interface HeaderState {
navCollapsed: boolean;
}
const initialState: HeaderState = {
navCollapsed: true
};
export function headerReducer(state = initialState, action: HeaderAction): HeaderState {
switch (action.type) {
case HeaderActionTypes.COLLAPSE: {
return Object.assign({}, state, {
navCollapsed: true
});
}
case HeaderActionTypes.EXPAND: {
return Object.assign({}, state, {
navCollapsed: false
});
}
case HeaderActionTypes.TOGGLE: {
return Object.assign({}, state, {
navCollapsed: !state.navCollapsed
});
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,17 @@
<li class="nav-item dropdown"
(mouseenter)="activateSection($event)"
(mouseleave)="deactivateSection($event)">
<a href="#" class="nav-link dropdown-toggle" routerLinkActive="active"
id="browseDropdown" (click)="toggleSection($event)"
data-toggle="dropdown">
<ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container>
</a>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
class="m-0 shadow-none border-top-0 dropdown-menu show">
<ng-container *ngFor="let subSection of (subSections | async)">
<ng-container
*ngComponentOutlet="itemComponents.get(subSection.id); injector: itemInjectors.get(subSection.id);"></ng-container>
</ng-container>
</ul>
</li>

View File

@@ -0,0 +1,27 @@
@import '../../../styles/variables.scss';
.dropdown-menu {
overflow: hidden;
min-width: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
::ng-deep a.nav-link {
padding-right: $spacer;
padding-left: $spacer;
white-space: nowrap;
}
}
/** Mobile menu styling **/
@media screen and (max-width: map-get($grid-breakpoints, md)) {
.dropdown-toggle {
&:after {
float: right;
margin-top: $spacer/2;
}
}
.dropdown-menu {
border: 0;
}
}

View File

@@ -0,0 +1,176 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component';
import { By } from '@angular/platform-browser';
import { MenuServiceStub } from '../../shared/testing/menu-service-stub';
import { Component } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { HostWindowService } from '../../shared/host-window.service';
import { MenuService } from '../../shared/menu/menu.service';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('ExpandableNavbarSectionComponent', () => {
let component: ExpandableNavbarSectionComponent;
let fixture: ComponentFixture<ExpandableNavbarSectionComponent>;
const menuService = new MenuServiceStub();
describe('on larger screens', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [ExpandableNavbarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }
]
}).overrideComponent(ExpandableNavbarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(ExpandableNavbarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when the mouse enters the section header', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown'));
sidebarToggler.triggerEventHandler('mouseenter', {
preventDefault: () => {/**/
}
});
});
it('should call activateSection on the menuService', () => {
expect(menuService.activateSection).toHaveBeenCalled();
});
});
describe('when the mouse leaves the section header', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown'));
sidebarToggler.triggerEventHandler('mouseleave', {
preventDefault: () => {/**/
}
});
});
it('should call deactivateSection on the menuService', () => {
expect(menuService.deactivateSection).toHaveBeenCalled();
});
});
describe('when a click occurs on the section header', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')).query(By.css('a'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should not call toggleActiveSection on the menuService', () => {
expect(menuService.toggleActiveSection).not.toHaveBeenCalled();
});
});
});
describe('on smaller, mobile screens', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [ExpandableNavbarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(300) }
]
}).overrideComponent(ExpandableNavbarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(ExpandableNavbarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
describe('when the mouse enters the section header', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown'));
sidebarToggler.triggerEventHandler('mouseenter', {
preventDefault: () => {/**/
}
});
});
it('should not call activateSection on the menuService', () => {
expect(menuService.activateSection).not.toHaveBeenCalled();
});
});
describe('when the mouse leaves the section header', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown'));
sidebarToggler.triggerEventHandler('mouseleave', {
preventDefault: () => {/**/
}
});
});
it('should not call deactivateSection on the menuService', () => {
expect(menuService.deactivateSection).not.toHaveBeenCalled();
});
});
describe('when a click occurs on the section header link', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')).query(By.css('a'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleActiveSection on the menuService', () => {
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
});
});
// declare a test component
@Component({
selector: 'ds-test-cmp',
template: ``
})
class TestComponent {
}

View File

@@ -0,0 +1,83 @@
import { Component, Inject, Injector, OnInit } from '@angular/core';
import { NavbarSectionComponent } from '../navbar-section/navbar-section.component';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID } from '../../shared/menu/initial-menus-state';
import { slide } from '../../shared/animations/slide';
import { first } from 'rxjs/operators';
import { HostWindowService } from '../../shared/host-window.service';
import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator';
/**
* Represents an expandable section in the navbar
*/
@Component({
selector: 'ds-expandable-navbar-section',
templateUrl: './expandable-navbar-section.component.html',
styleUrls: ['./expandable-navbar-section.component.scss'],
animations: [slide]
})
@rendersSectionForMenu(MenuID.PUBLIC, true)
export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit {
/**
* This section resides in the Public Navbar
*/
menuID = MenuID.PUBLIC;
constructor(@Inject('sectionDataProvider') menuSection,
protected menuService: MenuService,
protected injector: Injector,
private windowService: HostWindowService
) {
super(menuSection, menuService, injector);
}
ngOnInit() {
super.ngOnInit();
}
/**
* Overrides the super function that activates this section (triggered on hover)
* Has an extra check to make sure the section can only be activated on non-mobile devices
* @param {Event} event The user event that triggered this function
*/
activateSection(event): void {
this.windowService.isXsOrSm().pipe(
first()
).subscribe((isMobile) => {
if (!isMobile) {
super.activateSection(event);
}
});
}
/**
* Overrides the super function that deactivates this section (triggered on hover)
* Has an extra check to make sure the section can only be deactivated on non-mobile devices
* @param {Event} event The user event that triggered this function
*/
deactivateSection(event): void {
this.windowService.isXsOrSm().pipe(
first()
).subscribe((isMobile) => {
if (!isMobile) {
super.deactivateSection(event);
}
});
}
/**
* Overrides the super function that toggles this section (triggered on click)
* Has an extra check to make sure the section can only be toggled on mobile devices
* @param {Event} event The user event that triggered this function
*/
toggleSection(event): void {
event.preventDefault();
this.windowService.isXsOrSm().pipe(
first()
).subscribe((isMobile) => {
if (isMobile) {
super.toggleSection(event);
}
});
}
}

View File

@@ -0,0 +1,4 @@
<li class="nav-item">
<ng-container
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container>
</li>

View File

@@ -0,0 +1,53 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NavbarSectionComponent } from './navbar-section.component';
import { HostWindowService } from '../../shared/host-window.service';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MenuService } from '../../shared/menu/menu.service';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub';
import { Component } from '@angular/core';
import { MenuServiceStub } from '../../shared/testing/menu-service-stub';
import { of as observableOf } from 'rxjs';
describe('NavbarSectionComponent', () => {
let component: NavbarSectionComponent;
let fixture: ComponentFixture<NavbarSectionComponent>;
const menuService = new MenuServiceStub();
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [NavbarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }
]
}).overrideComponent(NavbarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(NavbarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
// declare a test component
@Component({
selector: 'ds-test-cmp',
template: ``
})
class TestComponent {
}

View File

@@ -0,0 +1,32 @@
import { Component, Inject, Injector, OnInit } from '@angular/core';
import { MenuSectionComponent } from '../../shared/menu/menu-section/menu-section.component';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID } from '../../shared/menu/initial-menus-state';
import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator';
/**
* Represents a non-expandable section in the navbar
*/
@Component({
selector: 'ds-navbar-section',
templateUrl: './navbar-section.component.html',
styleUrls: ['./navbar-section.component.scss']
})
@rendersSectionForMenu(MenuID.PUBLIC, false)
export class NavbarSectionComponent extends MenuSectionComponent implements OnInit {
/**
* This section resides in the Public Navbar
*/
menuID = MenuID.PUBLIC;
constructor(@Inject('sectionDataProvider') menuSection,
protected menuService: MenuService,
protected injector: Injector
) {
super(menuSection, menuService, injector);
}
ngOnInit() {
super.ngOnInit();
}
}

View File

@@ -0,0 +1,17 @@
<nav [ngClass]="{'open': !(menuCollapsed | async)}"
[@slideMobileNav]="!(windowService.isXsOrSm() | async) ? 'default' : ((menuCollapsed | async) ? 'collapsed' : 'expanded')"
class="navbar navbar-light navbar-expand-md p-md-0 navbar-container"> <!-- TODO remove navbar-container class when https://github.com/twbs/bootstrap/issues/24726 is fixed -->
<div class="container">
<div class="reset-padding-md w-100">
<div id="collapsingNav">
<ul class="navbar-nav mr-auto shadow-none">
<ng-container *ngFor="let section of (sections | async)">
<ng-container
*ngComponentOutlet="sectionComponents.get(section.id); injector: sectionInjectors.get(section.id);"></ng-container>
</ng-container>
</ul>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,39 @@
@import '../../styles/variables.scss';
nav.navbar {
border-bottom: 1px $gray-400 solid;
align-items: baseline;
}
/** Mobile menu styling **/
@media screen and (max-width: map-get($grid-breakpoints, md)) {
.navbar {
width: 100%;
background-color: $white;
position: absolute;
overflow: hidden;
height: 0;
&.open {
height: 100vh; //doesn't matter because wrapper is sticky
}
}
}
@media screen and (min-width: map-get($grid-breakpoints, md)) {
.reset-padding-md {
margin-left: -$spacer/2;
margin-right: -$spacer/2;
}
}
/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */
.navbar-expand-md.navbar-container {
@media screen and (max-width: map-get($grid-breakpoints, md)) {
> .container {
padding: 0 $spacer;
}
padding: 0;
}
}

View File

@@ -0,0 +1,52 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { NavbarComponent } from './navbar.component';
import { ReactiveFormsModule } from '@angular/forms';
import { HostWindowService } from '../shared/host-window.service';
import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { MenuService } from '../shared/menu/menu.service';
import { MenuServiceStub } from '../shared/testing/menu-service-stub';
let comp: NavbarComponent;
let fixture: ComponentFixture<NavbarComponent>;
describe('NavbarComponent', () => {
const menuService = new MenuServiceStub();
// async beforeEach
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
NoopAnimationsModule,
ReactiveFormsModule],
declarations: [NavbarComponent],
providers: [
{ provide: Injector, useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents(); // compile template and css
}));
// synchronous beforeEach
beforeEach(() => {
spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([]));
fixture = TestBed.createComponent(NavbarComponent);
comp = fixture.componentInstance;
});
it('should create', () => {
expect(comp).toBeTruthy();
});
});

View File

@@ -0,0 +1,115 @@
import { Component, Injector, OnInit } from '@angular/core';
import { slideMobileNav } from '../shared/animations/slide';
import { MenuComponent } from '../shared/menu/menu.component';
import { MenuService } from '../shared/menu/menu.service';
import { MenuID, MenuItemType } from '../shared/menu/initial-menus-state';
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';
/**
* Component representing the public navbar
*/
@Component({
selector: 'ds-navbar',
styleUrls: ['navbar.component.scss'],
templateUrl: 'navbar.component.html',
animations: [slideMobileNav]
})
export class NavbarComponent extends MenuComponent implements OnInit {
/**
* The menu ID of the Navbar is PUBLIC
* @type {MenuID.PUBLIC}
*/
menuID = MenuID.PUBLIC;
constructor(protected menuService: MenuService,
protected injector: Injector,
public windowService: HostWindowService
) {
super(menuService, injector);
}
ngOnInit(): void {
this.createMenu();
super.ngOnInit();
}
/**
* Initialize all menu sections and items for this menu
*/
createMenu() {
const menuList = [
/* News */
{
id: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.browse_global'
} as TextMenuItemModel,
index: 0
},
{
id: 'browse_global_communities_and_collections',
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.browse_global_communities_and_collections',
link: '#'
} as LinkMenuItemModel,
},
{
id: 'browse_global_global_by_issue_date',
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.browse_global_by_issue_date',
link: '#'
} as LinkMenuItemModel,
}, {
id: 'browse_global_global_by_title',
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.browse_global_by_title',
link: '/browse/title'
} as LinkMenuItemModel,
},
{
id: 'browse_global_by_author',
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.browse_global_by_author',
link: '/browse/author'
} as LinkMenuItemModel,
},
/* Statistics */
{
id: 'statistics',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: '#'
} as LinkMenuItemModel,
index: 2
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
}
}

View File

@@ -1,26 +1,31 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { HeaderEffects } from './header.effects'; import { NavbarEffects } from './navbar.effects';
import { HeaderCollapseAction } from './header.actions';
import { HostWindowResizeAction } from '../shared/host-window.actions'; import { HostWindowResizeAction } from '../shared/host-window.actions';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles'; import { cold, hot } from 'jasmine-marbles';
import * as fromRouter from '@ngrx/router-store'; import * as fromRouter from '@ngrx/router-store';
import { CollapseMenuAction } from '../shared/menu/menu.actions';
import { MenuID } from '../shared/menu/initial-menus-state';
import { MenuService } from '../shared/menu/menu.service';
import { MenuServiceStub } from '../shared/testing/menu-service-stub';
describe('HeaderEffects', () => { describe('NavbarEffects', () => {
let headerEffects: HeaderEffects; let navbarEffects: NavbarEffects;
let actions: Observable<any>; let actions: Observable<any>;
const menuService = new MenuServiceStub();
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
HeaderEffects, NavbarEffects,
provideMockActions(() => actions), provideMockActions(() => actions),
{ provide: MenuService, useValue: menuService },
// other providers // other providers
], ],
}); });
headerEffects = TestBed.get(HeaderEffects); navbarEffects = TestBed.get(NavbarEffects);
}); });
describe('resize$', () => { describe('resize$', () => {
@@ -28,9 +33,9 @@ describe('HeaderEffects', () => {
it('should return a COLLAPSE action in response to a RESIZE action', () => { it('should return a COLLAPSE action in response to a RESIZE action', () => {
actions = hot('--a-', { a: new HostWindowResizeAction(800, 600) }); actions = hot('--a-', { a: new HostWindowResizeAction(800, 600) });
const expected = cold('--b-', { b: new HeaderCollapseAction() }); const expected = cold('--b-', { b: new CollapseMenuAction(MenuID.PUBLIC) });
expect(headerEffects.resize$).toBeObservable(expected); expect(navbarEffects.resize$).toBeObservable(expected);
}); });
}); });
@@ -40,9 +45,9 @@ describe('HeaderEffects', () => {
it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => { it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => {
actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } }); actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } });
const expected = cold('--b-', { b: new HeaderCollapseAction() }); const expected = cold('--b-', { b: new CollapseMenuAction(MenuID.PUBLIC) });
expect(headerEffects.routeChange$).toBeObservable(expected); expect(navbarEffects.routeChange$).toBeObservable(expected);
}); });
}); });

View File

@@ -0,0 +1,62 @@
import { first, map, switchMap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects'
import * as fromRouter from '@ngrx/router-store';
import { HostWindowActionTypes } from '../shared/host-window.actions';
import {
CollapseMenuAction,
ExpandMenuPreviewAction,
MenuActionTypes
} from '../shared/menu/menu.actions';
import { MenuID } from '../shared/menu/initial-menus-state';
import { MenuService } from '../shared/menu/menu.service';
import { MenuState } from '../shared/menu/menu.reducer';
@Injectable()
export class NavbarEffects {
menuID = MenuID.PUBLIC;
/**
* Effect that collapses the public menu on window resize
* @type {Observable<CollapseMenuAction>}
*/
@Effect() resize$ = this.actions$
.pipe(
ofType(HostWindowActionTypes.RESIZE),
map(() => new CollapseMenuAction(this.menuID))
);
/**
* Effect that collapses the public menu on reroute
* @type {Observable<CollapseMenuAction>}
*/
@Effect() routeChange$ = this.actions$
.pipe(
ofType(fromRouter.ROUTER_NAVIGATION),
map(() => new CollapseMenuAction(this.menuID))
);
/**
* Effect that collapses the public menu when the admin sidebar opens
* @type {Observable<CollapseMenuAction>}
*/
@Effect() openAdminSidebar$ = this.actions$
.pipe(
ofType(MenuActionTypes.EXPAND_MENU_PREVIEW),
switchMap((action: ExpandMenuPreviewAction) => {
return this.menuService.getMenu(action.menuID).pipe(
first(),
map((menu: MenuState) => {
if (menu.id === MenuID.ADMIN) {
if (!menu.previewCollapsed && menu.collapsed) {
return new CollapseMenuAction(MenuID.PUBLIC)
}
}
return { type: 'NO_ACTION' };
}));
})
);
constructor(private actions$: Actions, private menuService: MenuService) {
}
}

View File

@@ -0,0 +1,45 @@
import { SharedModule } from '../shared/shared.module';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { CoreModule } from '../core/core.module';
import { NavbarEffects } from './navbar.effects';
import { NavbarSectionComponent } from './navbar-section/navbar-section.component';
import { ExpandableNavbarSectionComponent } from './expandable-navbar-section/expandable-navbar-section.component';
import { NavbarComponent } from './navbar.component';
const effects = [
NavbarEffects
];
@NgModule({
imports: [
CommonModule,
SharedModule,
EffectsModule.forFeature(effects),
CoreModule.forRoot()
],
declarations: [
NavbarComponent,
NavbarSectionComponent,
ExpandableNavbarSectionComponent
],
providers: [
],
entryComponents: [
NavbarSectionComponent,
ExpandableNavbarSectionComponent
],
exports: [
NavbarComponent,
NavbarSectionComponent,
ExpandableNavbarSectionComponent
]
})
/**
* This module handles all components and pipes that are necessary for the horizontal navigation bar
*/
export class NavbarModule {
}

View File

@@ -1,4 +1,4 @@
<div class="page-not-found"> <div class="page-not-found container">
<h1>404</h1> <h1>404</h1>
<h2><small>{{"404.page-not-found" | translate}}</small></h2> <h2><small>{{"404.page-not-found" | translate}}</small></h2>
<br/> <br/>

View File

@@ -0,0 +1,26 @@
import {
animate,
animateChild,
group, query,
state,
style,
transition,
trigger
} from '@angular/animations';
const startStyle = style({ backgroundColor: '{{ startColor }}' });
const endStyle = style({ backgroundColor: '{{ endColor }}' });
export const bgColor = trigger('bgColor',
[
state('startBackground', startStyle, { params: { startColor: '*' } }),
state('endBackground', endStyle, { params: { endColor: '*' } }),
transition('startBackground <=> endBackground',
group(
[
query('@*', animateChild()),
animate('200ms'),
]
))
]);

View File

@@ -2,18 +2,18 @@ import { animate, state, transition, trigger, style } from '@angular/animations'
export const focusShadow = trigger('focusShadow', [ export const focusShadow = trigger('focusShadow', [
state('focus', style({ 'box-shadow': 'rgba(119, 119, 119, 0.6) 0px 0px 6px' })), state('focus', style({ boxShadow: 'rgba(119, 119, 119, 0.6) 0px 0px 6px' })),
state('blur', style({ 'box-shadow': 'none' })), state('blur', style({ boxShadow: 'none' })),
transition('focus <=> blur', animate(250)) transition('focus <=> blur', [animate('250ms')])
]); ]);
export const focusBackground = trigger('focusBackground', [ export const focusBackground = trigger('focusBackground', [
state('focus', style({ 'background-color': 'rgba(119, 119, 119, 0.1)' })), state('focus', style({ backgroundColor: 'rgba(119, 119, 119, 0.1)' })),
state('blur', style({ 'background-color': 'transparent' })), state('blur', style({ backgroundColor: 'transparent' })),
transition('focus <=> blur', animate(250)) transition('focus <=> blur', [animate('250ms')])
]); ]);

View File

@@ -24,3 +24,16 @@ export const rotateInOut = trigger('rotateInOut', [
rotateEnter, rotateEnter,
rotateLeave rotateLeave
]); ]);
const expandedStyle = { transform: 'rotate(90deg)' };
const collapsedStyle = { transform: 'rotate(0deg)' };
export const rotate = trigger('rotate',
[
state('expanded', style(expandedStyle)),
state('collapsed', style(collapsedStyle)),
transition('expanded <=> collapsed', [
animate('200ms')
])
]);

View File

@@ -1,10 +1,72 @@
import { animate, state, style, transition, trigger } from '@angular/animations'; import {
animate,
animateChild,
group,
query,
state,
style,
transition,
trigger
} from '@angular/animations';
export const slide = trigger('slide', [ export const slide = trigger('slide', [
state('expanded', style({ height: '*' })), state('expanded', style({ height: '*' })),
state('collapsed', style({ height: 0 })),
state('void', style({ height: 0 })),
state('*', style({ height: '*' })),
transition(':enter', [animate('200ms')]),
transition(':leave', [animate('200ms')]),
transition('expanded <=> collapsed', animate(250))
]);
export const slideHorizontal = trigger('slideHorizontal', [
state('void', style({ width: 0 })),
state('*', style({ width: '*' })),
transition(':enter', [animate('200ms')]),
transition(':leave', [animate('200ms')])
]);
export const slideMobileNav = trigger('slideMobileNav', [
state('expanded', style({ height: '100vh' })),
state('collapsed', style({ height: 0 })), state('collapsed', style({ height: 0 })),
transition('expanded <=> collapsed', animate(250)) transition('expanded <=> collapsed', animate('300ms'))
]);
const collapsedStyle = style({ marginLeft: '-{{ sidebarWidth }}' });
const expandedStyle = style({ marginLeft: '0' });
const options = { params: { sidebarWidth: '*' } };
export const slideSidebar = trigger('slideSidebar', [
transition('expanded => collapsed',
group
(
[
query('@*', animateChild()),
query('.sidebar-collapsible', expandedStyle, options),
query('.sidebar-collapsible', animate('300ms ease-in-out', collapsedStyle))
],
)),
transition('collapsed => expanded',
group
(
[
query('@*', animateChild()),
query('.sidebar-collapsible', collapsedStyle),
query('.sidebar-collapsible', animate('300ms ease-in-out', expandedStyle), options)
]
))
]);
export const slideSidebarPadding = trigger('slideSidebarPadding', [
state('hidden', style({ paddingLeft: 0 })),
state('shown', style({ paddingLeft: '{{ collapsedSidebarWidth }}' }), { params: { collapsedSidebarWidth: '*' } }),
state('expanded', style({ paddingLeft: '{{ totalSidebarWidth }}' }), { params: { totalSidebarWidth: '*' } }),
transition('hidden <=> shown', [animate('200ms')]),
transition('hidden <=> expanded', [animate('200ms')]),
transition('shown <=> expanded', [animate('200ms')]),
]); ]);

View File

@@ -1,25 +1,26 @@
<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 dropdown" (click)="$event.stopPropagation();"> <li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item" (click)="$event.stopPropagation();">
<div ngbDropdown placement="bottom-right" class="d-inline-block float-right" @fadeInOut> <div ngbDropdown placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="#" id="dropdownLogin" class="nav-link" (click)="$event.preventDefault()" ngbDropdownToggle><i class="fa fa-sign-in fa-fw" aria-hidden="true"></i> {{ 'nav.login' | translate }}<span class="caret"></span></a> <a href="#" id="dropdownLogin" (click)="$event.preventDefault()" ngbDropdownToggle class="px-1">{{ 'nav.login' | translate }}</a>
<div id="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu aria-labelledby="dropdownLogin"> <div id="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu aria-labelledby="dropdownLogin">
<ds-log-in></ds-log-in> <ds-log-in></ds-log-in>
</div> </div>
</div> </div>
</li> </li>
<li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item"> <li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a id="loginLink" class="nav-link" routerLink="/login" routerLinkActive="active"><i class="fa fa-sign-in fa-fw" aria-hidden="true"></i> {{ 'nav.login' | translate }}<span class="sr-only">(current)</span></a> <a id="loginLink" routerLink="/login" routerLinkActive="active" class="px-1" >{{ 'nav.login' | translate }}<span class="sr-only">(current)</span></a>
</li> </li>
<li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item"> <li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown placement="bottom-right" class="d-inline-block" [ngClass]="{'float-right': !(isXsOrSm$ | async)}" @fadeInOut> <div ngbDropdown placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="#" id="dropdownUser" class="nav-link" (click)="$event.preventDefault()" ngbDropdownToggle><i class="fa fa-user fa-fw" aria-hidden="true"></i>Hello {{(user | async).name}}<span class="caret"></span></a> <a href="#" id="dropdownUser" (click)="$event.preventDefault()" class="px-1" ngbDropdownToggle><i class="fas fa-user-circle fa-lg fa-fw" [title]="'nav.logout' | translate"></i></a>
<div id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser"> <ul id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser">
<ds-log-out></ds-log-out> <li class="dropdown-item">{{(user | async).name}}</li>
</div> <li class="dropdown-item"><ds-log-out></ds-log-out></li>
</ul>
</div> </div>
</li> </li>
<li *ngIf="(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item"> <li *ngIf="(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a id="logoutLink" class="nav-link" routerLink="/logout" routerLinkActive="active"><i class="fa fa-sign-out fa-fw" aria-hidden="true"></i> {{ 'nav.logout' | translate }}<span class="sr-only">(current)</span></a> <a id="logoutLink" routerLink="/logout" routerLinkActive="active" class="px-1"><i class="fas fa-user-circle fa-lg fa-fw" [title]="'nav.logout' | translate"></i><span class="sr-only">(current)</span></a>
</li> </li>
</ul> </ul>

View File

@@ -229,7 +229,7 @@ describe('AuthNavMenuComponent', () => {
component = null; component = null;
}); });
it('should render logout dropdown menu', () => { it('should render logout dropdown menu', () => {
const logoutDropdownMenu = deNavMenuItem.query(By.css('div[id=logoutDropdownMenu]')); const logoutDropdownMenu = deNavMenuItem.query(By.css('ul[id=logoutDropdownMenu]'));
expect(logoutDropdownMenu.nativeElement).toBeDefined(); expect(logoutDropdownMenu.nativeElement).toBeDefined();
}); });
}) })

View File

@@ -15,7 +15,7 @@
[ngbTooltip]="tipContent" [ngbTooltip]="tipContent"
triggers="manual" triggers="manual"
#t="ngbTooltip" #t="ngbTooltip"
class="fa {{icon.style}}" class="fas {{icon.style}}"
[class.mr-1]="!l" [class.mr-1]="!l"
[class.mr-2]="l" [class.mr-2]="l"
aria-hidden="true" aria-hidden="true"
@@ -23,7 +23,7 @@
(mouseover)="showTooltip(t, i, icon.metadata)" (mouseover)="showTooltip(t, i, icon.metadata)"
(mouseout)="t.close()"></i> (mouseout)="t.close()"></i>
</ng-container> </ng-container>
<p class="chip-label text-truncate d-table-cell">{{c.display}}</p><i class="fa fa-times ml-2" (click)="removeChips($event, i)"></i> <p class="chip-label text-truncate d-table-cell">{{c.display}}</p><i class="fas fa-times ml-2" (click)="removeChips($event, i)" [title]="'chips.remove' | translate"></i>
</span> </span>
</a> </a>
</li> </li>

View File

@@ -10,6 +10,7 @@ import { SortablejsModule } from 'angular-sortablejs';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model';
import { createTestComponent, hasClass } from '../testing/utils'; import { createTestComponent, hasClass } from '../testing/utils';
import { TranslateModule } from '@ngx-translate/core';
describe('ChipsComponent test suite', () => { describe('ChipsComponent test suite', () => {
@@ -27,6 +28,7 @@ describe('ChipsComponent test suite', () => {
imports: [ imports: [
NgbModule.forRoot(), NgbModule.forRoot(),
SortablejsModule.forRoot({animation: 150}), SortablejsModule.forRoot({animation: 150}),
TranslateModule.forRoot()
], ],
declarations: [ declarations: [
ChipsComponent, ChipsComponent,
@@ -148,7 +150,7 @@ describe('ChipsComponent test suite', () => {
name: 'mainField', name: 'mainField',
config: { config: {
withAuthority:{ withAuthority:{
style: 'fa-user' style: 'fas-user'
} }
} }
}, },
@@ -156,10 +158,10 @@ describe('ChipsComponent test suite', () => {
name: 'relatedField', name: 'relatedField',
config: { config: {
withAuthority:{ withAuthority:{
style: 'fa-user-alt' style: 'fas-user-alt'
}, },
withoutAuthority:{ withoutAuthority:{
style: 'fa-user-alt text-muted' style: 'fas-user-alt text-muted'
} }
} }
}, },
@@ -167,10 +169,10 @@ describe('ChipsComponent test suite', () => {
name: 'otherRelatedField', name: 'otherRelatedField',
config: { config: {
withAuthority:{ withAuthority:{
style: 'fa-user-alt' style: 'fas-user-alt'
}, },
withoutAuthority:{ withoutAuthority:{
style: 'fa-user-alt text-muted' style: 'fas-user-alt text-muted'
} }
} }
}, },
@@ -190,7 +192,7 @@ describe('ChipsComponent test suite', () => {
it('should show icon for every field that has a configured icon', () => { it('should show icon for every field that has a configured icon', () => {
const de = chipsFixture.debugElement.query(By.css('li.nav-item')); const de = chipsFixture.debugElement.query(By.css('li.nav-item'));
const icons = de.queryAll(By.css('i.fa')); const icons = de.queryAll(By.css('i.fas'));
expect(icons.length).toBe(4); expect(icons.length).toBe(4);
@@ -198,14 +200,14 @@ describe('ChipsComponent test suite', () => {
it('should has text-muted on icon style when field value had not authority', () => { it('should has text-muted on icon style when field value had not authority', () => {
const de = chipsFixture.debugElement.query(By.css('li.nav-item')); const de = chipsFixture.debugElement.query(By.css('li.nav-item'));
const icons = de.queryAll(By.css('i.fa')); const icons = de.queryAll(By.css('i.fas'));
expect(hasClass(icons[2].nativeElement, 'text-muted')).toBeTruthy(); expect(hasClass(icons[2].nativeElement, 'text-muted')).toBeTruthy();
}); });
it('should show tooltip on mouse over an icon', () => { it('should show tooltip on mouse over an icon', () => {
const de = chipsFixture.debugElement.query(By.css('li.nav-item')); const de = chipsFixture.debugElement.query(By.css('li.nav-item'));
const icons = de.queryAll(By.css('i.fa')); const icons = de.queryAll(By.css('i.fas'));
icons[0].triggerEventHandler('mouseover', null); icons[0].triggerEventHandler('mouseover', null);

View File

@@ -34,14 +34,14 @@ describe('ChipsItem model test suite', () => {
}); });
it('should update icons', () => { it('should update icons', () => {
const icons: ChipsItemIcon[] = [{metadata: 'test', hasAuthority: false, style: 'fa fa-plus'}]; const icons: ChipsItemIcon[] = [{metadata: 'test', hasAuthority: false, style: 'fas fa-plus'}];
item.updateIcons(icons); item.updateIcons(icons);
expect(item.icons).toEqual(icons); expect(item.icons).toEqual(icons);
}); });
it('should return true if has icons', () => { it('should return true if has icons', () => {
const icons: ChipsItemIcon[] = [{metadata: 'test', hasAuthority: false, style: 'fa fa-plus'}]; const icons: ChipsItemIcon[] = [{metadata: 'test', hasAuthority: false, style: 'fas fa-plus'}];
item.updateIcons(icons); item.updateIcons(icons);
const hasIcons = item.hasIcons(); const hasIcons = item.hasIcons();

View File

@@ -27,7 +27,7 @@ export const DATE_TEST_MODEL_CONFIG = {
placeholder: 'Date', placeholder: 'Date',
readOnly: false, readOnly: false,
required: true, required: true,
toggleIcon: 'fa fa-calendar' toggleIcon: 'fas fa-calendar'
}; };
describe('DsDatePickerComponent test suite', () => { describe('DsDatePickerComponent test suite', () => {

View File

@@ -2,7 +2,7 @@
class="close position-relative" class="close position-relative"
ngbTooltip="{{'form.group-collapse-help' | translate}}" ngbTooltip="{{'form.group-collapse-help' | translate}}"
placement="left"> placement="left">
<span class="fa fa-angle-up fa-fw fa-2x" <span class="fas fa-angle-up fa-fw fa-2x"
aria-hidden="true" aria-hidden="true"
(click)="collapseForm()"></span> (click)="collapseForm()"></span>
</a> </a>
@@ -10,7 +10,7 @@
class="close position-relative" class="close position-relative"
ngbTooltip="{{'form.group-expand-help' | translate}}" ngbTooltip="{{'form.group-expand-help' | translate}}"
placement="left"> placement="left">
<span class="fa fa-angle-down fa-fw fa-2x" <span class="fas fa-angle-down fa-fw fa-2x"
aria-hidden="true" aria-hidden="true"
(click)="expandForm()"></span> (click)="expandForm()"></span>
</a> </a>
@@ -33,21 +33,21 @@
class="btn btn-link" class="btn btn-link"
[disabled]="isMandatoryFieldEmpty()" [disabled]="isMandatoryFieldEmpty()"
(click)="save()"> (click)="save()">
<i class="fa fa-save text-primary fa-2x" <i class="fas fa-save text-primary fa-2x"
aria-hidden="true"></i> aria-hidden="true"></i>
</button> </button>
<button type="button" <button type="button"
class="btn btn-link" class="btn btn-link"
[disabled]="!editMode" [disabled]="!editMode"
(click)="delete()"> (click)="delete()">
<i class="fa fa-trash text-danger fa-2x" <i class="fas fa-trash text-danger fa-2x"
aria-hidden="true"></i> aria-hidden="true"></i>
</button> </button>
<button type="button" <button type="button"
class="btn btn-link" class="btn btn-link"
[disabled]="isMandatoryFieldEmpty()" [disabled]="isMandatoryFieldEmpty()"
(click)="clear()"> (click)="clear()">
<i class="fa fa-undo fa-2x" <i class="fas fa-undo fa-2x"
aria-hidden="true"></i> aria-hidden="true"></i>
</button> </button>

View File

@@ -41,6 +41,6 @@
(keypress)="preventEventsPropagation($event)" (keypress)="preventEventsPropagation($event)"
(keydown)="preventEventsPropagation($event)" (keydown)="preventEventsPropagation($event)"
(keyup)="onKeyUp($event)"/> (keyup)="onKeyUp($event)"/>
<i *ngIf="searching" class="fa fa-circle-o-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i> <i *ngIf="searching" class="fas fa-circle-o-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
</ds-chips> </ds-chips>

View File

@@ -2,7 +2,7 @@
{{ r.display}} {{ r.display}}
</ng-template> </ng-template>
<div class="position-relative right-addon"> <div class="position-relative right-addon">
<i *ngIf="searching" class="fa fa-circle-o-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i> <i *ngIf="searching" class="fas fa-circle-o-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
<input class="form-control" <input class="form-control"
[attr.autoComplete]="model.autoComplete" [attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages" [class.is-invalid]="showErrorMessages"

View File

@@ -11,7 +11,7 @@ export class DateFieldParser extends FieldParser {
let malformedDate = false; let malformedDate = false;
const inputDateModelConfig: DynamicDatePickerModelConfig = this.initModel(null, label); const inputDateModelConfig: DynamicDatePickerModelConfig = this.initModel(null, label);
inputDateModelConfig.toggleIcon = 'fa fa-calendar'; inputDateModelConfig.toggleIcon = 'fas fa-calendar';
this.setValues(inputDateModelConfig as any, fieldValue); this.setValues(inputDateModelConfig as any, fieldValue);
// Init Data and validity check // Init Data and validity check
if (isNotEmpty(inputDateModelConfig.value)) { if (isNotEmpty(inputDateModelConfig.value)) {

View File

@@ -20,12 +20,12 @@
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
[disabled]="isItemReadOnly(context, index)" [disabled]="isItemReadOnly(context, index)"
(click)="insertItem($event, group.context, group.index + 1)"> (click)="insertItem($event, group.context, group.index + 1)">
<i class="fa fa-plus" aria-hidden="true"></i> <i class="fas fa-plus" aria-hidden="true"></i>
</button> </button>
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
(click)="removeItem($event, context, index)" (click)="removeItem($event, context, index)"
[disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)"> [disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)">
<i class="fa fa-trash" aria-hidden="true"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -37,7 +37,7 @@
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
(click)="removeItem($event, context, index)" (click)="removeItem($event, context, index)"
[disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)"> [disabled]="group.context.groups.length === 1 || isItemReadOnly(context, index)">
<i class="fa fa-trash" aria-hidden="true"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -3,17 +3,23 @@ import { cold, hot } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { AppState } from '../app.reducer'; import { AppState } from '../app.reducer';
import { GridBreakpoint, HostWindowService, WidthCategory } from './host-window.service'; import { HostWindowService, WidthCategory } from './host-window.service';
import { CSSVariableServiceStub } from './testing/css-variable-service-stub';
describe('HostWindowService', () => { describe('HostWindowService', () => {
let service: HostWindowService; let service: HostWindowService;
let store: Store<AppState>; let store: Store<AppState>;
enum GridBreakpoint {
SM_MIN = 576,
MD_MIN = 768,
LG_MIN = 992,
XL_MIN = 1200
};
describe('', () => { describe('', () => {
beforeEach(() => { beforeEach(() => {
const _initialState = { hostWindow: { width: 1600, height: 770 } }; const _initialState = { hostWindow: { width: 1600, height: 770 } };
store = new Store<AppState>(observableOf(_initialState), undefined, undefined); store = new Store<AppState>(observableOf(_initialState), undefined, undefined);
service = new HostWindowService(store); service = new HostWindowService(store, new CSSVariableServiceStub() as any);
}); });
it('isXs() should return false with width = 1600', () => { it('isXs() should return false with width = 1600', () => {
@@ -49,7 +55,7 @@ describe('HostWindowService', () => {
beforeEach(() => { beforeEach(() => {
const _initialState = { hostWindow: { width: 1100, height: 770 } }; const _initialState = { hostWindow: { width: 1100, height: 770 } };
store = new Store<AppState>(observableOf(_initialState), undefined, undefined); store = new Store<AppState>(observableOf(_initialState), undefined, undefined);
service = new HostWindowService(store); service = new HostWindowService(store, new CSSVariableServiceStub() as any);
}); });
it('isXs() should return false with width = 1100', () => { it('isXs() should return false with width = 1100', () => {
@@ -85,7 +91,7 @@ describe('HostWindowService', () => {
beforeEach(() => { beforeEach(() => {
const _initialState = { hostWindow: { width: 800, height: 770 } }; const _initialState = { hostWindow: { width: 800, height: 770 } };
store = new Store<AppState>(observableOf(_initialState), undefined, undefined); store = new Store<AppState>(observableOf(_initialState), undefined, undefined);
service = new HostWindowService(store); service = new HostWindowService(store, new CSSVariableServiceStub() as any);
}); });
it('isXs() should return false with width = 800', () => { it('isXs() should return false with width = 800', () => {
@@ -121,7 +127,7 @@ describe('HostWindowService', () => {
beforeEach(() => { beforeEach(() => {
const _initialState = { hostWindow: { width: 600, height: 770 } }; const _initialState = { hostWindow: { width: 600, height: 770 } };
store = new Store<AppState>(observableOf(_initialState), undefined, undefined); store = new Store<AppState>(observableOf(_initialState), undefined, undefined);
service = new HostWindowService(store); service = new HostWindowService(store, new CSSVariableServiceStub() as any);
}); });
it('isXs() should return false with width = 600', () => { it('isXs() should return false with width = 600', () => {
@@ -157,7 +163,7 @@ describe('HostWindowService', () => {
beforeEach(() => { beforeEach(() => {
const _initialState = { hostWindow: { width: 400, height: 770 } }; const _initialState = { hostWindow: { width: 400, height: 770 } };
store = new Store<AppState>(observableOf(_initialState), undefined, undefined); store = new Store<AppState>(observableOf(_initialState), undefined, undefined);
service = new HostWindowService(store); service = new HostWindowService(store, new CSSVariableServiceStub() as any);
}); });
it('isXs() should return true with width = 400', () => { it('isXs() should return true with width = 400', () => {
@@ -191,7 +197,7 @@ describe('HostWindowService', () => {
describe('widthCategory', () => { describe('widthCategory', () => {
beforeEach(() => { beforeEach(() => {
service = new HostWindowService({} as Store<AppState>); service = new HostWindowService({} as Store<AppState>, new CSSVariableServiceStub() as any);
}); });
it('should call getWithObs to get the current width', () => { it('should call getWithObs to get the current width', () => {

View File

@@ -1,20 +1,13 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { filter, distinctUntilChanged, map } from 'rxjs/operators'; import { filter, distinctUntilChanged, map, first } from 'rxjs/operators';
import { HostWindowState } from './host-window.reducer'; import { HostWindowState } from './host-window.reducer';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store'; import { createSelector, select, Store } from '@ngrx/store';
import { hasValue } from './empty.util'; import { hasValue } from './empty.util';
import { AppState } from '../app.reducer'; import { AppState } from '../app.reducer';
import { CSSVariableService } from './sass-helper/sass-helper.service';
// TODO: ideally we should get these from sass somehow
export enum GridBreakpoint {
SM_MIN = 576,
MD_MIN = 768,
LG_MIN = 992,
XL_MIN = 1200
}
export enum WidthCategory { export enum WidthCategory {
XS, XS,
@@ -29,10 +22,20 @@ const widthSelector = createSelector(hostWindowStateSelector, (hostWindow: HostW
@Injectable() @Injectable()
export class HostWindowService { export class HostWindowService {
private breakPoints: { XS_MIN, SM_MIN, MD_MIN, LG_MIN, XL_MIN } = {} as any;
constructor( constructor(
private store: Store<AppState> private store: Store<AppState>,
private variableService: CSSVariableService
) { ) {
/* See _exposed_variables.scss */
variableService.getAllVariables()
.subscribe((variables) => {
this.breakPoints.XL_MIN = parseInt(variables.xlMin, 10);
this.breakPoints.LG_MIN = parseInt(variables.lgMin, 10);
this.breakPoints.MD_MIN = parseInt(variables.mdMin, 10);
this.breakPoints.SM_MIN = parseInt(variables.smMin, 10);
});
} }
private getWidthObs(): Observable<number> { private getWidthObs(): Observable<number> {
@@ -45,13 +48,13 @@ export class HostWindowService {
get widthCategory(): Observable<WidthCategory> { get widthCategory(): Observable<WidthCategory> {
return this.getWidthObs().pipe( return this.getWidthObs().pipe(
map((width: number) => { map((width: number) => {
if (width < GridBreakpoint.SM_MIN) { if (width < this.breakPoints.SM_MIN) {
return WidthCategory.XS return WidthCategory.XS
} else if (width >= GridBreakpoint.SM_MIN && width < GridBreakpoint.MD_MIN) { } else if (width >= this.breakPoints.SM_MIN && width < this.breakPoints.MD_MIN) {
return WidthCategory.SM return WidthCategory.SM
} else if (width >= GridBreakpoint.MD_MIN && width < GridBreakpoint.LG_MIN) { } else if (width >= this.breakPoints.MD_MIN && width < this.breakPoints.LG_MIN) {
return WidthCategory.MD return WidthCategory.MD
} else if (width >= GridBreakpoint.LG_MIN && width < GridBreakpoint.XL_MIN) { } else if (width >= this.breakPoints.LG_MIN && width < this.breakPoints.XL_MIN) {
return WidthCategory.LG return WidthCategory.LG
} else { } else {
return WidthCategory.XL return WidthCategory.XL

View File

@@ -0,0 +1,40 @@
import { MenusState } from './menu.reducer';
/**
* Availavle Menu IDs
*/
export enum MenuID {
ADMIN = 'admin-sidebar',
PUBLIC = 'public'
}
/**
* List of possible MenuItemTypes
*/
export enum MenuItemType {
TEXT, LINK, ALTMETRIC, SEARCH
}
/**
* The initial state of the menus
*/
export const initialMenusState: MenusState = {
[MenuID.ADMIN]:
{
id: MenuID.ADMIN,
collapsed: true,
previewCollapsed: true,
visible: false,
sections: {},
sectionToSubsectionIndex: {}
},
[MenuID.PUBLIC]:
{
id: MenuID.PUBLIC,
collapsed: true,
previewCollapsed: true,
visible: true,
sections: {},
sectionToSubsectionIndex: {}
}
};

View File

@@ -0,0 +1,26 @@
import { MenuItemType } from './initial-menus-state';
const menuMenuItemComponentMap = new Map();
/**
* Decorator function to link a MenuItemType to a Component
* @param {MenuItemType} type The MenuItemType of the MenuSection's model
* @returns {(sectionComponent: GenericContructor) => void}
*/
export function rendersMenuItemForType(type: MenuItemType) {
return function decorator(sectionComponent: any) {
if (!sectionComponent) {
return;
}
menuMenuItemComponentMap.set(type, sectionComponent);
};
}
/**
* Retrieves the Component matching a given MenuItemType
* @param {MenuItemType} type The given MenuItemType
* @returns {GenericConstructor} The constructor of the Component that matches the MenuItemType
*/
export function getComponentForMenuItemType(type: MenuItemType) {
return menuMenuItemComponentMap.get(type);
}

View File

@@ -0,0 +1 @@
<a class="nav-item nav-link" [routerLink]="getRouterLink()">{{item.text | translate}}</a>

View File

@@ -0,0 +1,57 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { LinkMenuItemComponent } from './link-menu-item.component';
import { RouterLinkDirectiveStub } from '../../testing/router-link-directive-stub';
import { GLOBAL_CONFIG } from '../../../../config';
describe('LinkMenuItemComponent', () => {
let component: LinkMenuItemComponent;
let fixture: ComponentFixture<LinkMenuItemComponent>;
let debugElement: DebugElement;
const text = 'HELLO';
const link = 'http://google.com';
const nameSpace = 'dspace.com/';
const globalConfig = {
ui: {
nameSpace: nameSpace
}
} as any;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [LinkMenuItemComponent, RouterLinkDirectiveStub],
providers: [
{ provide: 'itemModelProvider', useValue: { text: text, link: link } },
{ provide: GLOBAL_CONFIG, useValue: globalConfig },
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LinkMenuItemComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should contain the correct text', () => {
const textContent = debugElement.query(By.css('a')).nativeElement.textContent;
expect(textContent).toEqual(text);
});
it('should have the right routerLink attribute', () => {
const linkDes = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub));
const routerLinkQuery = linkDes.map((de) => de.injector.get(RouterLinkDirectiveStub));
expect(routerLinkQuery.length).toBe(1);
expect(routerLinkQuery[0].routerLink).toBe(nameSpace + link);
});
});

View File

@@ -0,0 +1,24 @@
import { Component, Inject, Input } from '@angular/core';
import { LinkMenuItemModel } from './models/link.model';
import { MenuItemType } from '../initial-menus-state';
import { rendersMenuItemForType } from '../menu-item.decorator';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
/**
* Component that renders a menu section of type LINK
*/
@Component({
selector: 'ds-link-menu-item',
templateUrl: './link-menu-item.component.html'
})
@rendersMenuItemForType(MenuItemType.LINK)
export class LinkMenuItemComponent {
item: LinkMenuItemModel;
constructor(@Inject('itemModelProvider') item: LinkMenuItemModel, @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) {
this.item = item;
}
getRouterLink() {
return this.EnvConfig.ui.nameSpace + this.item.link;
}
}

View File

@@ -0,0 +1,10 @@
import { MenuItemType } from '../../initial-menus-state';
import { MenuItemModel } from './menu-item.model';
/**
* Model representing an Altmetric Menu Section
*/
export class AltmetricMenuItemModel implements MenuItemModel {
type = MenuItemType.ALTMETRIC;
url: string;
}

View File

@@ -0,0 +1,11 @@
import { MenuItemModel } from './menu-item.model';
import { MenuItemType } from '../../initial-menus-state';
/**
* Model representing an Link Menu Section
*/
export class LinkMenuItemModel implements MenuItemModel {
type = MenuItemType.LINK;
text: string;
link: string;
}

View File

@@ -0,0 +1,8 @@
import { MenuItemType } from '../../initial-menus-state';
/**
* Interface for models representing a Menu Section
*/
export interface MenuItemModel {
type: MenuItemType;
}

View File

@@ -0,0 +1,11 @@
import { MenuItemType } from '../../initial-menus-state';
import { MenuItemModel } from './menu-item.model';
/**
* Model representing an Search Bar Menu Section
*/
export class SearchMenuItemModel implements MenuItemModel {
type = MenuItemType.SEARCH;
placeholder: string;
action: string;
}

View File

@@ -0,0 +1,10 @@
import { MenuItemType } from '../../initial-menus-state';
import { MenuItemModel } from './menu-item.model';
/**
* Model representing an Text Menu Section
*/
export class TextMenuItemModel implements MenuItemModel {
type = MenuItemType.TEXT;
text: string;
}

View File

@@ -0,0 +1 @@
<span>{{item.text | translate}}</span>

View File

@@ -0,0 +1,39 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TextMenuItemComponent } from './text-menu-item.component';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
describe('TextMenuItemComponent', () => {
let component: TextMenuItemComponent;
let fixture: ComponentFixture<TextMenuItemComponent>;
let debugElement: DebugElement;
const text = 'HELLO';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [TextMenuItemComponent],
providers: [
{ provide: 'itemModelProvider', useValue: { text: text } },
],
schemas: [ NO_ERRORS_SCHEMA ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TextMenuItemComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should contain the correct text', () => {
expect(component).toBeTruthy();
});
it('should contain the text element', () => {
const textContent = debugElement.query(By.css('span')).nativeElement.textContent;
expect(textContent).toEqual(text);
});
});

View File

@@ -0,0 +1,19 @@
import { Component, Inject, Input } from '@angular/core';
import { TextMenuItemModel } from './models/text.model';
import { MenuItemType } from '../initial-menus-state';
import { rendersMenuItemForType } from '../menu-item.decorator';
/**
* Component that renders a menu section of type TEXT
*/
@Component({
selector: 'ds-text-menu-item',
templateUrl: './text-menu-item.component.html',
})
@rendersMenuItemForType(MenuItemType.TEXT)
export class TextMenuItemComponent {
item: TextMenuItemModel;
constructor(@Inject('itemModelProvider') item: TextMenuItemModel) {
this.item = item;
}
}

View File

@@ -0,0 +1,31 @@
import { MenuID } from './initial-menus-state';
const menuComponentMap = new Map();
/**
* Decorator function to render a MenuSection for a menu
* @param {MenuID} menuID The ID of the Menu in which the section is rendered
* @param {boolean} expandable True when the section should be expandable, false when if should not
* @returns {(menuSectionWrapperComponent: GenericConstructor) => void}
*/
export function rendersSectionForMenu(menuID: MenuID, expandable: boolean) {
return function decorator(menuSectionWrapperComponent: any) {
if (!menuSectionWrapperComponent) {
return;
}
if (!menuComponentMap.get(menuID)) {
menuComponentMap.set(menuID, new Map());
}
menuComponentMap.get(menuID).set(expandable, menuSectionWrapperComponent);
};
}
/**
* Retrieves the component matching the given MenuID and whether or not it should be expandable
* @param {MenuID} menuID The ID of the Menu in which the section is rendered
* @param {boolean} expandable True when the section should be expandable, false when if should not
* @returns {GenericConstructor} The constructor of the matching Component
*/
export function getComponentForMenu(menuID: MenuID, expandable: boolean) {
return menuComponentMap.get(menuID).get(expandable);
}

View File

@@ -0,0 +1,76 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { MenuSectionComponent } from './menu-section.component';
import { MenuService } from '../menu.service';
import { MenuServiceStub } from '../../testing/menu-service-stub';
import { MenuSection } from '../menu.reducer';
import { of as observableOf } from 'rxjs';
import { LinkMenuItemComponent } from '../menu-item/link-menu-item.component';
describe('MenuSectionComponent', () => {
let comp: MenuSectionComponent;
let fixture: ComponentFixture<MenuSectionComponent>;
let menuService: MenuService;
const dummySection = {
id: 'section',
visible: true,
active: false
} as any;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule],
declarations: [MenuSectionComponent],
providers: [
{ provide: Injector, useValue: {} },
{ provide: MenuService, useClass: MenuServiceStub },
{ provide: MenuSection, useValue: dummySection },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MenuSectionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MenuSectionComponent);
comp = fixture.componentInstance;
menuService = (comp as any).menuService;
spyOn(comp as any, 'getMenuItemComponent').and.returnValue(LinkMenuItemComponent);
spyOn(comp as any, 'getItemModelInjector').and.returnValue(observableOf({}));
fixture.detectChanges();
});
describe('toggleSection', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
comp.toggleSection(new Event('click'));
});
it('should trigger the toggleActiveSection function on the menu service', () => {
expect(menuService.toggleActiveSection).toHaveBeenCalledWith(comp.menuID, dummySection.id);
})
});
describe('activateSection', () => {
beforeEach(() => {
spyOn(menuService, 'activateSection');
comp.activateSection(new Event('click'));
});
it('should trigger the activateSection function on the menu service', () => {
expect(menuService.activateSection).toHaveBeenCalledWith(comp.menuID, dummySection.id);
})
});
describe('deactivateSection', () => {
beforeEach(() => {
spyOn(menuService, 'deactivateSection');
comp.deactivateSection(new Event('click'));
});
it('should trigger the deactivateSection function on the menu service', () => {
expect(menuService.deactivateSection).toHaveBeenCalledWith(comp.menuID, dummySection.id);
})
});
});

View File

@@ -0,0 +1,127 @@
import { Component, Injector } from '@angular/core';
import { MenuService } from '../menu.service';
import { MenuSection } from '../menu.reducer';
import { getComponentForMenuItemType } from '../menu-item.decorator';
import { MenuID, MenuItemType } from '../initial-menus-state';
import { hasNoValue } from '../../empty.util';
import { Observable } from 'rxjs/internal/Observable';
import { MenuItemModel } from '../menu-item/models/menu-item.model';
import { distinctUntilChanged } from 'rxjs/operators';
import { GenericConstructor } from '../../../core/shared/generic-constructor';
/**
* A basic implementation of a menu section's component
*/
@Component({
selector: 'ds-menu-section',
template: ''
})
export class MenuSectionComponent {
/**
* Observable that emits whether or not this section is currently active
*/
active: Observable<boolean>;
/**
* The ID of the menu this section resides in
*/
menuID: MenuID;
/**
* List of Injectors for each dynamically rendered menu item of this section
*/
itemInjectors: Map<string, Injector> = new Map<string, Injector>();
/**
* List of child Components for each dynamically rendered menu item of this section
*/
itemComponents: Map<string, GenericConstructor<MenuSectionComponent>> = new Map<string, GenericConstructor<MenuSectionComponent>>();
/**
* List of available subsections in this section
*/
subSections: Observable<MenuSection[]>;
constructor(public section: MenuSection, protected menuService: MenuService, protected injector: Injector) {
}
/**
* Set initial values for instance variables
*/
ngOnInit(): void {
this.active = this.menuService.isSectionActive(this.menuID, this.section.id).pipe(distinctUntilChanged());
this.initializeInjectorData();
}
/**
* Activate this section if it's currently inactive, deactivate it when it's currently active
* @param {Event} event The user event that triggered this method
*/
toggleSection(event: Event) {
event.preventDefault();
this.menuService.toggleActiveSection(this.menuID, this.section.id);
}
/**
* Activate this section
* @param {Event} event The user event that triggered this method
*/
activateSection(event: Event) {
event.preventDefault();
this.menuService.activateSection(this.menuID, this.section.id);
}
/**
* Deactivate this section
* @param {Event} event The user event that triggered this method
*/
deactivateSection(event: Event) {
event.preventDefault();
this.menuService.deactivateSection(this.menuID, this.section.id);
}
/**
* Method for initializing all injectors and component constructors for the menu items in this section
*/
private initializeInjectorData() {
this.itemInjectors.set(this.section.id, this.getItemModelInjector(this.section.model));
this.itemComponents.set(this.section.id, this.getMenuItemComponent(this.section.model));
this.subSections = this.menuService.getSubSectionsByParentID(this.menuID, this.section.id);
this.subSections.subscribe((sections: MenuSection[]) => {
sections.forEach((section: MenuSection) => {
this.itemInjectors.set(section.id, this.getItemModelInjector(section.model));
this.itemComponents.set(section.id, this.getMenuItemComponent(section.model));
})
})
}
/**
* Retrieve the component for a given MenuItemModel object
* @param {MenuItemModel} itemModel The given MenuItemModel
* @returns {GenericConstructor} Emits the constructor of the Component that should be used to render this menu item model
*/
private getMenuItemComponent(itemModel?: MenuItemModel) {
if (hasNoValue(itemModel)) {
itemModel = this.section.model;
}
const type: MenuItemType = itemModel.type;
return getComponentForMenuItemType(type);
}
/**
* Retrieve the Injector for a given MenuItemModel object
* @param {MenuItemModel} itemModel The given MenuItemModel
* @returns {Injector} The Injector that injects the data for this menu item into the item's component
*/
private getItemModelInjector(itemModel?: MenuItemModel) {
if (hasNoValue(itemModel)) {
itemModel = this.section.model;
}
return Injector.create({
providers: [{ provide: 'itemModelProvider', useFactory: () => (itemModel), deps: [] }],
parent: this.injector
});
}
}

View File

@@ -0,0 +1,226 @@
import { Action } from '@ngrx/store';
import { MenuID } from './initial-menus-state';
import { type } from '../ngrx/type';
import { MenuSection } from './menu.reducer';
/**
* For each action type in an action group, make a simple
* enum object for all of this group's action types.
*
* The 'type' utility function coerces strings into string
* literal types and runs a simple check to guarantee all
* action types in the application are unique.
*/
export const MenuActionTypes = {
COLLAPSE_MENU: type('dspace/menu/COLLAPSE_MENU'),
TOGGLE_MENU: type('dspace/menu/TOGGLE_MENU'),
EXPAND_MENU: type('dspace/menu/EXPAND_MENU'),
SHOW_MENU: type('dspace/menu/SHOW_MENU'),
HIDE_MENU: type('dspace/menu/HIDE_MENU'),
COLLAPSE_MENU_PREVIEW: type('dspace/menu/COLLAPSE_MENU_PREVIEW'),
EXPAND_MENU_PREVIEW: type('dspace/menu/EXPAND_MENU_PREVIEW'),
ADD_SECTION: type('dspace/menu-section/ADD_SECTION'),
REMOVE_SECTION: type('dspace/menu-section/REMOVE_SECTION'),
SHOW_SECTION: type('dspace/menu-section/SHOW_SECTION'),
HIDE_SECTION: type('dspace/menu-section/HIDE_SECTION'),
ACTIVATE_SECTION: type('dspace/menu-section/ACTIVATE_SECTION'),
DEACTIVATE_SECTION: type('dspace/menu-section/DEACTIVATE_SECTION'),
TOGGLE_ACTIVE_SECTION: type('dspace/menu-section/TOGGLE_ACTIVE_SECTION'),
};
/* tslint:disable:max-classes-per-file */
// MENU STATE ACTIONS
/**
* Action used to collapse a single menu
*/
export class CollapseMenuAction implements Action {
type = MenuActionTypes.COLLAPSE_MENU;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
/**
* Action used to expand a single menu
*/
export class ExpandMenuAction implements Action {
type = MenuActionTypes.EXPAND_MENU;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
/**
* Action used to collapse a single menu when it's expanded and expanded it when it's collapse
*/
export class ToggleMenuAction implements Action {
type = MenuActionTypes.TOGGLE_MENU;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
/**
* Action used to show a single menu
*/
export class ShowMenuAction implements Action {
type = MenuActionTypes.SHOW_MENU;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
/**
* Action used to hide a single menu
*/
export class HideMenuAction implements Action {
type = MenuActionTypes.HIDE_MENU;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
/**
* Action used to collapse a single menu's preview
*/
export class CollapseMenuPreviewAction implements Action {
type = MenuActionTypes.COLLAPSE_MENU_PREVIEW;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
/**
* Action used to expand a single menu's preview
*/
export class ExpandMenuPreviewAction implements Action {
type = MenuActionTypes.EXPAND_MENU_PREVIEW;
menuID: MenuID;
constructor(menuID: MenuID) {
this.menuID = menuID;
}
}
// MENU SECTION ACTIONS
/**
* Action used to perform state changes for a section of a certain menu
*/
export abstract class MenuSectionAction implements Action {
type;
menuID: MenuID;
id: string;
constructor(menuID: MenuID, id: string) {
this.menuID = menuID;
this.id = id;
}
}
/**
* Action used to add a section to a certain menu
*/
export class AddMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.ADD_SECTION;
section: MenuSection;
constructor(menuID: MenuID, section: MenuSection) {
super(menuID, section.id);
this.section = section;
}
}
/**
* Action used to remove a section from a certain menu
*/
export class RemoveMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.REMOVE_SECTION;
constructor(menuID: MenuID, id: string) {
super(menuID, id);
}
}
/**
* Action used to hide a section of a certain menu
*/
export class HideMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.HIDE_SECTION;
constructor(menuID: MenuID, id: string) {
super(menuID, id);
}
}
/**
* Action used to show a section of a certain menu
*/
export class ShowMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.SHOW_SECTION;
constructor(menuID: MenuID, id: string) {
super(menuID, id);
}
}
/**
* Action used to make a section of a certain menu active
*/
export class ActivateMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.ACTIVATE_SECTION;
constructor(menuID: MenuID, id: string) {
super(menuID, id);
}
}
/**
* Action used to make a section of a certain menu inactive
*/
export class DeactivateMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.DEACTIVATE_SECTION;
constructor(menuID: MenuID, id: string) {
super(menuID, id);
}
}
/**
* Action used to make an active section of a certain menu inactive or an inactive section of a certain menu active
*/
export class ToggleActiveMenuSectionAction extends MenuSectionAction {
type = MenuActionTypes.TOGGLE_ACTIVE_SECTION;
constructor(menuID: MenuID, id: string) {
super(menuID, id);
}
}
export type MenuAction =
CollapseMenuAction
| ExpandMenuAction
| ToggleMenuAction
| ShowMenuAction
| HideMenuAction
| AddMenuSectionAction
| RemoveMenuSectionAction
| ShowMenuSectionAction
| HideMenuSectionAction
| ActivateMenuSectionAction
| DeactivateMenuSectionAction
| ToggleActiveMenuSectionAction
/* tslint:enable:max-classes-per-file */

View File

@@ -0,0 +1,90 @@
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { MenuService } from './menu.service';
import { MenuComponent } from './menu.component';
import { MenuServiceStub } from '../testing/menu-service-stub';
import { of as observableOf } from 'rxjs';
import { MenuSection } from './menu.reducer';
describe('MenuComponent', () => {
let comp: MenuComponent;
let fixture: ComponentFixture<MenuComponent>;
let menuService: MenuService;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule],
declarations: [MenuComponent],
providers: [
{ provide: Injector, useValue: {} },
{ provide: MenuService, useClass: MenuServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MenuComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MenuComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
menuService = (comp as any).menuService;
spyOn(comp as any, 'getSectionDataInjector').and.returnValue(MenuSection);
spyOn(comp as any, 'getSectionComponent').and.returnValue(observableOf({}));
fixture.detectChanges();
});
describe('toggle', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
comp.toggle(new Event('click'));
});
it('should trigger the toggleMenu function on the menu service', () => {
expect(menuService.toggleMenu).toHaveBeenCalledWith(comp.menuID);
})
});
describe('expand', () => {
beforeEach(() => {
spyOn(menuService, 'expandMenu');
comp.expand(new Event('click'));
});
it('should trigger the expandMenu function on the menu service', () => {
expect(menuService.expandMenu).toHaveBeenCalledWith(comp.menuID);
})
});
describe('collapse', () => {
beforeEach(() => {
spyOn(menuService, 'collapseMenu');
comp.collapse(new Event('click'));
});
it('should trigger the collapseMenu function on the menu service', () => {
expect(menuService.collapseMenu).toHaveBeenCalledWith(comp.menuID);
})
});
describe('expandPreview', () => {
it('should trigger the expandPreview function on the menu service after 100ms', fakeAsync(() => {
spyOn(menuService, 'expandMenuPreview');
comp.expandPreview(new Event('click'));
tick(99);
expect(menuService.expandMenuPreview).not.toHaveBeenCalled();
tick(1);
expect(menuService.expandMenuPreview).toHaveBeenCalledWith(comp.menuID);
}))
});
describe('collapsePreview', () => {
it('should trigger the collapsePreview function on the menu service after 400ms', fakeAsync(() => {
spyOn(menuService, 'collapseMenuPreview');
comp.collapsePreview(new Event('click'));
tick(399);
expect(menuService.collapseMenuPreview).not.toHaveBeenCalled();
tick(1);
expect(menuService.collapseMenuPreview).toHaveBeenCalledWith(comp.menuID);
}))
});
});

View File

@@ -0,0 +1,168 @@
import { ChangeDetectionStrategy, Component, Injector, OnInit } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID } from '../../shared/menu/initial-menus-state';
import { MenuSection } from '../../shared/menu/menu.reducer';
import { first, map } from 'rxjs/operators';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { hasValue } from '../empty.util';
import { MenuSectionComponent } from './menu-section/menu-section.component';
import { getComponentForMenu } from './menu-section.decorator';
import Timer = NodeJS.Timer;
/**
* A basic implementation of a MenuComponent
*/
@Component({
selector: 'ds-menu',
template: ''
})
export class MenuComponent implements OnInit {
/**
* The ID of the Menu (See MenuID)
*/
menuID: MenuID;
/**
* Observable that emits whether or not this menu is currently collapsed
*/
menuCollapsed: Observable<boolean>;
/**
* Observable that emits whether or not this menu's preview is currently collapsed
*/
menuPreviewCollapsed: Observable<boolean>;
/**
* Observable that emits whether or not this menu is currently visible
*/
menuVisible: Observable<boolean>;
/**
* List of top level sections in this Menu
*/
sections: Observable<MenuSection[]>;
/**
* List of Injectors for each dynamically rendered menu section
*/
sectionInjectors: Map<string, Injector> = new Map<string, Injector>();
/**
* List of child Components for each dynamically rendered menu section
*/
sectionComponents: Map<string, GenericConstructor<MenuSectionComponent>> = new Map<string, GenericConstructor<MenuSectionComponent>>();
/**
* Prevent unnecessary rerendering
*/
changeDetection: ChangeDetectionStrategy.OnPush;
/**
* Timer to briefly delay the sidebar preview from opening or closing
*/
private previewTimer: Timer;
constructor(protected menuService: MenuService, protected injector: Injector) {
}
/**
* Sets all instance variables to their initial values
*/
ngOnInit(): void {
this.menuCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.menuPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.menuVisible = this.menuService.isMenuVisible(this.menuID);
this.sections = this.menuService.getMenuTopSections(this.menuID).pipe(first());
this.sections.subscribe((sections: MenuSection[]) => {
sections.forEach((section: MenuSection) => {
this.sectionInjectors.set(section.id, this.getSectionDataInjector(section));
this.getSectionComponent(section).pipe(first()).subscribe((constr) => this.sectionComponents.set(section.id, constr));
})
})
}
/**
* Collapse this menu when it's currently expanded, expand it when its currently collapsed
* @param {Event} event The user event that triggered this method
*/
toggle(event: Event) {
event.preventDefault();
this.menuService.toggleMenu(this.menuID);
}
/**
* Expand this menu
* @param {Event} event The user event that triggered this method
*/
expand(event: Event) {
event.preventDefault();
this.menuService.expandMenu(this.menuID);
}
/**
* Collapse this menu
* @param {Event} event The user event that triggered this method
*/
collapse(event: Event) {
event.preventDefault();
this.menuService.collapseMenu(this.menuID);
}
/**
* Expand this menu's preview
* @param {Event} event The user event that triggered this method
*/
expandPreview(event: Event) {
event.preventDefault();
this.previewToggleDebounce(() => this.menuService.expandMenuPreview(this.menuID), 100);
}
/**
* Collapse this menu's preview
* @param {Event} event The user event that triggered this method
*/
collapsePreview(event: Event) {
event.preventDefault();
this.previewToggleDebounce(() => this.menuService.collapseMenuPreview(this.menuID), 400);
}
/**
* delay the handler function by the given amount of time
*
* @param {Function} handler The function to delay
* @param {number} ms The amount of ms to delay the handler function by
*/
private previewToggleDebounce(handler: () => void, ms: number): void {
if (hasValue(this.previewTimer)) {
clearTimeout(this.previewTimer);
}
this.previewTimer = setTimeout(handler, ms);
}
/**
* Retrieve the component for a given MenuSection object
* @param {MenuSection} section The given MenuSection
* @returns {Observable<GenericConstructor<MenuSectionComponent>>} Emits the constructor of the Component that should be used to render this object
*/
private getSectionComponent(section: MenuSection): Observable<GenericConstructor<MenuSectionComponent>> {
return this.menuService.hasSubSections(this.menuID, section.id).pipe(
map((expandable: boolean) => {
return getComponentForMenu(this.menuID, expandable);
}
),
);
}
/**
* Retrieve the Injector for a given MenuSection object
* @param {MenuSection} section The given MenuSection
* @returns {Injector} The Injector that injects the data for this menu section into the section's component
*/
private getSectionDataInjector(section: MenuSection) {
return Injector.create({
providers: [{ provide: 'sectionDataProvider', useFactory: () => (section), deps: [] }],
parent: this.injector
});
}
}

View File

@@ -0,0 +1,54 @@
import { MenuSectionComponent } from './menu-section/menu-section.component';
import { MenuComponent } from './menu.component';
import { NgModule } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterModule } from '@angular/router';
import { LinkMenuItemComponent } from './menu-item/link-menu-item.component';
import { TextMenuItemComponent } from './menu-item/text-menu-item.component';
const COMPONENTS = [
MenuSectionComponent,
MenuComponent,
LinkMenuItemComponent,
TextMenuItemComponent
];
const ENTRY_COMPONENTS = [
LinkMenuItemComponent,
TextMenuItemComponent
];
const MODULES = [
TranslateModule,
RouterModule
];
const PROVIDERS = [
];
@NgModule({
imports: [
...MODULES
],
declarations: [
...COMPONENTS,
...ENTRY_COMPONENTS,
],
providers: [
...PROVIDERS
],
exports: [
...COMPONENTS,
...MODULES
],
entryComponents: [
...ENTRY_COMPONENTS
]
})
/**
* This module handles all components, providers and modules that are needed for the menu
*/
export class MenuModule {
}

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