Merge branch 'rename-entities' into mixing-text-authority-entities

This commit is contained in:
Art Lowel
2019-01-22 10:15:26 +01:00
184 changed files with 7654 additions and 2054 deletions

View File

@@ -52,5 +52,25 @@ module.exports = {
// Log directory // Log directory
logDirectory: '.', logDirectory: '.',
// NOTE: will log all redux actions and transfers in console // NOTE: will log all redux actions and transfers in console
debug: false debug: false,
// Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: 'en',
// Languages. DSpace Angular holds a message catalog for each of the following languages. When set to active, users will be able to switch to the use of this language in the user interface.
languages: [{
code: 'en',
label: 'English',
active: true,
}, {
code: 'de',
label: 'Deutsch',
active: true,
}, {
code: 'cs',
label: 'Čeština',
active: true,
}, {
code: 'nl',
label: 'Nederlands',
active: false,
}]
}; };

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

@@ -22,6 +22,9 @@
}, },
"sub-collection-list": { "sub-collection-list": {
"head": "Collections of this Community" "head": "Collections of this Community"
},
"sub-community-list": {
"head": "Communities of this Community"
} }
}, },
"item": { "item": {
@@ -43,6 +46,120 @@
"simple": "Simple item page", "simple": "Simple item page",
"full": "Full item page" "full": "Full item page"
} }
},
"select": {
"table": {
"collection": "Collection",
"author": "Author",
"title": "Title"
},
"confirm": "Confirm selected"
},
"edit": {
"head": "Edit Item",
"tabs": {
"status": {
"head": "Item Status",
"description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.",
"labels": {
"id": "Item Internal ID",
"handle": "Handle",
"lastModified": "Last Modified",
"itemPage": "Item Page"
},
"buttons": {
"authorizations": {
"label": "Edit item's authorization policies",
"button": "Authorizations..."
},
"withdraw": {
"label": "Withdraw item from the repository",
"button": "Withdraw..."
},
"reinstate": {
"label": "Reinstate item into the repository",
"button": "Reinstate..."
},
"move": {
"label": "Move item to another collection",
"button": "Move..."
},
"private": {
"label": "Make item private",
"button": "Make it private..."
},
"public": {
"label": "Make item public",
"button": "Make it public..."
},
"delete": {
"label": "Completely expunge item",
"button": "Permanently delete"
},
"mappedCollections": {
"label": "Manage mapped collections",
"button": "Mapped collections"
}
}
},
"bitstreams": {
"head": "Item Bitstreams"
},
"metadata": {
"head": "Item Metadata"
},
"view": {
"head": "View Item"
},
"curate": {
"head": "Curate"
}
},
"modify.overview": {
"field": "Field",
"value": "Value",
"language": "Language"
},
"withdraw": {
"header": "Withdraw item: {{ id }}",
"description": "Are you sure this item should be withdrawn from the archive?",
"confirm": "Withdraw",
"cancel": "Cancel",
"success": "The item was withdrawn successfully",
"error": "An error occured while withdrawing the item"
},
"reinstate": {
"header": "Reinstate item: {{ id }}",
"description": "Are you sure this item should be reinstated to the archive?",
"confirm": "Reinstate",
"cancel": "Cancel",
"success": "The item was reinstated successfully",
"error": "An error occured while reinstating the item"
},
"private": {
"header": "Make item private: {{ id }}",
"description": "Are you sure this item should be made private in the archive?",
"confirm": "Make it Private",
"cancel": "Cancel",
"success": "The item is now private",
"error": "An error occured while making the item private"
},
"public": {
"header": "Make item public: {{ id }}",
"description": "Are you sure this item should be made public in the archive?",
"confirm": "Make it Public",
"cancel": "Cancel",
"success": "The item is now public",
"error": "An error occured while making the item public"
},
"delete": {
"header": "Delete item: {{ id }}",
"description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.",
"confirm": "Delete",
"cancel": "Cancel",
"success": "The item has been deleted",
"error": "An error occured while deleting the item"
}
} }
}, },
"relationships": { "relationships": {
@@ -114,9 +231,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",
@@ -295,12 +422,92 @@
} }
} }
}, },
"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...",
"community": "Loading community...", "community": "Loading community...",
"collection": "Loading collection...", "collection": "Loading collection...",
"sub-collections": "Loading sub-collections...", "sub-collections": "Loading sub-collections...",
"sub-communities": "Loading sub-communities...",
"recent-submissions": "Loading recent submissions...", "recent-submissions": "Loading recent submissions...",
"item": "Loading item...", "item": "Loading item...",
"objects": "Loading...", "objects": "Loading...",
@@ -313,6 +520,7 @@
"community": "Error fetching community", "community": "Error fetching community",
"collection": "Error fetching collection", "collection": "Error fetching collection",
"sub-collections": "Error fetching sub-collections", "sub-collections": "Error fetching sub-collections",
"sub-communities": "Error fetching sub-communities",
"recent-submissions": "Error fetching recent submissions", "recent-submissions": "Error fetching recent submissions",
"item": "Error fetching item", "item": "Error fetching item",
"objects": "Error fetching objects", "objects": "Error fetching objects",
@@ -365,5 +573,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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.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

@@ -24,6 +24,7 @@
[content]="communityPayload.copyrightText" [content]="communityPayload.copyrightText"
[hasInnerHtml]="true"> [hasInnerHtml]="true">
</ds-comcol-page-content> </ds-comcol-page-content>
<ds-community-page-sub-community-list [community]="communityPayload"></ds-community-page-sub-community-list>
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list> <ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module';
import { CommunityPageComponent } from './community-page.component'; import { CommunityPageComponent } from './community-page.component';
import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component';
import { CommunityPageRoutingModule } from './community-page-routing.module'; import { CommunityPageRoutingModule } from './community-page-routing.module';
import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -16,6 +17,7 @@ import { CommunityPageRoutingModule } from './community-page-routing.module';
declarations: [ declarations: [
CommunityPageComponent, CommunityPageComponent,
CommunityPageSubCollectionListComponent, CommunityPageSubCollectionListComponent,
CommunityPageSubCommunityListComponent,
] ]
}) })
export class CommunityPageModule { export class CommunityPageModule {

View File

@@ -1,5 +1,5 @@
<ng-container *ngVar="(subCollectionsRDObs | async) as subCollectionsRD"> <ng-container *ngVar="(subCollectionsRDObs | async) as subCollectionsRD">
<div *ngIf="subCollectionsRD?.hasSucceeded" @fadeIn> <div *ngIf="subCollectionsRD?.hasSucceeded && subCollectionsRD?.payload.totalElements > 0" @fadeIn>
<h2>{{'community.sub-collection-list.head' | translate}}</h2> <h2>{{'community.sub-collection-list.head' | translate}}</h2>
<ul> <ul>
<li *ngFor="let collection of subCollectionsRD?.payload.page"> <li *ngFor="let collection of subCollectionsRD?.payload.page">

View File

@@ -0,0 +1,15 @@
<ng-container *ngVar="(subCommunitiesRDObs | async) as subCommunitiesRD">
<div *ngIf="subCommunitiesRD?.hasSucceeded && subCommunitiesRD?.payload.totalElements > 0" @fadeIn>
<h2>{{'community.sub-community-list.head' | translate}}</h2>
<ul>
<li *ngFor="let community of subCommunitiesRD?.payload.page">
<p>
<span class="lead"><a [routerLink]="['/communities', community.id]">{{community.name}}</a></span><br>
<span class="text-muted">{{community.shortDescription}}</span>
</p>
</li>
</ul>
</div>
<ds-error *ngIf="subCommunitiesRD?.hasFailed" message="{{'error.sub-communities' | translate}}"></ds-error>
<ds-loading *ngIf="subCommunitiesRD?.isLoading" message="{{'loading.sub-communities' | translate}}"></ds-loading>
</ng-container>

View File

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

View File

@@ -0,0 +1,96 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NO_ERRORS_SCHEMA} from '@angular/core';
import {CommunityPageSubCommunityListComponent} from './community-page-sub-community-list.component';
import {Community} from '../../core/shared/community.model';
import {RemoteData} from '../../core/data/remote-data';
import {PaginatedList} from '../../core/data/paginated-list';
import {PageInfo} from '../../core/shared/page-info.model';
import {SharedModule} from '../../shared/shared.module';
import {RouterTestingModule} from '@angular/router/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {By} from '@angular/platform-browser';
import {of as observableOf, Observable } from 'rxjs';
describe('SubCommunityList Component', () => {
let comp: CommunityPageSubCommunityListComponent;
let fixture: ComponentFixture<CommunityPageSubCommunityListComponent>;
const subcommunities = [Object.assign(new Community(), {
name: 'SubCommunity 1',
id: '123456789-1',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'SubCommunity 1'
}]
}),
Object.assign(new Community(), {
name: 'SubCommunity 2',
id: '123456789-2',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'SubCommunity 2'
}]
})
];
const emptySubCommunitiesCommunity = Object.assign(new Community(), {
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Test title'
}],
subcommunities: observableOf(new RemoteData(true, true, true,
undefined, new PaginatedList(new PageInfo(), [])))
});
const mockCommunity = Object.assign(new Community(), {
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Test title'
}],
subcommunities: observableOf(new RemoteData(true, true, true,
undefined, new PaginatedList(new PageInfo(), subcommunities)))
})
;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule,
RouterTestingModule.withRoutes([]),
NoopAnimationsModule],
declarations: [CommunityPageSubCommunityListComponent],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent);
comp = fixture.componentInstance;
});
it('should display a list of subCommunities', () => {
comp.community = mockCommunity;
fixture.detectChanges();
const subComList = fixture.debugElement.queryAll(By.css('li'));
expect(subComList.length).toEqual(2);
expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1');
expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2');
});
it('should not display the header when subCommunities are empty', () => {
comp.community = emptySubCommunitiesCommunity;
fixture.detectChanges();
const subComHead = fixture.debugElement.queryAll(By.css('h2'));
expect(subComHead.length).toEqual(0);
});
});

View File

@@ -0,0 +1,26 @@
import { Component, Input, OnInit } from '@angular/core';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { fadeIn } from '../../shared/animations/fade';
import { PaginatedList } from '../../core/data/paginated-list';
import {Observable} from 'rxjs';
@Component({
selector: 'ds-community-page-sub-community-list',
styleUrls: ['./community-page-sub-community-list.component.scss'],
templateUrl: './community-page-sub-community-list.component.html',
animations:[fadeIn]
})
/**
* Component to render the sub-communities of a Community
*/
export class CommunityPageSubCommunityListComponent implements OnInit {
@Input() community: Community;
subCommunitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>;
ngOnInit(): void {
this.subCommunitiesRDObs = this.community.subcommunities;
}
}

View File

@@ -1,9 +1,7 @@
<div class="jumbotron jumbotron-fluid"> <div class="jumbotron jumbotron-fluid">
<div class="container"> <div class="container">
<div class="d-flex"> <div class="d-flex flex-wrap">
<div class="dspace-logo-container"> <img class="mr-4 dspace-logo" src="assets/images/dspace-logo.svg" alt="" />
<img src="assets/images/dspace-logo.png" />
</div>
<div> <div>
<h1 class="display-3">Welcome to DSpace</h1> <h1 class="display-3">Welcome to DSpace</h1>
<p class="lead">DSpace is an open source software platform that enables organisations to:</p> <p class="lead">DSpace is an open source software platform that enables organisations to:</p>

View File

@@ -6,11 +6,11 @@
margin-bottom: -$content-spacing; margin-bottom: -$content-spacing;
} }
.dspace-logo-container { .display-3 {
margin: 10px 20px 0px 20px; word-break: break-word;
} }
.dspace-logo-container img { .dspace-logo {
max-height: 110px; height: 110px;
max-width: 110px; width: 110px;
} }

View File

@@ -0,0 +1,35 @@
import {RemoteData} from '../../core/data/remote-data';
import {hot} from 'jasmine-marbles';
import {Item} from '../../core/shared/item.model';
import {findSuccessfulAccordingTo} from './edit-item-operators';
describe('findSuccessfulAccordingTo', () => {
let mockItem1;
let mockItem2;
let predicate;
beforeEach(() => {
mockItem1 = new Item();
mockItem1.isWithdrawn = true;
mockItem2 = new Item();
mockItem1.isWithdrawn = false;
predicate = (rd: RemoteData<Item>) => rd.payload.isWithdrawn;
});
it('should return first successful RemoteData Observable that complies to predicate', () => {
const testRD = {
a: new RemoteData(false, false, true, null, undefined),
b: new RemoteData(false, false, false, null, mockItem1),
c: new RemoteData(false, false, true, null, mockItem2),
d: new RemoteData(false, false, true, null, mockItem1),
e: new RemoteData(false, false, true, null, mockItem2),
};
const source = hot('abcde', testRD);
const result = source.pipe(findSuccessfulAccordingTo(predicate));
result.subscribe((value) => expect(value).toEqual(testRD.d));
});
});

View File

@@ -0,0 +1,13 @@
import {RemoteData} from '../../core/data/remote-data';
import {Observable} from 'rxjs';
import {first} from 'rxjs/operators';
import {getAllSucceededRemoteData} from '../../core/shared/operators';
/**
* Return first Observable of a RemoteData object that complies to the provided predicate
* @param predicate
*/
export const findSuccessfulAccordingTo = <T>(predicate: (rd: RemoteData<T>) => boolean) =>
(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(getAllSucceededRemoteData(),
first(predicate));

View File

@@ -0,0 +1,36 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2">
<ngb-tabset>
<ngb-tab title="{{'item.edit.tabs.status.head' | translate}}">
<ng-template ngbTabContent>
<ds-item-status [item]="(itemRD$ | async)?.payload"></ds-item-status>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.bitstreams.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.metadata.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.view.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.curate.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
</ngb-tabset>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
import {fadeIn, fadeInOut} from '../../shared/animations/fade';
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {RemoteData} from '../../core/data/remote-data';
import {Item} from '../../core/shared/item.model';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
@Component({
selector: 'ds-edit-item-page',
templateUrl: './edit-item-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeIn,
fadeInOut
]
})
/**
* Page component for editing an item
*/
export class EditItemPageComponent implements OnInit {
/**
* The item to edit
*/
itemRD$: Observable<RemoteData<Item>>;
constructor(private route: ActivatedRoute) {
}
ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
}
}

View File

@@ -0,0 +1,40 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SharedModule} from '../../shared/shared.module';
import {EditItemPageRoutingModule} from './edit-item-page.routing.module';
import {EditItemPageComponent} from './edit-item-page.component';
import {ItemStatusComponent} from './item-status/item-status.component';
import {ItemOperationComponent} from './item-operation/item-operation.component';
import {ModifyItemOverviewComponent} from './modify-item-overview/modify-item-overview.component';
import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component';
import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component';
import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-simple-item-action.component';
import {ItemPrivateComponent} from './item-private/item-private.component';
import {ItemPublicComponent} from './item-public/item-public.component';
import {ItemDeleteComponent} from './item-delete/item-delete.component';
/**
* Module that contains all components related to the Edit Item page administrator functionality
*/
@NgModule({
imports: [
CommonModule,
SharedModule,
EditItemPageRoutingModule
],
declarations: [
EditItemPageComponent,
ItemOperationComponent,
AbstractSimpleItemActionComponent,
ModifyItemOverviewComponent,
ItemWithdrawComponent,
ItemReinstateComponent,
ItemPrivateComponent,
ItemPublicComponent,
ItemDeleteComponent,
ItemStatusComponent
]
})
export class EditItemPageModule {
}

View File

@@ -0,0 +1,72 @@
import {ItemPageResolver} from '../item-page.resolver';
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {EditItemPageComponent} from './edit-item-page.component';
import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component';
import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component';
import {ItemPrivateComponent} from './item-private/item-private.component';
import {ItemPublicComponent} from './item-public/item-public.component';
import {ItemDeleteComponent} from './item-delete/item-delete.component';
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
const ITEM_EDIT_PRIVATE_PATH = 'private';
const ITEM_EDIT_PUBLIC_PATH = 'public';
const ITEM_EDIT_DELETE_PATH = 'delete';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
*/
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: EditItemPageComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_PRIVATE_PATH,
component: ItemPrivateComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_PUBLIC_PATH,
component: ItemPublicComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_DELETE_PATH,
component: ItemDeleteComponent,
resolve: {
item: ItemPageResolver
}
}])
],
providers: [
ItemPageResolver,
]
})
export class EditItemPageRoutingModule {
}

View File

@@ -0,0 +1,118 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {RemoteData} from '../../../core/data/remote-data';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {ItemDataService} from '../../../core/data/item-data.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ItemDeleteComponent} from './item-delete.component';
import {getItemEditPath} from '../../item-page-routing.module';
let comp: ItemDeleteComponent;
let fixture: ComponentFixture<ItemDeleteComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService: ItemDataService;
let routeStub;
let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('ItemDeleteComponent', () => {
beforeEach(async(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
delete: observableOf(new RestResponse(true, '200'))
});
routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'fake-id'
})
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemDeleteComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(ItemDeleteComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the \'delete\' messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.delete.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.delete.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.delete.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.delete.cancel');
});
describe('performAction', () => {
it('should call delete function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse');
comp.performAction();
expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id);
expect(comp.processRestResponse).toHaveBeenCalled();
});
});
describe('processRestResponse', () => {
it('should navigate to the homepage on successful deletion of the item', () => {
comp.processRestResponse(successfulRestResponse);
expect(routerStub.navigate).toHaveBeenCalledWith(['']);
});
});
describe('processRestResponse', () => {
it('should navigate to the item edit page on failed deletion of the item', () => {
comp.processRestResponse(failRestResponse);
expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath('fake-id')]);
});
});
})
;

View File

@@ -0,0 +1,43 @@
import {Component} from '@angular/core';
import {first} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component';
import {getItemEditPath} from '../../item-page-routing.module';
@Component({
selector: 'ds-item-delete',
templateUrl: '../simple-item-action/abstract-simple-item-action.component.html'
})
/**
* Component responsible for rendering the item delete page
*/
export class ItemDeleteComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'delete';
/**
* Perform the delete action to the item
*/
performAction() {
this.itemDataService.delete(this.item.id).pipe(first()).subscribe(
(response: RestResponse) => {
this.processRestResponse(response);
}
);
}
/**
* Process the RestResponse retrieved from the server.
* When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page
* @param response
*/
processRestResponse(response: RestResponse) {
if (response.isSuccessful) {
this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success'));
this.router.navigate(['']);
} else {
this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error'));
this.router.navigate([getItemEditPath(this.item.id)]);
}
}
}

View File

@@ -0,0 +1,15 @@
<div class="col-3 float-left d-flex h-100 action-label">
<span class="justify-content-center align-self-center">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.label' | translate}}
</span>
</div>
<div *ngIf="!operation.disabled" class="col-9 float-left action-button">
<a class="btn btn-outline-secondary" href="{{operation.operationUrl}}">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</a>
</div>
<div *ngIf="operation.disabled" class="col-9 float-left action-button">
<span class="btn btn-danger">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</span>
</div>

View File

@@ -0,0 +1,45 @@
import {ItemOperation} from './itemOperation.model';
import {async, TestBed} from '@angular/core/testing';
import {ItemOperationComponent} from './item-operation.component';
import {TranslateModule} from '@ngx-translate/core';
import {By} from '@angular/platform-browser';
describe('ItemOperationComponent', () => {
let itemOperation: ItemOperation;
let fixture;
let comp;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ItemOperationComponent]
}).compileComponents();
}));
beforeEach(() => {
itemOperation = new ItemOperation('key1', 'url1');
fixture = TestBed.createComponent(ItemOperationComponent);
comp = fixture.componentInstance;
comp.operation = itemOperation;
fixture.detectChanges();
});
it('should render operation row', () => {
const span = fixture.debugElement.query(By.css('span')).nativeElement;
expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label');
const link = fixture.debugElement.query(By.css('a')).nativeElement;
expect(link.href).toContain('url1');
expect(link.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
});
it('should render disabled operation row', () => {
itemOperation.setDisabled(true);
fixture.detectChanges();
const span = fixture.debugElement.query(By.css('span')).nativeElement;
expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label');
const span2 = fixture.debugElement.query(By.css('span.btn-danger')).nativeElement;
expect(span2.textContent).toContain('item.edit.tabs.status.buttons.key1.button');
});
});

View File

@@ -0,0 +1,15 @@
import {Component, Input} from '@angular/core';
import {ItemOperation} from './itemOperation.model';
@Component({
selector: 'ds-item-operation',
templateUrl: './item-operation.component.html'
})
/**
* Operation that can be performed on an item
*/
export class ItemOperationComponent {
@Input() operation: ItemOperation;
}

View File

@@ -0,0 +1,25 @@
/**
* Represents an item operation used on the edit item page with a key, an operation URL to which will be navigated
* when performing the action and an option to disable the operation.
*/
export class ItemOperation {
operationKey: string;
operationUrl: string;
disabled: boolean;
constructor(operationKey: string, operationUrl: string) {
this.operationKey = operationKey;
this.operationUrl = operationUrl;
this.setDisabled(false);
}
/**
* Set whether this operation should be disabled
* @param disabled
*/
setDisabled(disabled: boolean): void {
this.disabled = disabled;
}
}

View File

@@ -0,0 +1,105 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {RemoteData} from '../../../core/data/remote-data';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {ItemDataService} from '../../../core/data/item-data.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ItemPrivateComponent} from './item-private.component';
let comp: ItemPrivateComponent;
let fixture: ComponentFixture<ItemPrivateComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService: ItemDataService;
let routeStub;
let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('ItemPrivateComponent', () => {
beforeEach(async(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockItemDataService = jasmine.createSpyObj('mockItemDataService',{
setDiscoverable: observableOf(new RestResponse(true, '200'))
});
routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'fake-id'
})
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemPrivateComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(ItemPrivateComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the \'private\' messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.private.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.private.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.private.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.private.cancel');
});
describe('performAction', () => {
it('should call setDiscoverable function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse');
comp.performAction();
expect(mockItemDataService.setDiscoverable).toHaveBeenCalledWith(mockItem.id, false);
expect(comp.processRestResponse).toHaveBeenCalled();
});
});
})
;

View File

@@ -0,0 +1,30 @@
import {Component} from '@angular/core';
import {first} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component';
import {RemoteData} from '../../../core/data/remote-data';
import {Item} from '../../../core/shared/item.model';
@Component({
selector: 'ds-item-private',
templateUrl: '../simple-item-action/abstract-simple-item-action.component.html'
})
/**
* Component responsible for rendering the make item private page
*/
export class ItemPrivateComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'private';
protected predicate = (rd: RemoteData<Item>) => !rd.payload.isDiscoverable;
/**
* Perform the make private action to the item
*/
performAction() {
this.itemDataService.setDiscoverable(this.item.id, false).pipe(first()).subscribe(
(response: RestResponse) => {
this.processRestResponse(response);
}
);
}
}

View File

@@ -0,0 +1,105 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {RemoteData} from '../../../core/data/remote-data';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {ItemDataService} from '../../../core/data/item-data.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ItemPublicComponent} from './item-public.component';
let comp: ItemPublicComponent;
let fixture: ComponentFixture<ItemPublicComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService: ItemDataService;
let routeStub;
let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('ItemPublicComponent', () => {
beforeEach(async(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockItemDataService = jasmine.createSpyObj('mockItemDataService',{
setDiscoverable: observableOf(new RestResponse(true, '200'))
});
routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'fake-id'
})
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemPublicComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(ItemPublicComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the \'public\' messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.public.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.public.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.public.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.public.cancel');
});
describe('performAction', () => {
it('should call setDiscoverable function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse');
comp.performAction();
expect(mockItemDataService.setDiscoverable).toHaveBeenCalledWith(mockItem.id, true);
expect(comp.processRestResponse).toHaveBeenCalled();
});
});
})
;

View File

@@ -0,0 +1,30 @@
import {Component} from '@angular/core';
import {first} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component';
import {RemoteData} from '../../../core/data/remote-data';
import {Item} from '../../../core/shared/item.model';
@Component({
selector: 'ds-item-public',
templateUrl: '../simple-item-action/abstract-simple-item-action.component.html'
})
/**
* Component responsible for rendering the make item public page
*/
export class ItemPublicComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'public';
protected predicate = (rd: RemoteData<Item>) => rd.payload.isDiscoverable;
/**
* Perform the make public action to the item
*/
performAction() {
this.itemDataService.setDiscoverable(this.item.id, true).pipe(first()).subscribe(
(response: RestResponse) => {
this.processRestResponse(response);
}
);
}
}

View File

@@ -0,0 +1,105 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {RemoteData} from '../../../core/data/remote-data';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {ItemDataService} from '../../../core/data/item-data.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ItemReinstateComponent} from './item-reinstate.component';
let comp: ItemReinstateComponent;
let fixture: ComponentFixture<ItemReinstateComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService: ItemDataService;
let routeStub;
let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('ItemReinstateComponent', () => {
beforeEach(async(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockItemDataService = jasmine.createSpyObj('mockItemDataService',{
setWithDrawn: observableOf(new RestResponse(true, '200'))
});
routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'fake-id'
})
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemReinstateComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(ItemReinstateComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the \'reinstate\' messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.reinstate.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.reinstate.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.reinstate.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.reinstate.cancel');
});
describe('performAction', () => {
it('should call setWithdrawn function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse');
comp.performAction();
expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem.id, false);
expect(comp.processRestResponse).toHaveBeenCalled();
});
});
})
;

View File

@@ -0,0 +1,30 @@
import {Component} from '@angular/core';
import {first} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component';
import {RemoteData} from '../../../core/data/remote-data';
import {Item} from '../../../core/shared/item.model';
@Component({
selector: 'ds-item-reinstate',
templateUrl: '../simple-item-action/abstract-simple-item-action.component.html'
})
/**
* Component responsible for rendering the Item Reinstate page
*/
export class ItemReinstateComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'reinstate';
protected predicate = (rd: RemoteData<Item>) => !rd.payload.isWithdrawn;
/**
* Perform the reinstate action to the item
*/
performAction() {
this.itemDataService.setWithDrawn(this.item.id, false).pipe(first()).subscribe(
(response: RestResponse) => {
this.processRestResponse(response);
}
);
}
}

View File

@@ -0,0 +1,21 @@
<p class="mt-2">{{'item.edit.tabs.status.description' | translate}}</p>
<div class="row">
<div *ngFor="let statusKey of statusDataKeys" class="w-100">
<div class="col-3 float-left status-label">
{{'item.edit.tabs.status.labels.' + statusKey | translate}}:
</div>
<div class="col-9 float-left status-data" id="status-{{statusKey}}">
{{statusData[statusKey]}}
</div>
</div>
<div class="col-3 float-left status-label">
{{'item.edit.tabs.status.labels.itemPage' | translate}}:
</div>
<div class="col-9 float-left status-data" id="status-itemPage">
<a href="{{getItemPage()}}">{{getItemPage()}}</a>
</div>
<div *ngFor="let operation of operations" class="w-100 pt-3">
<ds-item-operation [operation]="operation"></ds-item-operation>
</div>
</div>

View File

@@ -0,0 +1,68 @@
import { ItemStatusComponent } from './item-status.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { CommonModule } from '@angular/common';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
import { HostWindowService } from '../../../shared/host-window.service';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { RouterStub } from '../../../shared/testing/router-stub';
import { Item } from '../../../core/shared/item.model';
import { By } from '@angular/platform-browser';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent;
let fixture: ComponentFixture<ItemStatusComponent>;
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018'
});
const itemPageUrl = `fake-url/${mockItem.id}`;
const routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemStatusComponent],
providers: [
{ provide: Router, useValue: routerStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemStatusComponent);
comp = fixture.componentInstance;
comp.item = mockItem;
fixture.detectChanges();
});
it('should display the item\'s internal id', () => {
const statusId: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-id')).nativeElement;
expect(statusId.textContent).toContain(mockItem.id);
});
it('should display the item\'s handle', () => {
const statusHandle: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-handle')).nativeElement;
expect(statusHandle.textContent).toContain(mockItem.handle);
});
it('should display the item\'s last modified date', () => {
const statusLastModified: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-lastModified')).nativeElement;
expect(statusLastModified.textContent).toContain(mockItem.lastModified);
});
it('should display the item\'s page url', () => {
const statusItemPage: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-itemPage')).nativeElement;
expect(statusItemPage.textContent).toContain(itemPageUrl);
});
});

View File

@@ -0,0 +1,95 @@
import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
import {fadeIn, fadeInOut} from '../../../shared/animations/fade';
import {Item} from '../../../core/shared/item.model';
import {Router} from '@angular/router';
import {ItemOperation} from '../item-operation/itemOperation.model';
@Component({
selector: 'ds-item-status',
templateUrl: './item-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeIn,
fadeInOut
]
})
/**
* Component for displaying an item's status
*/
export class ItemStatusComponent implements OnInit {
/**
* The item to display the status for
*/
@Input() item: Item;
/**
* The data to show in the status
*/
statusData: any;
/**
* The keys of the data (to loop over)
*/
statusDataKeys;
/**
* The possible actions that can be performed on the item
* key: id value: url to action's component
*/
operations: ItemOperation[];
/**
* The keys of the actions (to loop over)
*/
actionsKeys;
constructor(private router: Router) {
}
ngOnInit(): void {
this.statusData = Object.assign({
id: this.item.id,
handle: this.item.handle,
lastModified: this.item.lastModified
});
this.statusDataKeys = Object.keys(this.statusData);
/*
The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button
*/
this.operations = [];
if (this.item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate'));
} else {
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw'));
}
if (this.item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private'));
} else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public'));
}
this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete'));
}
/**
* Get the url to the simple item page
* @returns {string} url
*/
getItemPage(): string {
return this.router.url.substr(0, this.router.url.lastIndexOf('/'));
}
/**
* Get the current url without query params
* @returns {string} url
*/
getCurrentUrl(): string {
if (this.router.url.indexOf('?') > -1) {
return this.router.url.substr(0, this.router.url.indexOf('?'));
} else {
return this.router.url;
}
}
}

View File

@@ -0,0 +1,105 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {RemoteData} from '../../../core/data/remote-data';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {ItemDataService} from '../../../core/data/item-data.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {ItemWithdrawComponent} from './item-withdraw.component';
import {By} from '@angular/platform-browser';
let comp: ItemWithdrawComponent;
let fixture: ComponentFixture<ItemWithdrawComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService: ItemDataService;
let routeStub;
let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('ItemWithdrawComponent', () => {
beforeEach(async(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockItemDataService = jasmine.createSpyObj('mockItemDataService',{
setWithDrawn: observableOf(new RestResponse(true, '200'))
});
routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'fake-id'
})
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot(),],
declarations: [ItemWithdrawComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(ItemWithdrawComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the \'withdraw\' messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.withdraw.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.withdraw.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.withdraw.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.withdraw.cancel');
});
describe('performAction', () => {
it('should call setWithdrawn function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse');
comp.performAction();
expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem.id, true);
expect(comp.processRestResponse).toHaveBeenCalled();
});
});
})
;

View File

@@ -0,0 +1,30 @@
import {Component} from '@angular/core';
import {first} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component';
import {RemoteData} from '../../../core/data/remote-data';
import {Item} from '../../../core/shared/item.model';
@Component({
selector: 'ds-item-withdraw',
templateUrl: '../simple-item-action/abstract-simple-item-action.component.html'
})
/**
* Component responsible for rendering the Item Withdraw page
*/
export class ItemWithdrawComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'withdraw';
protected predicate = (rd: RemoteData<Item>) => rd.payload.isWithdrawn;
/**
* Perform the withdraw action to the item
*/
performAction() {
this.itemDataService.setWithDrawn(this.item.id, true).pipe(first()).subscribe(
(response: RestResponse) => {
this.processRestResponse(response);
}
);
}
}

View File

@@ -0,0 +1,16 @@
<table id="metadata" class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{{'item.edit.modify.overview.field'| translate}}</th>
<th scope="col">{{'item.edit.modify.overview.value'| translate}}</th>
<th scope="col">{{'item.edit.modify.overview.language'| translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let metadatum of metadata" class="metadata-row">
<td>{{metadatum.key}}</td>
<td>{{metadatum.value}}</td>
<td>{{metadatum.language}}</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,55 @@
import {Item} from '../../../core/shared/item.model';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {ModifyItemOverviewComponent} from './modify-item-overview.component';
import {By} from '@angular/platform-browser';
import {TranslateModule} from '@ngx-translate/core';
let comp: ModifyItemOverviewComponent;
let fixture: ComponentFixture<ModifyItemOverviewComponent>;
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
metadata: [
{key: 'dc.title', value: 'Mock item title', language: 'en'},
{key: 'dc.contributor.author', value: 'Mayer, Ed', language: ''}
]
});
describe('ModifyItemOverviewComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ModifyItemOverviewComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ModifyItemOverviewComponent);
comp = fixture.componentInstance;
comp.item = mockItem;
fixture.detectChanges();
});
it('should render a table of existing metadata fields in the item', () => {
const metadataRows = fixture.debugElement.queryAll(By.css('tr.metadata-row'));
expect(metadataRows.length).toEqual(2);
const titleRow = metadataRows[0].queryAll(By.css('td'));
expect(titleRow.length).toEqual(3);
expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title');
expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title');
expect(titleRow[2].nativeElement.innerHTML).toContain('en');
const authorRow = metadataRows[1].queryAll(By.css('td'));
expect(authorRow.length).toEqual(3);
expect(authorRow[0].nativeElement.innerHTML).toContain('dc.contributor.author');
expect(authorRow[1].nativeElement.innerHTML).toContain('Mayer, Ed');
expect(authorRow[2].nativeElement.innerHTML).toEqual('');
});
});

View File

@@ -0,0 +1,20 @@
import {Component, Input, OnInit} from '@angular/core';
import {Item} from '../../../core/shared/item.model';
import {Metadatum} from '../../../core/shared/metadatum.model';
@Component({
selector: 'ds-modify-item-overview',
templateUrl: './modify-item-overview.component.html'
})
/**
* Component responsible for rendering a table containing the metadatavalues from the to be edited item
*/
export class ModifyItemOverviewComponent implements OnInit {
@Input() item: Item;
metadata: Metadatum[];
ngOnInit(): void {
this.metadata = this.item.metadata;
}
}

View File

@@ -0,0 +1,16 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{headerMessage | translate: {id: item.handle} }}</h2>
<p>{{descriptionMessage | translate}}</p>
<ds-modify-item-overview [item]="item"></ds-modify-item-overview>
<button (click)="performAction()" class="btn btn-outline-secondary perform-action">{{confirmMessage | translate}}
</button>
<button [routerLink]="['/items/', item.id, 'edit']" class="btn btn-outline-secondary cancel">
{{cancelMessage| translate}}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,142 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {CommonModule} from '@angular/common';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {ItemDataService} from '../../../core/data/item-data.service';
import {RemoteData} from '../../../core/data/remote-data';
import {AbstractSimpleItemActionComponent} from './abstract-simple-item-action.component';
import {By} from '@angular/platform-browser';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {of as observableOf} from 'rxjs';
import {getItemEditPath} from '../../item-page-routing.module';
/**
* Test component that implements the AbstractSimpleItemActionComponent used to test the
* AbstractSimpleItemActionComponent component
*/
@Component({
selector: 'ds-simple-action',
templateUrl: './abstract-simple-item-action.component.html'
})
export class MySimpleItemActionComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'myEditAction';
protected predicate = (rd: RemoteData<Item>) => rd.payload.isWithdrawn;
performAction() {
// do nothing
}
}
let comp: MySimpleItemActionComponent;
let fixture: ComponentFixture<MySimpleItemActionComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService;
let routeStub;
let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('AbstractSimpleItemActionComponent', () => {
beforeEach(async(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockItemDataService = jasmine.createSpyObj({
findById: observableOf(new RemoteData(false, false, true, undefined, mockItem))
});
routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'fake-id'
})
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [MySimpleItemActionComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(MySimpleItemActionComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the provided messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.myEditAction.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.myEditAction.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.myEditAction.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.myEditAction.cancel');
});
it('should perform action when the button is clicked', () => {
spyOn(comp, 'performAction');
const performButton = fixture.debugElement.query(By.css('.perform-action'));
performButton.triggerEventHandler('click', null);
expect(comp.performAction).toHaveBeenCalled();
});
it('should process a RestResponse to navigate and display success notification', () => {
spyOn(notificationsServiceStub, 'success');
comp.processRestResponse(successfulRestResponse);
expect(notificationsServiceStub.success).toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]);
});
it('should process a RestResponse to navigate and display success notification', () => {
spyOn(notificationsServiceStub, 'error');
comp.processRestResponse(failRestResponse);
expect(notificationsServiceStub.error).toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]);
});
});

View File

@@ -0,0 +1,84 @@
import {Component, OnInit, Predicate} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {ItemDataService} from '../../../core/data/item-data.service';
import {TranslateService} from '@ngx-translate/core';
import {Item} from '../../../core/shared/item.model';
import {RemoteData} from '../../../core/data/remote-data';
import {Observable} from 'rxjs';
import {getSucceededRemoteData} from '../../../core/shared/operators';
import {first, map} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {findSuccessfulAccordingTo} from '../edit-item-operators';
import {getItemEditPath} from '../../item-page-routing.module';
/**
* Component to render and handle simple item edit actions such as withdrawal and reinstatement.
* This component is not meant to be used itself but to be extended.
*/
@Component({
selector: 'ds-simple-action',
templateUrl: './abstract-simple-item-action.component.html'
})
export class AbstractSimpleItemActionComponent implements OnInit {
itemRD$: Observable<RemoteData<Item>>;
item: Item;
protected messageKey: string;
confirmMessage: string;
cancelMessage: string;
headerMessage: string;
descriptionMessage: string;
protected predicate: Predicate<RemoteData<Item>>;
constructor(protected route: ActivatedRoute,
protected router: Router,
protected notificationsService: NotificationsService,
protected itemDataService: ItemDataService,
protected translateService: TranslateService) {
}
ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(
map((data) => data.item),
getSucceededRemoteData()
)as Observable<RemoteData<Item>>;
this.itemRD$.pipe(first()).subscribe((rd) => {
this.item = rd.payload;
}
);
this.confirmMessage = 'item.edit.' + this.messageKey + '.confirm';
this.cancelMessage = 'item.edit.' + this.messageKey + '.cancel';
this.headerMessage = 'item.edit.' + this.messageKey + '.header';
this.descriptionMessage = 'item.edit.' + this.messageKey + '.description';
}
/**
* Perform the operation linked to this action
*/
performAction() {
// Overwrite in subclasses
};
/**
* Process the response obtained during the performAction method and navigate back to the edit page
* @param response from the action in the performAction method
*/
processRestResponse(response: RestResponse) {
if (response.isSuccessful) {
this.itemDataService.findById(this.item.id).pipe(
findSuccessfulAccordingTo(this.predicate)).subscribe(() => {
this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success'));
this.router.navigate([getItemEditPath(this.item.id)]);
});
} else {
this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error'));
this.router.navigate([getItemEditPath(this.item.id)]);
}
}
}

View File

@@ -4,6 +4,18 @@ import { RouterModule } from '@angular/router';
import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageComponent } from './simple/item-page.component';
import { FullItemPageComponent } from './full/full-item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component';
import { ItemPageResolver } from './item-page.resolver'; import { ItemPageResolver } from './item-page.resolver';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import {URLCombiner} from '../core/url-combiner/url-combiner';
import {getItemModulePath} from '../app-routing.module';
export function getItemPageRoute(itemId: string) {
return new URLCombiner(getItemModulePath(), itemId).toString();
}
export function getItemEditPath(id: string) {
return new URLCombiner(getItemModulePath(),ITEM_EDIT_PATH.replace(/:id/, id)).toString()
}
const ITEM_EDIT_PATH = ':id/edit';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -22,6 +34,11 @@ import { ItemPageResolver } from './item-page.resolver';
resolve: { resolve: {
item: ItemPageResolver item: ItemPageResolver
} }
},
{
path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard]
} }
]) ])
], ],

View File

@@ -29,12 +29,14 @@ import { JournalComponent } from './simple/item-types/journal/journal.component'
import { JournalVolumeComponent } from './simple/item-types/journal-volume/journal-volume.component'; import { JournalVolumeComponent } from './simple/item-types/journal-volume/journal-volume.component';
import { JournalIssueComponent } from './simple/item-types/journal-issue/journal-issue.component'; import { JournalIssueComponent } from './simple/item-types/journal-issue/journal-issue.component';
import { ItemComponent } from './simple/item-types/shared/item.component'; import { ItemComponent } from './simple/item-types/shared/item.component';
import { EditItemPageModule } from './edit-item-page/edit-item-page.module';
import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component';
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
EditItemPageModule,
ItemPageRoutingModule, ItemPageRoutingModule,
SearchPageModule SearchPageModule
], ],

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

@@ -2,11 +2,12 @@
@import '../../../../styles/mixins.scss'; @import '../../../../styles/mixins.scss';
:host { :host {
border: 1px solid map-get($theme-colors, light); border: 1px solid map-get($theme-colors, light);
.search-filter-wrapper.closed { cursor: pointer;
overflow: hidden; .search-filter-wrapper.closed {
} overflow: hidden;
.filter-toggle { }
line-height: $line-height-base; .filter-toggle {
} line-height: $line-height-base;
} }
}

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

@@ -3,6 +3,10 @@ import { RouterModule } from '@angular/router';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
const ITEM_MODULE_PATH = 'items';
export function getItemModulePath() {
return `/${ITEM_MODULE_PATH}`;
}
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot([ RouterModule.forRoot([
@@ -10,7 +14,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' },
{ path: 'communities', loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: 'communities', loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' },
{ path: 'admin', loadChildren: './+admin/admin.module#AdminModule' }, { path: 'admin', loadChildren: './+admin/admin.module#AdminModule' },

View File

@@ -1,20 +1,22 @@
<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">
</ds-notifications-board> </ds-notifications-board>
<main class="main-content"> <main class="main-content">
<div class="container" *ngIf="isLoading"> <div class="container" *ngIf="isLoading">
<ds-loading message="{{'loading.default' | translate}}"></ds-loading> <ds-loading message="{{'loading.default' | translate}}"></ds-loading>
</div> </div>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<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,21 +55,35 @@ 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 // Load all the languages that are defined as active from the config file
translate.setDefaultLang('en'); translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
// the lang to use, if the lang isn't available, it will use the current loader to get them
translate.use('en'); // Load the default language from the config file
translate.setDefaultLang(config.defaultLanguage);
// Attempt to get the browser language from the user
if (translate.getLangs().includes(translate.getBrowserLang())) {
translate.use(translate.getBrowserLang());
} else {
translate.use(config.defaultLanguage);
}
metadata.listenForRouteChange(); metadata.listenForRouteChange();
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 +94,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

@@ -65,6 +65,8 @@ 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 { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service';
import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
import { MenuService } from '../shared/menu/menu.service';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -129,6 +131,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,24 +1,44 @@
import { Store } from '@ngrx/store'; import {Store} from '@ngrx/store';
import { cold, getTestScheduler } from 'jasmine-marbles'; import {cold, getTestScheduler} from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import {TestScheduler} from 'rxjs/testing';
import { BrowseService } from '../browse/browse.service'; import {BrowseService} from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import {RemoteDataBuildService} from '../cache/builders/remote-data-build.service';
import { ResponseCacheService } from '../cache/response-cache.service'; import {ResponseCacheService} from '../cache/response-cache.service';
import { CoreState } from '../core.reducers'; import {CoreState} from '../core.reducers';
import { ItemDataService } from './item-data.service'; import {ItemDataService} from './item-data.service';
import { RequestService } from './request.service'; import {RequestService} from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import {HALEndpointService} from '../shared/hal-endpoint.service';
import { FindAllOptions } from './request.models'; import {FindAllOptions, RestRequest} from './request.models';
import {Observable, of as observableOf} from 'rxjs';
import {ResponseCacheEntry} from '../cache/response-cache.reducer';
import {RestResponse} from '../cache/response-cache.models';
describe('ItemDataService', () => { describe('ItemDataService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: ItemDataService; let service: ItemDataService;
let bs: BrowseService; let bs: BrowseService;
const requestService = {} as RequestService; const requestService = {
const responseCache = {} as ResponseCacheService; generateRequestId(): string {
return scopeID;
},
configure(request: RestRequest) {
// Do nothing
}
} as RequestService;
const responseCache = {
get(href: string) {
const responseCacheEntry = new ResponseCacheEntry();
responseCacheEntry.response = new RestResponse(true, '200');
return observableOf(responseCacheEntry);
}
} as ResponseCacheService;
const rdbService = {} as RemoteDataBuildService; const rdbService = {} as RemoteDataBuildService;
const store = {} as Store<CoreState>; const store = {} as Store<CoreState>;
const halEndpointService = {} as HALEndpointService; const halEndpointService = {
getEndpoint(linkPath: string): Observable<string> {
return cold('a', {a: itemEndpoint});
}
} as HALEndpointService;
const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39';
const options = Object.assign(new FindAllOptions(), { const options = Object.assign(new FindAllOptions(), {
@@ -34,10 +54,12 @@ describe('ItemDataService', () => {
const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`; const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`;
const serviceEndpoint = `https://rest.api/core/items`; const serviceEndpoint = `https://rest.api/core/items`;
const browseError = new Error('getBrowseURL failed'); const browseError = new Error('getBrowseURL failed');
const itemEndpoint = 'https://rest.api/core/items';
const ScopedItemEndpoint = `https://rest.api/core/items/${scopeID}`;
function initMockBrowseService(isSuccessful: boolean) { function initMockBrowseService(isSuccessful: boolean) {
const obs = isSuccessful ? const obs = isSuccessful ?
cold('--a-', { a: itemBrowseEndpoint }) : cold('--a-', {a: itemBrowseEndpoint}) :
cold('--#-', undefined, browseError); cold('--#-', undefined, browseError);
return jasmine.createSpyObj('bs', { return jasmine.createSpyObj('bs', {
getBrowseURLFor: obs getBrowseURLFor: obs
@@ -65,7 +87,7 @@ describe('ItemDataService', () => {
service = initTestService(); service = initTestService();
const result = service.getBrowseEndpoint(options); const result = service.getBrowseEndpoint(options);
const expected = cold('--b-', { b: scopedEndpoint }); const expected = cold('--b-', {b: scopedEndpoint});
expect(result).toBeObservable(expected); expect(result).toBeObservable(expected);
}); });
@@ -83,4 +105,70 @@ describe('ItemDataService', () => {
}); });
}); });
}); });
describe('getItemWithdrawEndpoint', () => {
beforeEach(() => {
scheduler = getTestScheduler();
service = initTestService();
});
it('should return the endpoint to withdraw and reinstate items', () => {
const result = service.getItemWithdrawEndpoint(scopeID);
const expected = cold('a', {a: ScopedItemEndpoint});
expect(result).toBeObservable(expected);
});
it('should setWithDrawn', () => {
const expected = new RestResponse(true, '200');
const result = service.setWithDrawn(scopeID, true);
result.subscribe((v) => expect(v).toEqual(expected));
});
});
describe('getItemDiscoverableEndpoint', () => {
beforeEach(() => {
scheduler = getTestScheduler();
service = initTestService();
});
it('should return the endpoint to make an item private or public', () => {
const result = service.getItemDiscoverableEndpoint(scopeID);
const expected = cold('a', {a: ScopedItemEndpoint});
expect(result).toBeObservable(expected);
});
it('should setDiscoverable', () => {
const expected = new RestResponse(true, '200');
const result = service.setDiscoverable(scopeID, false);
result.subscribe((v) => expect(v).toEqual(expected));
});
});
describe('getItemDeleteEndpoint', () => {
beforeEach(() => {
scheduler = getTestScheduler();
service = initTestService();
});
it('should return the endpoint to make an item private or public', () => {
const result = service.getItemDeleteEndpoint(scopeID);
const expected = cold('a', {a: ScopedItemEndpoint});
expect(result).toBeObservable(expected);
});
it('should delete the item', () => {
const expected = new RestResponse(true, '200');
const result = service.delete(scopeID);
result.subscribe((v) => expect(v).toEqual(expected));
});
});
}); });

View File

@@ -1,21 +1,22 @@
import {distinctUntilChanged, filter, map} from 'rxjs/operators';
import {Injectable} from '@angular/core';
import {Store} from '@ngrx/store';
import {Observable} from 'rxjs';
import {isNotEmpty} from '../../shared/empty.util';
import {BrowseService} from '../browse/browse.service';
import {RemoteDataBuildService} from '../cache/builders/remote-data-build.service';
import {NormalizedItem} from '../cache/models/normalized-item.model';
import {ResponseCacheService} from '../cache/response-cache.service';
import {CoreState} from '../core.reducers';
import {Item} from '../shared/item.model';
import {URLCombiner} from '../url-combiner/url-combiner';
import {distinctUntilChanged, map, filter} from 'rxjs/operators'; import {DataService} from './data.service';
import { Injectable } from '@angular/core'; import {RequestService} from './request.service';
import { Store } from '@ngrx/store'; import {HALEndpointService} from '../shared/hal-endpoint.service';
import { Observable } from 'rxjs'; import {DeleteRequest, FindAllOptions, PatchRequest, RestRequest} from './request.models';
import { isNotEmpty } from '../../shared/empty.util'; import {configureRequest, getResponseFromSelflink} from '../shared/operators';
import { BrowseService } from '../browse/browse.service'; import {ResponseCacheEntry} from '../cache/response-cache.reducer';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedItem } from '../cache/models/normalized-item.model';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { Item } from '../shared/item.model';
import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { FindAllOptions } from './request.models';
@Injectable() @Injectable()
export class ItemDataService extends DataService<NormalizedItem, Item> { export class ItemDataService extends DataService<NormalizedItem, Item> {
@@ -48,4 +49,93 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
distinctUntilChanged(),); distinctUntilChanged(),);
} }
/**
* Get the endpoint for item withdrawal and reinstatement
* @param itemId
*/
public getItemWithdrawEndpoint(itemId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getFindByIDHref(endpoint, itemId))
);
}
/**
* Get the endpoint to make item private and public
* @param itemId
*/
public getItemDiscoverableEndpoint(itemId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getFindByIDHref(endpoint, itemId))
);
}
/**
* Get the endpoint to delete the item
* @param itemId
*/
public getItemDeleteEndpoint(itemId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getFindByIDHref(endpoint, itemId))
);
}
/**
* Set the isWithdrawn state of an item to a specified state
* @param itemId
* @param withdrawn
*/
public setWithDrawn(itemId: string, withdrawn: boolean) {
const patchOperation = [{
op: 'replace', path: '/withdrawn', value: withdrawn
}];
return this.getItemWithdrawEndpoint(itemId).pipe(
distinctUntilChanged(),
map((endpointURL: string) =>
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
),
configureRequest(this.requestService),
map((request: RestRequest) => request.href),
getResponseFromSelflink(this.responseCache),
map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response)
);
}
/**
* Set the isDiscoverable state of an item to a specified state
* @param itemId
* @param discoverable
*/
public setDiscoverable(itemId: string, discoverable: boolean) {
const patchOperation = [{
op: 'replace', path: '/discoverable', value: discoverable
}];
return this.getItemDiscoverableEndpoint(itemId).pipe(
distinctUntilChanged(),
map((endpointURL: string) =>
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
),
configureRequest(this.requestService),
map((request: RestRequest) => request.href),
getResponseFromSelflink(this.responseCache),
map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response)
);
}
/**
* Delete the item
* @param itemId
*/
public delete(itemId: string) {
return this.getItemDeleteEndpoint(itemId).pipe(
distinctUntilChanged(),
map((endpointURL: string) =>
new DeleteRequest(this.requestService.generateRequestId(), endpointURL)
),
configureRequest(this.requestService),
map((request: RestRequest) => request.href),
getResponseFromSelflink(this.responseCache),
map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response)
);
}
} }

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

@@ -61,6 +61,6 @@ export class Community extends DSpaceObject {
collections: Observable<RemoteData<PaginatedList<Collection>>>; collections: Observable<RemoteData<PaginatedList<Collection>>>;
subcommunities: Observable<RemoteData<PaginatedList<Collection>>>; subcommunities: Observable<RemoteData<PaginatedList<Community>>>;
} }

View File

@@ -1,18 +1,23 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import {cold, getTestScheduler, hot} from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import {TestScheduler} from 'rxjs/testing';
import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import {getMockRequestService} from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import {getMockResponseCacheService} from '../../shared/mocks/mock-response-cache.service';
import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import {ResponseCacheEntry} from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service'; import {ResponseCacheService} from '../cache/response-cache.service';
import { GetRequest, RestRequest } from '../data/request.models'; import {GetRequest} from '../data/request.models';
import { RequestEntry } from '../data/request.reducer'; import {RequestEntry} from '../data/request.reducer';
import { RequestService } from '../data/request.service'; import {RequestService} from '../data/request.service';
import { import {
configureRequest, configureRequest,
filterSuccessfulResponses, getRemoteDataPayload, filterSuccessfulResponses,
getRequestFromSelflink, getResourceLinksFromResponse, getAllSucceededRemoteData,
getResponseFromSelflink getRemoteDataPayload,
getRequestFromSelflink,
getResourceLinksFromResponse,
getResponseFromSelflink,
getSucceededRemoteData
} from './operators'; } from './operators';
import {RemoteData} from '../data/remote-data';
describe('Core Module - RxJS Operators', () => { describe('Core Module - RxJS Operators', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -20,11 +25,11 @@ describe('Core Module - RxJS Operators', () => {
const testSelfLink = 'https://rest.api/'; const testSelfLink = 'https://rest.api/';
const testRCEs = { const testRCEs = {
a: { response: { isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd'] } }, a: {response: {isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd']}},
b: { response: { isSuccessful: false, resourceSelfLinks: ['e', 'f'] } }, b: {response: {isSuccessful: false, resourceSelfLinks: ['e', 'f']}},
c: { response: { isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i'] } }, c: {response: {isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i']}},
d: { response: { isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n'] } }, d: {response: {isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n']}},
e: { response: { isSuccessful: 1, resourceSelfLinks: [] } } e: {response: {isSuccessful: 1, resourceSelfLinks: []}}
}; };
beforeEach(() => { beforeEach(() => {
@@ -36,31 +41,31 @@ describe('Core Module - RxJS Operators', () => {
it('should return the RequestEntry corresponding to the self link in the source', () => { it('should return the RequestEntry corresponding to the self link in the source', () => {
requestService = getMockRequestService(); requestService = getMockRequestService();
const source = hot('a', { a: testSelfLink }); const source = hot('a', {a: testSelfLink});
const result = source.pipe(getRequestFromSelflink(requestService)); const result = source.pipe(getRequestFromSelflink(requestService));
const expected = cold('a', { a: new RequestEntry()}); const expected = cold('a', {a: new RequestEntry()});
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
it('should use the requestService to fetch the request by its self link', () => { it('should use the requestService to fetch the request by its self link', () => {
requestService = getMockRequestService(); requestService = getMockRequestService();
const source = hot('a', { a: testSelfLink }); const source = hot('a', {a: testSelfLink});
scheduler.schedule(() => source.pipe(getRequestFromSelflink(requestService)).subscribe()); scheduler.schedule(() => source.pipe(getRequestFromSelflink(requestService)).subscribe());
scheduler.flush(); scheduler.flush();
expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink) expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink);
}); });
it('shouldn\'t return anything if there is no request matching the self link', () => { it('shouldn\'t return anything if there is no request matching the self link', () => {
requestService = getMockRequestService(cold('a', { a: undefined })); requestService = getMockRequestService(cold('a', {a: undefined}));
const source = hot('a', { a: testSelfLink }); const source = hot('a', {a: testSelfLink});
const result = source.pipe(getRequestFromSelflink(requestService)); const result = source.pipe(getRequestFromSelflink(requestService));
const expected = cold('-'); const expected = cold('-');
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
}); });
@@ -74,31 +79,31 @@ describe('Core Module - RxJS Operators', () => {
it('should return the ResponseCacheEntry corresponding to the self link in the source', () => { it('should return the ResponseCacheEntry corresponding to the self link in the source', () => {
responseCacheService = getMockResponseCacheService(); responseCacheService = getMockResponseCacheService();
const source = hot('a', { a: testSelfLink }); const source = hot('a', {a: testSelfLink});
const result = source.pipe(getResponseFromSelflink(responseCacheService)); const result = source.pipe(getResponseFromSelflink(responseCacheService));
const expected = cold('a', { a: new ResponseCacheEntry()}); const expected = cold('a', {a: new ResponseCacheEntry()});
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
it('should use the responseCacheService to fetch the response by the request\'s link', () => { it('should use the responseCacheService to fetch the response by the request\'s link', () => {
responseCacheService = getMockResponseCacheService(); responseCacheService = getMockResponseCacheService();
const source = hot('a', { a: testSelfLink }); const source = hot('a', {a: testSelfLink});
scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe()); scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe());
scheduler.flush(); scheduler.flush();
expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink) expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink);
}); });
it('shouldn\'t return anything if there is no response matching the request\'s link', () => { it('shouldn\'t return anything if there is no response matching the request\'s link', () => {
responseCacheService = getMockResponseCacheService(undefined, cold('a', { a: undefined })); responseCacheService = getMockResponseCacheService(undefined, cold('a', {a: undefined}));
const source = hot('a', { a: testSelfLink }); const source = hot('a', {a: testSelfLink});
const result = source.pipe(getResponseFromSelflink(responseCacheService)); const result = source.pipe(getResponseFromSelflink(responseCacheService));
const expected = cold('-'); const expected = cold('-');
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
}); });
@@ -108,7 +113,7 @@ describe('Core Module - RxJS Operators', () => {
const result = source.pipe(filterSuccessfulResponses()); const result = source.pipe(filterSuccessfulResponses());
const expected = cold('a--d-', testRCEs); const expected = cold('a--d-', testRCEs);
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
}); });
@@ -121,7 +126,7 @@ describe('Core Module - RxJS Operators', () => {
d: testRCEs.d.response.resourceSelfLinks d: testRCEs.d.response.resourceSelfLinks
}); });
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
}); });
@@ -129,24 +134,61 @@ describe('Core Module - RxJS Operators', () => {
it('should call requestService.configure with the source request', () => { it('should call requestService.configure with the source request', () => {
requestService = getMockRequestService(); requestService = getMockRequestService();
const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testSelfLink); const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testSelfLink);
const source = hot('a', { a: testRequest }); const source = hot('a', {a: testRequest});
scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe());
scheduler.flush(); scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(testRequest) expect(requestService.configure).toHaveBeenCalledWith(testRequest);
}); });
}); });
describe('getRemoteDataPayload', () => { describe('getRemoteDataPayload', () => {
it('should return the payload of the source RemoteData', () => { it('should return the payload of the source RemoteData', () => {
const testRD = { a: { payload: 'a' } }; const testRD = {a: {payload: 'a'}};
const source = hot('a', testRD); const source = hot('a', testRD);
const result = source.pipe(getRemoteDataPayload()); const result = source.pipe(getRemoteDataPayload());
const expected = cold('a', { const expected = cold('a', {
a: testRD.a.payload, a: testRD.a.payload,
}); });
expect(result).toBeObservable(expected) expect(result).toBeObservable(expected);
}); });
}); });
describe('getSucceededRemoteData', () => {
it('should return the first() hasSucceeded RemoteData Observable', () => {
const testRD = {
a: new RemoteData(false, false, true, null, undefined),
b: new RemoteData(false, false, false, null, 'b'),
c: new RemoteData(false, false, undefined, null, 'c'),
d: new RemoteData(false, false, true, null, 'd'),
e: new RemoteData(false, false, true, null, 'e'),
};
const source = hot('abcde', testRD);
const result = source.pipe(getSucceededRemoteData());
result.subscribe((value) => expect(value)
.toEqual(new RemoteData(false, false, true, null, 'd')));
});
});
describe('getAllSucceededRemoteData', () => {
it('should return all hasSucceeded RemoteData Observables', () => {
const testRD = {
a: new RemoteData(false, false, true, null, undefined),
b: new RemoteData(false, false, false, null, 'b'),
c: new RemoteData(false, false, undefined, null, 'c'),
d: new RemoteData(false, false, true, null, 'd'),
e: new RemoteData(false, false, true, null, 'e'),
};
const source = hot('abcde', testRD);
const result = source.pipe(getAllSucceededRemoteData());
const expected = cold('---de', testRD);
expect(result).toBeObservable(expected);
});
});
}); });

View File

@@ -54,12 +54,16 @@ export const getSucceededRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> => <T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded)); source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded));
export const getAllSucceededRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(filter((rd: RemoteData<T>) => rd.hasSucceeded));
export const toDSpaceObjectListRD = () => export const toDSpaceObjectListRD = () =>
<T extends DSpaceObject>(source: Observable<RemoteData<PaginatedList<SearchResult<T>>>>): Observable<RemoteData<PaginatedList<T>>> => <T extends DSpaceObject>(source: Observable<RemoteData<PaginatedList<SearchResult<T>>>>): Observable<RemoteData<PaginatedList<T>>> =>
source.pipe( source.pipe(
map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => { map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => {
const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult<T>) => searchResult.dspaceObject); const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult<T>) => searchResult.dspaceObject);
const payload = Object.assign(rd.payload, { page: dsoPage }) as any; const payload = Object.assign(rd.payload, {page: dsoPage}) as any;
return Object.assign(rd, {payload: payload}); return Object.assign(rd, {payload: payload});
}) })
); );

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"/>
</a>
<nav class="navbar navbar-light navbar-expand-md float-right px-0">
<a href="#" class="px-1"><i class="fas fa-search fa-lg fa-fw" [title]="'nav.search' | translate"></i></a>
<ds-lang-switch></ds-lang-switch>
<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>
</nav>
</div> </div>
<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>
</button>
<div [ngbCollapse]="(isNavBarCollapsed | async)" class="collapse navbar-collapse" id="collapsingNav">
<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>
</div>
</nav>
</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;
}
} }
.navbar-toggler .navbar-toggler-icon {
header nav.navbar .navbar-toggler:hover { background-image: none !important;
cursor: pointer; line-height: 1.5;
}
header nav.navbar .navbar-toggler .navbar-toggler-icon {
background-image: none !important;
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>

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