mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-17 15:03:07 +00:00
Merge branch 'w2p-55990_Move-item-component' into Move-item-component
This commit is contained in:
@@ -7,6 +7,8 @@ import { RouterModule } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component';
|
||||
import {MetadataFieldFormComponent} from './metadata-schema/metadata-field-form/metadata-field-form.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -19,7 +21,12 @@ import { SharedModule } from '../../shared/shared.module';
|
||||
declarations: [
|
||||
MetadataRegistryComponent,
|
||||
MetadataSchemaComponent,
|
||||
BitstreamFormatsComponent
|
||||
BitstreamFormatsComponent,
|
||||
MetadataSchemaFormComponent,
|
||||
MetadataFieldFormComponent
|
||||
],
|
||||
entryComponents: [
|
||||
|
||||
]
|
||||
})
|
||||
export class AdminRegistriesModule {
|
||||
|
@@ -6,13 +6,24 @@ import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
/**
|
||||
* This component renders a list of bitstream formats
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-bitstream-formats',
|
||||
templateUrl: './bitstream-formats.component.html'
|
||||
})
|
||||
export class BitstreamFormatsComponent {
|
||||
|
||||
/**
|
||||
* A paginated list of bitstream formats to be shown on the page
|
||||
*/
|
||||
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
|
||||
|
||||
/**
|
||||
* The current pagination configuration for the page
|
||||
* Currently simply renders all bitstream formats
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'registry-bitstreamformats-pagination',
|
||||
pageSize: 10000
|
||||
@@ -22,11 +33,18 @@ export class BitstreamFormatsComponent {
|
||||
this.updateFormats();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the page is changed, make sure to update the list of bitstreams to match the new page
|
||||
* @param event The page change event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.updateFormats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to update the bitstream formats that are shown
|
||||
*/
|
||||
private updateFormats() {
|
||||
this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config);
|
||||
}
|
||||
|
@@ -0,0 +1,151 @@
|
||||
import { Action } from '@ngrx/store';
|
||||
import { type } from '../../../shared/ngrx/type';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
|
||||
/**
|
||||
* For each action type in an action group, make a simple
|
||||
* enum object for all of this group's action types.
|
||||
*
|
||||
* The 'type' utility function coerces strings into string
|
||||
* literal types and runs a simple check to guarantee all
|
||||
* action types in the application are unique.
|
||||
*/
|
||||
export const MetadataRegistryActionTypes = {
|
||||
|
||||
EDIT_SCHEMA: type('dspace/metadata-registry/EDIT_SCHEMA'),
|
||||
CANCEL_EDIT_SCHEMA: type('dspace/metadata-registry/CANCEL_SCHEMA'),
|
||||
SELECT_SCHEMA: type('dspace/metadata-registry/SELECT_SCHEMA'),
|
||||
DESELECT_SCHEMA: type('dspace/metadata-registry/DESELECT_SCHEMA'),
|
||||
DESELECT_ALL_SCHEMA: type('dspace/metadata-registry/DESELECT_ALL_SCHEMA'),
|
||||
|
||||
EDIT_FIELD: type('dspace/metadata-registry/EDIT_FIELD'),
|
||||
CANCEL_EDIT_FIELD: type('dspace/metadata-registry/CANCEL_FIELD'),
|
||||
SELECT_FIELD: type('dspace/metadata-registry/SELECT_FIELD'),
|
||||
DESELECT_FIELD: type('dspace/metadata-registry/DESELECT_FIELD'),
|
||||
DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD')
|
||||
};
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
* Used to edit a metadata schema in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryEditSchemaAction implements Action {
|
||||
type = MetadataRegistryActionTypes.EDIT_SCHEMA;
|
||||
|
||||
schema: MetadataSchema;
|
||||
|
||||
constructor(registry: MetadataSchema) {
|
||||
this.schema = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to cancel the editing of a metadata schema in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryCancelSchemaAction implements Action {
|
||||
type = MetadataRegistryActionTypes.CANCEL_EDIT_SCHEMA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to select a single metadata schema in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistrySelectSchemaAction implements Action {
|
||||
type = MetadataRegistryActionTypes.SELECT_SCHEMA;
|
||||
|
||||
schema: MetadataSchema;
|
||||
|
||||
constructor(registry: MetadataSchema) {
|
||||
this.schema = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to deselect a single metadata schema in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryDeselectSchemaAction implements Action {
|
||||
type = MetadataRegistryActionTypes.DESELECT_SCHEMA;
|
||||
|
||||
schema: MetadataSchema;
|
||||
|
||||
constructor(registry: MetadataSchema) {
|
||||
this.schema = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to deselect all metadata schemas in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryDeselectAllSchemaAction implements Action {
|
||||
type = MetadataRegistryActionTypes.DESELECT_ALL_SCHEMA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to edit a metadata field in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryEditFieldAction implements Action {
|
||||
type = MetadataRegistryActionTypes.EDIT_FIELD;
|
||||
|
||||
field: MetadataField;
|
||||
|
||||
constructor(registry: MetadataField) {
|
||||
this.field = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to cancel the editing of a metadata field in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryCancelFieldAction implements Action {
|
||||
type = MetadataRegistryActionTypes.CANCEL_EDIT_FIELD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to select a single metadata field in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistrySelectFieldAction implements Action {
|
||||
type = MetadataRegistryActionTypes.SELECT_FIELD;
|
||||
|
||||
field: MetadataField;
|
||||
|
||||
constructor(registry: MetadataField) {
|
||||
this.field = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to deselect a single metadata field in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryDeselectFieldAction implements Action {
|
||||
type = MetadataRegistryActionTypes.DESELECT_FIELD;
|
||||
|
||||
field: MetadataField;
|
||||
|
||||
constructor(registry: MetadataField) {
|
||||
this.field = registry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to deselect all metadata fields in the metadata registry
|
||||
*/
|
||||
export class MetadataRegistryDeselectAllFieldAction implements Action {
|
||||
type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD;
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Export a type alias of all actions in this action group
|
||||
* so that reducers can easily compose action types
|
||||
* These are all the actions to perform on the metadata registry state
|
||||
*/
|
||||
export type MetadataRegistryAction
|
||||
= MetadataRegistryEditSchemaAction
|
||||
| MetadataRegistryCancelSchemaAction
|
||||
| MetadataRegistrySelectSchemaAction
|
||||
| MetadataRegistryDeselectSchemaAction
|
||||
| MetadataRegistryEditFieldAction
|
||||
| MetadataRegistryCancelFieldAction
|
||||
| MetadataRegistrySelectFieldAction
|
||||
| MetadataRegistryDeselectFieldAction;
|
@@ -1,42 +1,61 @@
|
||||
<div class="container">
|
||||
<div class="metadata-registry row">
|
||||
<div class="col-12">
|
||||
<div class="metadata-registry row">
|
||||
<div class="col-12">
|
||||
|
||||
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h2>
|
||||
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h2>
|
||||
|
||||
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
|
||||
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
|
||||
|
||||
<ds-metadata-schema-form (submitForm)="forceUpdateSchemas()"></ds-metadata-schema-form>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(metadataSchemas | async)?.payload"
|
||||
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="metadata-schemas" class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"></th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page"
|
||||
[ngClass]="{'table-primary' : isActive(schema) | async}">
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
[checked]="isSelected(schema) | async"
|
||||
(change)="selectMetadataSchema(schema, $event)"
|
||||
>
|
||||
</label>
|
||||
</td>
|
||||
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>
|
||||
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td>
|
||||
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(metadataSchemas | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||
{{'admin.registries.metadata.schemas.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button *ngIf="(metadataSchemas | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteSchemas()">{{'admin.registries.metadata.schemas.table.delete' | translate}}</button>
|
||||
</div>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(metadataSchemas | async)?.payload"
|
||||
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
<div class="table-responsive">
|
||||
<table id="metadata-schemas" class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page">
|
||||
<td><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>
|
||||
<td><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td>
|
||||
<td><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
<div *ngIf="(metadataSchemas | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||
{{'admin.registries.metadata.schemas.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,5 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
|
||||
.selectable-row:hover {
|
||||
cursor: pointer;
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import { MetadataRegistryComponent } from './metadata-registry.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
@@ -13,6 +13,10 @@ import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||
import { HostWindowService } from '../../../shared/host-window.service';
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
|
||||
describe('MetadataRegistryComponent', () => {
|
||||
let comp: MetadataRegistryComponent;
|
||||
@@ -33,9 +37,18 @@ describe('MetadataRegistryComponent', () => {
|
||||
}
|
||||
];
|
||||
const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
|
||||
/* tslint:disable:no-empty */
|
||||
const registryServiceStub = {
|
||||
getMetadataSchemas: () => mockSchemas
|
||||
getMetadataSchemas: () => mockSchemas,
|
||||
getActiveMetadataSchema: () => observableOf(undefined),
|
||||
getSelectedMetadataSchemas: () => observableOf([]),
|
||||
editMetadataSchema: (schema) => {},
|
||||
cancelEditMetadataSchema: () => {},
|
||||
deleteMetadataSchema: () => observableOf(new RestResponse(true, 200, 'OK')),
|
||||
deselectAllMetadataSchema: () => {},
|
||||
clearMetadataSchemaRequests: () => observableOf(undefined)
|
||||
};
|
||||
/* tslint:enable:no-empty */
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -43,8 +56,12 @@ describe('MetadataRegistryComponent', () => {
|
||||
declarations: [MetadataRegistryComponent, PaginationComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||
]
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(MetadataRegistryComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -52,19 +69,66 @@ describe('MetadataRegistryComponent', () => {
|
||||
fixture = TestBed.createComponent(MetadataRegistryComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
registryService = (comp as any).service;
|
||||
});
|
||||
|
||||
beforeEach(inject([RegistryService], (s) => {
|
||||
registryService = s;
|
||||
}));
|
||||
|
||||
it('should contain two schemas', () => {
|
||||
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas>tbody')).nativeElement;
|
||||
expect(tbody.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should contain the correct schemas', () => {
|
||||
const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(3)')).nativeElement;
|
||||
const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(4)')).nativeElement;
|
||||
expect(dcName.textContent).toBe('dc');
|
||||
|
||||
const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement;
|
||||
const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(4)')).nativeElement;
|
||||
expect(mockName.textContent).toBe('mock');
|
||||
});
|
||||
|
||||
describe('when clicking a metadata schema row', () => {
|
||||
let row: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'editMetadataSchema');
|
||||
row = fixture.debugElement.query(By.css('.selectable-row')).nativeElement;
|
||||
row.click();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should start editing the selected schema', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(registryService.editMetadataSchema).toHaveBeenCalledWith(mockSchemasList[0]);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should cancel editing the selected schema when clicked again', async(() => {
|
||||
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(mockSchemasList[0]));
|
||||
spyOn(registryService, 'cancelEditMetadataSchema');
|
||||
row.click();
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
expect(registryService.cancelEditMetadataSchema).toHaveBeenCalled();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('when deleting metadata schemas', () => {
|
||||
const selectedSchemas = Array(mockSchemasList[0]);
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'deleteMetadataSchema').and.callThrough();
|
||||
spyOn(registryService, 'getSelectedMetadataSchemas').and.returnValue(observableOf(selectedSchemas));
|
||||
comp.deleteSchemas();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should call deleteMetadataSchema with the selected id', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(registryService.deleteMetadataSchema).toHaveBeenCalledWith(selectedSchemas[0].id);
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
@@ -1,34 +1,173 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
import { zip } from 'rxjs/internal/observable/zip';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { Route, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-registry',
|
||||
templateUrl: './metadata-registry.component.html'
|
||||
templateUrl: './metadata-registry.component.html',
|
||||
styleUrls: ['./metadata-registry.component.scss']
|
||||
})
|
||||
/**
|
||||
* A component used for managing all existing metadata schemas within the repository.
|
||||
* The admin can create, edit or delete metadata schemas here.
|
||||
*/
|
||||
export class MetadataRegistryComponent {
|
||||
|
||||
/**
|
||||
* A list of all the current metadata schemas within the repository
|
||||
*/
|
||||
metadataSchemas: Observable<RemoteData<PaginatedList<MetadataSchema>>>;
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of metadata schemas
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'registry-metadataschemas-pagination',
|
||||
pageSize: 10000
|
||||
pageSize: 25
|
||||
});
|
||||
|
||||
constructor(private registryService: RegistryService) {
|
||||
constructor(private registryService: RegistryService,
|
||||
private notificationsService: NotificationsService,
|
||||
private router: Router,
|
||||
private translateService: TranslateService) {
|
||||
this.updateSchemas();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.updateSchemas();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of schemas by fetching it from the rest api or cache
|
||||
*/
|
||||
private updateSchemas() {
|
||||
this.metadataSchemas = this.registryService.getMetadataSchemas(this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of schemas by first clearing the cache related to metadata schemas, then performing
|
||||
* a new REST call
|
||||
*/
|
||||
public forceUpdateSchemas() {
|
||||
this.registryService.clearMetadataSchemaRequests().subscribe();
|
||||
this.updateSchemas();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing the selected metadata schema
|
||||
* @param schema
|
||||
*/
|
||||
editSchema(schema: MetadataSchema) {
|
||||
this.getActiveSchema().pipe(take(1)).subscribe((activeSchema) => {
|
||||
if (schema === activeSchema) {
|
||||
this.registryService.cancelEditMetadataSchema();
|
||||
} else {
|
||||
this.registryService.editMetadataSchema(schema);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given metadata schema is active (being edited)
|
||||
* @param schema
|
||||
*/
|
||||
isActive(schema: MetadataSchema): Observable<boolean> {
|
||||
return this.getActiveSchema().pipe(
|
||||
map((activeSchema) => schema === activeSchema)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active metadata schema (being edited)
|
||||
*/
|
||||
getActiveSchema(): Observable<MetadataSchema> {
|
||||
return this.registryService.getActiveMetadataSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a metadata schema within the list (checkbox)
|
||||
* @param schema
|
||||
* @param event
|
||||
*/
|
||||
selectMetadataSchema(schema: MetadataSchema, event) {
|
||||
event.target.checked ?
|
||||
this.registryService.selectMetadataSchema(schema) :
|
||||
this.registryService.deselectMetadataSchema(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given metadata schema is selected in the list (checkbox)
|
||||
* @param schema
|
||||
*/
|
||||
isSelected(schema: MetadataSchema): Observable<boolean> {
|
||||
return this.registryService.getSelectedMetadataSchemas().pipe(
|
||||
map((schemas) => schemas.find((selectedSchema) => selectedSchema === schema) != null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the selected metadata schemas
|
||||
*/
|
||||
deleteSchemas() {
|
||||
this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe(
|
||||
(schemas) => {
|
||||
const tasks$ = [];
|
||||
for (const schema of schemas) {
|
||||
if (hasValue(schema.id)) {
|
||||
tasks$.push(this.registryService.deleteMetadataSchema(schema.id));
|
||||
}
|
||||
}
|
||||
zip(...tasks$).subscribe((responses: RestResponse[]) => {
|
||||
const successResponses = responses.filter((response: RestResponse) => response.isSuccessful);
|
||||
const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful);
|
||||
if (successResponses.length > 0) {
|
||||
this.showNotification(true, successResponses.length);
|
||||
}
|
||||
if (failedResponses.length > 0) {
|
||||
this.showNotification(false, failedResponses.length);
|
||||
}
|
||||
this.registryService.deselectAllMetadataSchema();
|
||||
this.registryService.cancelEditMetadataSchema();
|
||||
this.forceUpdateSchemas();
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notifications for an amount of deleted metadata schemas
|
||||
* @param success Whether or not the notification should be a success message (error message when false)
|
||||
* @param amount The amount of deleted metadata schemas
|
||||
*/
|
||||
showNotification(success: boolean, amount: number) {
|
||||
const prefix = 'admin.registries.schema.notification';
|
||||
const suffix = success ? 'success' : 'failure';
|
||||
const messages = observableCombineLatest(
|
||||
this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
|
||||
this.translateService.get(`${prefix}.deleted.${suffix}`, { amount: amount })
|
||||
);
|
||||
messages.subscribe(([head, content]) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(head, content)
|
||||
} else {
|
||||
this.notificationsService.error(head, content)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
MetadataRegistryCancelFieldAction,
|
||||
MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction,
|
||||
MetadataRegistryDeselectAllSchemaAction, MetadataRegistryDeselectFieldAction,
|
||||
MetadataRegistryDeselectSchemaAction, MetadataRegistryEditFieldAction,
|
||||
MetadataRegistryEditSchemaAction, MetadataRegistrySelectFieldAction,
|
||||
MetadataRegistrySelectSchemaAction
|
||||
} from './metadata-registry.actions';
|
||||
import { metadataRegistryReducer, MetadataRegistryState } from './metadata-registry.reducers';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
|
||||
class NullAction extends MetadataRegistryEditSchemaAction {
|
||||
type = null;
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const schema: MetadataSchema = Object.assign(new MetadataSchema(),
|
||||
{
|
||||
id: 'schema-id',
|
||||
self: 'http://rest.self/schema/dc',
|
||||
prefix: 'dc',
|
||||
namespace: 'http://dublincore.org/documents/dcmi-terms/'
|
||||
});
|
||||
|
||||
const schema2: MetadataSchema = Object.assign(new MetadataSchema(),
|
||||
{
|
||||
id: 'another-schema-id',
|
||||
self: 'http://rest.self/schema/dcterms',
|
||||
prefix: 'dcterms',
|
||||
namespace: 'http://purl.org/dc/terms/'
|
||||
});
|
||||
|
||||
const field: MetadataField = Object.assign(new MetadataField(),
|
||||
{
|
||||
id: 'author-field-id',
|
||||
self: 'http://rest.self/field/author',
|
||||
element: 'contributor',
|
||||
qualifier: 'author',
|
||||
scopeNote: 'Author of an item',
|
||||
schema: schema
|
||||
});
|
||||
|
||||
const field2: MetadataField = Object.assign(new MetadataField(),
|
||||
{
|
||||
id: 'title-field-id',
|
||||
self: 'http://rest.self/field/title',
|
||||
element: 'title',
|
||||
qualifier: null,
|
||||
scopeNote: 'Title of an item',
|
||||
schema: schema
|
||||
});
|
||||
|
||||
const initialState: MetadataRegistryState = {
|
||||
editSchema: null,
|
||||
selectedSchemas: [],
|
||||
editField: null,
|
||||
selectedFields: []
|
||||
};
|
||||
|
||||
const editState: MetadataRegistryState = {
|
||||
editSchema: schema,
|
||||
selectedSchemas: [],
|
||||
editField: field,
|
||||
selectedFields: []
|
||||
};
|
||||
|
||||
const selectState: MetadataRegistryState = {
|
||||
editSchema: null,
|
||||
selectedSchemas: [schema2],
|
||||
editField: null,
|
||||
selectedFields: [field2]
|
||||
};
|
||||
|
||||
const moreSelectState: MetadataRegistryState = {
|
||||
editSchema: null,
|
||||
selectedSchemas: [schema, schema2],
|
||||
editField: null,
|
||||
selectedFields: [field, field2]
|
||||
};
|
||||
|
||||
describe('metadataRegistryReducer', () => {
|
||||
|
||||
it('should return the current state when no valid actions have been made', () => {
|
||||
const state = initialState;
|
||||
const action = new NullAction();
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should start with an the initial state', () => {
|
||||
const state = initialState;
|
||||
const action = new NullAction();
|
||||
const initState = metadataRegistryReducer(undefined, action);
|
||||
|
||||
expect(initState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should update the current state to change the editSchema to a new schema when MetadataRegistryEditSchemaAction is dispatched', () => {
|
||||
const state = editState;
|
||||
const action = new MetadataRegistryEditSchemaAction(schema2);
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.editSchema).toEqual(schema2);
|
||||
});
|
||||
|
||||
it('should update the current state to remove the editSchema from the state when MetadataRegistryCancelSchemaAction is dispatched', () => {
|
||||
const state = editState;
|
||||
const action = new MetadataRegistryCancelSchemaAction();
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.editSchema).toEqual(null);
|
||||
});
|
||||
|
||||
it('should update the current state to add a given schema to the selectedSchemas when MetadataRegistrySelectSchemaAction is dispatched', () => {
|
||||
const state = selectState;
|
||||
const action = new MetadataRegistrySelectSchemaAction(schema);
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.selectedSchemas).toContain(schema);
|
||||
expect(newState.selectedSchemas).toContain(schema2);
|
||||
});
|
||||
|
||||
it('should update the current state to remove a given schema to the selectedSchemas when MetadataRegistryDeselectSchemaAction is dispatched', () => {
|
||||
const state = selectState;
|
||||
const action = new MetadataRegistryDeselectSchemaAction(schema2);
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.selectedSchemas).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update the current state to remove a given schema to the selectedSchemas when MetadataRegistryDeselectAllSchemaAction is dispatched', () => {
|
||||
const state = selectState;
|
||||
const action = new MetadataRegistryDeselectAllSchemaAction();
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.selectedSchemas).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update the current state to change the editField to a new field when MetadataRegistryEditFieldAction is dispatched', () => {
|
||||
const state = editState;
|
||||
const action = new MetadataRegistryEditFieldAction(field2);
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.editField).toEqual(field2);
|
||||
});
|
||||
|
||||
it('should update the current state to remove the editField from the state when MetadataRegistryCancelFieldAction is dispatched', () => {
|
||||
const state = editState;
|
||||
const action = new MetadataRegistryCancelFieldAction();
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.editField).toEqual(null);
|
||||
});
|
||||
|
||||
it('should update the current state to add a given field to the selectedFields when MetadataRegistrySelectFieldAction is dispatched', () => {
|
||||
const state = selectState;
|
||||
const action = new MetadataRegistrySelectFieldAction(field);
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.selectedFields).toContain(field);
|
||||
expect(newState.selectedFields).toContain(field2);
|
||||
});
|
||||
|
||||
it('should update the current state to remove a given field to the selectedFields when MetadataRegistryDeselectFieldAction is dispatched', () => {
|
||||
const state = selectState;
|
||||
const action = new MetadataRegistryDeselectFieldAction(field2);
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.selectedFields).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update the current state to remove a given field to the selectedFields when MetadataRegistryDeselectAllFieldAction is dispatched', () => {
|
||||
const state = selectState;
|
||||
const action = new MetadataRegistryDeselectAllFieldAction();
|
||||
const newState = metadataRegistryReducer(state, action);
|
||||
|
||||
expect(newState.selectedFields).toEqual([]);
|
||||
});
|
||||
});
|
@@ -0,0 +1,111 @@
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import {
|
||||
MetadataRegistryAction,
|
||||
MetadataRegistryActionTypes,
|
||||
MetadataRegistryDeselectFieldAction,
|
||||
MetadataRegistryDeselectSchemaAction,
|
||||
MetadataRegistryEditFieldAction,
|
||||
MetadataRegistryEditSchemaAction,
|
||||
MetadataRegistrySelectFieldAction,
|
||||
MetadataRegistrySelectSchemaAction
|
||||
} from './metadata-registry.actions';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
|
||||
/**
|
||||
* The metadata registry state.
|
||||
* @interface MetadataRegistryState
|
||||
*/
|
||||
export interface MetadataRegistryState {
|
||||
editSchema: MetadataSchema;
|
||||
selectedSchemas: MetadataSchema[];
|
||||
editField: MetadataField;
|
||||
selectedFields: MetadataField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial state.
|
||||
*/
|
||||
const initialState: MetadataRegistryState = {
|
||||
editSchema: null,
|
||||
selectedSchemas: [],
|
||||
editField: null,
|
||||
selectedFields: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Reducer that handles MetadataRegistryActions to modify metadata schema and/or field states
|
||||
* @param state The current MetadataRegistryState
|
||||
* @param action The MetadataRegistryAction to perform on the state
|
||||
*/
|
||||
export function metadataRegistryReducer(state = initialState, action: MetadataRegistryAction): MetadataRegistryState {
|
||||
|
||||
switch (action.type) {
|
||||
|
||||
case MetadataRegistryActionTypes.EDIT_SCHEMA: {
|
||||
return Object.assign({}, state, {
|
||||
editSchema: (action as MetadataRegistryEditSchemaAction).schema
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.CANCEL_EDIT_SCHEMA: {
|
||||
return Object.assign({}, state, {
|
||||
editSchema: null
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.SELECT_SCHEMA: {
|
||||
return Object.assign({}, state, {
|
||||
selectedSchemas: [...state.selectedSchemas, (action as MetadataRegistrySelectSchemaAction).schema]
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.DESELECT_SCHEMA: {
|
||||
return Object.assign({}, state, {
|
||||
selectedSchemas: state.selectedSchemas.filter(
|
||||
(selectedSchema) => selectedSchema !== (action as MetadataRegistryDeselectSchemaAction).schema
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.DESELECT_ALL_SCHEMA: {
|
||||
return Object.assign({}, state, {
|
||||
selectedSchemas: []
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.EDIT_FIELD: {
|
||||
return Object.assign({}, state, {
|
||||
editField: (action as MetadataRegistryEditFieldAction).field
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.CANCEL_EDIT_FIELD: {
|
||||
return Object.assign({}, state, {
|
||||
editField: null
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.SELECT_FIELD: {
|
||||
return Object.assign({}, state, {
|
||||
selectedFields: [...state.selectedFields, (action as MetadataRegistrySelectFieldAction).field]
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.DESELECT_FIELD: {
|
||||
return Object.assign({}, state, {
|
||||
selectedFields: state.selectedFields.filter(
|
||||
(selectedField) => selectedField !== (action as MetadataRegistryDeselectFieldAction).field
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
case MetadataRegistryActionTypes.DESELECT_ALL_FIELD: {
|
||||
return Object.assign({}, state, {
|
||||
selectedFields: []
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
<div *ngIf="registryService.getActiveMetadataSchema() | async; then editheader; else createHeader"></div>
|
||||
|
||||
<ng-template #createHeader>
|
||||
<h4>{{messagePrefix + '.create' | translate}}</h4>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #editheader>
|
||||
<h4>{{messagePrefix + '.edit' | translate}}</h4>
|
||||
</ng-template>
|
||||
|
||||
<ds-form [formId]="formId"
|
||||
[formModel]="formModel"
|
||||
[formGroup]="formGroup"
|
||||
[formLayout]="formLayout"
|
||||
(cancel)="onCancel()"
|
||||
(submitForm)="onSubmit()">
|
||||
|
||||
</ds-form>
|
@@ -0,0 +1,106 @@
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MetadataSchemaFormComponent } from './metadata-schema-form.component';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
|
||||
|
||||
describe('MetadataSchemaFormComponent', () => {
|
||||
let component: MetadataSchemaFormComponent;
|
||||
let fixture: ComponentFixture<MetadataSchemaFormComponent>;
|
||||
let registryService: RegistryService;
|
||||
|
||||
/* tslint:disable:no-empty */
|
||||
const registryServiceStub = {
|
||||
getActiveMetadataSchema: () => observableOf(undefined),
|
||||
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
|
||||
cancelEditMetadataSchema: () => {}
|
||||
};
|
||||
const formBuilderServiceStub = {
|
||||
createFormGroup: () => {
|
||||
return {
|
||||
patchValue: () => {}
|
||||
};
|
||||
}
|
||||
};
|
||||
/* tslint:enable:no-empty */
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [ MetadataSchemaFormComponent, EnumKeysPipe ],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: FormBuilderService, useValue: formBuilderServiceStub }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MetadataSchemaFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
beforeEach(inject([RegistryService], (s) => {
|
||||
registryService = s;
|
||||
}));
|
||||
|
||||
describe('when submitting the form', () => {
|
||||
const namespace = 'fake namespace';
|
||||
const prefix = 'fake';
|
||||
|
||||
const expected = Object.assign(new MetadataSchema(), {
|
||||
namespace: namespace,
|
||||
prefix: prefix
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(component.submitForm, 'emit');
|
||||
component.name.value = prefix;
|
||||
component.namespace.value = namespace;
|
||||
});
|
||||
|
||||
describe('without an active schema', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(undefined));
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit a new schema using the correct values', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('with an active schema', () => {
|
||||
const expectedWithId = Object.assign(new MetadataSchema(), {
|
||||
id: 1,
|
||||
namespace: namespace,
|
||||
prefix: prefix
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId));
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should edit the existing schema using the correct values', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,171 @@
|
||||
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import {
|
||||
DynamicFormControlModel,
|
||||
DynamicFormGroupModel,
|
||||
DynamicFormLayout,
|
||||
DynamicInputModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-schema-form',
|
||||
templateUrl: './metadata-schema-form.component.html'
|
||||
})
|
||||
/**
|
||||
* A form used for creating and editing metadata schemas
|
||||
*/
|
||||
export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* A unique id used for ds-form
|
||||
*/
|
||||
formId = 'metadata-schema-form';
|
||||
|
||||
/**
|
||||
* The prefix for all messages related to this form
|
||||
*/
|
||||
messagePrefix = 'admin.registries.metadata.form';
|
||||
|
||||
/**
|
||||
* A dynamic input model for the name field
|
||||
*/
|
||||
name: DynamicInputModel;
|
||||
|
||||
/**
|
||||
* A dynamic input model for the namespace field
|
||||
*/
|
||||
namespace: DynamicInputModel;
|
||||
|
||||
/**
|
||||
* A list of all dynamic input models
|
||||
*/
|
||||
formModel: DynamicFormControlModel[];
|
||||
|
||||
/**
|
||||
* Layout used for structuring the form inputs
|
||||
*/
|
||||
formLayout: DynamicFormLayout = {
|
||||
name: {
|
||||
grid: {
|
||||
host: 'col col-sm-6 d-inline-block'
|
||||
}
|
||||
},
|
||||
namespace: {
|
||||
grid: {
|
||||
host: 'col col-sm-6 d-inline-block'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A FormGroup that combines all inputs
|
||||
*/
|
||||
formGroup: FormGroup;
|
||||
|
||||
/**
|
||||
* An EventEmitter that's fired whenever the form is being submitted
|
||||
*/
|
||||
@Output() submitForm: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
constructor(public registryService: RegistryService, private formBuilderService: FormBuilderService, private translateService: TranslateService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
combineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.name`),
|
||||
this.translateService.get(`${this.messagePrefix}.namespace`)
|
||||
).subscribe(([name, namespace]) => {
|
||||
this.name = new DynamicInputModel({
|
||||
id: 'name',
|
||||
label: name,
|
||||
name: 'name',
|
||||
validators: {
|
||||
required: null,
|
||||
pattern: '^[^ ,_]{1,32}$'
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
this.namespace = new DynamicInputModel({
|
||||
id: 'namespace',
|
||||
label: namespace,
|
||||
name: 'namespace',
|
||||
validators: {
|
||||
required: null,
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
this.formModel = [
|
||||
this.namespace,
|
||||
this.name
|
||||
];
|
||||
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||
this.registryService.getActiveMetadataSchema().subscribe((schema) => {
|
||||
this.formGroup.patchValue({
|
||||
name: schema != null ? schema.prefix : '',
|
||||
namespace: schema != null ? schema.namespace : ''
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop editing the currently selected metadata schema
|
||||
*/
|
||||
onCancel() {
|
||||
this.registryService.cancelEditMetadataSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form
|
||||
* When the schema has an id attached -> Edit the schema
|
||||
* When the schema has no id attached -> Create new schema
|
||||
* Emit the updated/created schema using the EventEmitter submitForm
|
||||
*/
|
||||
onSubmit() {
|
||||
this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe(
|
||||
(schema) => {
|
||||
const values = {
|
||||
prefix: this.name.value,
|
||||
namespace: this.namespace.value
|
||||
};
|
||||
if (schema == null) {
|
||||
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), values)).subscribe((newSchema) => {
|
||||
this.submitForm.emit(newSchema);
|
||||
});
|
||||
} else {
|
||||
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), {
|
||||
id: schema.id,
|
||||
prefix: (values.prefix ? values.prefix : schema.prefix),
|
||||
namespace: (values.namespace ? values.namespace : schema.namespace)
|
||||
})).subscribe((updatedSchema) => {
|
||||
this.submitForm.emit(updatedSchema);
|
||||
});
|
||||
}
|
||||
this.clearFields();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty
|
||||
*/
|
||||
clearFields() {
|
||||
this.formGroup.patchValue({
|
||||
prefix: '',
|
||||
namespace: ''
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current edit when component is destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
<div *ngIf="registryService.getActiveMetadataField() | async; then editheader; else createHeader"></div>
|
||||
|
||||
<ng-template #createHeader>
|
||||
<h4>{{messagePrefix + '.create' | translate}}</h4>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #editheader>
|
||||
<h4>{{messagePrefix + '.edit' | translate}}</h4>
|
||||
</ng-template>
|
||||
|
||||
<ds-form [formId]="formId"
|
||||
[formModel]="formModel"
|
||||
[formLayout]="formLayout"
|
||||
[formGroup]="formGroup"
|
||||
(cancel)="onCancel()"
|
||||
(submit)="onSubmit()">
|
||||
|
||||
</ds-form>
|
@@ -0,0 +1,126 @@
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MetadataFieldFormComponent } from './metadata-field-form.component';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
|
||||
|
||||
describe('MetadataFieldFormComponent', () => {
|
||||
let component: MetadataFieldFormComponent;
|
||||
let fixture: ComponentFixture<MetadataFieldFormComponent>;
|
||||
let registryService: RegistryService;
|
||||
|
||||
const metadataSchema = Object.assign(new MetadataSchema(), {
|
||||
id: 1,
|
||||
namespace: 'fake schema',
|
||||
prefix: 'fake'
|
||||
});
|
||||
|
||||
/* tslint:disable:no-empty */
|
||||
const registryServiceStub = {
|
||||
getActiveMetadataField: () => observableOf(undefined),
|
||||
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field),
|
||||
cancelEditMetadataField: () => {},
|
||||
cancelEditMetadataSchema: () => {},
|
||||
};
|
||||
const formBuilderServiceStub = {
|
||||
createFormGroup: () => {
|
||||
return {
|
||||
patchValue: () => {}
|
||||
};
|
||||
}
|
||||
};
|
||||
/* tslint:enable:no-empty */
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [ MetadataFieldFormComponent, EnumKeysPipe ],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: FormBuilderService, useValue: formBuilderServiceStub }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MetadataFieldFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.metadataSchema = metadataSchema;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
beforeEach(inject([RegistryService], (s) => {
|
||||
registryService = s;
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
component = null;
|
||||
registryService = null
|
||||
})
|
||||
|
||||
describe('when submitting the form', () => {
|
||||
const element = 'fakeElement';
|
||||
const qualifier = 'fakeQualifier';
|
||||
const scopeNote = 'fakeScopeNote';
|
||||
|
||||
const expected = Object.assign(new MetadataField(), {
|
||||
schema: metadataSchema,
|
||||
element: element,
|
||||
qualifier: qualifier,
|
||||
scopeNote: scopeNote
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(component.submitForm, 'emit');
|
||||
component.element.value = element;
|
||||
component.qualifier.value = qualifier;
|
||||
component.scopeNote.value = scopeNote;
|
||||
});
|
||||
|
||||
describe('without an active field', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(undefined));
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit a new field using the correct values', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('with an active field', () => {
|
||||
const expectedWithId = Object.assign(new MetadataField(), {
|
||||
id: 1,
|
||||
schema: metadataSchema,
|
||||
element: element,
|
||||
qualifier: qualifier,
|
||||
scopeNote: scopeNote
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(expectedWithId));
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should edit the existing field using the correct values', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,201 @@
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
|
||||
import {
|
||||
DynamicFormControlModel,
|
||||
DynamicFormLayout,
|
||||
DynamicInputModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-field-form',
|
||||
templateUrl: './metadata-field-form.component.html'
|
||||
})
|
||||
/**
|
||||
* A form used for creating and editing metadata fields
|
||||
*/
|
||||
export class MetadataFieldFormComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* A unique id used for ds-form
|
||||
*/
|
||||
formId = 'metadata-field-form';
|
||||
|
||||
/**
|
||||
* The prefix for all messages related to this form
|
||||
*/
|
||||
messagePrefix = 'admin.registries.schema.form';
|
||||
|
||||
/**
|
||||
* The metadata schema this field is attached to
|
||||
*/
|
||||
@Input() metadataSchema: MetadataSchema;
|
||||
|
||||
/**
|
||||
* A dynamic input model for the element field
|
||||
*/
|
||||
element: DynamicInputModel;
|
||||
|
||||
/**
|
||||
* A dynamic input model for the qualifier field
|
||||
*/
|
||||
qualifier: DynamicInputModel;
|
||||
|
||||
/**
|
||||
* A dynamic input model for the scopeNote field
|
||||
*/
|
||||
scopeNote: DynamicInputModel;
|
||||
|
||||
/**
|
||||
* A list of all dynamic input models
|
||||
*/
|
||||
formModel: DynamicFormControlModel[];
|
||||
|
||||
/**
|
||||
* Layout used for structuring the form inputs
|
||||
*/
|
||||
formLayout: DynamicFormLayout = {
|
||||
element: {
|
||||
grid: {
|
||||
host: 'col col-sm-6 d-inline-block'
|
||||
}
|
||||
},
|
||||
qualifier: {
|
||||
grid: {
|
||||
host: 'col col-sm-6 d-inline-block'
|
||||
}
|
||||
},
|
||||
scopeNote: {
|
||||
grid: {
|
||||
host: 'col col-sm-12 d-inline-block'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A FormGroup that combines all inputs
|
||||
*/
|
||||
formGroup: FormGroup;
|
||||
|
||||
/**
|
||||
* An EventEmitter that's fired whenever the form is being submitted
|
||||
*/
|
||||
@Output() submitForm: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
constructor(public registryService: RegistryService,
|
||||
private formBuilderService: FormBuilderService,
|
||||
private translateService: TranslateService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component, setting up the necessary Models for the dynamic form
|
||||
*/
|
||||
ngOnInit() {
|
||||
combineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.element`),
|
||||
this.translateService.get(`${this.messagePrefix}.qualifier`),
|
||||
this.translateService.get(`${this.messagePrefix}.scopenote`)
|
||||
).subscribe(([element, qualifier, scopenote]) => {
|
||||
this.element = new DynamicInputModel({
|
||||
id: 'element',
|
||||
label: element,
|
||||
name: 'element',
|
||||
validators: {
|
||||
required: null,
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
this.qualifier = new DynamicInputModel({
|
||||
id: 'qualifier',
|
||||
label: qualifier,
|
||||
name: 'qualifier',
|
||||
required: false,
|
||||
});
|
||||
this.scopeNote = new DynamicInputModel({
|
||||
id: 'scopeNote',
|
||||
label: scopenote,
|
||||
name: 'scopeNote',
|
||||
required: false,
|
||||
});
|
||||
this.formModel = [
|
||||
this.element,
|
||||
this.qualifier,
|
||||
this.scopeNote
|
||||
];
|
||||
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||
this.registryService.getActiveMetadataField().subscribe((field) => {
|
||||
this.formGroup.patchValue({
|
||||
element: field != null ? field.element : '',
|
||||
qualifier: field != null ? field.qualifier : '',
|
||||
scopeNote: field != null ? field.scopeNote : ''
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop editing the currently selected metadata field
|
||||
*/
|
||||
onCancel() {
|
||||
this.registryService.cancelEditMetadataField();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form
|
||||
* When the field has an id attached -> Edit the field
|
||||
* When the field has no id attached -> Create new field
|
||||
* Emit the updated/created field using the EventEmitter submitForm
|
||||
*/
|
||||
onSubmit() {
|
||||
this.registryService.getActiveMetadataField().pipe(take(1)).subscribe(
|
||||
(field) => {
|
||||
const values = {
|
||||
schema: this.metadataSchema,
|
||||
element: this.element.value,
|
||||
qualifier: this.qualifier.value,
|
||||
scopeNote: this.scopeNote.value
|
||||
};
|
||||
if (field == null) {
|
||||
this.registryService.createOrUpdateMetadataField(Object.assign(new MetadataField(), values)).subscribe((newField) => {
|
||||
this.submitForm.emit(newField);
|
||||
});
|
||||
} else {
|
||||
this.registryService.createOrUpdateMetadataField(Object.assign(new MetadataField(), {
|
||||
id: field.id,
|
||||
schema: this.metadataSchema,
|
||||
element: (values.element ? values.element : field.element),
|
||||
qualifier: (values.qualifier ? values.qualifier : field.qualifier),
|
||||
scopeNote: (values.scopeNote ? values.scopeNote : field.scopeNote)
|
||||
})).subscribe((updatedField) => {
|
||||
this.submitForm.emit(updatedField);
|
||||
});
|
||||
}
|
||||
this.clearFields();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all input-fields to be empty
|
||||
*/
|
||||
clearFields() {
|
||||
this.formGroup.patchValue({
|
||||
element: '',
|
||||
qualifier: '',
|
||||
scopeNote: ''
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current edit when component is destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
@@ -6,36 +6,56 @@
|
||||
|
||||
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:namespace }}</p>
|
||||
|
||||
<ds-metadata-field-form
|
||||
[metadataSchema]="(metadataSchema | async)?.payload"
|
||||
(submitForm)="forceUpdateFields()"></ds-metadata-field-form>
|
||||
|
||||
<h3>{{'admin.registries.schema.fields.head' | translate}}</h3>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(metadataFields | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(metadataFields | async)?.payload"
|
||||
[collectionSize]="(metadataFields | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hideGear]="false"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
<div class="table-responsive">
|
||||
<table id="metadata-fields" class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let field of (metadataFields | async)?.payload?.page">
|
||||
<td>{{(metadataSchema | async)?.payload?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
|
||||
<td>{{field.scopeNote}}</td>
|
||||
<tr *ngFor="let field of (metadataFields | async)?.payload?.page"
|
||||
[ngClass]="{'table-primary' : isActive(field) | async}">
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
[checked]="isSelected(field) | async"
|
||||
(change)="selectMetadataField(field, $event)">
|
||||
</label>
|
||||
</td>
|
||||
<td class="selectable-row" (click)="editField(field)">{{(metadataSchema | async)?.payload?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
|
||||
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
<div *ngIf="(metadataFields | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||
|
||||
<div *ngIf="(metadataFields | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||
{{'admin.registries.schema.fields.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button [routerLink]="['/admin/registries/metadata']" class="btn btn-primary">{{'admin.registries.schema.return' | translate}}</button>
|
||||
<button *ngIf="(metadataFields | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFields()">{{'admin.registries.schema.fields.table.delete' | translate}}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,5 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
|
||||
.selectable-row:hover {
|
||||
cursor: pointer;
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import { MetadataSchemaComponent } from './metadata-schema.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
@@ -17,6 +17,10 @@ import { HostWindowService } from '../../../shared/host-window.service';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
|
||||
describe('MetadataSchemaComponent', () => {
|
||||
let comp: MetadataSchemaComponent;
|
||||
@@ -38,40 +42,53 @@ describe('MetadataSchemaComponent', () => {
|
||||
];
|
||||
const mockFieldsList = [
|
||||
{
|
||||
id: 1,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
|
||||
element: 'contributor',
|
||||
qualifier: 'advisor',
|
||||
scopenote: null,
|
||||
scopeNote: null,
|
||||
schema: mockSchemasList[0]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
|
||||
element: 'contributor',
|
||||
qualifier: 'author',
|
||||
scopenote: null,
|
||||
scopeNote: null,
|
||||
schema: mockSchemasList[0]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
|
||||
element: 'contributor',
|
||||
qualifier: 'editor',
|
||||
scopenote: 'test scope note',
|
||||
scopeNote: 'test scope note',
|
||||
schema: mockSchemasList[1]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
|
||||
element: 'contributor',
|
||||
qualifier: 'illustrator',
|
||||
scopenote: null,
|
||||
scopeNote: null,
|
||||
schema: mockSchemasList[1]
|
||||
}
|
||||
];
|
||||
const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
|
||||
/* tslint:disable:no-empty */
|
||||
const registryServiceStub = {
|
||||
getMetadataSchemas: () => mockSchemas,
|
||||
getMetadataFieldsBySchema: (schema: MetadataSchema) => observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
|
||||
getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
|
||||
getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0])),
|
||||
getActiveMetadataField: () => observableOf(undefined),
|
||||
getSelectedMetadataFields: () => observableOf([]),
|
||||
editMetadataField: (schema) => {},
|
||||
cancelEditMetadataField: () => {},
|
||||
deleteMetadataField: () => observableOf(new RestResponse(true, 200, 'OK')),
|
||||
deselectAllMetadataField: () => {},
|
||||
clearMetadataFieldRequests: () => observableOf(undefined)
|
||||
};
|
||||
/* tslint:enable:no-empty */
|
||||
const schemaNameParam = 'mock';
|
||||
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
|
||||
params: observableOf({
|
||||
@@ -87,8 +104,10 @@ describe('MetadataSchemaComponent', () => {
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
|
||||
{ provide: Router, useValue: new RouterStub() }
|
||||
]
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -96,9 +115,12 @@ describe('MetadataSchemaComponent', () => {
|
||||
fixture = TestBed.createComponent(MetadataSchemaComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
registryService = (comp as any).service;
|
||||
});
|
||||
|
||||
beforeEach(inject([RegistryService], (s) => {
|
||||
registryService = s;
|
||||
}));
|
||||
|
||||
it('should contain the schema prefix in the header', () => {
|
||||
const header: HTMLElement = fixture.debugElement.query(By.css('.metadata-schema #header')).nativeElement;
|
||||
expect(header.textContent).toContain('mock');
|
||||
@@ -110,10 +132,54 @@ describe('MetadataSchemaComponent', () => {
|
||||
});
|
||||
|
||||
it('should contain the correct fields', () => {
|
||||
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(1)')).nativeElement;
|
||||
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(2)')).nativeElement;
|
||||
expect(editorField.textContent).toBe('mock.contributor.editor');
|
||||
|
||||
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(1)')).nativeElement;
|
||||
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(2)')).nativeElement;
|
||||
expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
|
||||
});
|
||||
|
||||
describe('when clicking a metadata field row', () => {
|
||||
let row: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'editMetadataField');
|
||||
row = fixture.debugElement.query(By.css('.selectable-row')).nativeElement;
|
||||
row.click();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should start editing the selected field', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(registryService.editMetadataField).toHaveBeenCalledWith(mockFieldsList[2]);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should cancel editing the selected field when clicked again', async(() => {
|
||||
spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(mockFieldsList[2]));
|
||||
spyOn(registryService, 'cancelEditMetadataField');
|
||||
row.click();
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
expect(registryService.cancelEditMetadataField).toHaveBeenCalled();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('when deleting metadata fields', () => {
|
||||
const selectedFields = Array(mockFieldsList[2]);
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(registryService, 'deleteMetadataField').and.callThrough();
|
||||
spyOn(registryService, 'getSelectedMetadataFields').and.returnValue(observableOf(selectedFields));
|
||||
comp.deleteFields();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should call deleteMetadataField with the selected id', async(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(registryService.deleteMetadataField).toHaveBeenCalledWith(selectedFields[0].id);
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
@@ -1,30 +1,59 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { RestResponse } from '../../../core/cache/response.models';
|
||||
import { zip } from 'rxjs/internal/observable/zip';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-schema',
|
||||
templateUrl: './metadata-schema.component.html'
|
||||
templateUrl: './metadata-schema.component.html',
|
||||
styleUrls: ['./metadata-schema.component.scss']
|
||||
})
|
||||
/**
|
||||
* A component used for managing all existing metadata fields within the current metadata schema.
|
||||
* The admin can create, edit or delete metadata fields here.
|
||||
*/
|
||||
export class MetadataSchemaComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The namespace of the metadata schema
|
||||
*/
|
||||
namespace;
|
||||
|
||||
/**
|
||||
* The metadata schema
|
||||
*/
|
||||
metadataSchema: Observable<RemoteData<MetadataSchema>>;
|
||||
|
||||
/**
|
||||
* A list of all the fields attached to this metadata schema
|
||||
*/
|
||||
metadataFields: Observable<RemoteData<PaginatedList<MetadataField>>>;
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of metadata fields
|
||||
*/
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'registry-metadatafields-pagination',
|
||||
pageSize: 10000
|
||||
pageSize: 25,
|
||||
pageSizeOptions: [25, 50, 100, 200]
|
||||
});
|
||||
|
||||
constructor(private registryService: RegistryService, private route: ActivatedRoute) {
|
||||
constructor(private registryService: RegistryService,
|
||||
private route: ActivatedRoute,
|
||||
private notificationsService: NotificationsService,
|
||||
private router: Router,
|
||||
private translateService: TranslateService) {
|
||||
|
||||
}
|
||||
|
||||
@@ -34,22 +63,143 @@ export class MetadataSchemaComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component using the params within the url (schemaName)
|
||||
* @param params
|
||||
*/
|
||||
initialize(params) {
|
||||
this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName);
|
||||
this.updateFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.updateFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of fields by fetching it from the rest api or cache
|
||||
*/
|
||||
private updateFields() {
|
||||
this.metadataSchema.subscribe((schemaData) => {
|
||||
const schema = schemaData.payload;
|
||||
this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config);
|
||||
this.namespace = { namespace: schemaData.payload.namespace };
|
||||
this.namespace = {namespace: schemaData.payload.namespace};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of fields by first clearing the cache related to metadata fields, then performing
|
||||
* a new REST call
|
||||
*/
|
||||
public forceUpdateFields() {
|
||||
this.registryService.clearMetadataFieldRequests().subscribe();
|
||||
this.updateFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing the selected metadata field
|
||||
* @param field
|
||||
*/
|
||||
editField(field: MetadataField) {
|
||||
this.getActiveField().pipe(take(1)).subscribe((activeField) => {
|
||||
if (field === activeField) {
|
||||
this.registryService.cancelEditMetadataField();
|
||||
} else {
|
||||
this.registryService.editMetadataField(field);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given metadata field is active (being edited)
|
||||
* @param field
|
||||
*/
|
||||
isActive(field: MetadataField): Observable<boolean> {
|
||||
return this.getActiveField().pipe(
|
||||
map((activeField) => field === activeField)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active metadata field (being edited)
|
||||
*/
|
||||
getActiveField(): Observable<MetadataField> {
|
||||
return this.registryService.getActiveMetadataField();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a metadata field within the list (checkbox)
|
||||
* @param field
|
||||
* @param event
|
||||
*/
|
||||
selectMetadataField(field: MetadataField, event) {
|
||||
event.target.checked ?
|
||||
this.registryService.selectMetadataField(field) :
|
||||
this.registryService.deselectMetadataField(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given metadata field is selected in the list (checkbox)
|
||||
* @param field
|
||||
*/
|
||||
isSelected(field: MetadataField): Observable<boolean> {
|
||||
return this.registryService.getSelectedMetadataFields().pipe(
|
||||
map((fields) => fields.find((selectedField) => selectedField === field) != null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the selected metadata fields
|
||||
*/
|
||||
deleteFields() {
|
||||
this.registryService.getSelectedMetadataFields().pipe(take(1)).subscribe(
|
||||
(fields) => {
|
||||
const tasks$ = [];
|
||||
for (const field of fields) {
|
||||
if (hasValue(field.id)) {
|
||||
tasks$.push(this.registryService.deleteMetadataField(field.id));
|
||||
}
|
||||
}
|
||||
zip(...tasks$).subscribe((responses: RestResponse[]) => {
|
||||
const successResponses = responses.filter((response: RestResponse) => response.isSuccessful);
|
||||
const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful);
|
||||
if (successResponses.length > 0) {
|
||||
this.showNotification(true, successResponses.length);
|
||||
}
|
||||
if (failedResponses.length > 0) {
|
||||
this.showNotification(false, failedResponses.length);
|
||||
}
|
||||
this.registryService.deselectAllMetadataField();
|
||||
this.registryService.cancelEditMetadataField();
|
||||
this.forceUpdateFields();
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notifications for an amount of deleted metadata fields
|
||||
* @param success Whether or not the notification should be a success message (error message when false)
|
||||
* @param amount The amount of deleted metadata fields
|
||||
*/
|
||||
showNotification(success: boolean, amount: number) {
|
||||
const prefix = 'admin.registries.schema.notification';
|
||||
const suffix = success ? 'success' : 'failure';
|
||||
const messages = observableCombineLatest(
|
||||
this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
|
||||
this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount })
|
||||
);
|
||||
messages.subscribe(([head, content]) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(head, content)
|
||||
} else {
|
||||
this.notificationsService.error(head, content)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,10 @@ import { NgModule } from '@angular/core';
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: 'registries', loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }
|
||||
{
|
||||
path: 'registries',
|
||||
loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule'
|
||||
}
|
||||
])
|
||||
]
|
||||
})
|
||||
|
@@ -0,0 +1,11 @@
|
||||
<li class="sidebar-section">
|
||||
<a class="nav-item nav-link shortcut-icon" [routerLink]="itemModel.link">
|
||||
<i class="fas fa-{{section.icon}} fa-fw" [title]="('menu.section.icon.' + section.id) | translate"></i>
|
||||
</a>
|
||||
<div class="sidebar-collapsible">
|
||||
<span class="section-header-text">
|
||||
<ng-container
|
||||
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
@@ -0,0 +1 @@
|
||||
@import '../../../../styles/variables.scss';
|
@@ -0,0 +1,60 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MenuService } from '../../../shared/menu/menu.service';
|
||||
import { MenuServiceStub } from '../../../shared/testing/menu-service-stub';
|
||||
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
|
||||
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service-stub';
|
||||
import { Component } from '@angular/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { AdminSidebarSectionComponent } from './admin-sidebar-section.component';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
describe('AdminSidebarSectionComponent', () => {
|
||||
let component: AdminSidebarSectionComponent;
|
||||
let fixture: ComponentFixture<AdminSidebarSectionComponent>;
|
||||
const menuService = new MenuServiceStub();
|
||||
const iconString = 'test';
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
|
||||
declarations: [AdminSidebarSectionComponent, TestComponent],
|
||||
providers: [
|
||||
{ provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } },
|
||||
{ provide: MenuService, useValue: menuService },
|
||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||
]
|
||||
}).overrideComponent(AdminSidebarSectionComponent, {
|
||||
set: {
|
||||
entryComponents: [TestComponent]
|
||||
}
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the right icon', () => {
|
||||
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
|
||||
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
||||
});
|
||||
});
|
||||
|
||||
// declare a test component
|
||||
@Component({
|
||||
selector: 'ds-test-cmp',
|
||||
template: ``
|
||||
})
|
||||
class TestComponent {
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
import { Component, Inject, Injector, OnInit } from '@angular/core';
|
||||
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component';
|
||||
import { MenuID } from '../../../shared/menu/initial-menus-state';
|
||||
import { MenuService } from '../../../shared/menu/menu.service';
|
||||
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
|
||||
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
||||
import { MenuSection } from '../../../shared/menu/menu.reducer';
|
||||
|
||||
/**
|
||||
* Represents a non-expandable section in the admin sidebar
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-admin-sidebar-section',
|
||||
templateUrl: './admin-sidebar-section.component.html',
|
||||
styleUrls: ['./admin-sidebar-section.component.scss'],
|
||||
|
||||
})
|
||||
@rendersSectionForMenu(MenuID.ADMIN, false)
|
||||
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* This section resides in the Admin Sidebar
|
||||
*/
|
||||
menuID: MenuID = MenuID.ADMIN;
|
||||
itemModel;
|
||||
constructor(@Inject('sectionDataProvider') menuSection: MenuSection, protected menuService: MenuService, protected injector: Injector,) {
|
||||
super(menuSection, menuService, injector);
|
||||
this.itemModel = menuSection.model as LinkMenuItemModel;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
}
|
||||
}
|
52
src/app/+admin/admin-sidebar/admin-sidebar.component.html
Normal file
52
src/app/+admin/admin-sidebar/admin-sidebar.component.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<nav @slideHorizontal class="navbar navbar-dark bg-dark p-0"
|
||||
[ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}"
|
||||
[@slideSidebar]="{
|
||||
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),
|
||||
params: {sidebarWidth: (sidebarWidth | async)}
|
||||
}" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)"
|
||||
*ngIf="menuVisible | async" (mouseenter)="expandPreview($event)"
|
||||
(mouseleave)="collapsePreview($event)">
|
||||
<div class="sidebar-top-level-items">
|
||||
<ul class="navbar-nav">
|
||||
<li class="admin-menu-header sidebar-section">
|
||||
<a class="shortcut-icon navbar-brand mr-0" href="#">
|
||||
<span class="logo-wrapper">
|
||||
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
|
||||
[alt]="('menu.header.image.logo') | translate">
|
||||
</span>
|
||||
</a>
|
||||
<div class="sidebar-collapsible">
|
||||
<a class="navbar-brand mr-0" href="#">
|
||||
<h4 class="section-header-text mb-0">{{'menu.header.admin' |
|
||||
translate}}</h4>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<ng-container *ngFor="let section of (sections | async)">
|
||||
<ng-container
|
||||
*ngComponentOutlet="sectionComponents.get(section.id); injector: sectionInjectors.get(section.id);"></ng-container>
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-nav">
|
||||
<div class="sidebar-section" id="sidebar-collapse-toggle">
|
||||
<a class="nav-item nav-link shortcut-icon"
|
||||
href="#"
|
||||
(click)="toggle($event)">
|
||||
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
|
||||
[title]="'menu.section.icon.pin' | translate"></i>
|
||||
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
|
||||
[title]="'menu.section.icon.unpin' | translate"></i>
|
||||
</a>
|
||||
<div class="sidebar-collapsible">
|
||||
<a class="nav-item nav-link sidebar-section"
|
||||
href="#"
|
||||
(click)="toggle($event)">
|
||||
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
|
||||
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
77
src/app/+admin/admin-sidebar/admin-sidebar.component.scss
Normal file
77
src/app/+admin/admin-sidebar/admin-sidebar.component.scss
Normal file
@@ -0,0 +1,77 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
$icon-z-index: 10;
|
||||
|
||||
:host {
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
flex: 1 1 auto;
|
||||
nav {
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
> div {
|
||||
width: 100%;
|
||||
&.sidebar-top-level-items {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@include dark-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
&.inactive ::ng-deep .sidebar-collapsible {
|
||||
margin-left: -#{$sidebar-items-width};
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
.admin-menu-header {
|
||||
background-color: $admin-sidebar-header-bg;
|
||||
.logo-wrapper {
|
||||
img {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
.section-header-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
::ng-deep {
|
||||
.navbar-nav {
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
align-content: stretch;
|
||||
background-color: $dark;
|
||||
.nav-item {
|
||||
padding-top: $spacer;
|
||||
padding-bottom: $spacer;
|
||||
}
|
||||
.shortcut-icon {
|
||||
padding-left: $icon-padding;
|
||||
padding-right: $icon-padding;
|
||||
}
|
||||
.shortcut-icon, .icon-wrapper {
|
||||
background-color: inherit;
|
||||
z-index: $icon-z-index;
|
||||
}
|
||||
.sidebar-collapsible {
|
||||
width: $sidebar-items-width;
|
||||
position: relative;
|
||||
a {
|
||||
padding-right: $spacer;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&.active > .sidebar-collapsible > .nav-link {
|
||||
color: $navbar-dark-active-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
160
src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts
Normal file
160
src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { AdminSidebarComponent } from './admin-sidebar.component';
|
||||
import { MenuService } from '../../shared/menu/menu.service';
|
||||
import { MenuServiceStub } from '../../shared/testing/menu-service-stub';
|
||||
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
|
||||
import { CSSVariableServiceStub } from '../../shared/testing/css-variable-service-stub';
|
||||
import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
describe('AdminSidebarComponent', () => {
|
||||
let comp: AdminSidebarComponent;
|
||||
let fixture: ComponentFixture<AdminSidebarComponent>;
|
||||
const menuService = new MenuServiceStub();
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), NoopAnimationsModule],
|
||||
declarations: [AdminSidebarComponent],
|
||||
providers: [
|
||||
{ provide: Injector, useValue: {} },
|
||||
{ provide: MenuService, useValue: menuService },
|
||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||
{ provide: AuthService, useClass: AuthServiceStub },
|
||||
{
|
||||
provide: NgbModal, useValue: {
|
||||
open: () => {/*comment*/}
|
||||
}
|
||||
}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(AdminSidebarComponent, {
|
||||
set: {
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
}
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'getMenuTopSections').and.returnValue(observableOf([]));
|
||||
fixture = TestBed.createComponent(AdminSidebarComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
comp.sections = observableOf([]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('startSlide', () => {
|
||||
describe('when expanding', () => {
|
||||
beforeEach(() => {
|
||||
comp.sidebarClosed = true;
|
||||
comp.startSlide({ toState: 'expanded' } as any);
|
||||
});
|
||||
|
||||
it('should set the sidebarClosed to false', () => {
|
||||
expect(comp.sidebarClosed).toBeFalsy();
|
||||
})
|
||||
});
|
||||
|
||||
describe('when collapsing', () => {
|
||||
beforeEach(() => {
|
||||
comp.sidebarClosed = false;
|
||||
comp.startSlide({ toState: 'collapsed' } as any);
|
||||
});
|
||||
|
||||
it('should set the sidebarOpen to false', () => {
|
||||
expect(comp.sidebarOpen).toBeFalsy();
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
describe('finishSlide', () => {
|
||||
describe('when expanding', () => {
|
||||
beforeEach(() => {
|
||||
comp.sidebarClosed = true;
|
||||
comp.startSlide({ fromState: 'expanded' } as any);
|
||||
});
|
||||
|
||||
it('should set the sidebarClosed to true', () => {
|
||||
expect(comp.sidebarClosed).toBeTruthy();
|
||||
})
|
||||
});
|
||||
|
||||
describe('when collapsing', () => {
|
||||
beforeEach(() => {
|
||||
comp.sidebarClosed = false;
|
||||
comp.startSlide({ fromState: 'collapsed' } as any);
|
||||
});
|
||||
|
||||
it('should set the sidebarOpen to true', () => {
|
||||
expect(comp.sidebarOpen).toBeTruthy();
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
describe('when the collapse icon is clicked', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'toggleMenu');
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon'));
|
||||
sidebarToggler.triggerEventHandler('click', {
|
||||
preventDefault: () => {/**/
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should call toggleMenu on the menuService', () => {
|
||||
expect(menuService.toggleMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the collapse link is clicked', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'toggleMenu');
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a'));
|
||||
sidebarToggler.triggerEventHandler('click', {
|
||||
preventDefault: () => {/**/
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should call toggleMenu on the menuService', () => {
|
||||
expect(menuService.toggleMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the the mouse enters the nav tag', () => {
|
||||
it('should call expandPreview on the menuService after 100ms', fakeAsync(() => {
|
||||
spyOn(menuService, 'expandMenuPreview');
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
|
||||
sidebarToggler.triggerEventHandler('mouseenter', {
|
||||
preventDefault: () => {/**/
|
||||
}
|
||||
});
|
||||
tick(99);
|
||||
expect(menuService.expandMenuPreview).not.toHaveBeenCalled();
|
||||
tick(1);
|
||||
expect(menuService.expandMenuPreview).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('when the the mouse leaves the nav tag', () => {
|
||||
it('should call collapseMenuPreview on the menuService after 400ms', fakeAsync(() => {
|
||||
spyOn(menuService, 'collapseMenuPreview');
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
|
||||
sidebarToggler.triggerEventHandler('mouseleave', {
|
||||
preventDefault: () => {/**/
|
||||
}
|
||||
});
|
||||
tick(399);
|
||||
expect(menuService.collapseMenuPreview).not.toHaveBeenCalled();
|
||||
tick(1);
|
||||
expect(menuService.collapseMenuPreview).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
504
src/app/+admin/admin-sidebar/admin-sidebar.component.ts
Normal file
504
src/app/+admin/admin-sidebar/admin-sidebar.component.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { Component, Injector, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
|
||||
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
|
||||
import { MenuService } from '../../shared/menu/menu.service';
|
||||
import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state';
|
||||
import { MenuComponent } from '../../shared/menu/menu.component';
|
||||
import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model';
|
||||
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { combineLatest as combineLatestObservable } from 'rxjs';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model';
|
||||
import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
|
||||
import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
|
||||
import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
|
||||
import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
|
||||
import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
|
||||
import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
|
||||
|
||||
/**
|
||||
* Component representing the admin sidebar
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-admin-sidebar',
|
||||
templateUrl: './admin-sidebar.component.html',
|
||||
styleUrls: ['./admin-sidebar.component.scss'],
|
||||
animations: [slideHorizontal, slideSidebar]
|
||||
})
|
||||
export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
||||
/**
|
||||
* The menu ID of the Navbar is PUBLIC
|
||||
* @type {MenuID.ADMIN}
|
||||
*/
|
||||
menuID = MenuID.ADMIN;
|
||||
|
||||
/**
|
||||
* Observable that emits the width of the collapsible menu sections
|
||||
*/
|
||||
sidebarWidth: Observable<string>;
|
||||
|
||||
/**
|
||||
* Is true when the sidebar is open, is false when the sidebar is animating or closed
|
||||
* @type {boolean}
|
||||
*/
|
||||
sidebarOpen = true; // Open in UI, animation finished
|
||||
|
||||
/**
|
||||
* Is true when the sidebar is closed, is false when the sidebar is animating or open
|
||||
* @type {boolean}
|
||||
*/
|
||||
sidebarClosed = !this.sidebarOpen; // Closed in UI, animation finished
|
||||
|
||||
/**
|
||||
* Emits true when either the menu OR the menu's preview is expanded, else emits false
|
||||
*/
|
||||
sidebarExpanded: Observable<boolean>;
|
||||
|
||||
constructor(protected menuService: MenuService,
|
||||
protected injector: Injector,
|
||||
private variableService: CSSVariableService,
|
||||
private authService: AuthService,
|
||||
private modalService: NgbModal
|
||||
) {
|
||||
super(menuService, injector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set and calculate all initial values of the instance variables
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.createMenu();
|
||||
super.ngOnInit();
|
||||
this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth');
|
||||
this.authService.isAuthenticated()
|
||||
.subscribe((loggedIn: boolean) => {
|
||||
if (loggedIn) {
|
||||
this.menuService.showMenu(this.menuID);
|
||||
}
|
||||
});
|
||||
this.menuCollapsed.pipe(first())
|
||||
.subscribe((collapsed: boolean) => {
|
||||
this.sidebarOpen = !collapsed;
|
||||
this.sidebarClosed = collapsed;
|
||||
});
|
||||
this.sidebarExpanded = combineLatestObservable(this.menuCollapsed, this.menuPreviewCollapsed)
|
||||
.pipe(
|
||||
map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all menu sections and items for this menu
|
||||
*/
|
||||
private createMenu() {
|
||||
const menuList = [
|
||||
/* News */
|
||||
{
|
||||
id: 'new',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.new'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'plus-circle',
|
||||
index: 0
|
||||
},
|
||||
{
|
||||
id: 'new_community',
|
||||
parentID: 'new',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.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,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.new_item',
|
||||
link: '/submit'
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'new_item_version',
|
||||
parentID: 'new',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.new_item_version',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
|
||||
/* Edit */
|
||||
{
|
||||
id: 'edit',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.edit'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'pencil-alt',
|
||||
index: 1
|
||||
},
|
||||
{
|
||||
id: 'edit_community',
|
||||
parentID: 'edit',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.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,
|
||||
},
|
||||
|
||||
/* Import */
|
||||
{
|
||||
id: 'import',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.import'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'sign-in-alt',
|
||||
index: 2
|
||||
},
|
||||
{
|
||||
id: 'import_metadata',
|
||||
parentID: 'import',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.import_metadata',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'import_batch',
|
||||
parentID: 'import',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.import_batch',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
/* Export */
|
||||
{
|
||||
id: 'export',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.export'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'sign-out-alt',
|
||||
index: 3
|
||||
},
|
||||
{
|
||||
id: 'export_community',
|
||||
parentID: 'export',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.export_community',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'export_collection',
|
||||
parentID: 'export',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.export_collection',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'export_item',
|
||||
parentID: 'export',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.export_item',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
}, {
|
||||
id: 'export_metadata',
|
||||
parentID: 'export',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.export_metadata',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
|
||||
/* Access Control */
|
||||
{
|
||||
id: 'access_control',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.access_control'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'key',
|
||||
index: 4
|
||||
},
|
||||
{
|
||||
id: 'access_control_people',
|
||||
parentID: 'access_control',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.access_control_people',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'access_control_groups',
|
||||
parentID: 'access_control',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.access_control_groups',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'access_control_authorizations',
|
||||
parentID: 'access_control',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.access_control_authorizations',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
|
||||
/* Search */
|
||||
{
|
||||
id: 'find',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.find'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'search',
|
||||
index: 5
|
||||
},
|
||||
{
|
||||
id: 'find_items',
|
||||
parentID: 'find',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.find_items',
|
||||
link: '/search'
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'find_withdrawn_items',
|
||||
parentID: 'find',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.find_withdrawn_items',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'find_private_items',
|
||||
parentID: 'find',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.find_private_items',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
|
||||
/* Registries */
|
||||
{
|
||||
id: 'registries',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.registries'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'list',
|
||||
index: 6
|
||||
},
|
||||
{
|
||||
id: 'registries_metadata',
|
||||
parentID: 'registries',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.registries_metadata',
|
||||
link: 'admin/registries/metadata'
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
{
|
||||
id: 'registries_format',
|
||||
parentID: 'registries',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.registries_format',
|
||||
link: 'admin/registries/bitstream-formats'
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
|
||||
/* Curation tasks */
|
||||
{
|
||||
id: 'curation_tasks',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.curation_task',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
icon: 'filter',
|
||||
index: 7
|
||||
},
|
||||
|
||||
/* Statistics */
|
||||
{
|
||||
id: 'statistics_task',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.statistics_task',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
icon: 'chart-bar',
|
||||
index: 8
|
||||
},
|
||||
|
||||
/* Control Panel */
|
||||
{
|
||||
id: 'control_panel',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.control_panel',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
icon: 'cogs',
|
||||
index: 9
|
||||
},
|
||||
];
|
||||
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change this.collapsed to false when the slide animation ends and is sliding open
|
||||
* @param event The animation event
|
||||
*/
|
||||
startSlide(event: any): void {
|
||||
if (event.toState === 'expanded') {
|
||||
this.sidebarClosed = false;
|
||||
} else if (event.toState === 'collapsed') {
|
||||
this.sidebarOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change this.collapsed to false when the slide animation ends and is sliding open
|
||||
* @param event The animation event
|
||||
*/
|
||||
finishSlide(event: any): void {
|
||||
if (event.fromState === 'expanded') {
|
||||
this.sidebarClosed = true;
|
||||
} else if (event.fromState === 'collapsed') {
|
||||
this.sidebarOpen = true;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
<li class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
|
||||
[@bgColor]="{
|
||||
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
|
||||
params: {endColor: (sidebarActiveBg | async)}}">
|
||||
<div class="icon-wrapper">
|
||||
<a class="nav-item nav-link shortcut-icon" (click)="toggleSection($event)" href="#">
|
||||
<i class="fas fa-{{section.icon}} fa-fw" [title]="('menu.section.icon.' + section.id) | translate"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-collapsible">
|
||||
<a class="nav-item nav-link" href="#"
|
||||
(click)="toggleSection($event)">
|
||||
<span class="section-header-text">
|
||||
<ng-container
|
||||
*ngComponentOutlet="itemComponents.get(section.id); injector: itemInjectors.get(section.id);"></ng-container>
|
||||
</span>
|
||||
<i class="fas fa-chevron-right fa-pull-right"
|
||||
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i>
|
||||
</a>
|
||||
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
|
||||
<li *ngFor="let subSection of (subSections | async)">
|
||||
<ng-container
|
||||
*ngComponentOutlet="itemComponents.get(subSection.id); injector: itemInjectors.get(subSection.id);"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
@@ -0,0 +1,21 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
|
||||
::ng-deep {
|
||||
.fa-chevron-right {
|
||||
padding-left: $spacer/2;
|
||||
font-size: 0.5rem;
|
||||
line-height: 3;
|
||||
}
|
||||
|
||||
.sidebar-sub-level-items {
|
||||
list-style: disc;
|
||||
color: $navbar-dark-color;
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
.sidebar-collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
@@ -0,0 +1,84 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ExpandableAdminSidebarSectionComponent } from './expandable-admin-sidebar-section.component';
|
||||
import { MenuService } from '../../../shared/menu/menu.service';
|
||||
import { MenuServiceStub } from '../../../shared/testing/menu-service-stub';
|
||||
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
|
||||
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service-stub';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { Component } from '@angular/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
describe('ExpandableAdminSidebarSectionComponent', () => {
|
||||
let component: ExpandableAdminSidebarSectionComponent;
|
||||
let fixture: ComponentFixture<ExpandableAdminSidebarSectionComponent>;
|
||||
const menuService = new MenuServiceStub();
|
||||
const iconString = 'test';
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, TranslateModule.forRoot()],
|
||||
declarations: [ExpandableAdminSidebarSectionComponent, TestComponent],
|
||||
providers: [
|
||||
{ provide: 'sectionDataProvider', useValue: {icon: iconString} },
|
||||
{ provide: MenuService, useValue: menuService },
|
||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||
]
|
||||
}).overrideComponent(ExpandableAdminSidebarSectionComponent, {
|
||||
set: {
|
||||
entryComponents: [TestComponent]
|
||||
}
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
|
||||
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the right icon', () => {
|
||||
const icon = fixture.debugElement.query(By.css('.icon-wrapper')).query(By.css('i.fas'));
|
||||
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
||||
});
|
||||
|
||||
describe('when the icon is clicked', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'toggleActiveSection');
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('a.shortcut-icon'));
|
||||
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
|
||||
});
|
||||
|
||||
it('should call toggleActiveSection on the menuService', () => {
|
||||
expect(menuService.toggleActiveSection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the header text is clicked', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(menuService, 'toggleActiveSection');
|
||||
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-collapsible')).query(By.css('a'));
|
||||
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
|
||||
});
|
||||
|
||||
it('should call toggleActiveSection on the menuService', () => {
|
||||
expect(menuService.toggleActiveSection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// declare a test component
|
||||
@Component({
|
||||
selector: 'ds-test-cmp',
|
||||
template: ``
|
||||
})
|
||||
class TestComponent {
|
||||
}
|
@@ -0,0 +1,69 @@
|
||||
import { Component, Inject, Injector, OnInit } from '@angular/core';
|
||||
import { rotate } from '../../../shared/animations/rotate';
|
||||
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
|
||||
import { slide } from '../../../shared/animations/slide';
|
||||
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
|
||||
import { bgColor } from '../../../shared/animations/bgColor';
|
||||
import { MenuID } from '../../../shared/menu/initial-menus-state';
|
||||
import { MenuService } from '../../../shared/menu/menu.service';
|
||||
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
|
||||
|
||||
/**
|
||||
* Represents a expandable section in the sidebar
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-expandable-admin-sidebar-section',
|
||||
templateUrl: './expandable-admin-sidebar-section.component.html',
|
||||
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
|
||||
animations: [rotate, slide, bgColor]
|
||||
})
|
||||
|
||||
@rendersSectionForMenu(MenuID.ADMIN, true)
|
||||
export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit {
|
||||
/**
|
||||
* This section resides in the Admin Sidebar
|
||||
*/
|
||||
menuID = MenuID.ADMIN;
|
||||
|
||||
/**
|
||||
* The background color of the section when it's active
|
||||
*/
|
||||
sidebarActiveBg;
|
||||
|
||||
/**
|
||||
* Emits true when the sidebar is currently collapsed, true when it's expanded
|
||||
*/
|
||||
sidebarCollapsed: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits true when the sidebar's preview is currently collapsed, true when it's expanded
|
||||
*/
|
||||
sidebarPreviewCollapsed: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits true when the menu section is expanded, else emits false
|
||||
* This is true when the section is active AND either the sidebar or it's preview is open
|
||||
*/
|
||||
expanded: Observable<boolean>;
|
||||
|
||||
constructor(@Inject('sectionDataProvider') menuSection, protected menuService: MenuService,
|
||||
private variableService: CSSVariableService, protected injector: Injector) {
|
||||
super(menuSection, menuService, injector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set initial values for instance variables
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
this.sidebarActiveBg = this.variableService.getVariable('adminSidebarActiveBg');
|
||||
this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID);
|
||||
this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
|
||||
this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed)
|
||||
.pipe(
|
||||
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed)))
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,12 +1,14 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
|
||||
import { AdminRoutingModule } from './admin-routing.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AdminRegistriesModule,
|
||||
AdminRoutingModule
|
||||
]
|
||||
AdminRoutingModule,
|
||||
SharedModule,
|
||||
],
|
||||
})
|
||||
export class AdminModule {
|
||||
|
||||
|
@@ -1,11 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="browse-by-author w-100 row">
|
||||
<ds-browse-by class="col-xs-12 w-100"
|
||||
title="{{'browse.title' | translate:{collection: '', field: 'Author', value: (value)? '"' + value + '"': ''} }}"
|
||||
[objects$]="(items$ !== undefined)? items$ : authors$"
|
||||
[currentUrl]="currentUrl"
|
||||
[paginationConfig]="paginationConfig"
|
||||
[sortConfig]="sortConfig">
|
||||
</ds-browse-by>
|
||||
</div>
|
||||
</div>
|
@@ -1,107 +0,0 @@
|
||||
|
||||
import {combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { ItemDataService } from '../../core/data/item-data.service';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { BrowseEntry } from '../../core/shared/browse-entry.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-browse-by-author-page',
|
||||
styleUrls: ['./browse-by-author-page.component.scss'],
|
||||
templateUrl: './browse-by-author-page.component.html'
|
||||
})
|
||||
/**
|
||||
* Component for browsing (items) by author (dc.contributor.author)
|
||||
*/
|
||||
export class BrowseByAuthorPageComponent implements OnInit {
|
||||
|
||||
authors$: Observable<RemoteData<PaginatedList<BrowseEntry>>>;
|
||||
items$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'browse-by-author-pagination',
|
||||
currentPage: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
sortConfig: SortOptions = new SortOptions('dc.contributor.author', SortDirection.ASC);
|
||||
subs: Subscription[] = [];
|
||||
currentUrl: string;
|
||||
value = '';
|
||||
|
||||
public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute, private browseService: BrowseService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.currentUrl = this.route.snapshot.pathFromRoot
|
||||
.map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '')
|
||||
.join('/');
|
||||
this.updatePage({
|
||||
pagination: this.paginationConfig,
|
||||
sort: this.sortConfig
|
||||
});
|
||||
this.subs.push(
|
||||
observableCombineLatest(
|
||||
this.route.params,
|
||||
this.route.queryParams,
|
||||
(params, queryParams, ) => {
|
||||
return Object.assign({}, params, queryParams);
|
||||
})
|
||||
.subscribe((params) => {
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const sortDirection = params.sortDirection || this.sortConfig.direction;
|
||||
const sortField = params.sortField || this.sortConfig.field;
|
||||
this.value = +params.value || params.value || '';
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
const sort = Object.assign({},
|
||||
this.sortConfig,
|
||||
{ direction: sortDirection, field: sortField }
|
||||
);
|
||||
const searchOptions = {
|
||||
pagination: pagination,
|
||||
sort: sort
|
||||
};
|
||||
if (isNotEmpty(this.value)) {
|
||||
this.updatePageWithItems(searchOptions, this.value);
|
||||
} else {
|
||||
this.updatePage(searchOptions);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current page with searchOptions
|
||||
* @param searchOptions Options to narrow down your search:
|
||||
* { pagination: PaginationComponentOptions,
|
||||
* sort: SortOptions }
|
||||
*/
|
||||
updatePage(searchOptions) {
|
||||
this.authors$ = this.browseService.getBrowseEntriesFor('author', searchOptions);
|
||||
this.items$ = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current page with searchOptions and display items linked to author
|
||||
* @param searchOptions Options to narrow down your search:
|
||||
* { pagination: PaginationComponentOptions,
|
||||
* sort: SortOptions }
|
||||
* @param author The author's name for displaying items
|
||||
*/
|
||||
updatePageWithItems(searchOptions, author: string) {
|
||||
this.items$ = this.browseService.getBrowseItemsFor('author', author, searchOptions);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,104 @@
|
||||
import { BrowseByDatePageComponent } from './browse-by-date-page.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { MockRouter } from '../../shared/mocks/mock-router';
|
||||
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config';
|
||||
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
|
||||
import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec';
|
||||
|
||||
describe('BrowseByDatePageComponent', () => {
|
||||
let comp: BrowseByDatePageComponent;
|
||||
let fixture: ComponentFixture<BrowseByDatePageComponent>;
|
||||
let route: ActivatedRoute;
|
||||
|
||||
const mockCommunity = Object.assign(new Community(), {
|
||||
id: 'test-uuid',
|
||||
metadata: [
|
||||
{
|
||||
key: 'dc.title',
|
||||
value: 'test community'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const firstItem = Object.assign(new Item(), {
|
||||
id: 'first-item-id',
|
||||
metadata: {
|
||||
'dc.date.issued': [
|
||||
{
|
||||
value: '1950-01-01'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const mockBrowseService = {
|
||||
getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]),
|
||||
getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]),
|
||||
getFirstItemFor: () => observableOf(new RemoteData(false, false, true, undefined, firstItem))
|
||||
};
|
||||
|
||||
const mockDsoService = {
|
||||
findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity))
|
||||
};
|
||||
|
||||
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
|
||||
params: observableOf({}),
|
||||
queryParams: observableOf({}),
|
||||
data: observableOf({ metadata: 'dateissued', metadataField: 'dc.date.issued' })
|
||||
});
|
||||
|
||||
const mockCdRef = Object.assign({
|
||||
detectChanges: () => fixture.detectChanges()
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [BrowseByDatePageComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: GLOBAL_CONFIG, useValue: ENV_CONFIG },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||
{ provide: BrowseService, useValue: mockBrowseService },
|
||||
{ provide: DSpaceObjectDataService, useValue: mockDsoService },
|
||||
{ provide: Router, useValue: new MockRouter() },
|
||||
{ provide: ChangeDetectorRef, useValue: mockCdRef }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BrowseByDatePageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
route = (comp as any).route;
|
||||
});
|
||||
|
||||
it('should initialize the list of items', () => {
|
||||
comp.items$.subscribe((result) => {
|
||||
expect(result.payload.page).toEqual([firstItem]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a list of startsWith options with the earliest year at the end (rounded down by 10)', () => {
|
||||
expect(comp.startsWithOptions[comp.startsWithOptions.length - 1]).toEqual(1950);
|
||||
});
|
||||
|
||||
it('should create a list of startsWith options with the current year first', () => {
|
||||
expect(comp.startsWithOptions[0]).toEqual(new Date().getFullYear());
|
||||
});
|
||||
});
|
@@ -0,0 +1,115 @@
|
||||
import { ChangeDetectorRef, Component, Inject } from '@angular/core';
|
||||
import {
|
||||
BrowseByMetadataPageComponent,
|
||||
browseParamsToOptions
|
||||
} from '../+browse-by-metadata-page/browse-by-metadata-page.component';
|
||||
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
|
||||
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
|
||||
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-browse-by-date-page',
|
||||
styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'],
|
||||
templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html'
|
||||
})
|
||||
/**
|
||||
* Component for browsing items by metadata definition of type 'date'
|
||||
* A metadata definition is a short term used to describe one or multiple metadata fields.
|
||||
* An example would be 'dateissued' for 'dc.date.issued'
|
||||
*/
|
||||
export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
||||
|
||||
/**
|
||||
* The default metadata-field to use for determining the lower limit of the StartsWith dropdown options
|
||||
*/
|
||||
defaultMetadataField = 'dc.date.issued';
|
||||
|
||||
public constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig,
|
||||
protected route: ActivatedRoute,
|
||||
protected browseService: BrowseService,
|
||||
protected dsoService: DSpaceObjectDataService,
|
||||
protected router: Router,
|
||||
protected cdRef: ChangeDetectorRef) {
|
||||
super(route, browseService, dsoService, router);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.startsWithType = StartsWithType.date;
|
||||
this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig));
|
||||
this.subs.push(
|
||||
observableCombineLatest(
|
||||
this.route.params,
|
||||
this.route.queryParams,
|
||||
this.route.data,
|
||||
(params, queryParams, data ) => {
|
||||
return Object.assign({}, params, queryParams, data);
|
||||
})
|
||||
.subscribe((params) => {
|
||||
const metadataField = params.metadataField || this.defaultMetadataField;
|
||||
this.metadata = params.metadata || this.defaultMetadata;
|
||||
this.startsWith = +params.startsWith || params.startsWith;
|
||||
const searchOptions = browseParamsToOptions(params, Object.assign({}), this.sortConfig, this.metadata);
|
||||
this.updatePageWithItems(searchOptions, this.value);
|
||||
this.updateParent(params.scope);
|
||||
this.updateStartsWithOptions(this.metadata, metadataField, params.scope);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the StartsWith options
|
||||
* In this implementation, it creates a list of years starting from now, going all the way back to the earliest
|
||||
* date found on an item within this scope. The further back in time, the bigger the change in years become to avoid
|
||||
* extremely long lists with a one-year difference.
|
||||
* To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this.
|
||||
* @param definition The metadata definition to fetch the first item for
|
||||
* @param metadataField The metadata field to fetch the earliest date from (expects a date field)
|
||||
* @param scope The scope under which to fetch the earliest item for
|
||||
*/
|
||||
updateStartsWithOptions(definition: string, metadataField: string, scope?: string) {
|
||||
this.subs.push(
|
||||
this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData<Item>) => {
|
||||
let lowerLimit = this.config.browseBy.defaultLowerLimit;
|
||||
if (hasValue(firstItemRD.payload)) {
|
||||
const date = firstItemRD.payload.firstMetadataValue(metadataField);
|
||||
if (hasValue(date) && hasValue(+date.split('-')[0])) {
|
||||
lowerLimit = +date.split('-')[0];
|
||||
}
|
||||
}
|
||||
const options = [];
|
||||
const currentYear = new Date().getFullYear();
|
||||
const oneYearBreak = Math.floor((currentYear - this.config.browseBy.oneYearLimit) / 5) * 5;
|
||||
const fiveYearBreak = Math.floor((currentYear - this.config.browseBy.fiveYearLimit) / 10) * 10;
|
||||
if (lowerLimit <= fiveYearBreak) {
|
||||
lowerLimit -= 10;
|
||||
} else if (lowerLimit <= oneYearBreak) {
|
||||
lowerLimit -= 5;
|
||||
} else {
|
||||
lowerLimit -= 1;
|
||||
}
|
||||
let i = currentYear;
|
||||
while (i > lowerLimit) {
|
||||
options.push(i);
|
||||
if (i <= fiveYearBreak) {
|
||||
i -= 10;
|
||||
} else if (i <= oneYearBreak) {
|
||||
i -= 5;
|
||||
} else {
|
||||
i--;
|
||||
}
|
||||
}
|
||||
if (isNotEmpty(options)) {
|
||||
this.startsWithOptions = options;
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
<div class="container">
|
||||
<div class="browse-by-metadata w-100">
|
||||
<ds-browse-by *ngIf="startsWithOptions" class="col-xs-12 w-100"
|
||||
title="{{'browse.title' | translate:{collection: (parent$ | async)?.payload?.name || '', field: 'browse.metadata.' + metadata | translate, value: (value)? '"' + value + '"': ''} }}"
|
||||
[objects$]="(items$ !== undefined)? items$ : browseEntries$"
|
||||
[paginationConfig]="paginationConfig"
|
||||
[sortConfig]="sortConfig"
|
||||
[type]="startsWithType"
|
||||
[startsWithOptions]="startsWithOptions"
|
||||
[enableArrows]="true"
|
||||
(prev)="goPrev()"
|
||||
(next)="goNext()"
|
||||
(pageSizeChange)="pageSizeChange($event)"
|
||||
(sortDirectionChange)="sortDirectionChange($event)">
|
||||
</ds-browse-by>
|
||||
<ds-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-loading>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,164 @@
|
||||
import { BrowseByMetadataPageComponent, browseParamsToOptions } from './browse-by-metadata-page.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { PageInfo } from '../../core/shared/page-info.model';
|
||||
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
|
||||
import { SortDirection } from '../../core/cache/models/sort-options.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { MockRouter } from '../../shared/mocks/mock-router';
|
||||
|
||||
describe('BrowseByMetadataPageComponent', () => {
|
||||
let comp: BrowseByMetadataPageComponent;
|
||||
let fixture: ComponentFixture<BrowseByMetadataPageComponent>;
|
||||
let browseService: BrowseService;
|
||||
let route: ActivatedRoute;
|
||||
|
||||
const mockCommunity = Object.assign(new Community(), {
|
||||
id: 'test-uuid',
|
||||
metadata: [
|
||||
{
|
||||
key: 'dc.title',
|
||||
value: 'test community'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const mockEntries = [
|
||||
{
|
||||
type: 'author',
|
||||
authority: null,
|
||||
value: 'John Doe',
|
||||
language: 'en',
|
||||
count: 1
|
||||
},
|
||||
{
|
||||
type: 'author',
|
||||
authority: null,
|
||||
value: 'James Doe',
|
||||
language: 'en',
|
||||
count: 3
|
||||
},
|
||||
{
|
||||
type: 'subject',
|
||||
authority: null,
|
||||
value: 'Fake subject',
|
||||
language: 'en',
|
||||
count: 2
|
||||
}
|
||||
];
|
||||
|
||||
const mockItems = [
|
||||
Object.assign(new Item(), {
|
||||
id: 'fakeId'
|
||||
})
|
||||
];
|
||||
|
||||
const mockBrowseService = {
|
||||
getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData(mockEntries.filter((entry) => entry.type === options.metadataDefinition)),
|
||||
getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData(mockItems)
|
||||
};
|
||||
|
||||
const mockDsoService = {
|
||||
findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity))
|
||||
};
|
||||
|
||||
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
|
||||
params: observableOf({})
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [BrowseByMetadataPageComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||
{ provide: BrowseService, useValue: mockBrowseService },
|
||||
{ provide: DSpaceObjectDataService, useValue: mockDsoService },
|
||||
{ provide: Router, useValue: new MockRouter() }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BrowseByMetadataPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
browseService = (comp as any).browseService;
|
||||
route = (comp as any).route;
|
||||
route.params = observableOf({});
|
||||
comp.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should fetch the correct entries depending on the metadata definition', () => {
|
||||
comp.browseEntries$.subscribe((result) => {
|
||||
expect(result.payload.page).toEqual(mockEntries.filter((entry) => entry.type === 'author'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fetch any items when no value is provided', () => {
|
||||
expect(comp.items$).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('when a value is provided', () => {
|
||||
beforeEach(() => {
|
||||
const paramsWithValue = {
|
||||
metadata: 'author',
|
||||
value: 'John Doe'
|
||||
};
|
||||
|
||||
route.params = observableOf(paramsWithValue);
|
||||
comp.ngOnInit();
|
||||
});
|
||||
|
||||
it('should fetch items', () => {
|
||||
comp.items$.subscribe((result) => {
|
||||
expect(result.payload.page).toEqual(mockItems);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
describe('when calling browseParamsToOptions', () => {
|
||||
let result: BrowseEntrySearchOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
const paramsWithPaginationAndScope = {
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
sortDirection: SortDirection.ASC,
|
||||
sortField: 'fake-field',
|
||||
scope: 'fake-scope'
|
||||
};
|
||||
|
||||
result = browseParamsToOptions(paramsWithPaginationAndScope, Object.assign({}), Object.assign({}), 'author');
|
||||
});
|
||||
|
||||
it('should return BrowseEntrySearchOptions with the correct properties', () => {
|
||||
expect(result.metadataDefinition).toEqual('author');
|
||||
expect(result.pagination.currentPage).toEqual(5);
|
||||
expect(result.pagination.pageSize).toEqual(10);
|
||||
expect(result.sort.direction).toEqual(SortDirection.ASC);
|
||||
expect(result.sort.field).toEqual('fake-field');
|
||||
expect(result.scope).toEqual('fake-scope');
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
export function toRemoteData(objects: any[]): Observable<RemoteData<PaginatedList<any>>> {
|
||||
return observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), objects)));
|
||||
}
|
@@ -0,0 +1,263 @@
|
||||
import {combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { BrowseEntry } from '../../core/shared/browse-entry.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
|
||||
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-browse-by-metadata-page',
|
||||
styleUrls: ['./browse-by-metadata-page.component.scss'],
|
||||
templateUrl: './browse-by-metadata-page.component.html'
|
||||
})
|
||||
/**
|
||||
* Component for browsing (items) by metadata definition
|
||||
* A metadata definition is a short term used to describe one or multiple metadata fields.
|
||||
* An example would be 'author' for 'dc.contributor.*'
|
||||
*/
|
||||
export class BrowseByMetadataPageComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The list of browse-entries to display
|
||||
*/
|
||||
browseEntries$: Observable<RemoteData<PaginatedList<BrowseEntry>>>;
|
||||
|
||||
/**
|
||||
* The list of items to display when a value is present
|
||||
*/
|
||||
items$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
|
||||
/**
|
||||
* The current Community or Collection we're browsing metadata/items in
|
||||
*/
|
||||
parent$: Observable<RemoteData<DSpaceObject>>;
|
||||
|
||||
/**
|
||||
* The pagination config used to display the values
|
||||
*/
|
||||
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'browse-by-metadata-pagination',
|
||||
currentPage: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
/**
|
||||
* The sorting config used to sort the values (defaults to Ascending)
|
||||
*/
|
||||
sortConfig: SortOptions = new SortOptions('default', SortDirection.ASC);
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* The default metadata definition to resort to when none is provided
|
||||
*/
|
||||
defaultMetadata = 'author';
|
||||
|
||||
/**
|
||||
* The current metadata definition
|
||||
*/
|
||||
metadata = this.defaultMetadata;
|
||||
|
||||
/**
|
||||
* The type of StartsWith options to render
|
||||
* Defaults to text
|
||||
*/
|
||||
startsWithType = StartsWithType.text;
|
||||
|
||||
/**
|
||||
* The list of StartsWith options
|
||||
* Should be defined after ngOnInit is called!
|
||||
*/
|
||||
startsWithOptions;
|
||||
|
||||
/**
|
||||
* The value we're browing items for
|
||||
* - When the value is not empty, we're browsing items
|
||||
* - When the value is empty, we're browsing browse-entries (values for the given metadata definition)
|
||||
*/
|
||||
value = '';
|
||||
|
||||
/**
|
||||
* The current startsWith option (fetched and updated from query-params)
|
||||
*/
|
||||
startsWith: string;
|
||||
|
||||
public constructor(protected route: ActivatedRoute,
|
||||
protected browseService: BrowseService,
|
||||
protected dsoService: DSpaceObjectDataService,
|
||||
protected router: Router) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig));
|
||||
this.subs.push(
|
||||
observableCombineLatest(
|
||||
this.route.params,
|
||||
this.route.queryParams,
|
||||
(params, queryParams, ) => {
|
||||
return Object.assign({}, params, queryParams);
|
||||
})
|
||||
.subscribe((params) => {
|
||||
this.metadata = params.metadata || this.defaultMetadata;
|
||||
this.value = +params.value || params.value || '';
|
||||
this.startsWith = +params.startsWith || params.startsWith;
|
||||
const searchOptions = browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata);
|
||||
if (isNotEmpty(this.value)) {
|
||||
this.updatePageWithItems(searchOptions, this.value);
|
||||
} else {
|
||||
this.updatePage(searchOptions);
|
||||
}
|
||||
this.updateParent(params.scope);
|
||||
}));
|
||||
this.updateStartsWithTextOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the StartsWith options with text values
|
||||
* It adds the value "0-9" as well as all letters from A to Z
|
||||
*/
|
||||
updateStartsWithTextOptions() {
|
||||
this.startsWithOptions = ['0-9', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current page with searchOptions
|
||||
* @param searchOptions Options to narrow down your search:
|
||||
* { metadata: string
|
||||
* pagination: PaginationComponentOptions,
|
||||
* sort: SortOptions,
|
||||
* scope: string }
|
||||
*/
|
||||
updatePage(searchOptions: BrowseEntrySearchOptions) {
|
||||
this.browseEntries$ = this.browseService.getBrowseEntriesFor(searchOptions);
|
||||
this.items$ = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current page with searchOptions and display items linked to the given value
|
||||
* @param searchOptions Options to narrow down your search:
|
||||
* { metadata: string
|
||||
* pagination: PaginationComponentOptions,
|
||||
* sort: SortOptions,
|
||||
* scope: string }
|
||||
* @param value The value of the browse-entry to display items for
|
||||
*/
|
||||
updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) {
|
||||
this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the parent Community or Collection using their scope
|
||||
* @param scope The UUID of the Community or Collection to fetch
|
||||
*/
|
||||
updateParent(scope: string) {
|
||||
if (hasValue(scope)) {
|
||||
this.parent$ = this.dsoService.findById(scope).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the previous page
|
||||
*/
|
||||
goPrev() {
|
||||
if (this.items$) {
|
||||
this.items$.pipe(take(1)).subscribe((items) => {
|
||||
this.items$ = this.browseService.getPrevBrowseItems(items);
|
||||
});
|
||||
} else if (this.browseEntries$) {
|
||||
this.browseEntries$.pipe(take(1)).subscribe((entries) => {
|
||||
this.browseEntries$ = this.browseService.getPrevBrowseEntries(entries);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next page
|
||||
*/
|
||||
goNext() {
|
||||
if (this.items$) {
|
||||
this.items$.pipe(take(1)).subscribe((items) => {
|
||||
this.items$ = this.browseService.getNextBrowseItems(items);
|
||||
});
|
||||
} else if (this.browseEntries$) {
|
||||
this.browseEntries$.pipe(take(1)).subscribe((entries) => {
|
||||
this.browseEntries$ = this.browseService.getNextBrowseEntries(entries);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the page size
|
||||
* @param size
|
||||
*/
|
||||
pageSizeChange(size) {
|
||||
this.router.navigate([], {
|
||||
queryParams: Object.assign({ pageSize: size }),
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the sorting direction
|
||||
* @param direction
|
||||
*/
|
||||
sortDirectionChange(direction) {
|
||||
this.router.navigate([], {
|
||||
queryParams: Object.assign({ sortDirection: direction }),
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to transform query and url parameters into searchOptions used to fetch browse entries or items
|
||||
* @param params URL and query parameters
|
||||
* @param paginationConfig Pagination configuration
|
||||
* @param sortConfig Sorting configuration
|
||||
* @param metadata Optional metadata definition to fetch browse entries/items for
|
||||
*/
|
||||
export function browseParamsToOptions(params: any,
|
||||
paginationConfig: PaginationComponentOptions,
|
||||
sortConfig: SortOptions,
|
||||
metadata?: string): BrowseEntrySearchOptions {
|
||||
return new BrowseEntrySearchOptions(
|
||||
metadata,
|
||||
Object.assign({},
|
||||
paginationConfig,
|
||||
{
|
||||
currentPage: +params.page || paginationConfig.currentPage,
|
||||
pageSize: +params.pageSize || paginationConfig.pageSize
|
||||
}
|
||||
),
|
||||
Object.assign({},
|
||||
sortConfig,
|
||||
{
|
||||
direction: params.sortDirection || sortConfig.direction,
|
||||
field: params.sortField || sortConfig.field
|
||||
}
|
||||
),
|
||||
+params.startsWith || params.startsWith,
|
||||
params.scope
|
||||
);
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="browse-by-title w-100 row">
|
||||
<ds-browse-by class="col-xs-12 w-100"
|
||||
title="{{'browse.title' | translate:{collection: '', field: 'Title', value: ''} }}"
|
||||
[objects$]="items$"
|
||||
[currentUrl]="currentUrl"
|
||||
[paginationConfig]="paginationConfig"
|
||||
[sortConfig]="sortConfig">
|
||||
</ds-browse-by>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,90 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec';
|
||||
import { BrowseByTitlePageComponent } from './browse-by-title-page.component';
|
||||
import { ItemDataService } from '../../core/data/item-data.service';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { MockRouter } from '../../shared/mocks/mock-router';
|
||||
|
||||
describe('BrowseByTitlePageComponent', () => {
|
||||
let comp: BrowseByTitlePageComponent;
|
||||
let fixture: ComponentFixture<BrowseByTitlePageComponent>;
|
||||
let itemDataService: ItemDataService;
|
||||
let route: ActivatedRoute;
|
||||
|
||||
const mockCommunity = Object.assign(new Community(), {
|
||||
id: 'test-uuid',
|
||||
metadata: [
|
||||
{
|
||||
key: 'dc.title',
|
||||
value: 'test community'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const mockItems = [
|
||||
Object.assign(new Item(), {
|
||||
id: 'fakeId',
|
||||
metadata: [
|
||||
{
|
||||
key: 'dc.title',
|
||||
value: 'Fake Title'
|
||||
}
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
const mockBrowseService = {
|
||||
getBrowseItemsFor: () => toRemoteData(mockItems),
|
||||
getBrowseEntriesFor: () => toRemoteData([])
|
||||
};
|
||||
|
||||
const mockDsoService = {
|
||||
findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity))
|
||||
};
|
||||
|
||||
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
|
||||
params: observableOf({}),
|
||||
data: observableOf({ metadata: 'title' })
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [BrowseByTitlePageComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||
{ provide: BrowseService, useValue: mockBrowseService },
|
||||
{ provide: DSpaceObjectDataService, useValue: mockDsoService },
|
||||
{ provide: Router, useValue: new MockRouter() }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BrowseByTitlePageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
itemDataService = (comp as any).itemDataService;
|
||||
route = (comp as any).route;
|
||||
});
|
||||
|
||||
it('should initialize the list of items', () => {
|
||||
comp.items$.subscribe((result) => {
|
||||
expect(result.payload.page).toEqual(mockItems);
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,88 +1,51 @@
|
||||
|
||||
import {combineLatest as observableCombineLatest, Observable , Subscription } from 'rxjs';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { combineLatest as observableCombineLatest } from 'rxjs';
|
||||
import { Component } from '@angular/core';
|
||||
import { ItemDataService } from '../../core/data/item-data.service';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import {
|
||||
BrowseByMetadataPageComponent,
|
||||
browseParamsToOptions
|
||||
} from '../+browse-by-metadata-page/browse-by-metadata-page.component';
|
||||
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { BrowseService } from '../../core/browse/browse.service';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-browse-by-title-page',
|
||||
styleUrls: ['./browse-by-title-page.component.scss'],
|
||||
templateUrl: './browse-by-title-page.component.html'
|
||||
styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'],
|
||||
templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html'
|
||||
})
|
||||
/**
|
||||
* Component for browsing items by title (dc.title)
|
||||
*/
|
||||
export class BrowseByTitlePageComponent implements OnInit {
|
||||
|
||||
items$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'browse-by-title-pagination',
|
||||
currentPage: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC);
|
||||
subs: Subscription[] = [];
|
||||
currentUrl: string;
|
||||
|
||||
public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute) {
|
||||
export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
|
||||
|
||||
public constructor(protected route: ActivatedRoute,
|
||||
protected browseService: BrowseService,
|
||||
protected dsoService: DSpaceObjectDataService,
|
||||
protected router: Router) {
|
||||
super(route, browseService, dsoService, router);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.currentUrl = this.route.snapshot.pathFromRoot
|
||||
.map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '')
|
||||
.join('/');
|
||||
this.updatePage({
|
||||
pagination: this.paginationConfig,
|
||||
sort: this.sortConfig
|
||||
});
|
||||
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
|
||||
this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig));
|
||||
this.subs.push(
|
||||
observableCombineLatest(
|
||||
this.route.params,
|
||||
this.route.queryParams,
|
||||
(params, queryParams, ) => {
|
||||
return Object.assign({}, params, queryParams);
|
||||
this.route.data,
|
||||
(params, queryParams, data ) => {
|
||||
return Object.assign({}, params, queryParams, data);
|
||||
})
|
||||
.subscribe((params) => {
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const sortDirection = params.sortDirection || this.sortConfig.direction;
|
||||
const sortField = params.sortField || this.sortConfig.field;
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
const sort = Object.assign({},
|
||||
this.sortConfig,
|
||||
{ direction: sortDirection, field: sortField }
|
||||
);
|
||||
this.updatePage({
|
||||
pagination: pagination,
|
||||
sort: sort
|
||||
});
|
||||
this.metadata = params.metadata || this.defaultMetadata;
|
||||
this.updatePageWithItems(browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata), undefined);
|
||||
this.updateParent(params.scope)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current page with searchOptions
|
||||
* @param searchOptions Options to narrow down your search:
|
||||
* { pagination: PaginationComponentOptions,
|
||||
* sort: SortOptions }
|
||||
*/
|
||||
updatePage(searchOptions) {
|
||||
this.items$ = this.itemDataService.findAll({
|
||||
currentPage: searchOptions.pagination.currentPage,
|
||||
elementsPerPage: searchOptions.pagination.pageSize,
|
||||
sort: searchOptions.sort
|
||||
});
|
||||
this.updateStartsWithTextOptions();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
125
src/app/+browse-by/browse-by-guard.spec.ts
Normal file
125
src/app/+browse-by/browse-by-guard.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { first } from 'rxjs/operators';
|
||||
import { BrowseByGuard } from './browse-by-guard';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
describe('BrowseByGuard', () => {
|
||||
describe('canActivate', () => {
|
||||
let guard: BrowseByGuard;
|
||||
let dsoService: any;
|
||||
let translateService: any;
|
||||
|
||||
const name = 'An interesting DSO';
|
||||
const title = 'Author';
|
||||
const field = 'Author';
|
||||
const metadata = 'author';
|
||||
const metadataField = 'dc.contributor';
|
||||
const scope = '1234-65487-12354-1235';
|
||||
const value = 'Filter';
|
||||
|
||||
beforeEach(() => {
|
||||
dsoService = {
|
||||
findById: (id: string) => observableOf({ payload: { name: name }, hasSucceeded: true })
|
||||
};
|
||||
|
||||
translateService = {
|
||||
instant: () => field
|
||||
};
|
||||
guard = new BrowseByGuard(dsoService, translateService);
|
||||
});
|
||||
|
||||
it('should return true, and sets up the data correctly, with a scope and value', () => {
|
||||
const scopedRoute = {
|
||||
data: {
|
||||
title: field,
|
||||
metadataField,
|
||||
},
|
||||
params: {
|
||||
metadata,
|
||||
},
|
||||
queryParams: {
|
||||
scope,
|
||||
value
|
||||
}
|
||||
};
|
||||
guard.canActivate(scopedRoute as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) => {
|
||||
const result = {
|
||||
title,
|
||||
metadata,
|
||||
metadataField,
|
||||
collection: name,
|
||||
field,
|
||||
value: '"' + value + '"'
|
||||
};
|
||||
expect(scopedRoute.data).toEqual(result);
|
||||
expect(canActivate).toEqual(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true, and sets up the data correctly, with a scope and without value', () => {
|
||||
const scopedNoValueRoute = {
|
||||
data: {
|
||||
title: field,
|
||||
metadataField,
|
||||
},
|
||||
params: {
|
||||
metadata,
|
||||
},
|
||||
queryParams: {
|
||||
scope
|
||||
}
|
||||
};
|
||||
|
||||
guard.canActivate(scopedNoValueRoute as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) => {
|
||||
const result = {
|
||||
title,
|
||||
metadata,
|
||||
metadataField,
|
||||
collection: name,
|
||||
field,
|
||||
value: ''
|
||||
};
|
||||
expect(scopedNoValueRoute.data).toEqual(result);
|
||||
expect(canActivate).toEqual(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true, and sets up the data correctly, without a scope and with a value', () => {
|
||||
const route = {
|
||||
data: {
|
||||
title: field,
|
||||
metadataField,
|
||||
},
|
||||
params: {
|
||||
metadata,
|
||||
},
|
||||
queryParams: {
|
||||
value
|
||||
}
|
||||
};
|
||||
guard.canActivate(route as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) => {
|
||||
const result = {
|
||||
title,
|
||||
metadata,
|
||||
metadataField,
|
||||
collection: '',
|
||||
field,
|
||||
value: '"' + value + '"'
|
||||
};
|
||||
expect(route.data).toEqual(result);
|
||||
expect(canActivate).toEqual(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
52
src/app/+browse-by/browse-by-guard.ts
Normal file
52
src/app/+browse-by/browse-by-guard.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
/**
|
||||
* A guard taking care of the correct route.data being set for the Browse-By components
|
||||
*/
|
||||
export class BrowseByGuard implements CanActivate {
|
||||
|
||||
constructor(protected dsoService: DSpaceObjectDataService,
|
||||
protected translate: TranslateService) {
|
||||
}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||
const title = route.data.title;
|
||||
const metadata = route.params.metadata || route.queryParams.metadata || route.data.metadata;
|
||||
const metadataField = route.data.metadataField;
|
||||
const scope = route.queryParams.scope;
|
||||
const value = route.queryParams.value;
|
||||
const metadataTranslated = this.translate.instant('browse.metadata.' + metadata);
|
||||
if (hasValue(scope)) {
|
||||
const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getSucceededRemoteData());
|
||||
return dsoAndMetadata$.pipe(
|
||||
map((dsoRD) => {
|
||||
const name = dsoRD.payload.name;
|
||||
route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value);
|
||||
return observableOf(true);
|
||||
}
|
||||
}
|
||||
|
||||
private createData(title, metadata, metadataField, collection, field, value) {
|
||||
return {
|
||||
title: title,
|
||||
metadata: metadata,
|
||||
metadataField: metadataField,
|
||||
collection: collection,
|
||||
field: field,
|
||||
value: hasValue(value) ? `"${value}"` : ''
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,13 +1,16 @@
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component';
|
||||
import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component';
|
||||
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 { BrowseByGuard } from './browse-by-guard';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: 'title', component: BrowseByTitlePageComponent },
|
||||
{ path: 'author', component: BrowseByAuthorPageComponent }
|
||||
{ path: 'title', component: BrowseByTitlePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'title', title: 'browse.title' } },
|
||||
{ path: 'dateissued', component: BrowseByDatePageComponent, canActivate: [BrowseByGuard], data: { metadata: 'dateissued', metadataField: 'dc.date.issued', title: 'browse.title' } },
|
||||
{ path: ':metadata', component: BrowseByMetadataPageComponent, canActivate: [BrowseByGuard], data: { title: 'browse.title' } }
|
||||
])
|
||||
]
|
||||
})
|
||||
|
@@ -4,8 +4,10 @@ import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-ti
|
||||
import { ItemDataService } from '../core/data/item-data.service';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { BrowseByRoutingModule } from './browse-by-routing.module';
|
||||
import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component';
|
||||
import { BrowseService } from '../core/browse/browse.service';
|
||||
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 { BrowseByGuard } from './browse-by-guard';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -15,11 +17,13 @@ import { BrowseService } from '../core/browse/browse.service';
|
||||
],
|
||||
declarations: [
|
||||
BrowseByTitlePageComponent,
|
||||
BrowseByAuthorPageComponent
|
||||
BrowseByMetadataPageComponent,
|
||||
BrowseByDatePageComponent
|
||||
],
|
||||
providers: [
|
||||
ItemDataService,
|
||||
BrowseService
|
||||
BrowseService,
|
||||
BrowseByGuard
|
||||
]
|
||||
})
|
||||
export class BrowseByModule {
|
||||
|
@@ -0,0 +1,71 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import {
|
||||
DynamicInputModel,
|
||||
DynamicTextAreaModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model';
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
|
||||
|
||||
/**
|
||||
* Form used for creating and editing collections
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-collection-form',
|
||||
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
|
||||
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
|
||||
})
|
||||
export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
||||
/**
|
||||
* @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited
|
||||
*/
|
||||
@Input() dso: Collection = new Collection();
|
||||
|
||||
/**
|
||||
* @type {ResourceType.Collection} This is a collection-type form
|
||||
*/
|
||||
protected type = ResourceType.Collection;
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for creating/editing a collection
|
||||
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||
*/
|
||||
formModel: DynamicFormControlModel[] = [
|
||||
new DynamicInputModel({
|
||||
id: 'title',
|
||||
name: 'dc.title',
|
||||
required: true,
|
||||
validators: {
|
||||
required: null
|
||||
},
|
||||
errorMessages: {
|
||||
required: 'Please enter a name for this title'
|
||||
},
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'description',
|
||||
name: 'dc.description',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'abstract',
|
||||
name: 'dc.description.abstract',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'rights',
|
||||
name: 'dc.rights',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'tableofcontents',
|
||||
name: 'dc.description.tableofcontents',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'license',
|
||||
name: 'dc.rights.license',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'provenance',
|
||||
name: 'dc.description.provenance',
|
||||
}),
|
||||
];
|
||||
}
|
@@ -3,10 +3,57 @@ 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';
|
||||
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
|
||||
import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard';
|
||||
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import { getCollectionModulePath } from '../app-routing.module';
|
||||
|
||||
export const COLLECTION_PARENT_PARAMETER = 'parent';
|
||||
|
||||
export function getCollectionPageRoute(collectionId: string) {
|
||||
return new URLCombiner(getCollectionModulePath(), collectionId).toString();
|
||||
}
|
||||
|
||||
export function getCollectionEditPath(id: string) {
|
||||
return new URLCombiner(getCollectionModulePath(), COLLECTION_EDIT_PATH.replace(/:id/, id)).toString()
|
||||
}
|
||||
|
||||
export function getCollectionCreatePath() {
|
||||
return new URLCombiner(getCollectionModulePath(), COLLECTION_CREATE_PATH).toString()
|
||||
}
|
||||
|
||||
const COLLECTION_CREATE_PATH = 'create';
|
||||
const COLLECTION_EDIT_PATH = ':id/edit';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: COLLECTION_CREATE_PATH,
|
||||
component: CreateCollectionPageComponent,
|
||||
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
|
||||
},
|
||||
{
|
||||
path: COLLECTION_EDIT_PATH,
|
||||
pathMatch: 'full',
|
||||
component: EditCollectionPageComponent,
|
||||
canActivate: [AuthenticatedGuard],
|
||||
resolve: {
|
||||
dso: CollectionPageResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id/delete',
|
||||
pathMatch: 'full',
|
||||
component: DeleteCollectionPageComponent,
|
||||
canActivate: [AuthenticatedGuard],
|
||||
resolve: {
|
||||
dso: CollectionPageResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
component: CollectionPageComponent,
|
||||
@@ -19,6 +66,7 @@ import { CollectionPageResolver } from './collection-page.resolver';
|
||||
],
|
||||
providers: [
|
||||
CollectionPageResolver,
|
||||
CreateCollectionPageGuard
|
||||
]
|
||||
})
|
||||
export class CollectionPageRoutingModule {
|
||||
|
@@ -1,56 +1,62 @@
|
||||
<div class="container">
|
||||
<div class="collection-page"
|
||||
*ngVar="(collectionRD$ | async) as collectionRD">
|
||||
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="collectionRD?.payload as collection">
|
||||
<!-- Collection Name -->
|
||||
<ds-comcol-page-header
|
||||
[name]="collection.name">
|
||||
</ds-comcol-page-header>
|
||||
<!-- Collection logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
[alternateText]="'Collection Logo'">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Introductionary text -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.introductoryText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
<!-- News -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.sidebarText"
|
||||
[hasInnerHtml]="true"
|
||||
[title]="'community.page.news'">
|
||||
</ds-comcol-page-content>
|
||||
<!-- Copyright -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.copyrightText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
<!-- Licence -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.license"
|
||||
[title]="'collection.page.license'">
|
||||
</ds-comcol-page-content>
|
||||
</div>
|
||||
<div class="collection-page"
|
||||
*ngVar="(collectionRD$ | async) as collectionRD">
|
||||
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="collectionRD?.payload as collection">
|
||||
<!-- Collection Name -->
|
||||
<ds-comcol-page-header
|
||||
[name]="collection.name">
|
||||
</ds-comcol-page-header>
|
||||
<!-- Browse-By Links -->
|
||||
<ds-comcol-page-browse-by [id]="collection.id"></ds-comcol-page-browse-by>
|
||||
<!-- Collection logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
[alternateText]="'Collection Logo'">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Introductionary text -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.introductoryText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
<!-- News -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.sidebarText"
|
||||
[hasInnerHtml]="true"
|
||||
[title]="'community.page.news'">
|
||||
</ds-comcol-page-content>
|
||||
<!-- Copyright -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.copyrightText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
<!-- Licence -->
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.dcLicense"
|
||||
[title]="'collection.page.license'">
|
||||
</ds-comcol-page-content>
|
||||
</div>
|
||||
<br>
|
||||
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
||||
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
|
||||
<ds-viewable-collection
|
||||
[config]="paginationConfig"
|
||||
[sortConfig]="sortConfig"
|
||||
[objects]="itemRD"
|
||||
[hideGear]="true"
|
||||
(paginationChange)="onPaginationChange($event)">
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
<ds-error *ngIf="itemRD?.hasFailed"
|
||||
message="{{'error.recent-submissions' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="!itemRD || itemRD.isLoading"
|
||||
message="{{'loading.recent-submissions' | translate}}"></ds-loading>
|
||||
</ng-container>
|
||||
</div>
|
||||
<ds-error *ngIf="collectionRD?.hasFailed"
|
||||
message="{{'error.collection' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="collectionRD?.isLoading"
|
||||
message="{{'loading.collection' | translate}}"></ds-loading>
|
||||
</div>
|
||||
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading>
|
||||
<br>
|
||||
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
||||
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
|
||||
<ds-viewable-collection
|
||||
[config]="paginationConfig"
|
||||
[sortConfig]="sortConfig"
|
||||
[objects]="itemRD"
|
||||
[hideGear]="true"
|
||||
(paginationChange)="onPaginationChange($event)">
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}"></ds-loading>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { BehaviorSubject, of as observableOf, Observable, Subject } from 'rxjs';
|
||||
import { filter, flatMap, map, startWith, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
|
||||
import { SearchService } from '../+search-page/search-service/search.service';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||
import { PaginatedList } from '../core/data/paginated-list';
|
||||
@@ -10,16 +13,17 @@ import { MetadataService } from '../core/metadata/metadata.service';
|
||||
import { Bitstream } from '../core/shared/bitstream.model';
|
||||
|
||||
import { Collection } from '../core/shared/collection.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
import { Item } from '../core/shared/item.model';
|
||||
import {
|
||||
getSucceededRemoteData,
|
||||
redirectToPageNotFoundOn404,
|
||||
toDSpaceObjectListRD
|
||||
} from '../core/shared/operators';
|
||||
|
||||
import { fadeIn, fadeInOut } from '../shared/animations/fade';
|
||||
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../shared/empty.util';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { filter, flatMap, map } from 'rxjs/operators';
|
||||
import { SearchService } from '../+search-page/search-service/search.service';
|
||||
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
|
||||
import { toDSpaceObjectListRD } from '../core/shared/operators';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-collection-page',
|
||||
@@ -31,20 +35,23 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
fadeInOut
|
||||
]
|
||||
})
|
||||
export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
export class CollectionPageComponent implements OnInit {
|
||||
collectionRD$: Observable<RemoteData<Collection>>;
|
||||
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
paginationConfig: PaginationComponentOptions;
|
||||
sortConfig: SortOptions;
|
||||
private subs: Subscription[] = [];
|
||||
private collectionId: string;
|
||||
private paginationChanges$: Subject<{
|
||||
paginationConfig: PaginationComponentOptions,
|
||||
sortConfig: SortOptions
|
||||
}>;
|
||||
|
||||
constructor(
|
||||
private collectionDataService: CollectionDataService,
|
||||
private searchService: SearchService,
|
||||
private metadata: MetadataService,
|
||||
private route: ActivatedRoute
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
this.paginationConfig = new PaginationComponentOptions();
|
||||
this.paginationConfig.id = 'collection-page-pagination';
|
||||
@@ -55,42 +62,43 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.collectionRD$ = this.route.data.pipe(
|
||||
map((data) => data.collection)
|
||||
map((data) => data.collection as RemoteData<Collection>),
|
||||
redirectToPageNotFoundOn404(this.router),
|
||||
take(1)
|
||||
);
|
||||
this.logoRD$ = this.collectionRD$.pipe(
|
||||
map((rd: RemoteData<Collection>) => rd.payload),
|
||||
filter((collection: Collection) => hasValue(collection)),
|
||||
flatMap((collection: Collection) => collection.logo)
|
||||
);
|
||||
this.subs.push(
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
this.metadata.processRemoteData(this.collectionRD$);
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
this.updatePage({
|
||||
pagination: pagination,
|
||||
sort: this.sortConfig
|
||||
});
|
||||
})
|
||||
|
||||
this.paginationChanges$ = new BehaviorSubject({
|
||||
paginationConfig: this.paginationConfig,
|
||||
sortConfig: this.sortConfig
|
||||
});
|
||||
|
||||
this.itemRD$ = this.paginationChanges$.pipe(
|
||||
switchMap((dto) => this.collectionRD$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
map((rd) => rd.payload.id),
|
||||
switchMap((id: string) => {
|
||||
return this.searchService.search(
|
||||
new PaginatedSearchOptions({
|
||||
scope: id,
|
||||
pagination: dto.paginationConfig,
|
||||
sort: dto.sortConfig,
|
||||
dsoType: DSpaceObjectType.ITEM
|
||||
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>
|
||||
}),
|
||||
startWith(undefined) // Make sure switching pages shows loading component
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
updatePage(searchOptions) {
|
||||
this.itemRD$ = this.searchService.search(
|
||||
new PaginatedSearchOptions({
|
||||
scope: this.collectionId,
|
||||
pagination: searchOptions.pagination,
|
||||
sort: searchOptions.sort,
|
||||
dsoType: DSpaceObjectType.ITEM
|
||||
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
this.route.queryParams.pipe(take(1)).subscribe((params) => {
|
||||
this.metadata.processRemoteData(this.collectionRD$);
|
||||
this.onPaginationChange(params);
|
||||
})
|
||||
}
|
||||
|
||||
isNotEmpty(object: any) {
|
||||
@@ -98,15 +106,14 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onPaginationChange(event) {
|
||||
this.updatePage({
|
||||
pagination: {
|
||||
currentPage: event.page,
|
||||
pageSize: event.pageSize
|
||||
},
|
||||
sort: {
|
||||
field: event.sortField,
|
||||
direction: event.sortDirection
|
||||
}
|
||||
})
|
||||
this.paginationConfig.currentPage = +event.page || this.paginationConfig.currentPage;
|
||||
this.paginationConfig.pageSize = +event.pageSize || this.paginationConfig.pageSize;
|
||||
this.sortConfig.direction = event.sortDirection || this.sortConfig.direction;
|
||||
this.sortConfig.field = event.sortField || this.sortConfig.field;
|
||||
|
||||
this.paginationChanges$.next({
|
||||
paginationConfig: this.paginationConfig,
|
||||
sortConfig: this.sortConfig
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -5,17 +5,27 @@ import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { CollectionPageComponent } from './collection-page.component';
|
||||
import { CollectionPageRoutingModule } from './collection-page-routing.module';
|
||||
import { SearchPageModule } from '../+search-page/search-page.module';
|
||||
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
|
||||
import { CollectionFormComponent } from './collection-form/collection-form.component';
|
||||
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
|
||||
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
|
||||
import { SearchService } from '../+search-page/search-service/search.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SearchPageModule,
|
||||
CollectionPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
CollectionPageComponent,
|
||||
CreateCollectionPageComponent,
|
||||
EditCollectionPageComponent,
|
||||
DeleteCollectionPageComponent,
|
||||
CollectionFormComponent
|
||||
],
|
||||
providers: [
|
||||
SearchService
|
||||
]
|
||||
})
|
||||
export class CollectionPageModule {
|
||||
|
28
src/app/+collection-page/collection-page.resolver.spec.ts
Normal file
28
src/app/+collection-page/collection-page.resolver.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { first } from 'rxjs/operators';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CollectionPageResolver } from './collection-page.resolver';
|
||||
|
||||
describe('CollectionPageResolver', () => {
|
||||
describe('resolve', () => {
|
||||
let resolver: CollectionPageResolver;
|
||||
let collectionService: any;
|
||||
const uuid = '1234-65487-12354-1235';
|
||||
|
||||
beforeEach(() => {
|
||||
collectionService = {
|
||||
findById: (id: string) => observableOf({ payload: { id }, hasSucceeded: true })
|
||||
};
|
||||
resolver = new CollectionPageResolver(collectionService);
|
||||
});
|
||||
|
||||
it('should resolve a collection with the correct id', () => {
|
||||
resolver.resolve({ params: { id: uuid } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(resolved) => {
|
||||
expect(resolved.payload.id).toEqual(uuid);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@@ -4,7 +4,8 @@ import { Collection } from '../core/shared/collection.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
import { find } from 'rxjs/operators';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific collection before the route is activated
|
||||
@@ -18,11 +19,12 @@ export class CollectionPageResolver implements Resolve<RemoteData<Collection>> {
|
||||
* Method for resolving a collection based on the parameters in the current route
|
||||
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||
* @returns Observable<<RemoteData<Collection>> Emits the found collection based on the parameters in the current route
|
||||
* @returns Observable<<RemoteData<Collection>> Emits the found collection based on the parameters in the current route,
|
||||
* or an error if something went wrong
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Collection>> {
|
||||
return this.collectionService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,8 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 pb-4">
|
||||
<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)"></ds-collection-form>
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,46 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { CreateCollectionPageComponent } from './create-collection-page.component';
|
||||
|
||||
describe('CreateCollectionPageComponent', () => {
|
||||
let comp: CreateCollectionPageComponent;
|
||||
let fixture: ComponentFixture<CreateCollectionPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
|
||||
declarations: [CreateCollectionPageComponent],
|
||||
providers: [
|
||||
{ provide: CollectionDataService, useValue: {} },
|
||||
{
|
||||
provide: CommunityDataService,
|
||||
useValue: { findById: () => observableOf({ payload: { name: 'test' } }) }
|
||||
},
|
||||
{ provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } },
|
||||
{ provide: Router, useValue: {} },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CreateCollectionPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('frontendURL', () => {
|
||||
it('should have the right frontendURL set', () => {
|
||||
expect((comp as any).frontendURL).toEqual('/collections/');
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,28 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can create a new Collection
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-create-collection',
|
||||
styleUrls: ['./create-collection-page.component.scss'],
|
||||
templateUrl: './create-collection-page.component.html'
|
||||
})
|
||||
export class CreateCollectionPageComponent extends CreateComColPageComponent<Collection> {
|
||||
protected frontendURL = '/collections/';
|
||||
|
||||
public constructor(
|
||||
protected communityDataService: CommunityDataService,
|
||||
protected collectionDataService: CollectionDataService,
|
||||
protected routeService: RouteService,
|
||||
protected router: Router
|
||||
) {
|
||||
super(collectionDataService, communityDataService, routeService, router);
|
||||
}
|
||||
}
|
@@ -0,0 +1,67 @@
|
||||
import { CreateCollectionPageGuard } from './create-collection-page.guard';
|
||||
import { MockRouter } from '../../shared/mocks/mock-router';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
describe('CreateCollectionPageGuard', () => {
|
||||
describe('canActivate', () => {
|
||||
let guard: CreateCollectionPageGuard;
|
||||
let router;
|
||||
let communityDataServiceStub: any;
|
||||
|
||||
beforeEach(() => {
|
||||
communityDataServiceStub = {
|
||||
findById: (id: string) => {
|
||||
if (id === 'valid-id') {
|
||||
return observableOf(new RemoteData(false, false, true, null, new Community()));
|
||||
} else if (id === 'invalid-id') {
|
||||
return observableOf(new RemoteData(false, false, true, null, undefined));
|
||||
} else if (id === 'error-id') {
|
||||
return observableOf(new RemoteData(false, false, false, null, new Community()));
|
||||
}
|
||||
}
|
||||
};
|
||||
router = new MockRouter();
|
||||
|
||||
guard = new CreateCollectionPageGuard(router, communityDataServiceStub);
|
||||
});
|
||||
|
||||
it('should return true when the parent ID resolves to a community', () => {
|
||||
guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(true)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when no parent ID has been provided', () => {
|
||||
guard.canActivate({ queryParams: { } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(false)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when the parent ID does not resolve to a community', () => {
|
||||
guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(false)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when the parent ID resolves to an error response', () => {
|
||||
guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(false)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
|
||||
|
||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { getFinishedRemoteData } from '../../core/shared/operators';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Prevent creation of a collection without a parent community provided
|
||||
* @class CreateCollectionPageGuard
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateCollectionPageGuard implements CanActivate {
|
||||
public constructor(private router: Router, private communityService: CommunityDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when either a parent ID query parameter has been provided and the parent ID resolves to a valid parent community
|
||||
* Reroutes to a 404 page when the page cannot be activated
|
||||
* @method canActivate
|
||||
*/
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
|
||||
const parentID = route.queryParams.parent;
|
||||
if (hasNoValue(parentID)) {
|
||||
this.router.navigate(['/404']);
|
||||
return observableOf(false);
|
||||
}
|
||||
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
|
||||
.pipe(
|
||||
getFinishedRemoteData(),
|
||||
);
|
||||
|
||||
return parent.pipe(
|
||||
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
|
||||
tap((isValid: boolean) => {
|
||||
if (!isValid) {
|
||||
this.router.navigate(['/404']);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
<div class="container">
|
||||
<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>
|
||||
<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>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,41 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { DeleteCollectionPageComponent } from './delete-collection-page.component';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
|
||||
describe('DeleteCollectionPageComponent', () => {
|
||||
let comp: DeleteCollectionPageComponent;
|
||||
let fixture: ComponentFixture<DeleteCollectionPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
|
||||
declarations: [DeleteCollectionPageComponent],
|
||||
providers: [
|
||||
{ provide: CollectionDataService, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
|
||||
{ provide: NotificationsService, useValue: {} },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DeleteCollectionPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('frontendURL', () => {
|
||||
it('should have the right frontendURL set', () => {
|
||||
expect((comp as any).frontendURL).toEqual('/collections/');
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,29 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can delete an existing Collection
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-delete-collection',
|
||||
styleUrls: ['./delete-collection-page.component.scss'],
|
||||
templateUrl: './delete-collection-page.component.html'
|
||||
})
|
||||
export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Collection> {
|
||||
protected frontendURL = '/collections/';
|
||||
|
||||
public constructor(
|
||||
protected dsoDataService: CollectionDataService,
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute,
|
||||
protected notifications: NotificationsService,
|
||||
protected translate: TranslateService
|
||||
) {
|
||||
super(dsoDataService, router, route, notifications, translate);
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 pb-4">
|
||||
<h2 id="header" class="border-bottom pb-2">{{ 'collection.edit.head' | translate }}</h2>
|
||||
<ds-collection-form (submitForm)="onSubmit($event)" [dso]="(dsoRD$ | async)?.payload"></ds-collection-form>
|
||||
<a class="btn btn-danger"
|
||||
[routerLink]="'/collections/' + (dsoRD$ | async)?.payload.uuid + '/delete'">{{'collection.edit.delete'
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,39 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { EditCollectionPageComponent } from './edit-collection-page.component';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
describe('EditCollectionPageComponent', () => {
|
||||
let comp: EditCollectionPageComponent;
|
||||
let fixture: ComponentFixture<EditCollectionPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
|
||||
declarations: [EditCollectionPageComponent],
|
||||
providers: [
|
||||
{ provide: CollectionDataService, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditCollectionPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('frontendURL', () => {
|
||||
it('should have the right frontendURL set', () => {
|
||||
expect((comp as any).frontendURL).toEqual('/collections/');
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,25 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can edit an existing Collection
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-edit-collection',
|
||||
styleUrls: ['./edit-collection-page.component.scss'],
|
||||
templateUrl: './edit-collection-page.component.html'
|
||||
})
|
||||
export class EditCollectionPageComponent extends EditComColPageComponent<Collection> {
|
||||
protected frontendURL = '/collections/';
|
||||
|
||||
public constructor(
|
||||
protected collectionDataService: CollectionDataService,
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute
|
||||
) {
|
||||
super(collectionDataService, router, route);
|
||||
}
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core';
|
||||
import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
|
||||
|
||||
/**
|
||||
* Form used for creating and editing communities
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-community-form',
|
||||
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
|
||||
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
|
||||
})
|
||||
export class CommunityFormComponent extends ComColFormComponent<Community> {
|
||||
/**
|
||||
* @type {Community} A new community when a community is being created, an existing Input community when a community is being edited
|
||||
*/
|
||||
@Input() dso: Community = new Community();
|
||||
|
||||
/**
|
||||
* @type {ResourceType.Community} This is a community-type form
|
||||
*/
|
||||
protected type = ResourceType.Community;
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for creating/editing a community
|
||||
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||
*/
|
||||
formModel: DynamicFormControlModel[] = [
|
||||
new DynamicInputModel({
|
||||
id: 'title',
|
||||
name: 'dc.title',
|
||||
required: true,
|
||||
validators: {
|
||||
required: null
|
||||
},
|
||||
errorMessages: {
|
||||
required: 'Please enter a name for this title'
|
||||
},
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'description',
|
||||
name: 'dc.description',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'abstract',
|
||||
name: 'dc.description.abstract',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'rights',
|
||||
name: 'dc.rights',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'tableofcontents',
|
||||
name: 'dc.description.tableofcontents',
|
||||
}),
|
||||
];
|
||||
}
|
@@ -3,10 +3,57 @@ 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';
|
||||
import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component';
|
||||
import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard';
|
||||
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import { getCommunityModulePath } from '../app-routing.module';
|
||||
|
||||
export const COMMUNITY_PARENT_PARAMETER = 'parent';
|
||||
|
||||
export function getCommunityPageRoute(communityId: string) {
|
||||
return new URLCombiner(getCommunityModulePath(), communityId).toString();
|
||||
}
|
||||
|
||||
export function getCommunityEditPath(id: string) {
|
||||
return new URLCombiner(getCommunityModulePath(), COMMUNITY_EDIT_PATH.replace(/:id/, id)).toString()
|
||||
}
|
||||
|
||||
export function getCommunityCreatePath() {
|
||||
return new URLCombiner(getCommunityModulePath(), COMMUNITY_CREATE_PATH).toString()
|
||||
}
|
||||
|
||||
const COMMUNITY_CREATE_PATH = 'create';
|
||||
const COMMUNITY_EDIT_PATH = ':id/edit';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: COMMUNITY_CREATE_PATH,
|
||||
component: CreateCommunityPageComponent,
|
||||
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
|
||||
},
|
||||
{
|
||||
path: COMMUNITY_EDIT_PATH,
|
||||
pathMatch: 'full',
|
||||
component: EditCommunityPageComponent,
|
||||
canActivate: [AuthenticatedGuard],
|
||||
resolve: {
|
||||
dso: CommunityPageResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id/delete',
|
||||
pathMatch: 'full',
|
||||
component: DeleteCommunityPageComponent,
|
||||
canActivate: [AuthenticatedGuard],
|
||||
resolve: {
|
||||
dso: CommunityPageResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
component: CommunityPageComponent,
|
||||
@@ -19,6 +66,7 @@ import { CommunityPageResolver } from './community-page.resolver';
|
||||
],
|
||||
providers: [
|
||||
CommunityPageResolver,
|
||||
CreateCommunityPageGuard
|
||||
]
|
||||
})
|
||||
export class CommunityPageRoutingModule {
|
||||
|
@@ -3,6 +3,8 @@
|
||||
<div *ngIf="communityRD?.payload; let communityPayload">
|
||||
<!-- Community name -->
|
||||
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
|
||||
<!-- Browse-By Links -->
|
||||
<ds-comcol-page-browse-by [id]="communityPayload.id"></ds-comcol-page-browse-by>
|
||||
<!-- Community logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
@@ -24,9 +26,11 @@
|
||||
[content]="communityPayload.copyrightText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
<ds-community-page-sub-community-list [community]="communityPayload"></ds-community-page-sub-community-list>
|
||||
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading>
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import {mergeMap, filter, map} from 'rxjs/operators';
|
||||
import { mergeMap, filter, map } from 'rxjs/operators';
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { Subscription, Observable } from 'rxjs';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
@@ -13,6 +13,7 @@ import { MetadataService } from '../core/metadata/metadata.service';
|
||||
|
||||
import { fadeInOut } from '../shared/animations/fade';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { redirectToPageNotFoundOn404 } from '../core/shared/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-community-page',
|
||||
@@ -21,29 +22,37 @@ import { hasValue } from '../shared/empty.util';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [fadeInOut]
|
||||
})
|
||||
export class CommunityPageComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* This component represents a detail page for a single community
|
||||
*/
|
||||
export class CommunityPageComponent implements OnInit {
|
||||
/**
|
||||
* The community displayed on this page
|
||||
*/
|
||||
communityRD$: Observable<RemoteData<Community>>;
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* The logo of this community
|
||||
*/
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
constructor(
|
||||
private communityDataService: CommunityDataService,
|
||||
private metadata: MetadataService,
|
||||
private route: ActivatedRoute
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.communityRD$ = this.route.data.pipe(map((data) => data.community));
|
||||
this.communityRD$ = this.route.data.pipe(
|
||||
map((data) => data.community as RemoteData<Community>),
|
||||
redirectToPageNotFoundOn404(this.router)
|
||||
);
|
||||
this.logoRD$ = this.communityRD$.pipe(
|
||||
map((rd: RemoteData<Community>) => rd.payload),
|
||||
filter((community: Community) => hasValue(community)),
|
||||
mergeMap((community: Community) => community.logo));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -6,6 +6,11 @@ import { SharedModule } from '../shared/shared.module';
|
||||
import { CommunityPageComponent } from './community-page.component';
|
||||
import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component';
|
||||
import { CommunityPageRoutingModule } from './community-page-routing.module';
|
||||
import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component';
|
||||
import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component';
|
||||
import { CommunityFormComponent } from './community-form/community-form.component';
|
||||
import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component';
|
||||
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -16,8 +21,14 @@ import { CommunityPageRoutingModule } from './community-page-routing.module';
|
||||
declarations: [
|
||||
CommunityPageComponent,
|
||||
CommunityPageSubCollectionListComponent,
|
||||
CommunityPageSubCommunityListComponent,
|
||||
CreateCommunityPageComponent,
|
||||
EditCommunityPageComponent,
|
||||
DeleteCommunityPageComponent,
|
||||
CommunityFormComponent
|
||||
]
|
||||
})
|
||||
|
||||
export class CommunityPageModule {
|
||||
|
||||
}
|
||||
|
@@ -2,9 +2,10 @@ import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
import { Community } from '../core/shared/community.model';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
import { find } from 'rxjs/operators';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific community before the route is activated
|
||||
@@ -18,11 +19,12 @@ export class CommunityPageResolver implements Resolve<RemoteData<Community>> {
|
||||
* Method for resolving a community based on the parameters in the current route
|
||||
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||
* @returns Observable<<RemoteData<Community>> Emits the found community based on the parameters in the current route
|
||||
* @returns Observable<<RemoteData<Community>> Emits the found community based on the parameters in the current route,
|
||||
* or an error if something went wrong
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Community>> {
|
||||
return this.communityService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
find((RD) => hasValue(RD.error) || RD.hasSucceeded)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,11 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 pb-4">
|
||||
<ng-container *ngVar="(parentRD$ | async)?.payload as parent">
|
||||
<h2 *ngIf="!parent" id="header" class="border-bottom pb-2">{{ 'community.create.head' | translate }}</h2>
|
||||
<h2 *ngIf="parent" id="sub-header" class="border-bottom pb-2">{{ 'community.create.sub-head' | translate:{ parent: parent.name } }}</h2>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<ds-community-form (submitForm)="onSubmit($event)"></ds-community-form>
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,42 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { CreateCommunityPageComponent } from './create-community-page.component';
|
||||
|
||||
describe('CreateCommunityPageComponent', () => {
|
||||
let comp: CreateCommunityPageComponent;
|
||||
let fixture: ComponentFixture<CreateCommunityPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
|
||||
declarations: [CreateCommunityPageComponent],
|
||||
providers: [
|
||||
{ provide: CommunityDataService, useValue: { findById: () => observableOf({}) } },
|
||||
{ provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } },
|
||||
{ provide: Router, useValue: {} },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CreateCommunityPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('frontendURL', () => {
|
||||
it('should have the right frontendURL set', () => {
|
||||
expect((comp as any).frontendURL).toEqual('/communities/');
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,26 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can create a new Community
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-create-community',
|
||||
styleUrls: ['./create-community-page.component.scss'],
|
||||
templateUrl: './create-community-page.component.html'
|
||||
})
|
||||
export class CreateCommunityPageComponent extends CreateComColPageComponent<Community> {
|
||||
protected frontendURL = '/communities/';
|
||||
|
||||
public constructor(
|
||||
protected communityDataService: CommunityDataService,
|
||||
protected routeService: RouteService,
|
||||
protected router: Router
|
||||
) {
|
||||
super(communityDataService, communityDataService, routeService, router);
|
||||
}
|
||||
}
|
@@ -0,0 +1,67 @@
|
||||
import { CreateCommunityPageGuard } from './create-community-page.guard';
|
||||
import { MockRouter } from '../../shared/mocks/mock-router';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
describe('CreateCommunityPageGuard', () => {
|
||||
describe('canActivate', () => {
|
||||
let guard: CreateCommunityPageGuard;
|
||||
let router;
|
||||
let communityDataServiceStub: any;
|
||||
|
||||
beforeEach(() => {
|
||||
communityDataServiceStub = {
|
||||
findById: (id: string) => {
|
||||
if (id === 'valid-id') {
|
||||
return observableOf(new RemoteData(false, false, true, null, new Community()));
|
||||
} else if (id === 'invalid-id') {
|
||||
return observableOf(new RemoteData(false, false, true, null, undefined));
|
||||
} else if (id === 'error-id') {
|
||||
return observableOf(new RemoteData(false, false, false, null, new Community()));
|
||||
}
|
||||
}
|
||||
};
|
||||
router = new MockRouter();
|
||||
|
||||
guard = new CreateCommunityPageGuard(router, communityDataServiceStub);
|
||||
});
|
||||
|
||||
it('should return true when the parent ID resolves to a community', () => {
|
||||
guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(true)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true when no parent ID has been provided', () => {
|
||||
guard.canActivate({ queryParams: { } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(true)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when the parent ID does not resolve to a community', () => {
|
||||
guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(false)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when the parent ID resolves to an error response', () => {
|
||||
guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined)
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(canActivate) =>
|
||||
expect(canActivate).toEqual(false)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
|
||||
|
||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { getFinishedRemoteData } from '../../core/shared/operators';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Prevent creation of a community with an invalid parent community provided
|
||||
* @class CreateCommunityPageGuard
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateCommunityPageGuard implements CanActivate {
|
||||
public constructor(private router: Router, private communityService: CommunityDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community
|
||||
* Reroutes to a 404 page when the page cannot be activated
|
||||
* @method canActivate
|
||||
*/
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
|
||||
const parentID = route.queryParams.parent;
|
||||
if (hasNoValue(parentID)) {
|
||||
return observableOf(true);
|
||||
}
|
||||
|
||||
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
|
||||
.pipe(
|
||||
getFinishedRemoteData(),
|
||||
);
|
||||
|
||||
return parent.pipe(
|
||||
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
|
||||
tap((isValid: boolean) => {
|
||||
if (!isValid) {
|
||||
this.router.navigate(['/404']);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
<div class="container">
|
||||
<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>
|
||||
<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>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,42 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { DeleteCommunityPageComponent } from './delete-community-page.component';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
|
||||
describe('DeleteCommunityPageComponent', () => {
|
||||
let comp: DeleteCommunityPageComponent;
|
||||
let fixture: ComponentFixture<DeleteCommunityPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
|
||||
declarations: [DeleteCommunityPageComponent],
|
||||
providers: [
|
||||
{ provide: CommunityDataService, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
|
||||
{ provide: NotificationsService, useValue: {} },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DeleteCommunityPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('frontendURL', () => {
|
||||
it('should have the right frontendURL set', () => {
|
||||
expect((comp as any).frontendURL).toEqual('/communities/');
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,29 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can delete an existing Community
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-delete-community',
|
||||
styleUrls: ['./delete-community-page.component.scss'],
|
||||
templateUrl: './delete-community-page.component.html'
|
||||
})
|
||||
export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Community> {
|
||||
protected frontendURL = '/communities/';
|
||||
|
||||
public constructor(
|
||||
protected dsoDataService: CommunityDataService,
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute,
|
||||
protected notifications: NotificationsService,
|
||||
protected translate: TranslateService
|
||||
) {
|
||||
super(dsoDataService, router, route, notifications, translate);
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 pb-4">
|
||||
<h2 id="header" class="border-bottom pb-2">{{ 'community.edit.head' | translate }}</h2>
|
||||
<ds-community-form (submitForm)="onSubmit($event)"
|
||||
[dso]="(dsoRD$ | async)?.payload"></ds-community-form>
|
||||
<a class="btn btn-danger"
|
||||
[routerLink]="'/communities/' + (dsoRD$ | async)?.payload.uuid + '/delete'">{{'community.edit.delete'
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
|
@@ -0,0 +1,39 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { EditCommunityPageComponent } from './edit-community-page.component';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
|
||||
describe('EditCommunityPageComponent', () => {
|
||||
let comp: EditCommunityPageComponent;
|
||||
let fixture: ComponentFixture<EditCommunityPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
|
||||
declarations: [EditCommunityPageComponent],
|
||||
providers: [
|
||||
{ provide: CommunityDataService, useValue: {} },
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditCommunityPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('frontendURL', () => {
|
||||
it('should have the right frontendURL set', () => {
|
||||
expect((comp as any).frontendURL).toEqual('/communities/');
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,25 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can edit an existing Community
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-edit-community',
|
||||
styleUrls: ['./edit-community-page.component.scss'],
|
||||
templateUrl: './edit-community-page.component.html'
|
||||
})
|
||||
export class EditCommunityPageComponent extends EditComColPageComponent<Community> {
|
||||
protected frontendURL = '/communities/';
|
||||
|
||||
public constructor(
|
||||
protected communityDataService: CommunityDataService,
|
||||
protected router: Router,
|
||||
protected route: ActivatedRoute
|
||||
) {
|
||||
super(communityDataService, router, route);
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
<ng-container *ngVar="(subCollectionsRDObs | async) as subCollectionsRD">
|
||||
<div *ngIf="subCollectionsRD?.hasSucceeded" @fadeIn>
|
||||
<div *ngIf="subCollectionsRD?.hasSucceeded && subCollectionsRD?.payload.totalElements > 0" @fadeIn>
|
||||
<h2>{{'community.sub-collection-list.head' | translate}}</h2>
|
||||
<ul>
|
||||
<li *ngFor="let collection of subCollectionsRD?.payload.page">
|
||||
|
@@ -0,0 +1,15 @@
|
||||
<ng-container *ngVar="(subCommunitiesRDObs | async) as subCommunitiesRD">
|
||||
<div *ngIf="subCommunitiesRD?.hasSucceeded && subCommunitiesRD?.payload.totalElements > 0" @fadeIn>
|
||||
<h2>{{'community.sub-community-list.head' | translate}}</h2>
|
||||
<ul>
|
||||
<li *ngFor="let community of subCommunitiesRD?.payload.page">
|
||||
<p>
|
||||
<span class="lead"><a [routerLink]="['/communities', community.id]">{{community.name}}</a></span><br>
|
||||
<span class="text-muted">{{community.shortDescription}}</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ds-error *ngIf="subCommunitiesRD?.hasFailed" message="{{'error.sub-communities' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="subCommunitiesRD?.isLoading" message="{{'loading.sub-communities' | translate}}"></ds-loading>
|
||||
</ng-container>
|
@@ -0,0 +1 @@
|
||||
@import '../../../styles/variables.scss';
|
@@ -0,0 +1,90 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {NO_ERRORS_SCHEMA} from '@angular/core';
|
||||
import {CommunityPageSubCommunityListComponent} from './community-page-sub-community-list.component';
|
||||
import {Community} from '../../core/shared/community.model';
|
||||
import {RemoteData} from '../../core/data/remote-data';
|
||||
import {PaginatedList} from '../../core/data/paginated-list';
|
||||
import {PageInfo} from '../../core/shared/page-info.model';
|
||||
import {SharedModule} from '../../shared/shared.module';
|
||||
import {RouterTestingModule} from '@angular/router/testing';
|
||||
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {of as observableOf, Observable } from 'rxjs';
|
||||
|
||||
describe('SubCommunityList Component', () => {
|
||||
let comp: CommunityPageSubCommunityListComponent;
|
||||
let fixture: ComponentFixture<CommunityPageSubCommunityListComponent>;
|
||||
|
||||
const subcommunities = [Object.assign(new Community(), {
|
||||
id: '123456789-1',
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{ language: 'en_US', value: 'SubCommunity 1' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
Object.assign(new Community(), {
|
||||
id: '123456789-2',
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{ language: 'en_US', value: 'SubCommunity 2' }
|
||||
]
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
const emptySubCommunitiesCommunity = Object.assign(new Community(), {
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{ language: 'en_US', value: 'Test title' }
|
||||
]
|
||||
},
|
||||
subcommunities: observableOf(new RemoteData(true, true, true,
|
||||
undefined, new PaginatedList(new PageInfo(), [])))
|
||||
});
|
||||
|
||||
const mockCommunity = Object.assign(new Community(), {
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{ language: 'en_US', value: 'Test title' }
|
||||
]
|
||||
},
|
||||
subcommunities: observableOf(new RemoteData(true, true, true,
|
||||
undefined, new PaginatedList(new PageInfo(), subcommunities)))
|
||||
})
|
||||
;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), SharedModule,
|
||||
RouterTestingModule.withRoutes([]),
|
||||
NoopAnimationsModule],
|
||||
declarations: [CommunityPageSubCommunityListComponent],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent);
|
||||
comp = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should display a list of subCommunities', () => {
|
||||
comp.community = mockCommunity;
|
||||
fixture.detectChanges();
|
||||
|
||||
const subComList = fixture.debugElement.queryAll(By.css('li'));
|
||||
expect(subComList.length).toEqual(2);
|
||||
expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1');
|
||||
expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2');
|
||||
});
|
||||
|
||||
it('should not display the header when subCommunities are empty', () => {
|
||||
comp.community = emptySubCommunitiesCommunity;
|
||||
fixture.detectChanges();
|
||||
|
||||
const subComHead = fixture.debugElement.queryAll(By.css('h2'));
|
||||
expect(subComHead.length).toEqual(0);
|
||||
});
|
||||
});
|
@@ -0,0 +1,26 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
|
||||
import { fadeIn } from '../../shared/animations/fade';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import {Observable} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-community-page-sub-community-list',
|
||||
styleUrls: ['./community-page-sub-community-list.component.scss'],
|
||||
templateUrl: './community-page-sub-community-list.component.html',
|
||||
animations:[fadeIn]
|
||||
})
|
||||
/**
|
||||
* Component to render the sub-communities of a Community
|
||||
*/
|
||||
export class CommunityPageSubCommunityListComponent implements OnInit {
|
||||
@Input() community: Community;
|
||||
subCommunitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subCommunitiesRDObs = this.community.subcommunities;
|
||||
}
|
||||
}
|
@@ -1,20 +1,19 @@
|
||||
<div class="jumbotron jumbotron-fluid">
|
||||
<div class="container">
|
||||
<div class="d-flex">
|
||||
<div class="dspace-logo-container">
|
||||
<img src="assets/images/dspace-logo.png" />
|
||||
</div>
|
||||
<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 DSpace</h1>
|
||||
<p class="lead">DSpace is an open source software platform that enables organisations to:</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>capture and describe digital material using a submission workflow module, or a variety of programmatic ingest options
|
||||
<li>easily ingest documents, audio, video, datasets and their corresponding Dublin Core metadata
|
||||
</li>
|
||||
<li>distribute an organisation's digital assets over the web through a search and retrieval system
|
||||
<li>open up this content to local and global audiences, thanks to the OAI-PMH interface and Google Scholar optimizations
|
||||
</li>
|
||||
<li>preserve digital assets over the long term</li>
|
||||
<li>issue permanent urls and trustworthy identifiers, including optional integrations with handle.net and DataCite DOI</li>
|
||||
</ul>
|
||||
<p>Join an international community of <A HREF="https://wiki.duraspace.org/display/DSPACE/DSpace+Positioning" TARGET="_NEW">leading institutions using DSpace</A>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,11 +6,11 @@
|
||||
margin-bottom: -$content-spacing;
|
||||
}
|
||||
|
||||
.dspace-logo-container {
|
||||
margin: 10px 20px 0px 20px;
|
||||
.display-3 {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dspace-logo-container img {
|
||||
max-height: 110px;
|
||||
max-width: 110px;
|
||||
.dspace-logo {
|
||||
height: 110px;
|
||||
width: 110px;
|
||||
}
|
||||
|
@@ -5,6 +5,10 @@ import { Component } from '@angular/core';
|
||||
styleUrls: ['./home-news.component.scss'],
|
||||
templateUrl: './home-news.component.html'
|
||||
})
|
||||
|
||||
/**
|
||||
* Component to render the news section on the home page
|
||||
*/
|
||||
export class HomeNewsComponent {
|
||||
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<ds-home-news></ds-home-news>
|
||||
<div class="container">
|
||||
<ds-search-form></ds-search-form>
|
||||
<ds-search-form [inPlaceSearch]="false"></ds-search-form>
|
||||
<ds-top-level-community-list></ds-top-level-community-list>
|
||||
</div>
|
||||
|
@@ -1,12 +1,13 @@
|
||||
<ng-container *ngVar="(communitiesRDObs | async) as communitiesRD">
|
||||
<div *ngIf="communitiesRD?.hasSucceeded " @fadeInOut>
|
||||
<ng-container *ngVar="(communitiesRD$ | async) as communitiesRD">
|
||||
<div *ngIf="communitiesRD?.hasSucceeded ">
|
||||
<h2>{{'home.top-level-communities.head' | translate}}</h2>
|
||||
<p class="lead">{{'home.top-level-communities.help' | translate}}</p>
|
||||
<ds-viewable-collection
|
||||
[config]="config"
|
||||
[sortConfig]="sortConfig"
|
||||
[objects]="communitiesRD"
|
||||
(paginationChange)="updatePage($event)">
|
||||
[objects]="communitiesRD$ | async"
|
||||
[hideGear]="true"
|
||||
(paginationChange)="onPaginationChange($event)">
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
<ds-error *ngIf="communitiesRD?.hasFailed " message="{{'error.top-level-communites' | translate}}"></ds-error>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
@@ -9,7 +9,11 @@ import { Community } from '../../core/shared/community.model';
|
||||
|
||||
import { fadeInOut } from '../../shared/animations/fade';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* this component renders the Top-Level Community list
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-top-level-community-list',
|
||||
styleUrls: ['./top-level-community-list.component.scss'],
|
||||
@@ -17,9 +21,21 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [fadeInOut]
|
||||
})
|
||||
export class TopLevelCommunityListComponent {
|
||||
communitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>;
|
||||
|
||||
export class TopLevelCommunityListComponent implements OnInit {
|
||||
/**
|
||||
* A list of remote data objects of all top communities
|
||||
*/
|
||||
communitiesRD$: BehaviorSubject<RemoteData<PaginatedList<Community>>> = new BehaviorSubject<RemoteData<PaginatedList<Community>>>({} as any);
|
||||
|
||||
/**
|
||||
* The pagination configuration
|
||||
*/
|
||||
config: PaginationComponentOptions;
|
||||
|
||||
/**
|
||||
* The sorting configuration
|
||||
*/
|
||||
sortConfig: SortOptions;
|
||||
|
||||
constructor(private cds: CommunityDataService) {
|
||||
@@ -28,20 +44,34 @@ export class TopLevelCommunityListComponent {
|
||||
this.config.pageSize = 5;
|
||||
this.config.currentPage = 1;
|
||||
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
|
||||
|
||||
this.updatePage({
|
||||
page: this.config.currentPage,
|
||||
pageSize: this.config.pageSize,
|
||||
sortField: this.sortConfig.field,
|
||||
direction: this.sortConfig.direction
|
||||
});
|
||||
}
|
||||
|
||||
updatePage(data) {
|
||||
this.communitiesRDObs = this.cds.findTop({
|
||||
currentPage: data.page,
|
||||
elementsPerPage: data.pageSize,
|
||||
sort: { field: data.sortField, direction: data.sortDirection }
|
||||
ngOnInit() {
|
||||
this.updatePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one of the pagination settings is changed
|
||||
* @param event The new pagination data
|
||||
*/
|
||||
onPaginationChange(event) {
|
||||
this.config.currentPage = event.page;
|
||||
this.config.pageSize = event.pageSize;
|
||||
this.sortConfig.field = event.sortField;
|
||||
this.sortConfig.direction = event.sortDirection;
|
||||
this.updatePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of top communities
|
||||
*/
|
||||
updatePage() {
|
||||
this.cds.findTop({
|
||||
currentPage: this.config.currentPage,
|
||||
elementsPerPage: this.config.pageSize,
|
||||
sort: { field: this.sortConfig.field, direction: this.sortConfig.direction }
|
||||
}).pipe(take(1)).subscribe((results) => {
|
||||
this.communitiesRD$.next(results);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user