Merge branch 'w2p-55990_Move-item-component' into Move-item-component

This commit is contained in:
Yana De Pauw
2019-05-28 16:10:23 +02:00
1195 changed files with 65385 additions and 6910 deletions

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
@import '../../../../styles/variables.scss';
.selectable-row:hover {
cursor: pointer;
}

View File

@@ -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);
});
}));
});
});

View File

@@ -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)
}
});
}
}

View File

@@ -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([]);
});
});

View File

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

View File

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

View File

@@ -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);
});
}));
});
});
});

View File

@@ -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();
}
}

View File

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

View File

@@ -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);
});
}));
});
});
});

View File

@@ -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();
}
}

View File

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

View File

@@ -0,0 +1,5 @@
@import '../../../../styles/variables.scss';
.selectable-row:hover {
cursor: pointer;
}

View File

@@ -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);
});
}));
});
});

View File

@@ -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)
}
});
}
}

View File

@@ -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'
}
])
]
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
}));
});
});

View 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)? '&quot;' + value + '&quot;': ''} }}"
[objects$]="(items$ !== undefined)? items$ : authors$"
[currentUrl]="currentUrl"
[paginationConfig]="paginationConfig"
[sortConfig]="sortConfig">
</ds-browse-by>
</div>
</div>

View File

@@ -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());
}
}

View File

@@ -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());
});
});

View File

@@ -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();
}
})
);
}
}

View File

@@ -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)? '&quot;' + value + '&quot;': ''} }}"
[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>

View File

@@ -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)));
}

View File

@@ -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
);
}

View File

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

View File

@@ -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);
});
});
});

View File

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

View 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);
}
);
});
});
});

View 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}"` : ''
}
}
}

View File

@@ -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' } }
])
]
})

View File

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

View File

@@ -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',
}),
];
}

View File

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

View File

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

View File

@@ -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
});
}
}

View File

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

View 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);
}
);
});
});
});

View File

@@ -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),
);
}
}

View File

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

View File

@@ -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/');
})
});
});

View File

@@ -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);
}
}

View File

@@ -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)
);
});
});
});

View File

@@ -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']);
}
})
);
}
}

View File

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

View File

@@ -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/');
})
});
});

View File

@@ -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);
}
}

View File

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

View File

@@ -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/');
})
});
});

View File

@@ -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);
}
}

View File

@@ -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',
}),
];
}

View File

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

View File

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

View File

@@ -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());
}
}

View File

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

View File

@@ -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)
);
}
}

View File

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

View File

@@ -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/');
})
});
});

View File

@@ -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);
}
}

View File

@@ -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)
);
});
});
});

View File

@@ -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']);
}
})
);
}
}

View File

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

View File

@@ -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/');
})
});
});

View File

@@ -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);
}
}

View File

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

View File

@@ -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/');
})
});
});

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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