Merge remote-tracking branch 'remotes/origin/submission' into mydspace

This commit is contained in:
Giuseppe Digilio
2019-03-11 20:06:42 +01:00
198 changed files with 6124 additions and 878 deletions

View File

@@ -141,5 +141,19 @@ module.exports = {
code: 'nl',
label: 'Nederlands',
active: false,
}]
}],
// Browse-By Pages
browseBy: {
// Amount of years to display using jumps of one year (current year - oneYearLimit)
oneYearLimit: 10,
// Limit for years to display using jumps of five years (current year - fiveYearLimit)
fiveYearLimit: 30,
// The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items)
defaultLowerLimit: 1900
},
item: {
edit: {
undoTimeout: 10000 // 10 seconds
}
}
};

View File

@@ -123,6 +123,7 @@
"status": {
"head": "Item Status",
"description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.",
"title": "Item Edit - Status",
"labels": {
"id": "Item Internal ID",
"handle": "Handle",
@@ -165,16 +166,20 @@
}
},
"bitstreams": {
"head": "Item Bitstreams"
"head": "Item Bitstreams",
"title": "Item Edit - Bitstreams"
},
"metadata": {
"head": "Item Metadata"
"head": "Item Metadata",
"title": "Item Edit - Metadata"
},
"view": {
"head": "View Item"
"head": "View Item",
"title": "Item Edit - View"
},
"curate": {
"head": "Curate"
"head": "Curate",
"title": "Item Edit - Curate"
}
},
"modify.overview": {
@@ -188,7 +193,7 @@
"confirm": "Withdraw",
"cancel": "Cancel",
"success": "The item was withdrawn successfully",
"error": "An error occured while withdrawing the item"
"error": "An error occurred while withdrawing the item"
},
"reinstate": {
"header": "Reinstate item: {{ id }}",
@@ -196,7 +201,7 @@
"confirm": "Reinstate",
"cancel": "Cancel",
"success": "The item was reinstated successfully",
"error": "An error occured while reinstating the item"
"error": "An error occurred while reinstating the item"
},
"private": {
"header": "Make item private: {{ id }}",
@@ -204,7 +209,7 @@
"confirm": "Make it Private",
"cancel": "Cancel",
"success": "The item is now private",
"error": "An error occured while making the item private"
"error": "An error occurred while making the item private"
},
"public": {
"header": "Make item public: {{ id }}",
@@ -212,7 +217,7 @@
"confirm": "Make it Public",
"cancel": "Cancel",
"success": "The item is now public",
"error": "An error occured while making the item public"
"error": "An error occurred while making the item public"
},
"delete": {
"header": "Delete item: {{ id }}",
@@ -220,7 +225,48 @@
"confirm": "Delete",
"cancel": "Cancel",
"success": "The item has been deleted",
"error": "An error occured while deleting the item"
"error": "An error occurred while deleting the item"
},
"metadata": {
"add-button": "Add",
"discard-button": "Discard",
"reinstate-button": "Undo",
"save-button": "Save",
"headers": {
"field": "Field",
"value": "Value",
"language": "Lang",
"edit": "Edit"
},
"edit": {
"buttons": {
"edit": "Edit",
"unedit": "Stop editing",
"remove": "Remove",
"undo": "Undo changes"
}
},
"metadatafield": {
"invalid": "Please choose a valid metadata field"
},
"notifications": {
"outdated": {
"title": "Changed outdated",
"content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts"
},
"discarded": {
"title": "Changed discarded",
"content": "Your changes were discarded. To reinstate your changes click the 'Undo' button"
},
"invalid": {
"title": "Metadata invalid",
"content": "Your changes were not saved. Please make sure all fields are valid before you save."
},
"saved": {
"title": "Metadata saved",
"content": "Your changes to this item's metadata were saved."
}
}
}
}
},
@@ -346,9 +392,6 @@
"title": "Settings",
"sort-by": "Sort By",
"rpp": "Results per page"
},
"tab":{
"title":"Show"
}
},
"switch-configuration": {
@@ -424,15 +467,40 @@
},
"browse": {
"title": "Browsing {{ collection }} by {{ field }} {{ value }}",
"startsWith": {
"jump": "Jump to a point in the index:",
"choose_year": "(Choose year)",
"choose_start": "(Choose start)",
"type_date": "Or type in a date (year-month):",
"type_text": "Or enter first few letters:",
"months": {
"none": "(Choose month)",
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"submit": "Go"
},
"metadata": {
"title": "Title",
"author": "Author",
"subject": "Subject"
"subject": "Subject",
"dateissued": "Issue Date"
},
"comcol": {
"head": "Browse",
"by": {
"title": "By Title",
"dateissued": "By Issue Date",
"author": "By Author",
"subject": "By Subject"
}
@@ -614,7 +682,8 @@
"objects": "Loading...",
"search-results": "Loading search results...",
"mydspace-results": "Loading items...",
"browse-by": "Loading items..."
"browse-by": "Loading items...",
"browse-by-page": "Loading page..."
},
"error": {
"default": "Error",
@@ -727,7 +796,7 @@
"collection": "Collection",
"no-collection": "No collection found",
"search-collection": "Search for a collection",
"save_error_notice": "There was an issue when saving the item, please try again later.",
"deposit_success_notice": "Submission deposited successfully.",
"deposit_error_notice": "There was an issue when submitting the item, please try again later.",
"discard_success_notice": "Submission discarded successfully.",

View File

@@ -20,7 +20,8 @@ describe('MetadataSchemaFormComponent', () => {
/* tslint:disable:no-empty */
const registryServiceStub = {
getActiveMetadataSchema: () => observableOf(undefined),
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema)
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
cancelEditMetadataSchema: () => {}
};
const formBuilderServiceStub = {
createFormGroup: () => {

View File

@@ -27,7 +27,8 @@ describe('MetadataFieldFormComponent', () => {
/* tslint:disable:no-empty */
const registryServiceStub = {
getActiveMetadataField: () => observableOf(undefined),
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field)
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field),
cancelEditMetadataSchema: () => {},
};
const formBuilderServiceStub = {
createFormGroup: () => {

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,116 @@
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 { PaginatedList } from '../../core/data/paginated-list';
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

@@ -1,10 +1,18 @@
<div class="container">
<div class="browse-by-metadata w-100 row">
<ds-browse-by class="col-xs-12 w-100"
<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">
[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

@@ -19,6 +19,7 @@ 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;
@@ -86,7 +87,8 @@ describe('BrowseByMetadataPageComponent', () => {
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: BrowseService, useValue: mockBrowseService },
{ provide: DSpaceObjectDataService, useValue: mockDsoService }
{ provide: DSpaceObjectDataService, useValue: mockDsoService },
{ provide: Router, useValue: new MockRouter() }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();

View File

@@ -4,17 +4,17 @@ 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 } from '@angular/router';
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 { Community } from '../../core/shared/community.model';
import { Collection } from '../../core/shared/collection.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',
@@ -72,6 +72,18 @@ export class BrowseByMetadataPageComponent implements OnInit {
*/
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
@@ -79,9 +91,15 @@ export class BrowseByMetadataPageComponent implements OnInit {
*/
value = '';
public constructor(private route: ActivatedRoute,
private browseService: BrowseService,
private dsoService: DSpaceObjectDataService) {
/**
* 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 {
@@ -96,6 +114,7 @@ export class BrowseByMetadataPageComponent implements OnInit {
.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);
@@ -104,6 +123,15 @@ export class BrowseByMetadataPageComponent implements OnInit {
}
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('')];
}
/**
@@ -144,6 +172,58 @@ export class BrowseByMetadataPageComponent implements OnInit {
}
}
/**
* 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());
}
@@ -177,6 +257,7 @@ export function browseParamsToOptions(params: any,
field: params.sortField || sortConfig.field
}
),
+params.startsWith || params.startsWith,
params.scope
);
}

View File

@@ -1,10 +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: (parent$ | async)?.payload?.name || '', field: 'browse.metadata.title' | translate, value: ''} }}"
[objects$]="items$"
[paginationConfig]="paginationConfig"
[sortConfig]="sortConfig">
</ds-browse-by>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
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';
@@ -15,6 +15,8 @@ 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;
@@ -44,8 +46,9 @@ describe('BrowseByTitlePageComponent', () => {
})
];
const mockItemDataService = {
findAll: () => toRemoteData(mockItems)
const mockBrowseService = {
getBrowseItemsFor: () => toRemoteData(mockItems),
getBrowseEntriesFor: () => toRemoteData([])
};
const mockDsoService = {
@@ -53,7 +56,8 @@ describe('BrowseByTitlePageComponent', () => {
};
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: observableOf({})
params: observableOf({}),
data: observableOf({ metadata: 'title' })
});
beforeEach(async(() => {
@@ -62,8 +66,9 @@ describe('BrowseByTitlePageComponent', () => {
declarations: [BrowseByTitlePageComponent, EnumKeysPipe],
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: ItemDataService, useValue: mockItemDataService },
{ provide: DSpaceObjectDataService, useValue: mockDsoService }
{ provide: BrowseService, useValue: mockBrowseService },
{ provide: DSpaceObjectDataService, useValue: mockDsoService },
{ provide: Router, useValue: new MockRouter() }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();

View File

@@ -1,107 +1,51 @@
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 { 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 } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { hasValue } from '../../shared/empty.util';
import { Collection } from '../../core/shared/collection.model';
import { browseParamsToOptions } from '../+browse-by-metadata-page/browse-by-metadata-page.component';
import {
BrowseByMetadataPageComponent,
browseParamsToOptions
} from '../+browse-by-metadata-page/browse-by-metadata-page.component';
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
import { Community } from '../../core/shared/community.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 { 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 {
/**
* The list of items to display
*/
items$: Observable<RemoteData<PaginatedList<Item>>>;
/**
* The current Community or Collection we're browsing metadata/items in
*/
parent$: Observable<RemoteData<DSpaceObject>>;
/**
* The pagination configuration to use for displaying the list of items
*/
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'browse-by-title-pagination',
currentPage: 1,
pageSize: 20
});
/**
* The sorting configuration to use for displaying the list of items
* Sorted by title (Ascending by default)
*/
sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC);
/**
* List of subscriptions
*/
subs: Subscription[] = [];
public constructor(private itemDataService: ItemDataService,
private route: ActivatedRoute,
private dsoService: DSpaceObjectDataService) {
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.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) => {
this.updatePage(browseParamsToOptions(params, this.paginationConfig, this.sortConfig));
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: BrowseEntrySearchOptions) {
this.items$ = this.itemDataService.findAll({
currentPage: searchOptions.pagination.currentPage,
elementsPerPage: searchOptions.pagination.pageSize,
sort: searchOptions.sort,
scopeID: searchOptions.scope
});
}
/**
* 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()
);
}
this.updateStartsWithTextOptions();
}
ngOnDestroy(): void {

View File

@@ -0,0 +1,58 @@
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 { combineLatest as observableCombineLatest } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { getSucceededRemoteData } from '../core/shared/operators';
import { TranslateService } from '@ngx-translate/core';
@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.get('browse.metadata.' + metadata).pipe(take(1));
if (hasValue(scope)) {
const dsoAndMetadata$ = observableCombineLatest(metadataTranslated$, this.dsoService.findById(scope).pipe(getSucceededRemoteData()));
return dsoAndMetadata$.pipe(
map(([metadataTranslated, dsoRD]) => {
const name = dsoRD.payload.name;
route.data = this.createData(title, metadata, metadataField, name, metadataTranslated, value);;
return true;
})
);
} else {
return metadataTranslated$.pipe(
map((metadataTranslated: string) => {
route.data = this.createData(title, metadata, metadataField, '', metadataTranslated, value);
return 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

@@ -2,12 +2,15 @@ import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-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: ':metadata', component: BrowseByMetadataPageComponent }
{ 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

@@ -6,6 +6,8 @@ import { SharedModule } from '../shared/shared.module';
import { BrowseByRoutingModule } from './browse-by-routing.module';
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 { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse
],
declarations: [
BrowseByTitlePageComponent,
BrowseByMetadataPageComponent
BrowseByMetadataPageComponent,
BrowseByDatePageComponent
],
providers: [
ItemDataService,
BrowseService
BrowseService,
BrowseByGuard
]
})
export class BrowseByModule {

View File

@@ -3,7 +3,6 @@ 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 { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
import { Collection } from '../../core/shared/collection.model';
import { CollectionDataService } from '../../core/data/collection-data.service';
@@ -15,7 +14,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service';
styleUrls: ['./create-collection-page.component.scss'],
templateUrl: './create-collection-page.component.html'
})
export class CreateCollectionPageComponent extends CreateComColPageComponent<Collection, NormalizedCollection> {
export class CreateCollectionPageComponent extends CreateComColPageComponent<Collection> {
protected frontendURL = '/collections/';
public constructor(

View File

@@ -1,12 +1,8 @@
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 { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
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 { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
import { Collection } from '../../core/shared/collection.model';
import { TranslateService } from '@ngx-translate/core';
@@ -18,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core';
styleUrls: ['./delete-collection-page.component.scss'],
templateUrl: './delete-collection-page.component.html'
})
export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Collection, NormalizedCollection> {
export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Collection> {
protected frontendURL = '/collections/';
public constructor(

View File

@@ -13,7 +13,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service';
styleUrls: ['./edit-collection-page.component.scss'],
templateUrl: './edit-collection-page.component.html'
})
export class EditCollectionPageComponent extends EditComColPageComponent<Collection, NormalizedCollection> {
export class EditCollectionPageComponent extends EditComColPageComponent<Collection> {
protected frontendURL = '/collections/';
public constructor(

View File

@@ -4,7 +4,6 @@ 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 { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
/**
* Component that represents the page where a user can create a new Community
@@ -14,7 +13,7 @@ import { NormalizedCommunity } from '../../core/cache/models/normalized-communit
styleUrls: ['./create-community-page.component.scss'],
templateUrl: './create-community-page.component.html'
})
export class CreateCommunityPageComponent extends CreateComColPageComponent<Community, NormalizedCommunity> {
export class CreateCommunityPageComponent extends CreateComColPageComponent<Community> {
protected frontendURL = '/communities/';
public constructor(

View File

@@ -2,7 +2,6 @@ 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 { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
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';
@@ -15,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core';
styleUrls: ['./delete-community-page.component.scss'],
templateUrl: './delete-community-page.component.html'
})
export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Community, NormalizedCommunity> {
export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Community> {
protected frontendURL = '/communities/';
public constructor(

View File

@@ -2,7 +2,6 @@ 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 { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component';
/**
@@ -13,7 +12,7 @@ import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-p
styleUrls: ['./edit-community-page.component.scss'],
templateUrl: './edit-community-page.component.html'
})
export class EditCommunityPageComponent extends EditComColPageComponent<Community, NormalizedCommunity> {
export class EditCommunityPageComponent extends EditComColPageComponent<Community> {
protected frontendURL = '/communities/';
public constructor(

View File

@@ -3,33 +3,21 @@
<div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2">
<ngb-tabset>
<ngb-tab title="{{'item.edit.tabs.status.head' | translate}}">
<ng-template ngbTabContent>
<ds-item-status [item]="(itemRD$ | async)?.payload"></ds-item-status>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.bitstreams.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.metadata.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.view.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.tabs.curate.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
</ngb-tabset>
<ul class="nav nav-tabs justify-content-start">
<li *ngFor="let page of pages" class="nav-item">
<a class="nav-link"
[ngClass]="{'active' : page === currentPage}"
[routerLink]="['./' + page]">
{{'item.edit.tabs.' + page + '.head' | translate}}
</a>
</li>
</ul>
<div class="tab-pane active">
<div class="mb-4">
<router-outlet></router-outlet>
</div>
<a [routerLink]="getItemPage((itemRD$ | async)?.payload)" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
@import '../../../styles/variables.scss';
.btn {
min-width: $edit-item-button-min-width;
}

View File

@@ -1,10 +1,12 @@
import {fadeIn, fadeInOut} from '../../shared/animations/fade';
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {RemoteData} from '../../core/data/remote-data';
import {Item} from '../../core/shared/item.model';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util';
import { getItemPageRoute } from '../item-page-routing.module';
@Component({
selector: 'ds-edit-item-page',
@@ -25,11 +27,34 @@ export class EditItemPageComponent implements OnInit {
*/
itemRD$: Observable<RemoteData<Item>>;
constructor(private route: ActivatedRoute) {
/**
* The current page outlet string
*/
currentPage: string;
/**
* All possible page outlet strings
*/
pages: string[];
constructor(private route: ActivatedRoute, private router: Router) {
this.router.events.subscribe(() => {
this.currentPage = this.route.snapshot.firstChild.routeConfig.path;
});
}
ngOnInit(): void {
this.pages = this.route.routeConfig.children
.map((child: any) => child.path)
.filter((path: string) => isNotEmpty(path)); // ignore reroutes
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
}
/**
* Get the item page url
* @param item The item for which the url is requested
*/
getItemPage(item: Item): string {
return getItemPageRoute(item.id)
}
}

View File

@@ -1,17 +1,20 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SharedModule} from '../../shared/shared.module';
import {EditItemPageRoutingModule} from './edit-item-page.routing.module';
import {EditItemPageComponent} from './edit-item-page.component';
import {ItemStatusComponent} from './item-status/item-status.component';
import {ItemOperationComponent} from './item-operation/item-operation.component';
import {ModifyItemOverviewComponent} from './modify-item-overview/modify-item-overview.component';
import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component';
import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component';
import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-simple-item-action.component';
import {ItemPrivateComponent} from './item-private/item-private.component';
import {ItemPublicComponent} from './item-public/item-public.component';
import {ItemDeleteComponent} from './item-delete/item-delete.component';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../../shared/shared.module';
import { EditItemPageRoutingModule } from './edit-item-page.routing.module';
import { EditItemPageComponent } from './edit-item-page.component';
import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemOperationComponent } from './item-operation/item-operation.component';
import { ModifyItemOverviewComponent } from './modify-item-overview/modify-item-overview.component';
import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component';
import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component';
import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract-simple-item-action.component';
import { ItemPrivateComponent } from './item-private/item-private.component';
import { ItemPublicComponent } from './item-public/item-public.component';
import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
/**
* Module that contains all components related to the Edit Item page administrator functionality
@@ -32,7 +35,10 @@ import {ItemDeleteComponent} from './item-delete/item-delete.component';
ItemPrivateComponent,
ItemPublicComponent,
ItemDeleteComponent,
ItemStatusComponent
ItemStatusComponent,
ItemMetadataComponent,
ItemBitstreamsComponent,
EditInPlaceFieldComponent
]
})
export class EditItemPageModule {

View File

@@ -1,12 +1,15 @@
import {ItemPageResolver} from '../item-page.resolver';
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {EditItemPageComponent} from './edit-item-page.component';
import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component';
import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component';
import {ItemPrivateComponent} from './item-private/item-private.component';
import {ItemPublicComponent} from './item-public/item-public.component';
import {ItemDeleteComponent} from './item-delete/item-delete.component';
import { ItemPageResolver } from '../item-page.resolver';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component';
import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component';
import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component';
import { ItemPrivateComponent } from './item-private/item-private.component';
import { ItemPublicComponent } from './item-public/item-public.component';
import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -25,7 +28,40 @@ const ITEM_EDIT_DELETE_PATH = 'delete';
component: EditItemPageComponent,
resolve: {
item: ItemPageResolver
}
},
children: [
{
path: '',
redirectTo: 'status',
},
{
path: 'status',
component: ItemStatusComponent,
data: { title: 'item.edit.tabs.status.title' }
},
{
path: 'bitstreams',
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.bitstreams.title' }
},
{
path: 'metadata',
component: ItemMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title' }
},
{
path: 'view',
/* TODO - change when view page exists */
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.view.title' }
},
{
path: 'curate',
/* TODO - change when curate page exists */
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.curate.title' }
},
]
},
{
path: ITEM_EDIT_WITHDRAW_PATH,

View File

@@ -0,0 +1,3 @@
<div>
</div>

View File

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

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
@Component({
selector: 'ds-item-bitstreams',
styleUrls: ['./item-bitstreams.component.scss'],
templateUrl: './item-bitstreams.component.html',
})
/**
* Component for displaying an item's bitstreams edit page
*/
export class ItemBitstreamsComponent {
/* TODO implement */
}

View File

@@ -0,0 +1,70 @@
<td>
<div class="metadata-field">
<div *ngIf="!(editable | async)">
<span>{{metadata?.key?.split('.').join('.&#8203;')}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key"
(submitSuggestion)="update(suggestionControl)"
(clickSuggestion)="update(suggestionControl)"
(typeSuggestion)="update(suggestionControl)"
(dsClickOutside)="checkValidity(suggestionControl)"
(findSuggestions)="findMetadataFieldSuggestions($event)"
#suggestionControl="ngModel"
[dsInListValidator]="metadataFields"
[valid]="(valid | async) !== false"
dsAutoFocus autoFocusSelector=".suggestion_input"
[ngModelOptions]="{standalone: true}"
></ds-input-suggestions>
</div>
<small class="text-danger"
*ngIf="(valid | async) === false">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small>
</div>
</td>
<td class="w-100">
<div class="value-field">
<div *ngIf="!(editable | async)">
<span>{{metadata?.value}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<textarea class="form-control" type="textarea" [(ngModel)]="metadata.value" [dsDebounce]
(onDebounce)="update()"></textarea>
</div>
</div>
</td>
<td class="text-center">
<div class="language-field">
<div *ngIf="!(editable | async)">
<span>{{metadata?.language}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<input class="form-control" type="text" [(ngModel)]="metadata.language" [dsDebounce]
(onDebounce)="update()"/>
</div>
</div>
</td>
<td class="text-center">
<div class="btn-group edit-field">
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
(click)="setEditable(true)" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)"
(click)="setEditable(false)" class="btn btn-outline-success btn-sm"
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
<i class="fas fa-check fa-fw"></i>
</button>
<button [disabled]="!(canRemove() | async)" (click)="remove()"
class="btn btn-outline-danger btn-sm"
title="{{'item.edit.metadata.edit.buttons.remove' | translate}}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<button [disabled]="!(canUndo() | async)" (click)="removeChangesFromField()"
class="btn btn-outline-warning btn-sm"
title="{{'item.edit.metadata.edit.buttons.undo' | translate}}">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
</div>
</td>

View File

@@ -0,0 +1,14 @@
@import '../../../../../styles/variables.scss';
.btn[disabled] {
color: $gray-600;
border-color: $gray-600;
z-index: 0; // prevent border colors jumping on hover
}
.metadata-field {
width: $edit-item-metadata-field-width;
}
.language-field {
width: $edit-item-language-field-width;
}

View File

@@ -0,0 +1,432 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
import { RegistryService } from '../../../../core/registry/registry.service';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../../../shared/shared.module';
import { getTestScheduler } from 'jasmine-marbles';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { TestScheduler } from 'rxjs/testing';
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { TranslateModule } from '@ngx-translate/core';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>;
let de: DebugElement;
let el: HTMLElement;
let metadataFieldService;
let objectUpdatesService;
let paginatedMetadataFields;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' })
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'description',
qualifier: 'abstract'
});
const metadatum = Object.assign(new MetadatumViewModel(), {
key: 'dc.description.abstract',
value: 'Example abstract',
language: 'en'
});
const url = 'http://test-url.com/test-url';
const fieldUpdate = {
field: metadatum,
changeType: undefined
};
let scheduler: TestScheduler;
describe('EditInPlaceFieldComponent', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);
metadataFieldService = jasmine.createSpyObj({
queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)),
});
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
saveChangeFieldUpdate: {},
saveRemoveFieldUpdate: {},
setEditableFieldUpdate: {},
setValidFieldUpdate: {},
removeSingleFieldUpdate: {},
isEditable: observableOf(false), // should always return something --> its in ngOnInit
isValid: observableOf(true) // should always return something --> its in ngOnInit
}
);
TestBed.configureTestingModule({
imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
declarations: [EditInPlaceFieldComponent],
providers: [
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditInPlaceFieldComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement;
el = de.nativeElement;
comp.url = url;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
fixture.detectChanges();
});
describe('update', () => {
beforeEach(() => {
comp.update();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum);
});
});
describe('setEditable', () => {
const editable = false;
beforeEach(() => {
comp.setEditable(editable);
});
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => {
expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable);
});
});
describe('editable is true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
fixture.detectChanges();
});
it('the div should contain input fields or textareas', () => {
const inputField = de.queryAll(By.css('input'));
const textAreas = de.queryAll(By.css('textarea'));
expect(inputField.length + textAreas.length).toBeGreaterThan(0);
});
});
describe('editable is false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
const inputField = de.queryAll(By.css('input'));
const textAreas = de.queryAll(By.css('textarea'));
expect(inputField.length + textAreas.length).toBe(0);
});
});
describe('isValid is true', () => {
beforeEach(() => {
comp.valid = observableOf(true);
fixture.detectChanges();
});
it('the div should not contain an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBe(0);
});
});
describe('isValid is false', () => {
beforeEach(() => {
comp.valid = observableOf(false);
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0);
});
});
describe('remove', () => {
beforeEach(() => {
comp.remove();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum);
});
});
describe('removeChangesFromField', () => {
beforeEach(() => {
comp.removeChangesFromField();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid);
});
});
describe('findMetadataFieldSuggestions', () => {
const query = 'query string';
const metadataFieldSuggestions: InputSuggestion[] =
[
{ displayValue: mdField1.toString().split('.').join('.&#8203;'), value: mdField1.toString() },
{ displayValue: mdField2.toString().split('.').join('.&#8203;'), value: mdField2.toString() },
{ displayValue: mdField3.toString().split('.').join('.&#8203;'), value: mdField3.toString() }
];
beforeEach(() => {
comp.findMetadataFieldSuggestions(query);
});
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
});
it('it should set metadataFieldSuggestions to the right value', () => {
const expected = 'a';
scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions });
});
});
describe('canSetEditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
});
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
});
it('canSetEditable should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
});
})
});
});
describe('canSetUneditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
});
it('canSetUneditable should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
});
it('canSetUneditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false });
});
});
});
describe('when canSetEditable emits true', () => {
beforeEach(() => {
comp.editable = observableOf(false);
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with an edit icon', () => {
const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
expect(editIcon).toBe(false);
});
});
describe('when canSetEditable emits false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with an edit icon', () => {
const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
expect(editIcon).toBe(true);
});
});
describe('when canSetUneditable emits true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with a check icon', () => {
const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
expect(checkButtonAttrs).toBe(false);
});
});
describe('when canSetUneditable emits false', () => {
beforeEach(() => {
comp.editable = observableOf(true);
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with a check icon', () => {
const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
expect(checkButtonAttrs).toBe(true);
});
});
describe('when canRemove emits true', () => {
beforeEach(() => {
spyOn(comp, 'canRemove').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with a trash icon', () => {
const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
expect(trashButtonAttrs).toBe(false);
});
});
describe('when canRemove emits false', () => {
beforeEach(() => {
spyOn(comp, 'canRemove').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with a trash icon', () => {
const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
expect(trashButtonAttrs).toBe(true);
});
});
describe('when canUndo emits true', () => {
beforeEach(() => {
spyOn(comp, 'canUndo').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with an undo icon', () => {
const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
expect(undoIcon).toBe(false);
});
});
describe('when canUndo emits false', () => {
beforeEach(() => {
spyOn(comp, 'canUndo').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with an undo icon', () => {
const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
expect(undoIcon).toBe(true);
});
});
describe('canRemove', () => {
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
});
it('canRemove should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
});
it('canRemove should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false });
});
})
});
describe('canUndo', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
it('canUndo should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
});
});
describe('when editable is currently false', () => {
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
});
it('canUndo should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = undefined;
});
it('canUndo should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false });
});
});
});
});
});

View File

@@ -0,0 +1,194 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
@Component({
// tslint:disable-next-line:component-selector
selector: '[ds-edit-in-place-field]',
styleUrls: ['./edit-in-place-field.component.scss'],
templateUrl: './edit-in-place-field.component.html',
})
/**
* Component that displays a single metadatum of an item on the edit page
*/
export class EditInPlaceFieldComponent implements OnInit, OnChanges {
/**
* The current field, value and state of the metadatum
*/
@Input() fieldUpdate: FieldUpdate;
/**
* The current url of this page
*/
@Input() url: string;
/**
* List of strings with all metadata field keys available
*/
@Input() metadataFields: string[];
/**
* The metadatum of this field
*/
metadata: MetadatumViewModel;
/**
* Emits whether or not this field is currently editable
*/
editable: Observable<boolean>;
/**
* Emits whether or not this field is currently valid
*/
valid: Observable<boolean>;
/**
* The current suggestions for the metadatafield when editing
*/
metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]);
constructor(
private metadataFieldService: RegistryService,
private objectUpdatesService: ObjectUpdatesService,
) {
}
/**
* Sets up an observable that keeps track of the current editable and valid state of this field
*/
ngOnInit(): void {
this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid);
this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid);
}
/**
* Sends a new change update for this field to the object updates service
*/
update(ngModel?: NgModel) {
this.objectUpdatesService.saveChangeFieldUpdate(this.url, this.metadata);
if (hasValue(ngModel)) {
this.checkValidity(ngModel);
}
}
/**
* Method to check the validity of a form control
* @param ngModel
*/
private checkValidity(ngModel: NgModel) {
ngModel.control.setValue(ngModel.viewModel);
ngModel.control.updateValueAndValidity();
this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, ngModel.control.valid);
}
/**
* Sends a new editable state for this field to the service to change it
* @param editable The new editable state for this field
*/
setEditable(editable: boolean) {
this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable);
}
/**
* Sends a new remove update for this field to the object updates service
*/
remove() {
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.metadata);
}
/**
* Notifies the object updates service that the updates for the current field can be removed
*/
removeChangesFromField() {
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid);
}
/**
* Sets the current metadatafield based on the fieldUpdate input field
*/
ngOnChanges(): void {
this.metadata = cloneDeep(this.fieldUpdate.field) as MetadatumViewModel;
}
/**
* Requests all metadata fields that contain the query string in their key
* Then sets all found metadata fields as metadataFieldSuggestions
* @param query The query to look for
*/
findMetadataFieldSuggestions(query: string): void {
if (isNotEmpty(query)) {
this.metadataFieldService.queryMetadataFields(query).pipe(
// getSucceededRemoteData(),
take(1),
map((data) => data.payload.page)
).subscribe(
(fields: MetadataField[]) => this.metadataFieldSuggestions.next(
fields.map((field: MetadataField) => {
return {
displayValue: field.toString().split('.').join('.&#8203;'),
value: field.toString()
};
})
)
);
} else {
this.metadataFieldSuggestions.next([]);
}
}
/**
* Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not
*/
canSetEditable(): Observable<boolean> {
return this.editable.pipe(
map((editable: boolean) => {
if (editable) {
return false;
} else {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
}
})
);
}
/**
* Check if a user should be allowed to disabled editing this field
* @return an observable that emits true when the user should be able to disable editing this field and false when they should not
*/
canSetUneditable(): Observable<boolean> {
return this.editable;
}
/**
* Check if a user should be allowed to remove this field
* @return an observable that emits true when the user should be able to remove this field and false when they should not
*/
canRemove(): Observable<boolean> {
return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD);
}
/**
* Check if a user should be allowed to undo changes to this field
* @return an observable that emits true when the user should be able to undo changes to this field and false when they should not
*/
canUndo(): Observable<boolean> {
return this.editable.pipe(
map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable)
);
}
protected isNotEmpty(value): boolean {
return isNotEmpty(value);
}
}

View File

@@ -0,0 +1,64 @@
<div class="item-metadata">
<div class="button-row top d-flex">
<button class="mr-auto btn btn-success"
(click)="add()"><i
class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.add-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<table class="table table-responsive table-striped table-bordered">
<tbody>
<tr>
<th>{{'item.edit.metadata.headers.field' | translate}}</th>
<th>{{'item.edit.metadata.headers.value' | translate}}</th>
<th class="text-center">{{'item.edit.metadata.headers.language' | translate}}</th>
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field
[fieldUpdate]="updateValue || {}"
[metadataFields]="metadataFields$ | async"
[url]="url"
[ngClass]="{
'table-warning': updateValue.changeType === 0,
'table-danger': updateValue.changeType === 2,
'table-success': updateValue.changeType === 1
}">
</tr>
</tbody>
</table>
<div class="button-row bottom">
<div class="my-2 float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
@import '../../../../styles/variables.scss';
.button-row {
.btn {
margin-right: 0.5 * $spacer;
&:last-child {
margin-right: 0;
}
@media screen and (min-width: map-get($grid-breakpoints, sm)) {
min-width: $edit-item-button-min-width;
}
}
&.top .btn {
margin-top: $spacer/2;
margin-bottom: $spacer/2;
}
}

View File

@@ -0,0 +1,278 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { getTestScheduler } from 'jasmine-marbles';
import { ItemMetadataComponent } from './item-metadata.component';
import { TestScheduler } from 'rxjs/testing';
import { SharedModule } from '../../../shared/shared.module';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateModule } from '@ngx-translate/core';
import { ItemDataService } from '../../../core/data/item-data.service';
import { By } from '@angular/platform-browser';
import {
INotification,
Notification
} from '../../../shared/notifications/models/notification.model';
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { RouterStub } from '../../../shared/testing/router-stub';
import { GLOBAL_CONFIG } from '../../../../config';
import { Item } from '../../../core/shared/item.model';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { RemoteData } from '../../../core/data/remote-data';
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { RegistryService } from '../../../core/registry/registry.service';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { MetadataField } from '../../../core/metadata/metadatafield.model';
import { Metadata } from '../../../core/shared/metadata.utils';
let comp: ItemMetadataComponent;
let fixture: ComponentFixture<ItemMetadataComponent>;
let de: DebugElement;
let el: HTMLElement;
let objectUpdatesService;
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
const date = new Date();
const router = new RouterStub();
let metadataFieldService;
let paginatedMetadataFields;
let routeStub;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'description',
qualifier: 'abstract'
});
let itemService;
const notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification,
success: successNotification
}
);
const metadatum1 = Object.assign(new MetadatumViewModel(), {
key: 'dc.description.abstract',
value: 'Example abstract',
language: 'en'
});
const metadatum2 = Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Title test',
language: 'de'
});
const metadatum3 = Object.assign(new MetadatumViewModel(), {
key: 'dc.contributor.author',
value: 'Shakespeare, William',
});
const url = 'http://test-url.com/test-url';
router.url = url;
const fieldUpdate1 = {
field: metadatum1,
changeType: undefined
};
const fieldUpdate2 = {
field: metadatum2,
changeType: FieldChangeType.REMOVE
};
const fieldUpdate3 = {
field: metadatum3,
changeType: undefined
};
let scheduler: TestScheduler;
let item;
describe('ItemMetadataComponent', () => {
beforeEach(async(() => {
item = Object.assign(new Item(), {
metadata: {
[metadatum1.key]: [metadatum1],
[metadatum2.key]: [metadatum2],
[metadatum3.key]: [metadatum3]
}
},
{
lastModified: date
}
)
;
itemService = jasmine.createSpyObj('itemService', {
update: observableOf(new RemoteData(false, false, true, undefined, item)),
commitUpdates: {}
});
routeStub = {
parent: {
data: observableOf({ item: new RemoteData(false, false, true, null, item) })
}
};
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);
metadataFieldService = jasmine.createSpyObj({
getAllMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields))
});
scheduler = getTestScheduler();
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdates: observableOf({
[metadatum1.uuid]: fieldUpdate1,
[metadatum2.uuid]: fieldUpdate2,
[metadatum3.uuid]: fieldUpdate3
}),
saveAddFieldUpdate: {},
discardFieldUpdates: {},
reinstateFieldUpdates: observableOf(true),
initialize: {},
getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]),
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
isValidPage: observableOf(true)
}
);
TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()],
declarations: [ItemMetadataComponent],
providers: [
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
{ provide: RegistryService, useValue: metadataFieldService },
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(ItemMetadataComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement;
el = de.nativeElement;
comp.url = url;
fixture.detectChanges();
});
describe('add', () => {
const md = new MetadatumViewModel();
beforeEach(() => {
comp.add(md);
});
it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md);
});
});
describe('discard', () => {
beforeEach(() => {
comp.discard();
});
it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => {
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification);
});
});
describe('reinstate', () => {
beforeEach(() => {
comp.reinstate();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => {
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url);
});
});
describe('submit', () => {
beforeEach(() => {
comp.submit();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList);
expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) }));
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList);
});
});
describe('hasChanges', () => {
describe('when the objectUpdatesService\'s hasUpdated method returns true', () => {
beforeEach(() => {
objectUpdatesService.hasUpdates.and.returnValue(observableOf(true));
});
it('should return an observable that emits true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true });
});
});
describe('when the objectUpdatesService\'s hasUpdated method returns false', () => {
beforeEach(() => {
objectUpdatesService.hasUpdates.and.returnValue(observableOf(false));
});
it('should return an observable that emits false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false });
});
});
});
describe('changeType is UPDATE', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('the div should have class table-warning', () => {
const element = de.queryAll(By.css('tr'))[1].nativeElement;
expect(element.classList).toContain('table-warning');
});
});
describe('changeType is ADD', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('the div should have class table-success', () => {
const element = de.queryAll(By.css('tr'))[1].nativeElement;
expect(element.classList).toContain('table-success');
});
});
describe('changeType is REMOVE', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('the div should have class table-danger', () => {
const element = de.queryAll(By.css('tr'))[1].nativeElement;
expect(element.classList).toContain('table-danger');
});
});
});

View File

@@ -0,0 +1,233 @@
import { Component, Inject, Input, OnInit } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import {
FieldUpdate,
FieldUpdates,
Identifiable
} from '../../../core/data/object-updates/object-updates.reducer';
import { first, map, switchMap, take, tap } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
import { TranslateService } from '@ngx-translate/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { MetadataField } from '../../../core/metadata/metadatafield.model';
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { Metadata } from '../../../core/shared/metadata.utils';
@Component({
selector: 'ds-item-metadata',
styleUrls: ['./item-metadata.component.scss'],
templateUrl: './item-metadata.component.html',
})
/**
* Component for displaying an item's metadata edit page
*/
export class ItemMetadataComponent implements OnInit {
/**
* The item to display the edit page for
*/
item: Item;
/**
* The current values and updates for all this item's metadata fields
*/
updates$: Observable<FieldUpdates>;
/**
* The current url of this page
*/
url: string;
/**
* The time span for being able to undo discarding changes
*/
private discardTimeOut: number;
/**
* Prefix for this component's notification translate keys
*/
private notificationsPrefix = 'item.edit.metadata.notifications.';
/**
* Observable with a list of strings with all existing metadata field keys
*/
metadataFields$: Observable<string[]>;
constructor(
private itemService: ItemDataService,
private objectUpdatesService: ObjectUpdatesService,
private router: Router,
private notificationsService: NotificationsService,
private translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
private route: ActivatedRoute,
private metadataFieldService: RegistryService,
) {
}
/**
* Set up and initialize all fields
*/
ngOnInit(): void {
this.metadataFields$ = this.findMetadataFields();
this.route.parent.data.pipe(map((data) => data.item))
.pipe(
first(),
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.item = item;
});
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
this.url = this.router.url;
if (this.url.indexOf('?') > 0) {
this.url = this.url.substr(0, this.url.indexOf('?'));
}
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
if (!hasChanges) {
this.initializeOriginalFields();
} else {
this.checkLastModified();
}
});
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
}
/**
* Sends a new add update for a field to the object updates service
* @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum
*/
add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
}
/**
* Request the object updates service to discard all current changes to this item
* Shows a notification to remind the user that they can undo this
*/
discard() {
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.objectUpdatesService.reinstateFieldUpdates(this.url);
}
/**
* Sends all initial values of this item to the object updates service
*/
private initializeOriginalFields() {
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
*/
trackUpdate(index, update: FieldUpdate) {
return update && update.field ? update.field.uuid : undefined;
}
/**
* Requests all current metadata for this item and requests the item service to update the item
* Makes sure the new version of the item is rendered on the page
*/
submit() {
this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
metadata$.pipe(
first(),
switchMap((metadata: MetadatumViewModel[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) });
return this.itemService.update(updatedItem);
}),
tap(() => this.itemService.commitUpdates()),
getSucceededRemoteData()
).subscribe(
(rd: RemoteData<Item>) => {
this.item = rd.payload;
this.initializeOriginalFields();
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
}
)
} else {
this.notificationsService.error(this.getNotificationTitle('invalid'), this.getNotificationContent('invalid'));
}
});
}
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.url);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.objectUpdatesService.isReinstatable(this.url);
}
/**
* Checks if the current item is still in sync with the version in the store
* If it's not, a notification is shown and the changes are removed
*/
private checkLastModified() {
const currentVersion = this.item.lastModified;
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
(updateVersion: Date) => {
if (updateVersion.getDate() !== currentVersion.getDate()) {
this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated'));
this.initializeOriginalFields();
}
}
);
}
/**
* Check if the current page is entirely valid
*/
private isValid() {
return this.objectUpdatesService.isValidPage(this.url);
}
/**
* Get translated notification title
* @param key
*/
private getNotificationTitle(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.title');
}
/**
* Get translated notification content
* @param key
*/
private getNotificationContent(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.content');
}
/**
* Method to request all metadata fields and convert them to a list of strings
*/
findMetadataFields(): Observable<string[]> {
return this.metadataFieldService.getAllMetadataFields().pipe(
getSucceededRemoteData(),
take(1),
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
}
}

View File

@@ -12,7 +12,7 @@
{{'item.edit.tabs.status.labels.itemPage' | translate}}:
</div>
<div class="col-9 float-left status-data" id="status-itemPage">
<a href="{{getItemPage()}}">{{getItemPage()}}</a>
<a href="{{getItemPage((itemRD$ | async)?.payload)}}">{{getItemPage((itemRD$ | async)?.payload)}}</a>
</div>
<div *ngFor="let operation of operations" class="w-100 pt-3">

View File

@@ -6,11 +6,12 @@ import { CommonModule } from '@angular/common';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
import { HostWindowService } from '../../../shared/host-window.service';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { RouterStub } from '../../../shared/testing/router-stub';
import { ActivatedRoute } from '@angular/router';
import { Item } from '../../../core/shared/item.model';
import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent;
@@ -22,17 +23,20 @@ describe('ItemStatusComponent', () => {
lastModified: '2018'
});
const itemPageUrl = `fake-url/${mockItem.id}`;
const routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
const itemPageUrl = `items/${mockItem.id}`;
const routeStub = {
parent: {
data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) })
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemStatusComponent],
providers: [
{ provide: Router, useValue: routerStub },
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
@@ -41,7 +45,6 @@ describe('ItemStatusComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ItemStatusComponent);
comp = fixture.componentInstance;
comp.item = mockItem;
fixture.detectChanges();
});
@@ -65,4 +68,5 @@ describe('ItemStatusComponent', () => {
expect(statusItemPage.textContent).toContain(itemPageUrl);
});
});
})
;

View File

@@ -1,8 +1,12 @@
import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
import {fadeIn, fadeInOut} from '../../../shared/animations/fade';
import {Item} from '../../../core/shared/item.model';
import {Router} from '@angular/router';
import {ItemOperation} from '../item-operation/itemOperation.model';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model';
import { first, map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module';
@Component({
selector: 'ds-item-status',
@@ -21,7 +25,7 @@ export class ItemStatusComponent implements OnInit {
/**
* The item to display the status for
*/
@Input() item: Item;
itemRD$: Observable<RemoteData<Item>>;
/**
* The data to show in the status
@@ -37,59 +41,62 @@ export class ItemStatusComponent implements OnInit {
* key: id value: url to action's component
*/
operations: ItemOperation[];
/**
* The keys of the actions (to loop over)
*/
actionsKeys;
constructor(private router: Router) {
constructor(private route: ActivatedRoute) {
}
ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item));
this.itemRD$.pipe(
first(),
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.statusData = Object.assign({
id: this.item.id,
handle: this.item.handle,
lastModified: this.item.lastModified
id: item.id,
handle: item.handle,
lastModified: item.lastModified
});
this.statusDataKeys = Object.keys(this.statusData);
/*
The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button
*/
this.operations = [];
if (this.item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate'));
if (item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));
} else {
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw'));
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
}
if (this.item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private'));
if (item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
} else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public'));
this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
}
this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete'));
this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
});
}
/**
* Get the url to the simple item page
* @returns {string} url
*/
getItemPage(): string {
return this.router.url.substr(0, this.router.url.lastIndexOf('/'));
getItemPage(item: Item): string {
return getItemPageRoute(item.id)
}
/**
* Get the current url without query params
* @returns {string} url
*/
getCurrentUrl(): string {
if (this.router.url.indexOf('?') > -1) {
return this.router.url.substr(0, this.router.url.indexOf('?'));
} else {
return this.router.url;
}
getCurrentUrl(item: Item): string {
return getItemEditPath(item.id);
}
}

View File

@@ -1,6 +1,6 @@
import {Component, Input, OnInit} from '@angular/core';
import {Item} from '../../../core/shared/item.model';
import {MetadataMap} from '../../../core/shared/metadata.interfaces';
import {MetadataMap} from '../../../core/shared/metadata.models';
@Component({
selector: 'ds-modify-item-overview',

View File

@@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core';
import { MetadataValuesComponent } from '../metadata-values/metadata-values.component';
import { MetadataValue } from '../../../core/shared/metadata.interfaces';
import { MetadataValue } from '../../../core/shared/metadata.models';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link.

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core';
import { MetadataValue } from '../../../core/shared/metadata.interfaces';
import { MetadataValue } from '../../../core/shared/metadata.models';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.

View File

@@ -6,7 +6,7 @@ import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { ItemPageComponent } from '../simple/item-page.component';
import { MetadataMap } from '../../core/shared/metadata.interfaces';
import { MetadataMap } from '../../core/shared/metadata.models';
import { ItemDataService } from '../../core/data/item-data.service';
import { RemoteData } from '../../core/data/remote-data';

View File

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

View File

@@ -1,5 +1,5 @@
import { autoserialize, autoserializeAs } from 'cerialize';
import { MetadataMap } from '../core/shared/metadata.interfaces';
import { MetadataMap } from '../core/shared/metadata.models';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
/**

View File

@@ -21,6 +21,7 @@ import { SearchService } from '../../../search-service/search.service';
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
@Component({
@@ -60,7 +61,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
/**
* Emits the result values for this filter found by the current filter query
*/
filterSearchResults: Observable<any[]> = observableOf([]);
filterSearchResults: Observable<InputSuggestion[]> = observableOf([]);
/**
* Emits the active values for this filter
@@ -267,7 +268,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
map(
(rd: RemoteData<PaginatedList<FacetValue>>) => {
return rd.payload.page.map((facet) => {
return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
return {
displayValue: this.getDisplayValue(facet, data),
value: facet.value
}
})
}
))

View File

@@ -1,5 +1,5 @@
import { DSpaceObject } from '../core/shared/dspace-object.model';
import { MetadataMap } from '../core/shared/metadata.interfaces';
import { MetadataMap } from '../core/shared/metadata.models';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
/**

View File

@@ -1,4 +1,3 @@
import { autoserialize, autoserializeAs } from 'cerialize';
/**

View File

@@ -3,10 +3,10 @@ import { GenericConstructor } from '../shared/generic-constructor';
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { EPerson } from '../eperson/models/eperson.model';
import { CacheableObject } from '../cache/object-cache.reducer';
export class AuthObjectFactory {
public static getConstructor(type): GenericConstructor<NormalizedObject> {
public static getConstructor(type): GenericConstructor<NormalizedObject<CacheableObject>> {
switch (type) {
case AuthType.EPerson: {
return NormalizedEPerson

View File

@@ -152,7 +152,7 @@ export class AuthService {
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
// Review when https://jira.duraspace.org/browse/DS-4006 is fixed
// See https://github.com/DSpace/dspace-angular/issues/292
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
const person$ = this.rdbService.buildSingle<EPerson>(status.eperson.toString());
return person$.pipe(map((eperson) => eperson.payload));
} else {
throw(new Error('Not authenticated'));

View File

@@ -7,7 +7,7 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer';
@mapsTo(AuthStatus)
@inheritSerialization(NormalizedObject)
export class NormalizedAuthStatus extends NormalizedObject {
export class NormalizedAuthStatus extends NormalizedObject<AuthStatus> {
@autoserialize
id: string;

View File

@@ -10,7 +10,6 @@ import { AuthService } from './auth.service';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { CheckAuthenticationTokenAction } from './auth.actions';
import { EPerson } from '../eperson/models/eperson.model';
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
/**
* The auth service.
@@ -40,8 +39,10 @@ export class ServerAuthService extends AuthService {
if (status.authenticated) {
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
return person$.pipe(map((eperson) => eperson.payload));
const person$ = this.rdbService.buildSingle<EPerson>(status.eperson.toString());
return person$.pipe(
map((eperson) => eperson.payload)
);
} else {
throw(new Error('Not authenticated'));
}

View File

@@ -12,6 +12,7 @@ export class BrowseEntrySearchOptions {
constructor(public metadataDefinition: string,
public pagination?: PaginationComponentOptions,
public sort?: SortOptions,
public startsWith?: string,
public scope?: string) {
}
}

View File

@@ -281,4 +281,38 @@ describe('BrowseService', () => {
});
});
});
describe('getFirstItemFor', () => {
beforeEach(() => {
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(service, 'getBrowseDefinitions').and
.returnValue(hot('--a-', { a: {
payload: browseDefinitions
}}));
spyOn(rdbService, 'toRemoteDataObservable').and.callThrough();
});
describe('when getFirstItemFor is called with a valid browse definition id', () => {
const expectedURL = browseDefinitions[1]._links.items + '?page=0&size=1';
it('should configure a new BrowseItemsRequest', () => {
const expected = new BrowseItemsRequest(requestService.generateRequestId(), expectedURL);
scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected, undefined);
});
it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
service.getFirstItemFor(browseDefinitions[1].id);
expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
});
});
});
});

View File

@@ -1,17 +1,14 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
import { distinctUntilChanged, map, startWith, take } from 'rxjs/operators';
import {
ensureArrayHasValue,
ensureArrayHasValue, hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotEmptyOperator
} from '../../shared/empty.util';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortOptions } from '../cache/models/sort-options.model';
import { GenericSuccessResponse } from '../cache/response.models';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import {
@@ -26,17 +23,19 @@ import { BrowseEntry } from '../shared/browse-entry.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import {
configureRequest,
filterSuccessfulResponses,
getBrowseDefinitionLinks,
getRemoteDataPayload, getRequestFromRequestHref
filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence,
getRemoteDataPayload,
getRequestFromRequestHref
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { Item } from '../shared/item.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { GenericSuccessResponse } from '../cache/response.models';
import { RequestEntry } from '../data/request.reducer';
/**
* Service that performs all actions that have to do with browse.
* The service handling all browse requests
*/
@Injectable()
export class BrowseService {
@@ -62,6 +61,9 @@ export class BrowseService {
) {
}
/**
* Get all BrowseDefinitions
*/
getBrowseDefinitions(): Observable<RemoteData<BrowseDefinition[]>> {
const request$ = this.halService.getEndpoint(this.linkPath).pipe(
isNotEmptyOperator(),
@@ -84,8 +86,12 @@ export class BrowseService {
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
/**
* Get all BrowseEntries filtered or modified by BrowseEntrySearchOptions
* @param options
*/
getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
const request$ = this.getBrowseDefinitions().pipe(
return this.getBrowseDefinitions().pipe(
getBrowseDefinitionLinks(options.metadataDefinition),
hasValueOperator(),
map((_links: any) => _links.entries),
@@ -93,7 +99,7 @@ export class BrowseService {
map((href: string) => {
// TODO nearly identical to PaginatedSearchOptions => refactor
const args = [];
if (isNotEmpty(options.sort)) {
if (isNotEmpty(options.scope)) {
args.push(`scope=${options.scope}`);
}
if (isNotEmpty(options.sort)) {
@@ -103,49 +109,33 @@ export class BrowseService {
args.push(`page=${options.pagination.currentPage - 1}`);
args.push(`size=${options.pagination.pageSize}`);
}
if (isNotEmpty(options.startsWith)) {
args.push(`startsWith=${options.startsWith}`);
}
if (isNotEmpty(args)) {
href = new URLCombiner(href, `?${args.join('&')}`).toString();
}
return href;
}),
map((endpointURL: string) => new BrowseEntriesRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService)
getBrowseEntriesFor(this.requestService, this.rdb)
);
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<BrowseEntry>) => Object.assign(list, {
page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page
})),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
/**
* Get all items linked to a certain metadata value
* @param {string} definitionID definition ID to define the metadata-field (e.g. author)
* @param {string} filterValue metadata value to filter by (e.g. author's name)
* @param options Options to narrow down your search:
* { pagination: PaginationComponentOptions,
* sort: SortOptions }
* @param options Options to narrow down your search
* @returns {Observable<RemoteData<PaginatedList<Item>>>}
*/
getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable<RemoteData<PaginatedList<Item>>> {
const request$ = this.getBrowseDefinitions().pipe(
return this.getBrowseDefinitions().pipe(
getBrowseDefinitionLinks(options.metadataDefinition),
hasValueOperator(),
map((_links: any) => _links.items),
hasValueOperator(),
map((href: string) => {
const args = [];
if (isNotEmpty(options.sort)) {
if (isNotEmpty(options.scope)) {
args.push(`scope=${options.scope}`);
}
if (isNotEmpty(options.sort)) {
@@ -155,6 +145,9 @@ export class BrowseService {
args.push(`page=${options.pagination.currentPage - 1}`);
args.push(`size=${options.pagination.pageSize}`);
}
if (isNotEmpty(options.startsWith)) {
args.push(`startsWith=${options.startsWith}`);
}
if (isNotEmpty(filterValue)) {
args.push(`filterValue=${filterValue}`);
}
@@ -163,26 +156,83 @@ export class BrowseService {
}
return href;
}),
map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService)
getBrowseItemsFor(this.requestService, this.rdb)
);
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((response: GenericSuccessResponse<Item[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<Item>) => Object.assign(list, {
page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page
})),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
/**
* Get the first item for a metadata definition in an optional scope
* @param definition
* @param scope
*/
getFirstItemFor(definition: string, scope?: string): Observable<RemoteData<Item>> {
return this.getBrowseDefinitions().pipe(
getBrowseDefinitionLinks(definition),
hasValueOperator(),
map((_links: any) => _links.items),
hasValueOperator(),
map((href: string) => {
const args = [];
if (hasValue(scope)) {
args.push(`scope=${scope}`);
}
args.push('page=0');
args.push('size=1');
if (isNotEmpty(args)) {
href = new URLCombiner(href, `?${args.join('&')}`).toString();
}
return href;
}),
getBrowseItemsFor(this.requestService, this.rdb),
getFirstOccurrence()
);
}
/**
* Get the previous page of items using the paginated list's prev link
* @param items
*/
getPrevBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> {
return observableOf(items.payload.prev).pipe(
getBrowseItemsFor(this.requestService, this.rdb)
);
}
/**
* Get the next page of items using the paginated list's next link
* @param items
*/
getNextBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> {
return observableOf(items.payload.next).pipe(
getBrowseItemsFor(this.requestService, this.rdb)
);
}
/**
* Get the previous page of browse-entries using the paginated list's prev link
* @param entries
*/
getPrevBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
return observableOf(entries.payload.prev).pipe(
getBrowseEntriesFor(this.requestService, this.rdb)
);
}
/**
* Get the next page of browse-entries using the paginated list's next link
* @param entries
*/
getNextBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
return observableOf(entries.payload.next).pipe(
getBrowseEntriesFor(this.requestService, this.rdb)
);
}
/**
* Get the browse URL by providing a metadatum key and linkPath
* @param metadatumKey
* @param linkPath
*/
getBrowseURLFor(metadataKey: string, linkPath: string): Observable<string> {
const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey);
return this.getBrowseDefinitions().pipe(
@@ -206,3 +256,79 @@ export class BrowseService {
}
}
/**
* Operator for turning a href into a PaginatedList of BrowseEntries
* @param requestService
* @param responseCache
* @param rdb
*/
export const getBrowseEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<string>): Observable<RemoteData<PaginatedList<BrowseEntry>>> =>
source.pipe(
map((href: string) => new BrowseEntriesRequest(requestService.generateRequestId(), href)),
configureRequest(requestService),
toRDPaginatedBrowseEntries(requestService, rdb)
);
/**
* Operator for turning a href into a PaginatedList of Items
* @param requestService
* @param responseCache
* @param rdb
*/
export const getBrowseItemsFor = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<string>): Observable<RemoteData<PaginatedList<Item>>> =>
source.pipe(
map((href: string) => new BrowseItemsRequest(requestService.generateRequestId(), href)),
configureRequest(requestService),
toRDPaginatedBrowseItems(requestService, rdb)
);
/**
* Operator for turning a RestRequest into a PaginatedList of Items
* @param requestService
* @param responseCache
* @param rdb
*/
export const toRDPaginatedBrowseItems = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<RestRequest>): Observable<RemoteData<PaginatedList<Item>>> => {
const href$ = source.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService));
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((response: GenericSuccessResponse<Item[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<Item>) => Object.assign(list, {
page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page
})),
distinctUntilChanged()
);
return rdb.toRemoteDataObservable(requestEntry$, payload$);
};
/**
* Operator for turning a RestRequest into a PaginatedList of BrowseEntries
* @param requestService
* @param responseCache
* @param rdb
*/
export const toRDPaginatedBrowseEntries = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<RestRequest>): Observable<RemoteData<PaginatedList<BrowseEntry>>> => {
const href$ = source.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService));
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<BrowseEntry>) => Object.assign(list, {
page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page
})),
distinctUntilChanged()
);
return rdb.toRemoteDataObservable(requestEntry$, payload$);
};

View File

@@ -35,7 +35,7 @@ export class NormalizedObjectBuildService {
*
* @param {TDomain} domainModel a domain model
*/
normalize<TDomain extends CacheableObject, TNormalized extends NormalizedObject>(domainModel: TDomain): TNormalized {
normalize<T extends CacheableObject>(domainModel: T): NormalizedObject<T> {
const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type);
const relationships = getRelationships(normalizedConstructor) || [];

View File

@@ -1,6 +1,8 @@
import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs';
import { Injectable } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs';
import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util';
import { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data';
@@ -8,7 +10,6 @@ import { RemoteDataError } from '../../data/remote-data-error';
import { GetRequest } from '../../data/request.models';
import { RequestEntry } from '../../data/request.reducer';
import { RequestService } from '../../data/request.service';
import { NormalizedObject } from '../models/normalized-object.model';
import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response.models';
@@ -20,6 +21,7 @@ import {
getRequestFromRequestUUID,
getResourceLinksFromResponse
} from '../../shared/operators';
import { CacheableObject } from '../object-cache.reducer';
@Injectable()
export class RemoteDataBuildService {
@@ -27,7 +29,7 @@ export class RemoteDataBuildService {
protected requestService: RequestService) {
}
buildSingle<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<TDomain>> {
buildSingle<T extends CacheableObject>(href$: string | Observable<string>): Observable<RemoteData<T>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
@@ -44,13 +46,13 @@ export class RemoteDataBuildService {
const payload$ =
observableCombineLatest(
href$.pipe(
switchMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
switchMap((href: string) => this.objectCache.getBySelfLink<T>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
return this.objectCache.getBySelfLink<T>(resourceSelfLinks[0]);
} else {
return observableOf(undefined);
}
@@ -67,8 +69,8 @@ export class RemoteDataBuildService {
}
}),
hasValueOperator(),
map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
map((normalized: NormalizedObject<T>) => {
return this.build<T>(normalized);
}),
startWith(undefined),
distinctUntilChanged()
@@ -79,8 +81,8 @@ export class RemoteDataBuildService {
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, payload]) => {
const requestPending = hasValue(reqEntry) && hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean;
let error: RemoteDataError;
if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
@@ -105,7 +107,7 @@ export class RemoteDataBuildService {
);
}
buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> {
buildList<T extends CacheableObject>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<T>>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
@@ -115,9 +117,9 @@ export class RemoteDataBuildService {
getResourceLinksFromResponse(),
flatMap((resourceUUIDs: string[]) => {
return this.objectCache.getList(resourceUUIDs).pipe(
map((normList: TNormalized[]) => {
return normList.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
map((normList: Array<NormalizedObject<T>>) => {
return normList.map((normalized: NormalizedObject<T>) => {
return this.build<T>(normalized);
});
}));
}),
@@ -147,7 +149,7 @@ export class RemoteDataBuildService {
return this.toRemoteDataObservable(requestEntry$, payload$);
}
build<TNormalized, TDomain>(normalized: TNormalized): TDomain {
build<T extends CacheableObject>(normalized: NormalizedObject<T>): T {
const links: any = {};
const relationships = getRelationships(normalized.constructor) || [];

View File

@@ -11,7 +11,7 @@ import { SupportLevel } from './support-level.model';
*/
@mapsTo(BitstreamFormat)
@inheritSerialization(NormalizedObject)
export class NormalizedBitstreamFormat extends NormalizedObject {
export class NormalizedBitstreamFormat extends NormalizedObject<BitstreamFormat> {
/**
* Short description of this Bitstream Format

View File

@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
*/
@mapsTo(Bitstream)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBitstream extends NormalizedDSpaceObject {
export class NormalizedBitstream extends NormalizedDSpaceObject<Bitstream> {
/**
* The size of this bitstream in bytes

View File

@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
*/
@mapsTo(Bundle)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBundle extends NormalizedDSpaceObject {
export class NormalizedBundle extends NormalizedDSpaceObject<Bundle> {
/**
* The primary bitstream of this Bundle
*/

View File

@@ -1,4 +1,4 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
import { Collection } from '../../shared/collection.model';
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
*/
@mapsTo(Collection)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedCollection extends NormalizedDSpaceObject {
export class NormalizedCollection extends NormalizedDSpaceObject<Collection> {
/**
* A string representing the unique handle of this Collection
@@ -35,28 +35,28 @@ export class NormalizedCollection extends NormalizedDSpaceObject {
/**
* The Bitstream that represents the logo of this Collection
*/
@autoserialize
@deserialize
@relationship(ResourceType.Bitstream, false)
logo: string;
/**
* An array of Communities that are direct parents of this Collection
*/
@autoserialize
@deserialize
@relationship(ResourceType.Community, true)
parents: string[];
/**
* The Community that owns this Collection
*/
@autoserialize
@deserialize
@relationship(ResourceType.Community, false)
owner: string;
/**
* List of Items that are part of (not necessarily owned by) this Collection
*/
@autoserialize
@deserialize
@relationship(ResourceType.Item, true)
items: string[];

View File

@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
*/
@mapsTo(Community)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedCommunity extends NormalizedDSpaceObject {
export class NormalizedCommunity extends NormalizedDSpaceObject<Community> {
/**
* A string representing the unique handle of this Community

View File

@@ -1,6 +1,6 @@
import { autoserialize, autoserializeAs, deserialize, serialize } from 'cerialize';
import { autoserializeAs, deserializeAs } from 'cerialize';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { MetadataMap } from '../../shared/metadata.interfaces';
import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models';
import { ResourceType } from '../../shared/resource-type';
import { mapsTo } from '../builders/build-decorators';
import { NormalizedObject } from './normalized-object.model';
@@ -9,7 +9,7 @@ import { NormalizedObject } from './normalized-object.model';
* An model class for a DSpaceObject.
*/
@mapsTo(DSpaceObject)
export class NormalizedDSpaceObject extends NormalizedObject {
export class NormalizedDSpaceObject<T extends DSpaceObject> extends NormalizedObject<T> {
/**
* The link to the rest endpoint where this object can be found
@@ -17,7 +17,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@deserialize
@deserializeAs(String)
self: string;
/**
@@ -31,35 +31,32 @@ export class NormalizedDSpaceObject extends NormalizedObject {
/**
* The universally unique identifier of this DSpaceObject
*
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@autoserialize
@autoserializeAs(String)
uuid: string;
/**
* A string representing the kind of DSpaceObject, e.g. community, item, …
*/
@autoserialize
@autoserializeAs(String)
type: ResourceType;
/**
* All metadata of this DSpaceObject
*/
@autoserialize
@autoserializeAs(MetadataMapSerializer)
metadata: MetadataMap;
/**
* An array of DSpaceObjects that are direct parents of this DSpaceObject
*/
@deserialize
@deserializeAs(String)
parents: string[];
/**
* The DSpaceObject that owns this DSpaceObject
*/
@deserialize
@deserializeAs(String)
owner: string;
/**
@@ -68,7 +65,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@deserialize
@deserializeAs(Object)
_links: {
[name: string]: string
}

View File

@@ -1,4 +1,4 @@
import { inheritSerialization, autoserialize, autoserializeAs } from 'cerialize';
import { inheritSerialization, deserialize, autoserialize, autoserializeAs } from 'cerialize';
import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
import { Item } from '../../shared/item.model';
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
*/
@mapsTo(Item)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedItem extends NormalizedDSpaceObject {
export class NormalizedItem extends NormalizedDSpaceObject<Item> {
/**
* A string representing the unique handle of this Item
@@ -21,7 +21,7 @@ export class NormalizedItem extends NormalizedDSpaceObject {
/**
* The Date of the last modification of this Item
*/
@autoserialize
@deserialize
lastModified: Date;
/**
@@ -45,21 +45,21 @@ export class NormalizedItem extends NormalizedDSpaceObject {
/**
* An array of Collections that are direct parents of this Item
*/
@autoserialize
@deserialize
@relationship(ResourceType.Collection, true)
parents: string[];
/**
* The Collection that owns this Item
*/
@autoserialize
@deserialize
@relationship(ResourceType.Collection, false)
owningCollection: string;
/**
* List of Bitstreams that are owned by this Item
*/
@autoserialize
@deserialize
@relationship(ResourceType.Bitstream, true)
bitstreams: string[];

View File

@@ -8,7 +8,7 @@ import { License } from '../../shared/license.model';
*/
@mapsTo(License)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedLicense extends NormalizedDSpaceObject {
export class NormalizedLicense extends NormalizedDSpaceObject<License> {
/**
* A boolean representing if this License is custom or not

View File

@@ -15,13 +15,14 @@ import { NormalizedWorkflowItem } from '../../submission/models/normalized-workf
import { NormalizedClaimedTask } from '../../tasks/models/normalized-claimed-task-object.model';
import { NormalizedPoolTask } from '../../tasks/models/normalized-pool-task-object.model';
import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model';
import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model';
import { SubmissionFormsModel } from '../../config/models/config-submission-forms.model';
import { SubmissionSectionModel } from '../../config/models/config-submission-section.model';
import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model';
import { CacheableObject } from '../object-cache.reducer';
import { NormalizedSubmissionDefinitionsModel } from '../../config/models/normalized-config-submission-definitions.model';
import { NormalizedSubmissionFormsModel } from '../../config/models/normalized-config-submission-forms.model';
import { NormalizedSubmissionSectionModel } from '../../config/models/normalized-config-submission-section.model';
export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject<CacheableObject>> {
switch (type) {
case ResourceType.Bitstream: {
return NormalizedBitstream
@@ -47,9 +48,6 @@ export class NormalizedObjectFactory {
case ResourceType.ResourcePolicy: {
return NormalizedResourcePolicy
}
case ResourceType.Workspaceitem: {
return NormalizedWorkspaceItem
}
case ResourceType.EPerson: {
return NormalizedEPerson
}
@@ -62,6 +60,9 @@ export class NormalizedObjectFactory {
case ResourceType.MetadataField: {
return NormalizedGroup
}
case ResourceType.Workspaceitem: {
return NormalizedWorkspaceItem
}
case ResourceType.Workflowitem: {
return NormalizedWorkflowItem
}
@@ -71,20 +72,17 @@ export class NormalizedObjectFactory {
case ResourceType.PoolTask: {
return NormalizedPoolTask
}
case ResourceType.BitstreamFormat: {
return NormalizedBitstreamFormat
}
case ResourceType.SubmissionDefinition:
case ResourceType.SubmissionDefinitions: {
return SubmissionDefinitionsModel
return NormalizedSubmissionDefinitionsModel
}
case ResourceType.SubmissionForm:
case ResourceType.SubmissionForms: {
return SubmissionFormsModel
return NormalizedSubmissionFormsModel
}
case ResourceType.SubmissionSection:
case ResourceType.SubmissionSections: {
return SubmissionSectionModel
return NormalizedSubmissionSectionModel
}
default: {
return undefined;

View File

@@ -4,7 +4,7 @@ import { ResourceType } from '../../shared/resource-type';
/**
* An abstract model class for a NormalizedObject.
*/
export abstract class NormalizedObject implements CacheableObject {
export abstract class NormalizedObject<T extends CacheableObject> implements CacheableObject {
/**
* The link to the rest endpoint where this object can be found
@@ -13,11 +13,8 @@ export abstract class NormalizedObject implements CacheableObject {
self: string;
/**
* The universally unique identifier of this Object
* A string representing the kind of DSpaceObject, e.g. community, item, …
*/
@autoserialize
uuid: string;
@autoserialize
type: ResourceType;

View File

@@ -1,10 +1,9 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ResourcePolicy } from '../../shared/resource-policy.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { mapsTo } from '../builders/build-decorators';
import { NormalizedObject } from './normalized-object.model';
import { IDToUUIDSerializer } from '../id-to-uuid-serializer';
import { ResourceType } from '../../shared/resource-type';
import { ActionType } from './action-type.model';
/**
@@ -12,7 +11,7 @@ import { ActionType } from './action-type.model';
*/
@mapsTo(ResourcePolicy)
@inheritSerialization(NormalizedObject)
export class NormalizedResourcePolicy extends NormalizedObject {
export class NormalizedResourcePolicy extends NormalizedObject<ResourcePolicy> {
/**
* The action that is allowed by this Resource Policy

View File

@@ -78,7 +78,7 @@ export class ObjectCacheService {
* @return Observable<T>
* An observable of the requested object
*/
getByUUID<T extends NormalizedObject>(uuid: string): Observable<T> {
getByUUID<T extends CacheableObject>(uuid: string): Observable<NormalizedObject<T>> {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
mergeMap((selfLink: string) => this.getBySelfLink(selfLink)
@@ -86,7 +86,7 @@ export class ObjectCacheService {
)
}
getBySelfLink<T extends NormalizedObject>(selfLink: string): Observable<T> {
getBySelfLink<T extends CacheableObject>(selfLink: string): Observable<NormalizedObject<T>> {
return this.getEntry(selfLink).pipe(
map((entry: ObjectCacheEntry) => {
if (isNotEmpty(entry.patches)) {
@@ -99,8 +99,8 @@ export class ObjectCacheService {
}
),
map((entry: ObjectCacheEntry) => {
const type: GenericConstructor<NormalizedObject> = NormalizedObjectFactory.getConstructor(entry.data.type);
return Object.assign(new type(), entry.data) as T
const type: GenericConstructor<NormalizedObject<T>> = NormalizedObjectFactory.getConstructor(entry.data.type);
return Object.assign(new type(), entry.data) as NormalizedObject<T>
})
);
}
@@ -145,7 +145,7 @@ export class ObjectCacheService {
* The type of the objects to get
* @return Observable<Array<T>>
*/
getList<T extends NormalizedObject>(selfLinks: string[]): Observable<T[]> {
getList<T extends CacheableObject>(selfLinks: string[]): Observable<Array<NormalizedObject<T>>> {
return observableCombineLatest(
selfLinks.map((selfLink: string) => this.getBySelfLink<T>(selfLink))
);

View File

@@ -11,8 +11,9 @@ import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstream
import { AuthStatus } from '../auth/models/auth-status.model';
import { MetadataSchema } from '../metadata/metadataschema.model';
import { MetadataField } from '../metadata/metadatafield.model';
import { NormalizedObject } from './models/normalized-object.model';
import { PaginatedList } from '../data/paginated-list';
import { SubmissionObject } from '../submission/models/submission-object.model';
import { DSpaceObject } from '../shared/dspace-object.model';
/* tslint:disable:max-classes-per-file */
export class RestResponse {
@@ -233,7 +234,7 @@ export class PostPatchSuccessResponse extends RestResponse {
export class SubmissionSuccessResponse extends RestResponse {
constructor(
public dataDefinition: Array<NormalizedObject | ConfigObject | string>,
public dataDefinition: Array<SubmissionObject | ConfigObject | string>,
public statusCode: number,
public statusText: string,
public pageInfo?: PageInfo
@@ -244,7 +245,7 @@ export class SubmissionSuccessResponse extends RestResponse {
export class EpersonSuccessResponse extends RestResponse {
constructor(
public epersonDefinition: NormalizedObject[],
public epersonDefinition: DSpaceObject[],
public statusCode: number,
public statusText: string,
public pageInfo?: PageInfo

View File

@@ -26,7 +26,6 @@ export interface ServerSyncBufferState {
buffer: ServerSyncBufferEntry[];
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: ServerSyncBufferState = { buffer: [] };
/**

View File

@@ -6,10 +6,10 @@ import { ConfigRequest } from '../data/request.models';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { SubmissionDefinitionsModel } from './models/config-submission-definitions.model';
import { PaginatedList } from '../data/paginated-list';
import { PageInfo } from '../shared/page-info.model';
import { SubmissionSectionModel } from './models/config-submission-section.model';
import { NormalizedSubmissionDefinitionsModel } from './models/normalized-config-submission-definitions.model';
import { NormalizedSubmissionSectionModel } from './models/normalized-config-submission-section.model';
describe('ConfigResponseParsingService', () => {
let service: ConfigResponseParsingService;
@@ -173,7 +173,7 @@ describe('ConfigResponseParsingService', () => {
self: 'https://rest.api/config/submissiondefinitions/traditional/sections'
});
const definitions =
Object.assign(new SubmissionDefinitionsModel(), {
Object.assign(new NormalizedSubmissionDefinitionsModel(), {
isDefault: true,
name: 'traditional',
type: 'submissiondefinition',
@@ -183,7 +183,7 @@ describe('ConfigResponseParsingService', () => {
},
self: 'https://rest.api/config/submissiondefinitions/traditional',
sections: new PaginatedList(pageinfo, [
Object.assign(new SubmissionSectionModel(), {
Object.assign(new NormalizedSubmissionSectionModel(), {
header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
@@ -198,7 +198,7 @@ describe('ConfigResponseParsingService', () => {
},
self: 'https://rest.api/config/submissionsections/traditionalpageone',
}),
Object.assign(new SubmissionSectionModel(), {
Object.assign(new NormalizedSubmissionSectionModel(), {
header: 'submit.progressbar.describe.steptwo',
mandatory: true,
sectionType: 'submission-form',
@@ -213,7 +213,7 @@ describe('ConfigResponseParsingService', () => {
},
self: 'https://rest.api/config/submissionsections/traditionalpagetwo',
}),
Object.assign(new SubmissionSectionModel(), {
Object.assign(new NormalizedSubmissionSectionModel(), {
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
@@ -228,7 +228,7 @@ describe('ConfigResponseParsingService', () => {
},
self: 'https://rest.api/config/submissionsections/upload',
}),
Object.assign(new SubmissionSectionModel(), {
Object.assign(new NormalizedSubmissionSectionModel(), {
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',

View File

@@ -1,30 +1,29 @@
import { GenericConstructor } from '../../shared/generic-constructor';
import { SubmissionSectionModel } from './config-submission-section.model';
import { SubmissionFormsModel } from './config-submission-forms.model';
import { SubmissionDefinitionsModel } from './config-submission-definitions.model';
import { ConfigType } from './config-type';
import { ConfigObject } from './config.model';
import { SubmissionUploadsModel } from './config-submission-uploads.model';
import { NormalizedSubmissionDefinitionsModel } from './normalized-config-submission-definitions.model';
import { NormalizedSubmissionFormsModel } from './normalized-config-submission-forms.model';
import { NormalizedSubmissionSectionModel } from './normalized-config-submission-section.model';
import { NormalizedSubmissionUploadsModel } from './normalized-config-submission-uploads.model';
export class ConfigObjectFactory {
public static getConstructor(type): GenericConstructor<ConfigObject> {
switch (type) {
case ConfigType.SubmissionDefinition:
case ConfigType.SubmissionDefinitions: {
return SubmissionDefinitionsModel
return NormalizedSubmissionDefinitionsModel
}
case ConfigType.SubmissionForm:
case ConfigType.SubmissionForms: {
return SubmissionFormsModel
return NormalizedSubmissionFormsModel
}
case ConfigType.SubmissionSection:
case ConfigType.SubmissionSections: {
return SubmissionSectionModel
return NormalizedSubmissionSectionModel
}
case ConfigType.SubmissionUpload:
case ConfigType.SubmissionUploads: {
return SubmissionUploadsModel
return NormalizedSubmissionUploadsModel
}
default: {
return undefined;

View File

@@ -1,15 +1,17 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model';
import { SubmissionSectionModel } from './config-submission-section.model';
import { PaginatedList } from '../../data/paginated-list';
@inheritSerialization(ConfigObject)
export class SubmissionDefinitionsModel extends ConfigObject {
@autoserialize
/**
* A boolean representing if this submission definition is the default or not
*/
isDefault: boolean;
@autoserializeAs(SubmissionSectionModel)
/**
* A list of SubmissionSectionModel that are present in this submission definition
*/
sections: PaginatedList<SubmissionSectionModel>;
}

View File

@@ -1,14 +1,20 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model';
import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model';
/**
* An interface that define a form row and its properties.
*/
export interface FormRowModel {
fields: FormFieldModel[];
}
@inheritSerialization(ConfigObject)
/**
* A model class for a NormalizedObject.
*/
export class SubmissionFormsModel extends ConfigObject {
@autoserialize
/**
* An array of [FormRowModel] that are present in this form
*/
rows: FormRowModel[];
}

View File

@@ -1,23 +1,34 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model';
import { SectionsType } from '../../../submission/sections/sections-type';
@inheritSerialization(ConfigObject)
export class SubmissionSectionModel extends ConfigObject {
@autoserialize
header: string;
@autoserialize
mandatory: boolean;
@autoserialize
sectionType: SectionsType;
@autoserialize
visibility: {
/**
* An interface that define section visibility and its properties.
*/
export interface SubmissionSectionVisibility {
main: any,
other: any
}
}
export class SubmissionSectionModel extends ConfigObject {
/**
* The header for this section
*/
header: string;
/**
* A boolean representing if this submission section is the mandatory or not
*/
mandatory: boolean;
/**
* A string representing the kind of section object
*/
sectionType: SectionsType;
/**
* The [SubmissionSectionVisibility] object for this section
*/
visibility: SubmissionSectionVisibility
}

View File

@@ -1,30 +1,21 @@
import {autoserialize, autoserializeAs, inheritSerialization} from 'cerialize';
import { ConfigObject } from './config.model';
import { AccessConditionOption } from './config-access-condition-option.model';
import {SubmissionFormsModel} from './config-submission-forms.model';
import { SubmissionFormsModel } from './config-submission-forms.model';
/**
* Normalized model class for the configuration describing the submission upload section
*/
@inheritSerialization(ConfigObject)
export class SubmissionUploadsModel extends ConfigObject {
/**
* A list of available bitstream access conditions
*/
@autoserialize
accessConditionOptions: AccessConditionOption[];
/**
* An object representing the configuration describing the bistream metadata form
*/
@autoserializeAs(SubmissionFormsModel)
metadata: SubmissionFormsModel;
@autoserialize
required: boolean;
@autoserialize
maxSize: number;
}

View File

@@ -1,8 +1,3 @@
/**
* TODO replace with actual string enum after upgrade to TypeScript 2.4:
* https://github.com/Microsoft/TypeScript/pull/15486
*/
export enum ConfigType {
SubmissionDefinitions = 'submissiondefinitions',
SubmissionDefinition = 'submissiondefinition',

View File

@@ -1,10 +1,27 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { NormalizedObject } from '../../cache/models/normalized-object.model';
import { CacheableObject } from '../../cache/object-cache.reducer';
import { ResourceType } from '../../shared/resource-type';
@inheritSerialization(NormalizedObject)
export abstract class ConfigObject extends NormalizedObject {
export abstract class ConfigObject implements CacheableObject {
@autoserialize
/**
* The name for this configuration
*/
public name: string;
/**
* A string representing the kind of config object
*/
public type: ResourceType;
/**
* The links to all related resources returned by the rest api.
*/
public _links: {
[name: string]: string
};
/**
* The link to the rest endpoint where this config object can be found
*/
self: string;
}

View File

@@ -0,0 +1,25 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { SubmissionSectionModel } from './config-submission-section.model';
import { PaginatedList } from '../../data/paginated-list';
import { NormalizedConfigObject } from './normalized-config.model';
import { SubmissionDefinitionsModel } from './config-submission-definitions.model';
/**
* Normalized class for the configuration describing the submission
*/
@inheritSerialization(NormalizedConfigObject)
export class NormalizedSubmissionDefinitionsModel extends NormalizedConfigObject<SubmissionDefinitionsModel> {
/**
* A boolean representing if this submission definition is the default or not
*/
@autoserialize
isDefault: boolean;
/**
* A list of SubmissionSectionModel that are present in this submission definition
*/
@autoserializeAs(SubmissionSectionModel)
sections: PaginatedList<SubmissionSectionModel>;
}

View File

@@ -0,0 +1,16 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { NormalizedConfigObject } from './normalized-config.model';
import { FormRowModel, SubmissionFormsModel } from './config-submission-forms.model';
/**
* Normalized class for the configuration describing the submission form
*/
@inheritSerialization(NormalizedConfigObject)
export class NormalizedSubmissionFormsModel extends NormalizedConfigObject<SubmissionFormsModel> {
/**
* An array of [FormRowModel] that are present in this form
*/
@autoserialize
rows: FormRowModel[];
}

View File

@@ -0,0 +1,37 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { SectionsType } from '../../../submission/sections/sections-type';
import { NormalizedConfigObject } from './normalized-config.model';
import { SubmissionFormsModel } from './config-submission-forms.model';
import { SubmissionSectionVisibility } from './config-submission-section.model';
/**
* Normalized class for the configuration describing the submission section
*/
@inheritSerialization(NormalizedConfigObject)
export class NormalizedSubmissionSectionModel extends NormalizedConfigObject<SubmissionFormsModel> {
/**
* The header for this section
*/
@autoserialize
header: string;
/**
* A boolean representing if this submission section is the mandatory or not
*/
@autoserialize
mandatory: boolean;
/**
* A string representing the kind of section object
*/
@autoserialize
sectionType: SectionsType;
/**
* The [SubmissionSectionVisibility] object for this section
*/
@autoserialize
visibility: SubmissionSectionVisibility
}

View File

@@ -0,0 +1,31 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { AccessConditionOption } from './config-access-condition-option.model';
import { SubmissionFormsModel } from './config-submission-forms.model';
import { NormalizedConfigObject } from './normalized-config.model';
import { SubmissionUploadsModel } from './config-submission-uploads.model';
/**
* Normalized class for the configuration describing the submission upload section
*/
@inheritSerialization(NormalizedConfigObject)
export class NormalizedSubmissionUploadsModel extends NormalizedConfigObject<SubmissionUploadsModel> {
/**
* A list of available bitstream access conditions
*/
@autoserialize
accessConditionOptions: AccessConditionOption[];
/**
* An object representing the configuration describing the bistream metadata form
*/
@autoserializeAs(SubmissionFormsModel)
metadata: SubmissionFormsModel;
@autoserialize
required: boolean;
@autoserialize
maxSize: number;
}

View File

@@ -0,0 +1,38 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { NormalizedObject } from '../../cache/models/normalized-object.model';
import { CacheableObject } from '../../cache/object-cache.reducer';
import { ResourceType } from '../../shared/resource-type';
/**
* Normalized abstract class for a configuration object
*/
@inheritSerialization(NormalizedObject)
export abstract class NormalizedConfigObject<T extends CacheableObject> implements CacheableObject {
/**
* The name for this configuration
*/
@autoserialize
public name: string;
/**
* A string representing the kind of config object
*/
@autoserialize
public type: ResourceType;
/**
* The links to all related resources returned by the rest api.
*/
@autoserialize
public _links: {
[name: string]: string
};
/**
* The link to the rest endpoint where this config object can be found
*/
@autoserialize
self: string;
}

View File

@@ -5,6 +5,7 @@ import { RequestEffects } from './data/request.effects';
import { AuthEffects } from './auth/auth.effects';
import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects';
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
export const coreEffects = [
RequestEffects,
@@ -12,5 +13,6 @@ export const coreEffects = [
UUIDIndexEffects,
AuthEffects,
JsonPatchOperationsEffects,
ServerSyncBufferEffects
ServerSyncBufferEffects,
ObjectUpdatesEffects
];

View File

@@ -77,6 +77,8 @@ import { MenuService } from '../shared/menu/menu.service';
import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service';
import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service';
import { RoleService } from './roles/role.service';
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
@@ -163,8 +165,10 @@ const PROVIDERS = [
FileService,
DSpaceObjectDataService,
DSOChangeAnalyzer,
DefaultChangeAnalyzer,
CSSVariableService,
MenuService,
ObjectUpdatesService,
MyDSpaceGuard,
RoleService,
MessageResponseParsingService,
@@ -182,20 +186,15 @@ const PROVIDERS = [
{ provide: NativeWindowService, useFactory: NativeWindowFactory }
];
const DIRECTIVES = [
];
@NgModule({
imports: [
...IMPORTS
],
declarations: [
...DECLARATIONS,
...DIRECTIVES
...DECLARATIONS
],
exports: [
...EXPORTS,
...DIRECTIVES
...EXPORTS
],
providers: [
...PROVIDERS

View File

@@ -1,4 +1,7 @@
import { ActionReducerMap, createFeatureSelector } from '@ngrx/store';
import {
ActionReducerMap,
createFeatureSelector,
} from '@ngrx/store';
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
import { indexReducer, IndexState } from './index/index.reducer';
@@ -6,10 +9,15 @@ import { requestReducer, RequestState } from './data/request.reducer';
import { authReducer, AuthState } from './auth/auth.reducer';
import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer';
import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer';
import {
objectUpdatesReducer,
ObjectUpdatesState
} from './data/object-updates/object-updates.reducer';
export interface CoreState {
'cache/object': ObjectCacheState,
'cache/syncbuffer': ServerSyncBufferState,
'cache/object-updates': ObjectUpdatesState
'data/request': RequestState,
'index': IndexState,
'auth': AuthState,
@@ -19,6 +27,7 @@ export interface CoreState {
export const coreReducers: ActionReducerMap<CoreState> = {
'cache/object': objectCacheReducer,
'cache/syncbuffer': serverSyncBufferReducer,
'cache/object-updates': objectUpdatesReducer,
'data/request': requestReducer,
'index': indexReducer,
'auth': authReducer,

View File

@@ -1,20 +1,17 @@
import { Inject, Injectable } from '@angular/core';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service';
import {
ErrorResponse,
GenericSuccessResponse,
RestResponse
} from '../cache/response.models';
import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { Item } from '../shared/item.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
/**
* A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[])
@@ -42,9 +39,11 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded)
&& Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceRESTv2Serializer(DSpaceObject);
const serializer = new DSpaceRESTv2Serializer(NormalizedDSpaceObject);
const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
return new GenericSuccessResponse(items, data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else if (hasValue(data.payload) && hasValue(data.payload.page)) {
return new GenericSuccessResponse([], data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(

View File

@@ -1,11 +1,12 @@
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { Operation } from 'fast-json-patch/lib/core';
import { CacheableObject } from '../cache/object-cache.reducer';
/**
* An interface to determine what differs between two
* NormalizedObjects
*/
export interface ChangeAnalyzer<TNormalized extends NormalizedObject> {
export interface ChangeAnalyzer<T extends CacheableObject> {
/**
* Compare two objects and return their differences as a
@@ -16,5 +17,5 @@ export interface ChangeAnalyzer<TNormalized extends NormalizedObject> {
* @param {NormalizedObject} object2
* The second object to compare
*/
diff(object1: TNormalized, object2: TNormalized): Operation[];
diff(object1: T | NormalizedObject<T>, object2: T | NormalizedObject<T>): Operation[];
}

View File

@@ -22,7 +22,7 @@ import { RemoteData } from './remote-data';
import { PaginatedList } from './paginated-list';
@Injectable()
export class CollectionDataService extends ComColDataService<NormalizedCollection, Collection> {
export class CollectionDataService extends ComColDataService<Collection> {
protected linkPath = 'collections';
protected forceBypassCache = false;
@@ -36,7 +36,7 @@ export class CollectionDataService extends ComColDataService<NormalizedCollectio
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer
protected comparator: DSOChangeAnalyzer<Collection>
) {
super();
}

View File

@@ -18,14 +18,16 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { HttpClient } from '@angular/common/http';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { Item } from '../shared/item.model';
import { Community } from '../shared/community.model';
const LINK_NAME = 'test';
/* tslint:disable:max-classes-per-file */
class NormalizedTestObject extends NormalizedObject {
class NormalizedTestObject extends NormalizedObject<Item> {
}
class TestService extends ComColDataService<NormalizedTestObject, any> {
class TestService extends ComColDataService<any> {
protected forceBypassCache = false;
constructor(
@@ -39,7 +41,7 @@ class TestService extends ComColDataService<NormalizedTestObject, any> {
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer,
protected comparator: DSOChangeAnalyzer<Community>,
protected linkPath: string
) {
super();

View File

@@ -1,28 +1,17 @@
import {
distinctUntilChanged,
filter,
first,
map,
mergeMap,
share,
take,
tap
} from 'rxjs/operators';
import { distinctUntilChanged, filter, map, mergeMap, share, take, tap } from 'rxjs/operators';
import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { NormalizedCommunity } from '../cache/models/normalized-community.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CommunityDataService } from './community-data.service';
import { DataService } from './data.service';
import { FindAllOptions, FindByIDRequest } from './request.models';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestEntry } from './request.reducer';
import { getResponseFromEntry } from '../shared/operators';
import { CacheableObject } from '../cache/object-cache.reducer';
export abstract class ComColDataService<TNormalized extends NormalizedObject, TDomain extends CacheableObject> extends DataService<TNormalized, TDomain> {
export abstract class ComColDataService<T extends CacheableObject> extends DataService<T> {
protected abstract cds: CommunityDataService;
protected abstract objectCache: ObjectCacheService;
protected abstract halService: HALEndpointService;

View File

@@ -21,7 +21,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
@Injectable()
export class CommunityDataService extends ComColDataService<NormalizedCommunity, Community> {
export class CommunityDataService extends ComColDataService<Community> {
protected linkPath = 'communities';
protected topLinkPath = 'communities/search/top';
protected cds = this;
@@ -36,7 +36,7 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer
protected comparator: DSOChangeAnalyzer<Community>
) {
super();
}
@@ -55,6 +55,6 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
this.requestService.configure(request);
});
return this.rdbService.buildList<NormalizedCommunity, Community>(hrefObs) as Observable<RemoteData<PaginatedList<Community>>>;
return this.rdbService.buildList<Community>(hrefObs) as Observable<RemoteData<PaginatedList<Community>>>;
}
}

View File

@@ -16,15 +16,17 @@ import { HttpClient } from '@angular/common/http';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { compare } from 'fast-json-patch';
import { Item } from '../shared/item.model';
const endpoint = 'https://rest.api/core';
// tslint:disable:max-classes-per-file
class NormalizedTestObject extends NormalizedObject {
class NormalizedTestObject extends NormalizedObject<Item> {
}
class TestService extends DataService<NormalizedTestObject, any> {
class TestService extends DataService<any> {
protected forceBypassCache = false;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,

View File

@@ -1,14 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
distinctUntilChanged,
filter,
find,
first,
map,
mergeMap,
switchMap,
take
} from 'rxjs/operators';
import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
@@ -34,7 +27,6 @@ import { Operation } from 'fast-json-patch';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceObject } from '../shared/dspace-object.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { ErrorResponse, RestResponse } from '../cache/response.models';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
@@ -44,8 +36,9 @@ import { CacheableObject } from '../cache/object-cache.reducer';
import { RequestEntry } from './request.reducer';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { ChangeAnalyzer } from './change-analyzer';
import { RestRequestMethod } from './rest-request-method';
export abstract class DataService<TNormalized extends NormalizedObject, TDomain extends CacheableObject> {
export abstract class DataService<T extends CacheableObject> {
protected abstract requestService: RequestService;
protected abstract rdbService: RemoteDataBuildService;
protected abstract dataBuildService: NormalizedObjectBuildService;
@@ -56,7 +49,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
protected abstract objectCache: ObjectCacheService;
protected abstract notificationsService: NotificationsService;
protected abstract http: HttpClient;
protected abstract comparator: ChangeAnalyzer<TNormalized>;
protected abstract comparator: ChangeAnalyzer<T>;
public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable<string>
@@ -106,7 +99,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
}
}
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<TDomain>>> {
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<T>>> {
const hrefObs = this.getFindAllHref(options);
hrefObs.pipe(
@@ -116,7 +109,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
this.requestService.configure(request, this.forceBypassCache);
});
return this.rdbService.buildList<TNormalized, TDomain>(hrefObs) as Observable<RemoteData<PaginatedList<TDomain>>>;
return this.rdbService.buildList<T>(hrefObs) as Observable<RemoteData<PaginatedList<T>>>;
}
/**
@@ -128,7 +121,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
return `${endpoint}/${resourceID}`;
}
findById(id: string): Observable<RemoteData<TDomain>> {
findById(id: string): Observable<RemoteData<T>> {
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, id)));
@@ -139,12 +132,12 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
this.requestService.configure(request, this.forceBypassCache);
});
return this.rdbService.buildSingle<TNormalized, TDomain>(hrefObs);
return this.rdbService.buildSingle<T>(hrefObs);
}
findByHref(href: string, options?: HttpOptions): Observable<RemoteData<TDomain>> {
findByHref(href: string, options?: HttpOptions): Observable<RemoteData<T>> {
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href, null, options), this.forceBypassCache);
return this.rdbService.buildSingle<TNormalized, TDomain>(href);
return this.rdbService.buildSingle<T>(href);
}
protected getSearchEndpoint(searchMethod: string): Observable<string> {
@@ -153,7 +146,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
map((href: string) => `${href}/${searchMethod}`));
}
protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<TDomain>>> {
protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<T>>> {
const hrefObs = this.getSearchByHref(searchMethod, options);
@@ -164,7 +157,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
this.requestService.configure(request, true);
});
return this.rdbService.buildList<TNormalized, TDomain>(hrefObs) as Observable<RemoteData<PaginatedList<TDomain>>>;
return this.rdbService.buildList<T>(hrefObs) as Observable<RemoteData<PaginatedList<T>>>;
}
/**
@@ -181,11 +174,10 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
update(object: TDomain): Observable<RemoteData<TDomain>> {
update(object: T): Observable<RemoteData<T>> {
const oldVersion$ = this.objectCache.getBySelfLink(object.self);
return oldVersion$.pipe(first(), mergeMap((oldVersion: TNormalized) => {
const newVersion = this.dataBuildService.normalize<TDomain, TNormalized>(object);
const operations = this.comparator.diff(oldVersion, newVersion);
return oldVersion$.pipe(take(1), mergeMap((oldVersion: T) => {
const operations = this.comparator.diff(oldVersion, object);
if (isNotEmpty(operations)) {
this.objectCache.addPatch(object.self, operations);
}
@@ -204,7 +196,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
* @param {string} parentUUID
* The UUID of the parent to create the new object under
*/
create(dso: TDomain, parentUUID: string): Observable<RemoteData<TDomain>> {
create(dso: T, parentUUID: string): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe(
isNotEmptyOperator(),
@@ -212,7 +204,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint)
);
const normalizedObject: TNormalized = this.dataBuildService.normalize<TDomain, TNormalized>(dso);
const normalizedObject: NormalizedObject<T> = this.dataBuildService.normalize<T>(dso);
const serializedDso = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(dso.type)).serialize(normalizedObject);
const request$ = endpoint$.pipe(
@@ -253,7 +245,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
* @param dso The DSpace Object to be removed
* Return an observable that emits true when the deletion was successful, false when it failed
*/
delete(dso: TDomain): Observable<boolean> {
delete(dso: T): Observable<boolean> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -273,4 +265,12 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
);
}
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
commitUpdates(method?: RestRequestMethod) {
this.requestService.commit(method);
}
}

View File

@@ -0,0 +1,29 @@
import { Operation } from 'fast-json-patch/lib/core';
import { compare } from 'fast-json-patch';
import { ChangeAnalyzer } from './change-analyzer';
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
import { Injectable } from '@angular/core';
import { DSpaceObject } from '../shared/dspace-object.model';
import { CacheableObject } from '../cache/object-cache.reducer';
import { NormalizedObject } from '../cache/models/normalized-object.model';
/**
* A class to determine what differs between two
* CacheableObjects
*/
@Injectable()
export class DefaultChangeAnalyzer<T extends CacheableObject> implements ChangeAnalyzer<T> {
/**
* Compare the metadata of two CacheableObject and return the differences as
* a JsonPatch Operation Array
*
* @param {NormalizedObject} object1
* The first object to compare
* @param {NormalizedObject} object2
* The second object to compare
*/
diff(object1: T | NormalizedObject<T>, object2: T | NormalizedObject<T>): Operation[] {
return compare(object1, object2);
}
}

View File

@@ -3,13 +3,14 @@ import { compare } from 'fast-json-patch';
import { ChangeAnalyzer } from './change-analyzer';
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
import { Injectable } from '@angular/core';
import { DSpaceObject } from '../shared/dspace-object.model';
/**
* A class to determine what differs between two
* DSpaceObjects
*/
@Injectable()
export class DSOChangeAnalyzer implements ChangeAnalyzer<NormalizedDSpaceObject> {
export class DSOChangeAnalyzer<T extends DSpaceObject> implements ChangeAnalyzer<T> {
/**
* Compare the metadata of two DSpaceObjects and return the differences as
@@ -20,7 +21,7 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer<NormalizedDSpaceObject>
* @param {NormalizedDSpaceObject} object2
* The second object to compare
*/
diff(object1: NormalizedDSpaceObject, object2: NormalizedDSpaceObject): Operation[] {
diff(object1: T | NormalizedDSpaceObject<T>, object2: T | NormalizedDSpaceObject<T>): Operation[] {
return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path }));
}
}

View File

@@ -13,6 +13,7 @@ import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { hasNoValue, hasValue } from '../../shared/empty.util';
import { DSpaceObject } from '../shared/dspace-object.model';
@Injectable()
export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
@@ -33,7 +34,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem
if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) {
processRequestDTO = { page: [] };
} else {
processRequestDTO = this.process<NormalizedObject, ResourceType>(data.payload, request.uuid);
processRequestDTO = this.process<NormalizedObject<DSpaceObject>, ResourceType>(data.payload, request.uuid);
}
let objectList = processRequestDTO;

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