Merge remote-tracking branch 'upstream/main' into w2p-76654_PaginationService

This commit is contained in:
Yana De Pauw
2021-04-06 10:43:20 +02:00
514 changed files with 6660 additions and 2045 deletions

View File

@@ -80,6 +80,19 @@ jobs:
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
docker container ls
# Wait until the REST API returns a 200 response (or for a max of 30 seconds)
# https://github.com/nev7n/wait_for_response
- name: Wait for DSpace REST Backend to be ready (for e2e tests)
uses: nev7n/wait_for_response@v1
with:
# We use the 'sites' endpoint to also ensure the database is ready
url: 'http://localhost:8080/server/api/core/sites'
responseCode: 200
timeout: 30000
- name: Get DSpace REST Backend info/properties
run: curl http://localhost:8080/server/api
- name: Run e2e tests (integration tests)
run: yarn run e2e:ci

View File

@@ -1,3 +1,7 @@
{
"typescript.check.workspaceVersion": false
"typescript.check.workspaceVersion": false,
"i18n-ally.localesPaths": [
"src/assets/i18n",
"src/app/core/locale"
]
}

View File

@@ -14,22 +14,22 @@ export class ProtractorPage {
}
getCurrentQuery(): promise.Promise<string> {
return element(by.css('#search-navbar-container form input')).getAttribute('value');
return element(by.css('.navbar-container #search-navbar-container form input')).getAttribute('value');
}
expandAndFocusSearchBox() {
element(by.css('#search-navbar-container form a')).click();
element(by.css('.navbar-container #search-navbar-container form a')).click();
}
setCurrentQuery(query: string) {
element(by.css('#search-navbar-container form input[name="query"]')).sendKeys(query);
element(by.css('.navbar-container #search-navbar-container form input[name="query"]')).sendKeys(query);
}
submitNavbarSearchForm() {
element(by.css('#search-navbar-container form .submit-icon')).click();
element(by.css('.navbar-container #search-navbar-container form .submit-icon')).click();
}
submitByPressingEnter() {
element(by.css('#search-navbar-container form input[name="query"]')).sendKeys(protractor.Key.ENTER);
element(by.css('.navbar-container #search-navbar-container form input[name="query"]')).sendKeys(protractor.Key.ENTER);
}
}

View File

@@ -119,10 +119,11 @@
"rxjs": "^6.6.3",
"rxjs-spy": "^7.5.3",
"sass-resources-loader": "^2.1.1",
"sortablejs": "1.10.1",
"sortablejs": "1.13.0",
"tslib": "^2.0.0",
"webfontloader": "1.6.28",
"zone.js": "^0.10.3"
"zone.js": "^0.10.3",
"@kolkov/ngx-gallery": "^1.2.3"
},
"devDependencies": {
"@angular-builders/custom-webpack": "10.0.1",

View File

@@ -10,7 +10,7 @@ const targetPath = './src/environments/environment.ts';
const colors = require('colors');
require('dotenv').config();
const merge = require('deepmerge');
const mergeOptions = { arrayMerge: (destinationArray, sourceArray, options) => sourceArray };
const environment = process.argv[2];
let environmentFilePath;
let production = false;
@@ -45,10 +45,10 @@ const processEnv = {
} as GlobalConfig;
import(environmentFilePath)
.then((file) => generateEnvironmentFile(merge.all([commonEnv, file.environment, processEnv])))
.then((file) => generateEnvironmentFile(merge.all([commonEnv, file.environment, processEnv], mergeOptions)))
.catch(() => {
console.log(colors.yellow.bold(`No specific environment file found for ` + environment));
generateEnvironmentFile(merge(commonEnv, processEnv))
generateEnvironmentFile(merge(commonEnv, processEnv, mergeOptions))
});
function generateEnvironmentFile(file: GlobalConfig): void {
@@ -65,7 +65,7 @@ function generateEnvironmentFile(file: GlobalConfig): void {
}
// allow to override a few important options by environment variables
function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
const result = {} as any;
if (hasValue(host)) {
result.host = host;

View File

@@ -1,31 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { GROUP_EDIT_PATH } from './admin-access-control-routing-paths';
@NgModule({
imports: [
RouterModule.forChild([
{ path: 'epeople', component: EPeopleRegistryComponent, data: { title: 'admin.access-control.epeople.title' } },
{ path: GROUP_EDIT_PATH, component: GroupsRegistryComponent, data: { title: 'admin.access-control.groups.title' } },
{
path: `${GROUP_EDIT_PATH}/:groupId`,
component: GroupFormComponent,
data: {title: 'admin.access-control.groups.title.singleGroup'}
},
{
path: `${GROUP_EDIT_PATH}/newGroup`,
component: GroupFormComponent,
data: {title: 'admin.access-control.groups.title.addGroup'}
},
])
]
})
/**
* Routing module for the AccessControl section of the admin sidebar
*/
export class AdminAccessControlRoutingModule {
}

View File

@@ -2,12 +2,7 @@ import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAdminModuleRoute } from '../app-routing-paths';
export const REGISTRIES_MODULE_PATH = 'registries';
export const ACCESS_CONTROL_MODULE_PATH = 'access-control';
export function getRegistriesModuleRoute() {
return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString();
}
export function getAccessControlModuleRoute() {
return new URLCombiner(getAdminModuleRoute(), ACCESS_CONTROL_MODULE_PATH).toString();
}

View File

@@ -6,7 +6,7 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
import { ACCESS_CONTROL_MODULE_PATH, REGISTRIES_MODULE_PATH } from './admin-routing-paths';
import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
@NgModule({
imports: [
@@ -16,11 +16,6 @@ import { ACCESS_CONTROL_MODULE_PATH, REGISTRIES_MODULE_PATH } from './admin-rout
loadChildren: () => import('./admin-registries/admin-registries.module')
.then((m) => m.AdminRegistriesModule),
},
{
path: ACCESS_CONTROL_MODULE_PATH,
loadChildren: () => import('./admin-access-control/admin-access-control.module')
.then((m) => m.AdminAccessControlModule),
},
{
path: 'search',
resolve: { breadcrumb: I18nBreadcrumbResolver },

View File

@@ -61,7 +61,7 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE
}
/**
* Fetch the component depending on the item's relationship type, view mode and context
* Fetch the component depending on the item's entity type, view mode and context
* @returns {GenericConstructor<Component>}
*/
private getComponent(): GenericConstructor<Component> {

View File

@@ -11,6 +11,8 @@ import { Collection } from '../../../../../core/shared/collection.model';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { getCollectionEditRoute } from '../../../../../+collection-page/collection-page-routing-paths';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
describe('CollectionAdminSearchResultListElementComponent', () => {
let component: CollectionAdminSearchResultListElementComponent;
@@ -33,7 +35,8 @@ describe('CollectionAdminSearchResultListElementComponent', () => {
RouterTestingModule.withRoutes([])
],
declarations: [CollectionAdminSearchResultListElementComponent],
providers: [{ provide: TruncatableService, useValue: {} }],
providers: [{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock }],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();

View File

@@ -11,6 +11,8 @@ import { CommunityAdminSearchResultListElementComponent } from './community-admi
import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model';
import { Community } from '../../../../../core/shared/community.model';
import { getCommunityEditRoute } from '../../../../../+community-page/community-page-routing-paths';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
describe('CommunityAdminSearchResultListElementComponent', () => {
let component: CommunityAdminSearchResultListElementComponent;
@@ -33,7 +35,8 @@ describe('CommunityAdminSearchResultListElementComponent', () => {
RouterTestingModule.withRoutes([])
],
declarations: [CommunityAdminSearchResultListElementComponent],
providers: [{ provide: TruncatableService, useValue: {} }],
providers: [{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock }],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();

View File

@@ -8,6 +8,8 @@ import { RouterTestingModule } from '@angular/router/testing';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { ItemAdminSearchResultListElementComponent } from './item-admin-search-result-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
describe('ItemAdminSearchResultListElementComponent', () => {
let component: ItemAdminSearchResultListElementComponent;
@@ -30,7 +32,8 @@ describe('ItemAdminSearchResultListElementComponent', () => {
RouterTestingModule.withRoutes([])
],
declarations: [ItemAdminSearchResultListElementComponent],
providers: [{ provide: TruncatableService, useValue: {} }],
providers: [{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock }],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();

View File

@@ -1,27 +1,28 @@
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 edit-link" [routerLink]="[getEditRoute()]" [title]="'admin.search.item.edit' | translate">
<i class="fa fa-edit"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.edit" | translate}}</span>
<a [ngClass]="{'btn-sm': small}" class="btn btn-secondary my-1 move-link" [routerLink]="[getMoveRoute()]" [title]="'admin.search.item.move' | translate">
<i class="fa fa-arrow-circle-right"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.move" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isWithdrawn" class="btn btn-light my-1 withdraw-link" [routerLink]="[getWithdrawRoute()]" [title]="'admin.search.item.withdraw' | translate">
<i class="fa fa-ban"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.withdraw" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isWithdrawn" class="btn btn-light my-1 reinstate-link" [routerLink]="[getReinstateRoute()]" [title]="'admin.search.item.reinstate' | translate">
<i class="fa fa-undo"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.reinstate" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isDiscoverable" class="btn btn-light my-1 private-link" [routerLink]="[getPrivateRoute()]" [title]="'admin.search.item.make-private' | translate">
<a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isDiscoverable" class="btn btn-secondary my-1 private-link" [routerLink]="[getPrivateRoute()]" [title]="'admin.search.item.make-private' | translate">
<i class="fa fa-eye-slash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.make-private" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isDiscoverable" class="btn btn-light my-1 public-link" [routerLink]="[getPublicRoute()]" [title]="'admin.search.item.make-public' | translate">
<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isDiscoverable" class="btn btn-secondary my-1 public-link" [routerLink]="[getPublicRoute()]" [title]="'admin.search.item.make-public' | translate">
<i class="fa fa-eye"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.make-public" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.search.item.delete' | translate">
<a [ngClass]="{'btn-sm': small}" class="btn btn-secondary my-1 edit-link" [routerLink]="[getEditRoute()]" [title]="'admin.search.item.edit' | translate">
<i class="fa fa-edit"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.edit" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isWithdrawn" class="btn btn-warning t my-1 withdraw-link" [routerLink]="[getWithdrawRoute()]" [title]="'admin.search.item.withdraw' | translate">
<i class="fa fa-ban"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.withdraw" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isWithdrawn" class="btn btn-warning my-1 reinstate-link" [routerLink]="[getReinstateRoute()]" [title]="'admin.search.item.reinstate' | translate">
<i class="fa fa-undo"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.reinstate" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" class="btn btn-danger my-1 delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.search.item.delete' | translate">
<i class="fa fa-trash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.delete" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 move-link" [routerLink]="[getMoveRoute()]" [title]="'admin.search.item.move' | translate">
<i class="fa fa-arrow-circle-right"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.move" | translate}}</span>
</a>

View File

@@ -1,11 +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 class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</a>
<div class="sidebar-collapsible">
<span class="section-header-text">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>
<span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>
</div>
</li>

View File

@@ -1,11 +1,12 @@
<nav @slideHorizontal class="navbar navbar-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)"
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)">
(mouseleave)="collapsePreview($event)"
role="navigation" [attr.aria-label]="'menu.header.admin.description' |translate">
<div class="sidebar-top-level-items">
<ul class="navbar-nav">
<li class="admin-menu-header sidebar-section">

View File

@@ -16,6 +16,8 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import createSpy = jasmine.createSpy;
describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent;
@@ -170,4 +172,150 @@ describe('AdminSidebarComponent', () => {
expect(menuService.collapseMenuPreview).toHaveBeenCalled();
}));
});
describe('menu', () => {
beforeEach(() => {
spyOn(menuService, 'addSection');
});
describe('for regular user', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => {
return observableOf(false);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should not show site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'admin_search', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'curation_tasks', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: false,
}));
});
it('should not show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
});
it('should not show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
});
it('should not show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'access_control', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'access_control', visible: false,
}));
});
});
describe('for site admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.AdministratorOf);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should contain site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'admin_search', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'curation_tasks', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
});
});
describe('for community admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCommunityAdmin);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_community', visible: true,
}));
});
});
describe('for collection admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCollectionAdmin);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_collection', visible: true,
}));
});
});
describe('for group admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanManageGroups);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'access_control', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'access_control', visible: true,
}));
});
});
});
});

View File

@@ -1,6 +1,6 @@
import { Component, Injector, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { combineLatest, combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { first, map, take } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { ScriptDataService } from '../../core/data/processes/script-data.service';
@@ -76,9 +76,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
*/
ngOnInit(): void {
this.createMenu();
this.createSiteAdministratorMenuSections();
this.createExportMenuSections();
this.createImportMenuSections();
super.ngOnInit();
this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth');
this.authService.isAuthenticated()
@@ -102,192 +99,210 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
* Initialize all menu sections and items for this menu
*/
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.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_process',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new'
} 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,
},
this.createMainMenuSections();
this.createSiteAdministratorMenuSections();
this.createExportMenuSections();
this.createImportMenuSections();
this.createAccessControlMenuSections();
}
/* 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.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/**
* Initialize the main menu sections.
* edit_community / edit_collection is only included if the current user is a Community or Collection admin
*/
createMainMenuSections() {
combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf)
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => {
const menuList = [
/* News */
{
id: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
index: 0
},
{
id: 'new_community',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_process',
parentID: 'new',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new'
} as LinkMenuItemModel,
},
{
id: 'new_item_version',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_item_version',
link: ''
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: ''
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Edit */
{
id: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit'
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1
},
{
id: 'edit_community',
parentID: 'edit',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/* Statistics */
{
id: 'statistics_task',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics_task',
link: ''
} as LinkMenuItemModel,
icon: 'chart-bar',
index: 8
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: ''
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* 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
},
/* 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
},
/* Processes */
{
id: 'processes',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.processes',
link: '/processes'
} as LinkMenuItemModel,
icon: 'terminal',
index: 10
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
/* Control Panel */
{
id: 'control_panel',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.control_panel',
link: ''
} as LinkMenuItemModel,
icon: 'cogs',
index: 9
},
/* Processes */
{
id: 'processes',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.processes',
link: '/processes'
} as LinkMenuItemModel,
icon: 'terminal',
index: 10
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
@@ -305,7 +320,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'sign-out-alt',
icon: 'file-export',
index: 3,
shouldPersistOnRouteChange: true
},
@@ -388,7 +403,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
type: MenuItemType.TEXT,
text: 'menu.section.import'
} as TextMenuItemModel,
icon: 'sign-in-alt',
icon: 'file-import',
index: 2
},
{
@@ -436,51 +451,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
createSiteAdministratorMenuSections() {
this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => {
const menuList = [
/* Access Control */
{
id: 'access_control',
active: false,
visible: authorized,
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: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/admin/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/admin/access-control/groups'
} as LinkMenuItemModel,
},
{
id: 'access_control_authorizations',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_authorizations',
link: ''
} as LinkMenuItemModel,
},
/* Admin Search */
{
id: 'admin_search',
@@ -564,6 +534,65 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
});
}
/**
* Create menu sections dependent on whether or not the current user can manage access control groups
*/
createAccessControlMenuSections() {
this.authorizationService.isAuthorized(FeatureID.CanManageGroups).subscribe((authorized) => {
const menuList = [
/* Access Control */
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/access-control/groups'
} as LinkMenuItemModel,
},
{
id: 'access_control_authorizations',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_authorizations',
link: ''
} as LinkMenuItemModel,
},
{
id: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true,
})));
});
}
/**
* Method to change this.collapsed to false when the slide animation ends and is sliding open
* @param event The animation event

View File

@@ -3,14 +3,14 @@
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 class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="#">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</a>
</div>
<div class="sidebar-collapsible">
<a class="nav-item nav-link" href="#"
(click)="toggleSection($event)">
<span class="section-header-text">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>

View File

@@ -16,4 +16,8 @@
display: flex;
flex-direction: column;
}
li.sidebar-section.expanded {
background-color: var(--ds-admin-sidebar-active-bg) !important;
}
}

View File

@@ -96,7 +96,7 @@ export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends S
}
/**
* Fetch the component depending on the item's relationship type, view mode and context
* Fetch the component depending on the item's entity type, view mode and context
* @returns {GenericConstructor<Component>}
*/
private getComponent(item: Item): GenericConstructor<Component> {

View File

@@ -16,6 +16,8 @@ import { Item } from '../../../../../core/shared/item.model';
import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
describe('WorkflowItemAdminWorkflowListElementComponent', () => {
let component: WorkflowItemSearchResultAdminWorkflowListElementComponent;
@@ -49,6 +51,7 @@ describe('WorkflowItemAdminWorkflowListElementComponent', () => {
providers: [
{ provide: TruncatableService, useValue: mockTruncatableService },
{ provide: LinkService, useValue: linkService },
{ provide: DSONameService, useClass: DSONameServiceMock }
],
schemas: [NO_ERRORS_SCHEMA]
})

View File

@@ -12,6 +12,7 @@ import { Item } from '../../../../../core/shared/item.model';
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
@listableObjectComponent(WorkflowItemSearchResult, ViewMode.ListElement, Context.AdminWorkflowSearch)
@Component({
@@ -29,8 +30,11 @@ export class WorkflowItemSearchResultAdminWorkflowListElementComponent extends S
*/
public item$: Observable<Item>;
constructor(private linkService: LinkService, protected truncatableService: TruncatableService) {
super(truncatableService);
constructor(private linkService: LinkService,
protected truncatableService: TruncatableService,
protected dsoNameService: DSONameService
) {
super(truncatableService, dsoNameService);
}
/**

View File

@@ -1,6 +1,6 @@
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { AdminAccessControlModule } from './admin-access-control/admin-access-control.module';
import { AccessControlModule } from '../access-control/access-control.module';
import { MetadataImportPageComponent } from './admin-import-metadata-page/metadata-import-page.component';
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
import { AdminRoutingModule } from './admin-routing.module';
@@ -21,7 +21,7 @@ const ENTRY_COMPONENTS = [
imports: [
AdminRoutingModule,
AdminRegistriesModule,
AdminAccessControlModule,
AccessControlModule,
AdminSearchModule.withEntryComponents(),
AdminWorkflowModuleModule.withEntryComponents(),
SharedModule,

View File

@@ -0,0 +1,28 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { BrowseBySwitcherComponent } from './browse-by-switcher.component';
/**
* Themed wrapper for BrowseBySwitcherComponent
*/
@Component({
selector: 'ds-themed-browse-by-switcher',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html'
})
export class ThemedBrowseBySwitcherComponent extends ThemedComponent<BrowseBySwitcherComponent> {
protected getComponentName(): string {
return 'BrowseBySwitcherComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/+browse-by/+browse-by-switcher/browse-by-switcher.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./browse-by-switcher.component`);
}
}

View File

@@ -1,9 +1,9 @@
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { BrowseByGuard } from './browse-by-guard';
import { BrowseBySwitcherComponent } from './+browse-by-switcher/browse-by-switcher.component';
import { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
import { ThemedBrowseBySwitcherComponent } from './+browse-by-switcher/themed-browse-by-switcher.component';
@NgModule({
imports: [
@@ -14,7 +14,7 @@ import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.reso
children: [
{
path: ':id',
component: BrowseBySwitcherComponent,
component: ThemedBrowseBySwitcherComponent,
canActivate: [BrowseByGuard],
resolve: { breadcrumb: BrowseByI18nBreadcrumbResolver },
data: { title: 'browse.title', breadcrumbKey: 'browse.metadata' }

View File

@@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component';
import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component';
import { BrowseBySwitcherComponent } from './+browse-by-switcher/browse-by-switcher.component';
import { ThemedBrowseBySwitcherComponent } from './+browse-by-switcher/themed-browse-by-switcher.component';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -20,6 +21,7 @@ const ENTRY_COMPONENTS = [
],
declarations: [
BrowseBySwitcherComponent,
ThemedBrowseBySwitcherComponent,
...ENTRY_COMPONENTS
],
exports: [

View File

@@ -7,7 +7,6 @@ import {
} from '@ng-dynamic-forms/core';
import { Collection } from '../../core/shared/collection.model';
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
import { Location } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CommunityDataService } from '../../core/data/community-data.service';
@@ -76,14 +75,13 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
}),
];
public constructor(protected location: Location,
protected formService: DynamicFormService,
public constructor(protected formService: DynamicFormService,
protected translate: TranslateService,
protected notificationsService: NotificationsService,
protected authService: AuthService,
protected dsoService: CommunityDataService,
protected requestService: RequestService,
protected objectCache: ObjectCacheService) {
super(location, formService, translate, notificationsService, authService, requestService, objectCache);
super(formService, translate, notificationsService, authService, requestService, objectCache);
}
}

View File

@@ -31,6 +31,7 @@
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="'./'"
[inPlaceSearch]="true"
[searchPlaceholder]="'collection.edit.item-mapper.search-form.placeholder' | translate"
(submitSearch)="performedSearch = true">
</ds-search-form>
</div>

View File

@@ -24,6 +24,10 @@ export function getCollectionEditRolesRoute(id) {
return new URLCombiner(getCollectionPageRoute(id), COLLECTION_EDIT_PATH, COLLECTION_EDIT_ROLES_PATH).toString();
}
export function getCollectionItemTemplateRoute(id) {
return new URLCombiner(getCollectionPageRoute(id), ITEMTEMPLATE_PATH).toString();
}
export const COLLECTION_CREATE_PATH = 'create';
export const COLLECTION_EDIT_PATH = 'edit';
export const COLLECTION_EDIT_ROLES_PATH = 'roles';

View File

@@ -1,7 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageResolver } from './collection-page.resolver';
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
@@ -21,6 +20,7 @@ import {
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedCollectionPageComponent } from './themed-collection-page.component';
@NgModule({
imports: [
@@ -62,7 +62,7 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
},
{
path: '',
component: CollectionPageComponent,
component: ThemedCollectionPageComponent,
pathMatch: 'full',
}
],

View File

@@ -35,7 +35,7 @@
</ds-comcol-page-content>
</header>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoute]="collectionPageRoute$ | async" [dso]="collection" [tooltipMsg]="'collection.page.edit'"></ds-dso-page-edit-button>
<ds-dso-page-edit-button *ngIf="isCollectionAdmin$ | async" [pageRoute]="collectionPageRoute$ | async" [dso]="collection" [tooltipMsg]="'collection.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<section class="comcol-page-browse-section">

View File

@@ -27,6 +27,8 @@ import { hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { AuthService } from '../core/auth/auth.service';
import { PaginationService } from '../core/pagination/pagination.service';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { getCollectionPageRoute } from './collection-page-routing-paths';
@Component({
@@ -50,6 +52,11 @@ export class CollectionPageComponent implements OnInit {
sortConfig: SortOptions
}>;
/**
* Whether the current user is a Community admin
*/
isCollectionAdmin$: Observable<boolean>;
/**
* Route to the community page
*/
@@ -63,7 +70,7 @@ export class CollectionPageComponent implements OnInit {
private router: Router,
private authService: AuthService,
private paginationService: PaginationService,
private authorizationDataService: AuthorizationDataService,
) {
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = 'cp';
@@ -83,6 +90,7 @@ export class CollectionPageComponent implements OnInit {
filter((collection: Collection) => hasValue(collection)),
mergeMap((collection: Collection) => collection.logo)
);
this.isCollectionAdmin$ = this.authorizationDataService.isAuthorized(FeatureID.IsCollectionAdmin);
this.paginationChanges$ = new BehaviorSubject({
paginationConfig: this.paginationConfig,

View File

@@ -13,6 +13,7 @@ import { CollectionItemMapperComponent } from './collection-item-mapper/collecti
import { SearchService } from '../core/shared/search/search.service';
import { StatisticsModule } from '../statistics/statistics.module';
import { CollectionFormModule } from './collection-form/collection-form.module';
import { ThemedCollectionPageComponent } from './themed-collection-page.component';
@NgModule({
imports: [
@@ -25,6 +26,7 @@ import { CollectionFormModule } from './collection-form/collection-form.module';
],
declarations: [
CollectionPageComponent,
ThemedCollectionPageComponent,
CreateCollectionPageComponent,
DeleteCollectionPageComponent,
EditItemTemplatePageComponent,

View File

@@ -4,5 +4,7 @@
<h2 id="sub-header" class="border-bottom pb-2">{{'collection.create.sub-head' | translate:{ parent: (parentRD$| async)?.payload.name } }}</h2>
</div>
</div>
<ds-collection-form (submitForm)="onSubmit($event)" (finish)="navigateToNewPage()"></ds-collection-form>
<ds-collection-form (submitForm)="onSubmit($event)"
(back)="navigateToHome()"
(finish)="navigateToNewPage()"></ds-collection-form>
</div>

View File

@@ -2,15 +2,18 @@
<div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate
}}</h2>
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate}}</h2>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<button class="btn btn-primary mr-2" (click)="onConfirm(dso)">
{{'community.delete.confirm' |
translate}}
</button>
<button class="btn btn-primary" (click)="onCancel(dso)">{{'community.delete.cancel' | translate}}
</button>
<div class="form-group row">
<div class="col text-right">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}}
</button>
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)">
<i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}
</button>
</div>
</div>
</div>
</ng-container>

View File

@@ -16,9 +16,7 @@
</button>
</div>
</div>
<ds-collection-form (submitForm)="onSubmit($event)"
[dso]="(dsoRD$ | async)?.payload"
<ds-collection-form [dso]="(dsoRD$ | async)?.payload"
(submitForm)="onSubmit($event)"
(back)="navigateToHomePage()"
(finish)="navigateToHomePage()"></ds-collection-form>
<a class="btn btn-danger"
[routerLink]="'/collections/' + (dsoRD$ | async)?.payload.uuid + '/delete'">{{'collection.edit.delete'
| translate}}</a>

View File

@@ -15,6 +15,7 @@ import { Collection } from '../../../core/shared/collection.model';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { RequestService } from '../../../core/data/request.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths';
describe('CollectionMetadataComponent', () => {
let comp: CollectionMetadataComponent;
@@ -35,11 +36,13 @@ describe('CollectionMetadataComponent', () => {
self: { href: 'collection-selflink' }
}
});
const collectionTemplateHref = 'rest/api/test/collections/template';
const itemTemplateServiceStub = Object.assign({
findByCollectionID: () => createSuccessfulRemoteDataObject$(template),
create: () => createSuccessfulRemoteDataObject$(template),
deleteByCollectionID: () => observableOf(true)
const itemTemplateServiceStub = jasmine.createSpyObj('itemTemplateService', {
findByCollectionID: createSuccessfulRemoteDataObject$(template),
create: createSuccessfulRemoteDataObject$(template),
deleteByCollectionID: observableOf(true),
getCollectionEndpoint: observableOf(collectionTemplateHref),
});
const notificationsService = jasmine.createSpyObj('notificationsService', {
@@ -50,7 +53,7 @@ describe('CollectionMetadataComponent', () => {
remove: {}
});
const requestService = jasmine.createSpyObj('requestService', {
removeByHrefSubstring: {}
setStaleByHrefSubstring: {}
});
beforeEach(waitForAsync(() => {
@@ -87,14 +90,14 @@ describe('CollectionMetadataComponent', () => {
it('should navigate to the collection\'s itemtemplate page', () => {
spyOn(router, 'navigate');
comp.addItemTemplate();
expect(router.navigate).toHaveBeenCalledWith(['collections', collection.uuid, 'itemtemplate']);
expect(router.navigate).toHaveBeenCalledWith([getCollectionItemTemplateRoute(collection.uuid)]);
});
});
describe('deleteItemTemplate', () => {
describe('when delete returns a success', () => {
beforeEach(() => {
spyOn(itemTemplateService, 'deleteByCollectionID').and.returnValue(observableOf(true));
(itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true));
comp.deleteItemTemplate();
});
@@ -103,14 +106,15 @@ describe('CollectionMetadataComponent', () => {
});
it('should reset related object and request cache', () => {
expect(objectCache.remove).toHaveBeenCalledWith(template.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(collection.self);
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(collectionTemplateHref);
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(template.self);
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(collection.self);
});
});
describe('when delete returns a failure', () => {
beforeEach(() => {
spyOn(itemTemplateService, 'deleteByCollectionID').and.returnValue(observableOf(false));
(itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(false));
comp.deleteItemTemplate();
});

View File

@@ -7,12 +7,13 @@ import { ItemTemplateDataService } from '../../../core/data/item-template-data.s
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { switchMap, take } from 'rxjs/operators';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { switchMap, tap } from 'rxjs/operators';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { RequestService } from '../../../core/data/request.service';
import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths';
/**
* Component for editing a collection's metadata
@@ -53,8 +54,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
*/
initTemplateItem() {
this.itemTemplateRD$ = this.dsoRD$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
getFirstSucceededRemoteDataPayload(),
switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid))
);
}
@@ -64,19 +64,20 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
*/
addItemTemplate() {
const collection$ = this.dsoRD$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
take(1)
getFirstSucceededRemoteDataPayload(),
);
const template$ = collection$.pipe(
switchMap((collection: Collection) => this.itemTemplateService.create(new Item(), collection.uuid)),
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
take(1)
switchMap((collection: Collection) => this.itemTemplateService.create(new Item(), collection.uuid).pipe(
getFirstSucceededRemoteDataPayload(),
)),
);
const templateHref$ = collection$.pipe(
switchMap((collection) => this.itemTemplateService.getCollectionEndpoint(collection.id)),
);
combineLatestObservable(collection$, template$).subscribe(([collection, template]) => {
this.router.navigate(['collections', collection.uuid, 'itemtemplate']);
combineLatestObservable(collection$, template$, templateHref$).subscribe(([collection, template, templateHref]) => {
this.requestService.setStaleByHrefSubstring(templateHref);
this.router.navigate([getCollectionItemTemplateRoute(collection.uuid)]);
});
}
@@ -85,23 +86,30 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
*/
deleteItemTemplate() {
const collection$ = this.dsoRD$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
take(1)
getFirstSucceededRemoteDataPayload(),
);
const template$ = collection$.pipe(
switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid)),
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
take(1)
switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid).pipe(
getFirstSucceededRemoteDataPayload(),
)),
);
const templateHref$ = collection$.pipe(
switchMap((collection) => this.itemTemplateService.getCollectionEndpoint(collection.id)),
);
combineLatestObservable(collection$, template$).pipe(
switchMap(([collection, template]) => {
const success$ = this.itemTemplateService.deleteByCollectionID(template, collection.uuid);
this.objectCache.remove(template.self);
this.requestService.removeByHrefSubstring(collection.self);
return success$;
combineLatestObservable(collection$, template$, templateHref$).pipe(
switchMap(([collection, template, templateHref]) => {
return this.itemTemplateService.deleteByCollectionID(template, collection.uuid).pipe(
tap((success: boolean) => {
if (success) {
this.objectCache.remove(templateHref);
this.objectCache.remove(template.self);
this.requestService.setStaleByHrefSubstring(template.self);
this.requestService.setStaleByHrefSubstring(templateHref);
this.requestService.setStaleByHrefSubstring(collection.self);
}
})
);
})
).subscribe((success: boolean) => {
if (success) {

View File

@@ -11,6 +11,7 @@ import { GroupDataService } from '../../../core/eperson/group-data.service';
import { RequestService } from '../../../core/data/request.service';
import { RouterTestingModule } from '@angular/router/testing';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('CollectionRolesComponent', () => {
@@ -67,6 +68,7 @@ describe('CollectionRolesComponent', () => {
SharedModule,
RouterTestingModule.withRoutes([]),
TranslateModule.forRoot(),
NoopAnimationsModule
],
declarations: [
CollectionRolesComponent,

View File

@@ -31,6 +31,7 @@
[formModel]="formModel"
[formLayout]="formLayout"
[displaySubmit]="false"
[displayCancel]="false"
(dfChange)="onChange($event)"
(submitForm)="onSubmit()"
(cancel)="onCancel()"></ds-form>

View File

@@ -18,7 +18,14 @@ describe('EditCollectionPageComponent', () => {
dso: { payload: {} }
}),
routeConfig: {
children: []
children: [
{
path: 'mockUrl',
data: {
hideReturnButton: false
}
}
]
},
snapshot: {
firstChild: {

View File

@@ -12,6 +12,7 @@ import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/res
import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver';
import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component';
import { CollectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard';
/**
* Routing module that handles the routing for the Edit Collection page administrator functionality
@@ -26,6 +27,7 @@ import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit
},
data: { breadcrumbKey: 'collection.edit' },
component: EditCollectionPageComponent,
canActivate: [CollectionAdministratorGuard],
children: [
{
path: '',
@@ -91,7 +93,7 @@ import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit
{
path: 'mapper',
component: CollectionItemMapperComponent,
data: { title: 'collection.edit.tabs.item-mapper.title', showBreadcrumbs: true }
data: { title: 'collection.edit.tabs.item-mapper.title', hideReturnButton: true, showBreadcrumbs: true }
},
]
}

View File

@@ -1,9 +1,13 @@
<div class="container" *ngVar="(collectionRD$ | async)?.payload as collection">
<div class="row">
<div class="col-12">
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
<ds-item-metadata [updateService]="itemTemplateService"></ds-item-metadata>
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
<ng-container *ngIf="itemRD?.hasSucceeded">
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
<ds-item-metadata [updateService]="itemTemplateService" [item]="itemRD?.payload"></ds-item-metadata>
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
</ng-container>
<ds-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-loading>
<ds-alert *ngIf="itemRD?.hasFailed" [type]="AlertTypeEnum.Error" [content]="'collection.edit.template.error' | translate"></ds-alert>
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ import { ActivatedRoute } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { Collection } from '../../core/shared/collection.model';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { getCollectionEditRoute } from '../collection-page-routing-paths';
describe('EditItemTemplatePageComponent', () => {
@@ -24,11 +24,14 @@ describe('EditItemTemplatePageComponent', () => {
id: 'collection-id',
name: 'Fake Collection'
});
itemTemplateService = jasmine.createSpyObj('itemTemplateService', {
findByCollectionID: createSuccessfulRemoteDataObject$({})
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [EditItemTemplatePageComponent],
providers: [
{ provide: ItemTemplateDataService, useValue: {} },
{ provide: ItemTemplateDataService, useValue: itemTemplateService },
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -38,7 +41,6 @@ describe('EditItemTemplatePageComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(EditItemTemplatePageComponent);
comp = fixture.componentInstance;
itemTemplateService = (comp as any).itemTemplateService;
fixture.detectChanges();
});

View File

@@ -3,9 +3,12 @@ import { Observable } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Collection } from '../../core/shared/collection.model';
import { ActivatedRoute } from '@angular/router';
import { first, map } from 'rxjs/operators';
import { first, map, switchMap } from 'rxjs/operators';
import { ItemTemplateDataService } from '../../core/data/item-template-data.service';
import { getCollectionEditRoute } from '../collection-page-routing-paths';
import { Item } from '../../core/shared/item.model';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { AlertType } from '../../shared/alert/aletr-type';
@Component({
selector: 'ds-edit-item-template-page',
@@ -21,12 +24,27 @@ export class EditItemTemplatePageComponent implements OnInit {
*/
collectionRD$: Observable<RemoteData<Collection>>;
/**
* The template item
*/
itemRD$: Observable<RemoteData<Item>>;
/**
* The AlertType enumeration
* @type {AlertType}
*/
AlertTypeEnum = AlertType;
constructor(protected route: ActivatedRoute,
public itemTemplateService: ItemTemplateDataService) {
}
ngOnInit(): void {
this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso));
this.itemRD$ = this.collectionRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((collection) => this.itemTemplateService.findByCollectionID(collection.id)),
);
}
/**

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { CollectionPageComponent } from './collection-page.component';
/**
* Themed wrapper for CollectionPageComponent
*/
@Component({
selector: 'ds-themed-community-page',
styleUrls: [],
templateUrl: '../shared/theme-support/themed.component.html',
})
export class ThemedCollectionPageComponent extends ThemedComponent<CollectionPageComponent> {
protected getComponentName(): string {
return 'CollectionPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/+collection-page/collection-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./collection-page.component`);
}
}

View File

@@ -7,7 +7,6 @@ import {
} from '@ng-dynamic-forms/core';
import { Community } from '../../core/shared/community.model';
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
import { Location } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CommunityDataService } from '../../core/data/community-data.service';
@@ -68,14 +67,13 @@ export class CommunityFormComponent extends ComColFormComponent<Community> {
}),
];
public constructor(protected location: Location,
protected formService: DynamicFormService,
public constructor(protected formService: DynamicFormService,
protected translate: TranslateService,
protected notificationsService: NotificationsService,
protected authService: AuthService,
protected dsoService: CommunityDataService,
protected requestService: RequestService,
protected objectCache: ObjectCacheService) {
super(location, formService, translate, notificationsService, authService, requestService, objectCache);
super(formService, translate, notificationsService, authService, requestService, objectCache);
}
}

View File

@@ -1,7 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommunityPageComponent } from './community-page.component';
import { CommunityPageResolver } from './community-page.resolver';
import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
@@ -14,6 +13,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedCommunityPageComponent } from './themed-community-page.component';
@NgModule({
imports: [
@@ -45,7 +45,7 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
},
{
path: '',
component: CommunityPageComponent,
component: ThemedCommunityPageComponent,
pathMatch: 'full',
}
],

View File

@@ -21,7 +21,7 @@
</ds-comcol-page-content>
</header>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoute]="communityPageRoute$ | async" [dso]="communityPayload" [tooltipMsg]="'community.page.edit'"></ds-dso-page-edit-button>
<ds-dso-page-edit-button *ngIf="isCommunityAdmin$ | async" [pageRoute]="communityPageRoute$ | async" [dso]="communityPayload" [tooltipMsg]="'community.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<section class="comcol-page-browse-section">

View File

@@ -15,6 +15,8 @@ import { fadeInOut } from '../shared/animations/fade';
import { hasValue } from '../shared/empty.util';
import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../core/shared/operators';
import { AuthService } from '../core/auth/auth.service';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { getCommunityPageRoute } from './community-page-routing-paths';
@Component({
@@ -33,6 +35,11 @@ export class CommunityPageComponent implements OnInit {
*/
communityRD$: Observable<RemoteData<Community>>;
/**
* Whether the current user is a Community admin
*/
isCommunityAdmin$: Observable<boolean>;
/**
* The logo of this community
*/
@@ -49,6 +56,7 @@ export class CommunityPageComponent implements OnInit {
private route: ActivatedRoute,
private router: Router,
private authService: AuthService,
private authorizationDataService: AuthorizationDataService
) {
}
@@ -66,6 +74,6 @@ export class CommunityPageComponent implements OnInit {
getAllSucceededRemoteDataPayload(),
map((community) => getCommunityPageRoute(community.id))
);
this.isCommunityAdmin$ = this.authorizationDataService.isAuthorized(FeatureID.IsCommunityAdmin);
}
}

View File

@@ -11,6 +11,14 @@ import { CreateCommunityPageComponent } from './create-community-page/create-com
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
import { StatisticsModule } from '../statistics/statistics.module';
import { CommunityFormModule } from './community-form/community-form.module';
import { ThemedCommunityPageComponent } from './themed-community-page.component';
const DECLARATIONS = [CommunityPageComponent,
ThemedCommunityPageComponent,
CommunityPageSubCollectionListComponent,
CommunityPageSubCommunityListComponent,
CreateCommunityPageComponent,
DeleteCommunityPageComponent];
@NgModule({
imports: [
@@ -21,11 +29,10 @@ import { CommunityFormModule } from './community-form/community-form.module';
CommunityFormModule
],
declarations: [
CommunityPageComponent,
CommunityPageSubCollectionListComponent,
CommunityPageSubCommunityListComponent,
CreateCommunityPageComponent,
DeleteCommunityPageComponent
...DECLARATIONS
],
exports: [
...DECLARATIONS
]
})

View File

@@ -7,5 +7,7 @@
</ng-container>
</div>
</div>
<ds-community-form (submitForm)="onSubmit($event)" (finish)="navigateToNewPage()"></ds-community-form>
<ds-community-form (submitForm)="onSubmit($event)"
(back)="navigateToHome()"
(finish)="navigateToNewPage()"></ds-community-form>
</div>

View File

@@ -2,18 +2,20 @@
<div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate
}}</h2>
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate}}</h2>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<button class="btn btn-primary mr-2" (click)="onConfirm(dso)">
{{'community.delete.confirm' |
translate}}
</button>
<button class="btn btn-primary" (click)="onCancel(dso)">{{'community.delete.cancel' | translate}}
</button>
<div class="form-group row">
<div class="col text-right">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}}
</button>
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)">
<i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}
</button>
</div>
</div>
</div>
</ng-container>
</div>
</div>

View File

@@ -1,6 +1,5 @@
<ds-community-form (submitForm)="onSubmit($event)"
[dso]="(dsoRD$ | async)?.payload"
<ds-community-form [dso]="(dsoRD$ | async)?.payload"
(submitForm)="onSubmit($event)"
(back)="navigateToHomePage()"
(finish)="navigateToHomePage()"></ds-community-form>
<a class="btn btn-danger"
[routerLink]="'/communities/' + (dsoRD$ | async)?.payload.uuid + '/delete'">{{'community.edit.delete'
| translate}}</a>

View File

@@ -11,6 +11,7 @@ import { GroupDataService } from '../../../core/eperson/group-data.service';
import { SharedModule } from '../../../shared/shared.module';
import { RouterTestingModule } from '@angular/router/testing';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('CommunityRolesComponent', () => {
@@ -52,6 +53,7 @@ describe('CommunityRolesComponent', () => {
SharedModule,
RouterTestingModule.withRoutes([]),
TranslateModule.forRoot(),
NoopAnimationsModule
],
declarations: [
CommunityRolesComponent,

View File

@@ -18,7 +18,14 @@ describe('EditCommunityPageComponent', () => {
dso: { payload: {} }
}),
routeConfig: {
children: []
children: [
{
path: 'mockUrl',
data: {
hideReturnButton: false
}
}
]
},
snapshot: {
firstChild: {

View File

@@ -10,6 +10,7 @@ import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/res
import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver';
import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component';
import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard';
/**
* Routing module that handles the routing for the Edit Community page administrator functionality
@@ -24,6 +25,7 @@ import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit
},
data: { breadcrumbKey: 'community.edit' },
component: EditCommunityPageComponent,
canActivate: [CommunityAdministratorGuard],
children: [
{
path: '',

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { CommunityPageComponent } from './community-page.component';
/**
* Themed wrapper for CommunityPageComponent
*/
@Component({
selector: 'ds-themed-community-page',
styleUrls: [],
templateUrl: '../shared/theme-support/themed.component.html',
})
export class ThemedCommunityPageComponent extends ThemedComponent<CommunityPageComponent> {
protected getComponentName(): string {
return 'CommunityPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/+community-page/community-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./community-page.component`);
}
}

View File

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

View File

@@ -8,7 +8,14 @@
word-break: break-word;
}
.dspace-logo {
height: 110px;
width: 110px;
.jumbotron {
background-color: var(--ds-home-news-background-color);
}
a {
color: var(--ds-home-news-link-color);
@include hover {
color: var(--ds-home-news-link-hover-color);
}
}

View File

@@ -3,6 +3,6 @@
<ng-container *ngIf="(site$ | async) as site">
<ds-view-tracker [object]="site"></ds-view-tracker>
</ng-container>
<ds-search-form [inPlaceSearch]="false"></ds-search-form>
<ds-search-form [inPlaceSearch]="false" [searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-search-form>
<ds-top-level-community-list></ds-top-level-community-list>
</div>

View File

@@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { SubmissionImportExternalComponent } from '../submission/import-external/submission-import-external.component';
import { ThemedSubmissionImportExternalComponent } from '../submission/import-external/themed-submission-import-external.component';
@NgModule({
imports: [
@@ -9,7 +9,7 @@ import { SubmissionImportExternalComponent } from '../submission/import-external
{
canActivate: [ AuthenticatedGuard ],
path: '',
component: SubmissionImportExternalComponent,
component: ThemedSubmissionImportExternalComponent,
pathMatch: 'full',
data: {
title: 'submission.import-external.page.title'

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import {
FieldUpdate,
FieldUpdates
@@ -30,7 +30,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
/**
* The item to display the edit page for
*/
item: Item;
@Input() item: Item;
/**
* The current values and updates for all this item's fields
* Should be initialized in the initializeUpdates method of the child component
@@ -63,22 +63,24 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
* Initialize common properties between item-update components
*/
ngOnInit(): void {
this.itemUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)),
map((data: any) => data.dso),
tap((rd: RemoteData<Item>) => {
this.item = rd.payload;
}),
switchMap((rd: RemoteData<Item>) => {
return this.itemService.findByHref(rd.payload._links.self.href, true, true, ...ITEM_PAGE_LINKS_TO_FOLLOW);
}),
getAllSucceededRemoteData()
).subscribe((rd: RemoteData<Item>) => {
this.item = rd.payload;
this.itemPageRoute = getItemPageRoute(this.item);
this.postItemInit();
this.initializeUpdates();
});
if (hasValue(this.item)) {
this.setItem(this.item);
} else {
// The item wasn't provided through an input, retrieve it from the route instead.
this.itemUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)),
map((data: any) => data.dso),
tap((rd: RemoteData<Item>) => {
this.item = rd.payload;
}),
switchMap((rd: RemoteData<Item>) => {
return this.itemService.findByHref(rd.payload._links.self.href, true, true, ...ITEM_PAGE_LINKS_TO_FOLLOW);
}),
getAllSucceededRemoteData()
).subscribe((rd: RemoteData<Item>) => {
this.setItem(rd.payload);
});
}
this.discardTimeOut = environment.item.edit.undoTimeout;
this.url = this.router.url;
@@ -97,6 +99,13 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
this.initializeUpdates();
}
setItem(item: Item) {
this.item = item;
this.itemPageRoute = getItemPageRoute(this.item);
this.postItemInit();
this.initializeUpdates();
}
ngOnDestroy() {
if (hasValue(this.itemUpdateSubscription)) {
this.itemUpdateSubscription.unsubscribe();

View File

@@ -5,18 +5,29 @@
<div class="pt-2">
<ul class="nav nav-tabs justify-content-start">
<li *ngFor="let page of pages" class="nav-item">
<a class="nav-link"
[ngClass]="{'active' : page === currentPage}"
[routerLink]="['./' + page]">
{{'item.edit.tabs.' + page + '.head' | translate}}
<a *ngIf="(page.enabled | async)"
class="nav-link"
[ngClass]="{'active' : page.page === currentPage}"
[routerLink]="['./' + page.page]">
{{'item.edit.tabs.' + page.page + '.head' | translate}}
</a>
<span [ngbTooltip]="'item.edit.tabs.disabled.tooltip' | translate">
<button *ngIf="!(page.enabled | async)"
class="nav-link disabled">
{{'item.edit.tabs.' + page.page + '.head' | translate}}
</button>
</span>
</li>
</ul>
<div class="tab-pane active">
<div class="mb-4">
<router-outlet></router-outlet>
</div>
<a [routerLink]="getItemPage((itemRD$ | async)?.payload)" class="btn btn-outline-secondary">Cancel</a>
<div class="button-row bottom">
<div class="text-right">
<a [routerLink]="getItemPage((itemRD$ | async)?.payload)" role="button" class="btn btn-outline-secondary mr-1"><i class="fas fa-arrow-left"></i> {{'item.edit.return' | translate}}</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,107 @@
import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component';
import { Observable, of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { Item } from '../../core/shared/item.model';
describe('ItemPageComponent', () => {
let comp: EditItemPageComponent;
let fixture: ComponentFixture<EditItemPageComponent>;
class AcceptAllGuard implements CanActivate {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return observableOf(true);
}
}
// tslint:disable-next-line:max-classes-per-file
class AcceptNoneGuard implements CanActivate {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
console.log('BLA');
return observableOf(false);
}
}
const accesiblePages = ['accessible'];
const inaccesiblePages = ['inaccessible', 'inaccessibleDoubleGuard'];
const mockRoute = {
snapshot: {
firstChild: {
routeConfig: {
path: accesiblePages[0]
}
},
routerState: {
snapshot: undefined
}
},
routeConfig: {
children: [
{
path: accesiblePages[0],
canActivate: [AcceptAllGuard]
}, {
path: inaccesiblePages[0],
canActivate: [AcceptNoneGuard]
}, {
path: inaccesiblePages[1],
canActivate: [AcceptAllGuard, AcceptNoneGuard]
},
]
},
data: observableOf({dso: createSuccessfulRemoteDataObject(new Item())})
};
const mockRouter = {
routerState: {
snapshot: undefined
},
events: observableOf(undefined)
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
declarations: [EditItemPageComponent],
providers: [
{ provide: ActivatedRoute, useValue: mockRoute },
{ provide: Router, useValue: mockRouter },
AcceptAllGuard,
AcceptNoneGuard,
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(EditItemPageComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(EditItemPageComponent);
comp = fixture.componentInstance;
spyOn((comp as any).injector, 'get').and.callFake((a) => new a());
fixture.detectChanges();
}));
describe('ngOnInit', () => {
it('should enable tabs that the user can activate', fakeAsync(() => {
const enabledItems = fixture.debugElement.queryAll(By.css('a.nav-link'));
expect(enabledItems.length).toBe(accesiblePages.length);
}));
it('should disable tabs that the user can not activate', () => {
const disabledItems = fixture.debugElement.queryAll(By.css('button.nav-link.disabled'));
expect(disabledItems.length).toBe(inaccesiblePages.length);
});
});
});

View File

@@ -1,12 +1,13 @@
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ChangeDetectionStrategy, Component, Injector, OnInit } from '@angular/core';
import { ActivatedRoute, CanActivate, Route, Router } from '@angular/router';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { Observable } from 'rxjs';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { map } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util';
import { getItemPageRoute } from '../item-page-routing-paths';
import { GenericConstructor } from '../../core/shared/generic-constructor';
@Component({
selector: 'ds-edit-item-page',
@@ -35,18 +36,29 @@ export class EditItemPageComponent implements OnInit {
/**
* All possible page outlet strings
*/
pages: string[];
pages: { page: string, enabled: Observable<boolean> }[];
constructor(private route: ActivatedRoute, private router: Router) {
this.router.events.subscribe(() => {
this.currentPage = this.route.snapshot.firstChild.routeConfig.path;
});
constructor(private route: ActivatedRoute, private router: Router, private injector: Injector) {
this.router.events.subscribe(() => this.initPageParamsByRoute());
}
ngOnInit(): void {
this.initPageParamsByRoute();
this.pages = this.route.routeConfig.children
.map((child: any) => child.path)
.filter((path: string) => isNotEmpty(path)); // ignore reroutes
.filter((child: Route) => isNotEmpty(child.path))
.map((child: Route) => {
let enabled = observableOf(true);
if (isNotEmpty(child.canActivate)) {
enabled = observableCombineLatest(child.canActivate.map((guardConstructor: GenericConstructor<CanActivate>) => {
const guard: CanActivate = this.injector.get<CanActivate>(guardConstructor);
return guard.canActivate(this.route.snapshot, this.router.routerState.snapshot);
})
).pipe(
map((canActivateOutcomes: any[]) => canActivateOutcomes.every((e) => e === true))
);
}
return { page: child.path, enabled: enabled };
}); // ignore reroutes
this.itemRD$ = this.route.data.pipe(map((data) => data.dso));
}
@@ -57,4 +69,11 @@ export class EditItemPageComponent implements OnInit {
getItemPage(item: Item): string {
return getItemPageRoute(item);
}
/**
* Set page params depending on the route
*/
initPageParamsByRoute() {
this.currentPage = this.route.snapshot.firstChild.routeConfig.path;
}
}

View File

@@ -22,15 +22,17 @@ import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit
import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service';
import {
ITEM_EDIT_AUTHORIZATIONS_PATH,
ITEM_EDIT_MOVE_PATH,
ITEM_EDIT_DELETE_PATH,
ITEM_EDIT_PUBLIC_PATH,
ITEM_EDIT_MOVE_PATH,
ITEM_EDIT_PRIVATE_PATH,
ITEM_EDIT_PUBLIC_PATH,
ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH
} from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
import { ItemPageEditMetadataGuard } from '../item-page-edit-metadata.guard';
import { ItemPageAdministratorGuard } from '../item-page-administrator.guard';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -57,22 +59,26 @@ import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
{
path: 'status',
component: ItemStatusComponent,
data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
},
{
path: 'bitstreams',
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
},
{
path: 'metadata',
component: ItemMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true },
canActivate: [ItemPageEditMetadataGuard]
},
{
path: 'relationships',
component: ItemRelationshipsComponent,
data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true },
canActivate: [ItemPageEditMetadataGuard]
},
/* TODO - uncomment & fix when view page exists
{
@@ -89,12 +95,14 @@ import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
{
path: 'versionhistory',
component: ItemVersionHistoryComponent,
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
},
{
path: 'mapper',
component: ItemCollectionMapperComponent,
data: { title: 'item.edit.tabs.item-mapper.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.item-mapper.title', showBreadcrumbs: true },
canActivate: [ItemPageAdministratorGuard]
}
]
},
@@ -165,7 +173,9 @@ import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
ResourcePolicyResolver,
ResourcePolicyTargetResolver,
ItemPageReinstateGuard,
ItemPageWithdrawGuard
ItemPageWithdrawGuard,
ItemPageAdministratorGuard,
ItemPageEditMetadataGuard,
]
})
export class EditItemPageRoutingModule {

View File

@@ -5,22 +5,22 @@
class="fas fa-upload"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.upload-button" | translate}}</span>
</button>
<button class="btn btn-danger mr-1" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async) || submitting"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning mr-1" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || submitting"
<button class="btn btn-primary mr-1" [disabled]="!(hasChanges() | async) || submitting"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async) || submitting"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
</button>
</div>
<div *ngIf="item && bundles?.length > 0" class="container table-bordered mt-4">
@@ -48,12 +48,6 @@
<div class="button-row bottom">
<div class="mt-4 float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async) || submitting"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
@@ -64,6 +58,12 @@
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async) || submitting"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
</button>
</div>
</div>
</div>

View File

@@ -29,6 +29,7 @@
[query]="(searchOptions$ | async)?.query"
[currentUrl]="'./'"
[inPlaceSearch]="true"
[searchPlaceholder]="'item.edit.item-mapper.search-form.placeholder' | translate"
(submitSearch)="performedSearch = true">
</ds-search-form>
</div>

View File

@@ -173,6 +173,19 @@ describe('ItemDeleteComponent', () => {
.toHaveBeenCalledWith(mockItem.id, types.filter((type) => typesSelection[type]).map((type) => type.id));
expect(comp.notify).toHaveBeenCalled();
});
it('should call delete function from the ItemDataService with empty types', () => {
spyOn(comp, 'notify');
jasmine.getEnv().allowRespy(true);
spyOn(entityTypeService, 'getEntityTypeRelationships').and.returnValue([]);
comp.ngOnInit();
comp.performAction();
expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id, []);
expect(comp.notify).toHaveBeenCalled();
});
});
describe('notify', () => {
it('should navigate to the homepage on successful deletion of the item', () => {

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnInit } from '@angular/core';
import { defaultIfEmpty, filter, map, switchMap, take } from 'rxjs/operators';
import {defaultIfEmpty, filter, map, switchMap, take} from 'rxjs/operators';
import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import {
@@ -112,7 +112,7 @@ export class ItemDeleteComponent
super.ngOnInit();
this.url = this.router.url;
const label = this.item.firstMetadataValue('relationship.type');
const label = this.item.firstMetadataValue('dspace.entity.type');
if (label !== undefined) {
this.types$ = this.entityTypeService.getEntityTypeByLabel(label).pipe(
getFirstSucceededRemoteData(),
@@ -121,8 +121,11 @@ export class ItemDeleteComponent
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
switchMap((types) =>
combineLatest(types.map((type) => this.getRelationships(type))).pipe(
switchMap((types) => {
if (types.length === 0) {
return observableOf(types);
}
return combineLatest(types.map((type) => this.getRelationships(type))).pipe(
map((relationships) =>
types.reduce<RelationshipType[]>((includedTypes, type, index) => {
if (!includedTypes.some((includedType) => includedType.id === type.id)
@@ -133,8 +136,8 @@ export class ItemDeleteComponent
}
}, [])
),
)
),
);
})
);
} else {
this.types$ = observableOf([]);

View File

@@ -5,12 +5,6 @@
class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.add-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
@@ -21,6 +15,12 @@
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
</div>
<table class="table table-responsive table-striped table-bordered" *ngIf="((updates$ | async)| dsObjectValues).length > 0">
<tbody>
@@ -47,20 +47,20 @@
<ds-alert [content]="'item.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</div>
<div class="button-row bottom">
<div class="float-right">
<button class="btn btn-danger mr-1" *ngIf="!(isReinstatable() | async)"
<div class="mt-2 float-right">
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary mr-0" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
<button class="btn btn-warning mr-1" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@
</span>
</div>
<div *ngIf="!operation.disabled" class="col-9 float-left action-button">
<a class="btn btn-outline-secondary" [routerLink]="operation.operationUrl">
<a class="btn btn-outline-primary" [routerLink]="operation.operationUrl">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</a>
</div>
@@ -12,4 +12,4 @@
<span class="btn btn-danger">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</span>
</div>
</div>

View File

@@ -75,7 +75,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
*/
public initializeUpdates(): void {
const label = this.item.firstMetadataValue('relationship.type');
const label = this.item.firstMetadataValue('dspace.entity.type');
if (label !== undefined) {
this.entityType$ = this.entityTypeService.getEntityTypeByLabel(label).pipe(

View File

@@ -18,9 +18,8 @@ import { hasValue } from '../../shared/empty.util';
import { AuthService } from '../../core/auth/auth.service';
/**
* This component renders a simple item page.
* This component renders a full item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
@Component({

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { FullItemPageComponent } from './full-item-page.component';
/**
* Themed wrapper for FullItemPageComponent
*/
@Component({
selector: 'ds-themed-full-item-page',
styleUrls: [],
templateUrl: './../../shared/theme-support/themed.component.html',
})
export class ThemedFullItemPageComponent extends ThemedComponent<FullItemPageComponent> {
protected getComponentName(): string {
return 'FullItemPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/+item-page/full/full-item-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./full-item-page.component`);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver';
import { Item } from '../core/shared/item.model';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthService } from '../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring edit metadata rights
*/
export class ItemPageEditMetadataGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check edit metadata authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanEditMetadata);
}
}

View File

@@ -10,11 +10,11 @@ export function getItemModuleRoute() {
/**
* Get the route to an item's page
* Depending on the item's relationship type, the route will either start with /items or /entities
* Depending on the item's entity type, the route will either start with /items or /entities
* @param item The item to retrieve the route for
*/
export function getItemPageRoute(item: Item) {
const type = item.firstMetadataValue('relationship.type');
const type = item.firstMetadataValue('dspace.entity.type');
return getEntityPageRoute(type, item.uuid);
}

View File

@@ -1,18 +1,17 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ItemPageComponent } from './simple/item-page.component';
import { FullItemPageComponent } from './full/full-item-page.component';
import { ItemPageResolver } from './item-page.resolver';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
import { ITEM_EDIT_PATH, UPLOAD_BITSTREAM_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
@NgModule({
imports: [
@@ -27,18 +26,17 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
children: [
{
path: '',
component: ItemPageComponent,
component: ThemedItemPageComponent,
pathMatch: 'full',
},
{
path: 'full',
component: FullItemPageComponent,
component: ThemedFullItemPageComponent,
},
{
path: ITEM_EDIT_PATH,
loadChildren: () => import('./edit-item-page/edit-item-page.module')
.then((m) => m.EditItemPageModule),
canActivate: [ItemPageAdministratorGuard]
},
{
path: UPLOAD_BITSTREAM_PATH,
@@ -68,7 +66,7 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
ItemBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
ItemPageAdministratorGuard
ItemPageAdministratorGuard,
]
})

View File

@@ -25,6 +25,12 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
import { UntypedItemComponent } from './simple/item-types/untyped-item/untyped-item.component';
import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module';
import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module';
import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
import { MediaViewerComponent } from './media-viewer/media-viewer.component';
import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component';
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -34,7 +40,9 @@ const ENTRY_COMPONENTS = [
const DECLARATIONS = [
ItemPageComponent,
ThemedItemPageComponent,
FullItemPageComponent,
ThemedFullItemPageComponent,
MetadataUriValuesComponent,
ItemPageAuthorFieldComponent,
ItemPageDateFieldComponent,
@@ -50,6 +58,9 @@ const DECLARATIONS = [
ItemComponent,
UploadBitstreamComponent,
AbstractIncrementalListComponent,
MediaViewerComponent,
MediaViewerVideoComponent,
MediaViewerImageComponent
];
@NgModule({
@@ -60,7 +71,8 @@ const DECLARATIONS = [
EditItemPageModule,
StatisticsModule.forRoot(),
JournalEntitiesModule.withEntryComponents(),
ResearchEntitiesModule.withEntryComponents()
ResearchEntitiesModule.withEntryComponents(),
NgxGalleryModule,
],
declarations: [
...DECLARATIONS

View File

@@ -0,0 +1,7 @@
<div [class.change-gallery]="isAuthenticated$ | async">
<ngx-gallery
class="ngx-gallery"
[options]="galleryOptions"
[images]="galleryImages"
></ngx-gallery>
</div>

View File

@@ -0,0 +1,6 @@
.ngx-gallery {
display: inline-block;
margin-bottom: 20px;
width: 340px !important;
height: 279px !important;
}

View File

@@ -0,0 +1,89 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NgxGalleryOptions } from '@kolkov/ngx-gallery';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
import { MockBitstreamFormat1 } from '../../../shared/mocks/item.mock';
import { MediaViewerImageComponent } from './media-viewer-image.component';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
describe('MediaViewerImageComponent', () => {
let component: MediaViewerImageComponent;
let fixture: ComponentFixture<MediaViewerImageComponent>;
const authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(false)
});
const mockBitstream: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 10201,
content:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
format: observableOf(MockBitstreamFormat1),
bundleName: 'ORIGINAL',
_links: {
self: {
href:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713',
},
content: {
href:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
},
},
id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713',
uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713',
type: 'bitstream',
metadata: {
'dc.title': [
{
language: null,
value: 'test_word.docx',
},
],
},
});
const mockMediaViewerItems: MediaViewerItem[] = Object.assign(
new Array<MediaViewerItem>(),
[
{ bitstream: mockBitstream, format: 'image', thumbnail: null },
{ bitstream: mockBitstream, format: 'image', thumbnail: null },
]
);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports:[],
declarations: [MediaViewerImageComponent],
schemas: [NO_ERRORS_SCHEMA],
providers: [
{ provide: AuthService, useValue: authService },
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MediaViewerImageComponent);
component = fixture.componentInstance;
component.galleryOptions = [new NgxGalleryOptions({})];
component.galleryImages = component.convertToGalleryImage(
mockMediaViewerItems
);
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should contain a gallery options', () => {
expect(component.galleryOptions.length).toBeGreaterThan(0);
});
it('should contain an image array', () => {
expect(component.galleryImages.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,88 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgxGalleryImage, NgxGalleryOptions } from '@kolkov/ngx-gallery';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
import { NgxGalleryAnimation } from '@kolkov/ngx-gallery';
import { Observable } from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
/**
* This componenet render an image gallery for the image viewer
*/
@Component({
selector: 'ds-media-viewer-image',
templateUrl: './media-viewer-image.component.html',
styleUrls: ['./media-viewer-image.component.scss'],
})
export class MediaViewerImageComponent implements OnInit {
@Input() images: MediaViewerItem[];
@Input() preview?: boolean;
@Input() image?: string;
loggedin: boolean;
galleryOptions: NgxGalleryOptions[];
galleryImages: NgxGalleryImage[];
/**
* Whether or not the current user is authenticated
*/
isAuthenticated$: Observable<boolean>;
constructor(private authService: AuthService) {}
/**
* Thi method sets up the gallery settings and data
*/
ngOnInit(): void {
this.isAuthenticated$ = this.authService.isAuthenticated();
this.galleryOptions = [
{
preview: this.preview !== undefined ? this.preview : true,
image: true,
imageSize: 'contain',
thumbnails: false,
imageArrows: false,
startIndex: 0,
imageAnimation: NgxGalleryAnimation.Slide,
previewCloseOnEsc: true,
previewZoom: true,
previewRotate: true,
previewFullscreen: true,
},
];
if (this.image) {
this.galleryImages = [
{
small: this.image,
medium: this.image,
big: this.image,
},
];
} else {
this.galleryImages = this.convertToGalleryImage(this.images);
}
}
/**
* This method convert an array of MediaViewerItem into NgxGalleryImage array
* @param medias input NgxGalleryImage array
*/
convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] {
const mappadImages = [];
for (const image of medias) {
if (image.format === 'image') {
mappadImages.push({
small: image.thumbnail
? image.thumbnail
: './assets/images/replacement_image.svg',
medium: image.thumbnail
? image.thumbnail
: './assets/images/replacement_image.svg',
big: image.bitstream._links.content.href,
});
}
}
return mappadImages;
}
}

View File

@@ -0,0 +1,47 @@
<video
#media
[src]="filteredMedias[currentIndex].bitstream._links.content.href"
id="singleVideo"
[poster]="
filteredMedias[currentIndex].thumbnail ||
replacements[filteredMedias[currentIndex].format]
"
preload="none"
controls
></video>
<div class="buttons" *ngIf="filteredMedias?.length > 1">
<button
class="btn btn-primary previous"
[disabled]="currentIndex === 0"
(click)="prevMedia()"
>
{{ "media-viewer.previous" | translate }}
</button>
<button
class="btn btn-primary next"
[disabled]="currentIndex === filteredMedias.length - 1"
(click)="nextMedia()"
>
{{ "media-viewer.next" | translate }}
</button>
<div ngbDropdown class="d-inline-block">
<button
class="btn btn-outline-primary playlist"
id="dropdownBasic1"
ngbDropdownToggle
>
{{ "media-viewer.playlist" | translate }}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<button
ngbDropdownItem
*ngFor="let item of filteredMedias; index as indexOfelement"
class="list-element"
(click)="selectedMedia(indexOfelement)"
>
{{ item.bitstream.name }}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,4 @@
video {
width: 340px;
height: 279px;
}

View File

@@ -0,0 +1,145 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { FileSizePipe } from '../../../shared/utils/file-size-pipe';
import { VarDirective } from '../../../shared/utils/var.directive';
import { MetadataFieldWrapperComponent } from '../../field-components/metadata-field-wrapper/metadata-field-wrapper.component';
import { MockBitstreamFormat1 } from '../../../shared/mocks/item.mock';
import { MediaViewerVideoComponent } from './media-viewer-video.component';
import { By } from '@angular/platform-browser';
describe('MediaViewerVideoComponent', () => {
let component: MediaViewerVideoComponent;
let fixture: ComponentFixture<MediaViewerVideoComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
BrowserAnimationsModule,
],
declarations: [
MediaViewerVideoComponent,
VarDirective,
FileSizePipe,
MetadataFieldWrapperComponent,
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));
const mockBitstream: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 10201,
content:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
format: observableOf(MockBitstreamFormat1),
bundleName: 'ORIGINAL',
_links: {
self: {
href:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713',
},
content: {
href:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
},
},
id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713',
uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713',
type: 'bitstream',
metadata: {
'dc.title': [
{
language: null,
value: 'test_word.docx',
},
],
},
});
const mockMediaViewerItems: MediaViewerItem[] = Object.assign(
new Array<MediaViewerItem>(),
[
{ bitstream: mockBitstream, format: 'video', thumbnail: null },
{ bitstream: mockBitstream, format: 'video', thumbnail: null },
]
);
const mockMediaViewerItem: MediaViewerItem[] = Object.assign(
new Array<MediaViewerItem>(),
[{ bitstream: mockBitstream, format: 'video', thumbnail: null }]
);
beforeEach(() => {
fixture = TestBed.createComponent(MediaViewerVideoComponent);
component = fixture.componentInstance;
component.medias = mockMediaViewerItem;
component.filteredMedias = mockMediaViewerItem;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('should show controller buttons when the having mode then one video', () => {
beforeEach(() => {
component.medias = mockMediaViewerItems;
component.filteredMedias = mockMediaViewerItems;
fixture.detectChanges();
});
it('should show buttons', () => {
const controllerButtons = fixture.debugElement.query(By.css('.buttons'));
expect(controllerButtons).toBeTruthy();
});
describe('when the "Next" button is clicked', () => {
beforeEach(() => {
component.currentIndex = 0;
fixture.detectChanges();
});
it('should increase the index', () => {
const viewMore = fixture.debugElement.query(By.css('.next'));
viewMore.triggerEventHandler('click', null);
expect(component.currentIndex).toBe(1);
});
});
describe('when the "Previous" button is clicked', () => {
beforeEach(() => {
component.currentIndex = 1;
fixture.detectChanges();
});
it('should decrease the index', () => {
const viewMore = fixture.debugElement.query(By.css('.previous'));
viewMore.triggerEventHandler('click', null);
expect(component.currentIndex).toBe(0);
});
});
describe('when the "Playlist element" button is clicked', () => {
beforeEach(() => {
component.isCollapsed = true;
fixture.detectChanges();
});
it('should set the the index with the selected one', () => {
const viewMore = fixture.debugElement.query(By.css('.list-element'));
viewMore.triggerEventHandler('click', null);
expect(component.currentIndex).toBe(0);
});
});
});
});

View File

@@ -0,0 +1,55 @@
import { Component, Input, OnInit } from '@angular/core';
import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model';
/**
* This componenet renders a video viewer and playlist for the media viewer
*/
@Component({
selector: 'ds-media-viewer-video',
templateUrl: './media-viewer-video.component.html',
styleUrls: ['./media-viewer-video.component.scss'],
})
export class MediaViewerVideoComponent implements OnInit {
@Input() medias: MediaViewerItem[];
filteredMedias: MediaViewerItem[];
isCollapsed: boolean;
currentIndex = 0;
replacements = {
video: './assets/images/replacement_video.svg',
audio: './assets/images/replacement_audio.svg',
};
replacementThumbnail: string;
ngOnInit() {
this.isCollapsed = false;
this.filteredMedias = this.medias.filter(
(media) => media.format === 'audio' || media.format === 'video'
);
}
/**
* This method sets the reviced index into currentIndex
* @param index Selected index
*/
selectedMedia(index: number) {
this.currentIndex = index;
}
/**
* This method increade the number of the currentIndex
*/
nextMedia() {
this.currentIndex++;
}
/**
* This method decrese the number of the currentIndex
*/
prevMedia() {
this.currentIndex--;
}
}

View File

@@ -0,0 +1,36 @@
<ng-container *ngVar="mediaList$ | async as mediaList">
<ds-loading
*ngIf="isLoading"
message="{{ 'loading.default' | translate }}"
[showMessage]="false"
></ds-loading>
<div class="media-viewer" *ngIf="!isLoading">
<ng-container *ngIf="mediaList.length > 0">
<ng-container *ngIf="videoOptions">
<ng-container
*ngIf="
mediaList[0]?.format === 'video' || mediaList[0]?.format === 'audio'
"
>
<ds-media-viewer-video [medias]="mediaList"></ds-media-viewer-video>
</ng-container>
</ng-container>
<ng-container *ngIf="mediaList[0]?.format === 'image'">
<ds-media-viewer-image [images]="mediaList"></ds-media-viewer-image>
</ng-container>
</ng-container>
<ng-container
*ngIf="
((mediaList[0]?.format !== 'image') &&
(!videoOptions || mediaList[0]?.format !== 'video') &&
(!videoOptions || mediaList[0]?.format !== 'audio')) ||
mediaList.length === 0
"
>
<ds-media-viewer-image
[image]="mediaList[0]?.thumbnail || thumbnailPlaceholder"
[preview]="false"
></ds-media-viewer-image>
</ng-container>
</div>
</ng-container>

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,143 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Bitstream } from '../../core/shared/bitstream.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
import { MediaViewerComponent } from './media-viewer.component';
import { MockBitstreamFormat1 } from '../../shared/mocks/item.mock';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MediaViewerItem } from '../../core/shared/media-viewer-item.model';
import { VarDirective } from '../../shared/utils/var.directive';
import { MetadataFieldWrapperComponent } from '../field-components/metadata-field-wrapper/metadata-field-wrapper.component';
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
describe('MediaViewerComponent', () => {
let comp: MediaViewerComponent;
let fixture: ComponentFixture<MediaViewerComponent>;
const mockBitstream: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 10201,
content:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
format: observableOf(MockBitstreamFormat1),
bundleName: 'ORIGINAL',
_links: {
self: {
href:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713',
},
content: {
href:
'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
},
},
id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713',
uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713',
type: 'bitstream',
metadata: {
'dc.title': [
{
language: null,
value: 'test_word.docx',
},
],
},
});
const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(
createPaginatedList([mockBitstream])
),
});
const mockMediaViewerItem: MediaViewerItem = Object.assign(
new MediaViewerItem(),
{ bitstream: mockBitstream, format: 'image', thumbnail: null }
);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
BrowserAnimationsModule,
],
declarations: [
MediaViewerComponent,
VarDirective,
FileSizePipe,
MetadataFieldWrapperComponent,
],
providers: [
{ provide: BitstreamDataService, useValue: bitstreamDataService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MediaViewerComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('when the bitstreams are loading', () => {
beforeEach(() => {
comp.mediaList$.next([mockMediaViewerItem]);
comp.videoOptions = true;
comp.isLoading = true;
fixture.detectChanges();
});
it('should call the createMediaViewerItem', () => {
const mediaItem = comp.createMediaViewerItem(
mockBitstream,
MockBitstreamFormat1,
undefined
);
expect(mediaItem).toBeTruthy();
expect(mediaItem.thumbnail).toBe(null);
});
it('should display a loading component', () => {
const loading = fixture.debugElement.query(By.css('ds-loading'));
expect(loading.nativeElement).toBeDefined();
});
});
describe('when the bitstreams loading is failed', () => {
beforeEach(() => {
comp.mediaList$.next([]);
comp.videoOptions = true;
comp.isLoading = false;
fixture.detectChanges();
});
it('should call the createMediaViewerItem', () => {
const mediaItem = comp.createMediaViewerItem(
mockBitstream,
MockBitstreamFormat1,
undefined
);
expect(mediaItem).toBeTruthy();
expect(mediaItem.thumbnail).toBe(null);
});
it('should display a default, thumbnail', () => {
const defaultThumbnail = fixture.debugElement.query(
By.css('ds-media-viewer-image')
);
expect(defaultThumbnail.nativeElement).toBeDefined();
});
});
});

View File

@@ -0,0 +1,114 @@
import { Component, Input, OnInit } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { filter, take } from 'rxjs/operators';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { RemoteData } from '../../core/data/remote-data';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { Bitstream } from '../../core/shared/bitstream.model';
import { Item } from '../../core/shared/item.model';
import { MediaViewerItem } from '../../core/shared/media-viewer-item.model';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { hasValue } from '../../shared/empty.util';
import { followLink } from '../../shared/utils/follow-link-config.model';
/**
* This componenet renders the media viewers
*/
@Component({
selector: 'ds-media-viewer',
templateUrl: './media-viewer.component.html',
styleUrls: ['./media-viewer.component.scss'],
})
export class MediaViewerComponent implements OnInit {
@Input() item: Item;
@Input() videoOptions: boolean;
mediaList$: BehaviorSubject<MediaViewerItem[]>;
isLoading: boolean;
thumbnailPlaceholder = './assets/images/replacement_document.svg';
constructor(protected bitstreamDataService: BitstreamDataService) {}
/**
* This metod loads all the Bitstreams and Thumbnails and contert it to media item
*/
ngOnInit(): void {
this.mediaList$ = new BehaviorSubject([]);
this.isLoading = true;
this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD) => {
if (bitstreamsRD.payload.page.length === 0) {
this.isLoading = false;
this.mediaList$.next([]);
} else {
this.loadRemoteData('THUMBNAIL').subscribe((thumbnailsRD) => {
for (
let index = 0;
index < bitstreamsRD.payload.page.length;
index++
) {
bitstreamsRD.payload.page[index].format
.pipe(getFirstSucceededRemoteDataPayload())
.subscribe((format) => {
const current = this.mediaList$.getValue();
const mediaItem = this.createMediaViewerItem(
bitstreamsRD.payload.page[index],
format,
thumbnailsRD.payload && thumbnailsRD.payload.page[index]
);
this.mediaList$.next([...current, mediaItem]);
});
}
this.isLoading = false;
});
}
});
}
/**
* This method will retrieve the next page of Bitstreams from the external BitstreamDataService call.
* @param bundleName Bundle name
*/
loadRemoteData(
bundleName: string
): Observable<RemoteData<PaginatedList<Bitstream>>> {
return this.bitstreamDataService
.findAllByItemAndBundleName(
this.item,
bundleName,
{},
true,
true,
followLink('format')
)
.pipe(
filter(
(bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) =>
hasValue(bitstreamsRD) &&
(hasValue(bitstreamsRD.errorMessage) || hasValue(bitstreamsRD.payload))
),
take(1)
);
}
/**
* This method create MediaViewerItem from incoming bitstreams
* @param original original remote data bitstream
* @param format original bitstream format
* @param thumbnail trunbnail remote data bitstream
*/
createMediaViewerItem(
original: Bitstream,
format: BitstreamFormat,
thumbnail: Bitstream
): MediaViewerItem {
const mediaItem = new MediaViewerItem();
mediaItem.bitstream = original;
mediaItem.format = format.mimetype.split('/')[0];
mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null;
return mediaItem;
}
}

View File

@@ -1,5 +1,5 @@
<h2 class="item-page-title-field">
<div *ngIf="item.firstMetadataValue('relationship.type') as type">
<div *ngIf="item.firstMetadataValue('dspace.entity.type') as type">
{{ type.toLowerCase() + '.page.titleprefix' | translate }}
</div>
<ds-metadata-values [mdValues]="item?.allMetadata(fields)"></ds-metadata-values>

View File

@@ -8,9 +8,14 @@
</div>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ng-container *ngIf="!mediaViewer.image">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
</ng-container>
<ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-item-page-author-field [item]="object"></ds-item-page-author-field>
@@ -74,8 +79,8 @@
</ds-item-page-uri-field>
<ds-item-page-collections [item]="object"></ds-item-page-collections>
<div>
<a class="btn btn-outline-primary" [routerLink]="[itemPageRoute + '/full']">
{{"item.page.link.full" | translate}}
<a class="btn btn-outline-primary" role="button" [routerLink]="[itemPageRoute + '/full']">
<i class="fas fa-info-circle"></i> {{"item.page.link.full" | translate}}
</a>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../../../environments/environment';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model';
@@ -20,6 +21,7 @@ export class ItemComponent implements OnInit {
* Route to the item page
*/
itemPageRoute: string;
mediaViewer = environment.mediaViewer;
constructor(protected bitstreamDataService: BitstreamDataService) {
}

View File

@@ -8,9 +8,14 @@
</div>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ng-container *ngIf="!mediaViewer.image">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
</ng-container>
<ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-item-page-author-field [item]="object"></ds-item-page-author-field>
@@ -59,8 +64,8 @@
</ds-item-page-uri-field>
<ds-item-page-collections [item]="object"></ds-item-page-collections>
<div>
<a class="btn btn-outline-primary" [routerLink]="[itemPageRoute + '/full']">
{{"item.page.link.full" | translate}}
<a class="btn btn-outline-primary" [routerLink]="[itemPageRoute + '/full']" role="button">
<i class="fas fa-info-circle"></i> {{"item.page.link.full" | translate}}
</a>
</div>
</div>

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { ItemPageComponent } from './item-page.component';
/**
* Themed wrapper for ItemPageComponent
*/
@Component({
selector: 'ds-themed-item-page',
styleUrls: [],
templateUrl: './../../shared/theme-support/themed.component.html',
})
export class ThemedItemPageComponent extends ThemedComponent<ItemPageComponent> {
protected getComponentName(): string {
return 'ItemPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/+item-page/simple/item-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./item-page.component`);
}
}

View File

@@ -1,14 +1,13 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { LoginPageComponent } from './login-page.component';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { ThemedLoginPageComponent } from './themed-login-page.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: '', pathMatch: 'full', component: LoginPageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'login', title: 'login.title' } }
{ path: '', pathMatch: 'full', component: ThemedLoginPageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'login', title: 'login.title' } }
])
],
providers: [

View File

@@ -1,7 +1,7 @@
<div class="container w-100 h-100">
<div class="text-center mt-5 row justify-content-center">
<div>
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png" alt="{{'repository.image.logo' | translate}}">
<h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1>
<ds-log-in
[isStandalonePage]="true"></ds-log-in>

View File

@@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { LoginPageComponent } from './login-page.component';
import { LoginPageRoutingModule } from './login-page-routing.module';
import { ThemedLoginPageComponent } from './themed-login-page.component';
@NgModule({
imports: [
@@ -11,7 +12,8 @@ import { LoginPageRoutingModule } from './login-page-routing.module';
SharedModule,
],
declarations: [
LoginPageComponent
LoginPageComponent,
ThemedLoginPageComponent
]
})
export class LoginPageModule {

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