diff --git a/config/environment.default.js b/config/environment.default.js index cccb8e642c..61a38f0586 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -141,5 +141,10 @@ module.exports = { code: 'nl', label: 'Nederlands', active: false, - }] + }], + item: { + edit: { + undoTimeout: 10000 // 10 seconds + } + } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 30011b30c1..8c1f7e2a35 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -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." + } + } } } }, diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index 02cf168387..42b6d1f133 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -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: () => { diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 25502a27c8..4364b0234a 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -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: () => { diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts index 52497694b9..94229b4932 100644 --- a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts @@ -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 { +export class CreateCollectionPageComponent extends CreateComColPageComponent { protected frontendURL = '/collections/'; public constructor( diff --git a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts index 80abb83694..5f2bd89942 100644 --- a/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts +++ b/src/app/+collection-page/delete-collection-page/delete-collection-page.component.ts @@ -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 { +export class DeleteCollectionPageComponent extends DeleteComColPageComponent { protected frontendURL = '/collections/'; public constructor( diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts index 9bbdbfb9a1..a3978a5e43 100644 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -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 { +export class EditCollectionPageComponent extends EditComColPageComponent { protected frontendURL = '/collections/'; public constructor( diff --git a/src/app/+community-page/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts index 47fb065038..828d8338af 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -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 { +export class CreateCommunityPageComponent extends CreateComColPageComponent { protected frontendURL = '/communities/'; public constructor( diff --git a/src/app/+community-page/delete-community-page/delete-community-page.component.ts b/src/app/+community-page/delete-community-page/delete-community-page.component.ts index 01741a7577..9f1465a3c7 100644 --- a/src/app/+community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/+community-page/delete-community-page/delete-community-page.component.ts @@ -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 { +export class DeleteCommunityPageComponent extends DeleteComColPageComponent { protected frontendURL = '/communities/'; public constructor( diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.ts index 68f092e915..9f49ac49dd 100644 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.ts @@ -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 { +export class EditCommunityPageComponent extends EditComColPageComponent { protected frontendURL = '/communities/'; public constructor( diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.html b/src/app/+item-page/edit-item-page/edit-item-page.component.html index 001b484c2c..ca1c809cd9 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.html @@ -1,36 +1,24 @@
-
-
-

{{'item.edit.head' | translate}}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+

{{'item.edit.head' | translate}}

+ +
-
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.scss b/src/app/+item-page/edit-item-page/edit-item-page.component.scss new file mode 100644 index 0000000000..f22ca8f8de --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.scss @@ -0,0 +1,5 @@ +@import '../../../styles/variables.scss'; + +.btn { + min-width: $edit-item-button-min-width; +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index b8d3ca7957..4ea47f08e7 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -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>; - 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) + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 0a7b363d6a..0c1de642ce 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -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 { diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 8ef6f43e17..223b5f7c8e 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -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, diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html new file mode 100644 index 0000000000..b80e6e0678 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss new file mode 100644 index 0000000000..88eb98509a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables.scss'; \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts new file mode 100644 index 0000000000..71f25cd5cf --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -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 */ +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html new file mode 100644 index 0000000000..e9c5de95ca --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -0,0 +1,70 @@ + + + + +
+
+ {{metadata?.value}} +
+
+ +
+
+ + +
+
+ {{metadata?.language}} +
+
+ +
+
+ + +
+ + + + +
+ \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss new file mode 100644 index 0000000000..14782326f6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss @@ -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; +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts new file mode 100644 index 0000000000..09363b9964 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -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; +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('.​'), value: mdField1.toString() }, + { displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() }, + { displayValue: mdField3.toString().split('.').join('.​'), 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 }); + }); + }); + }); + + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts new file mode 100644 index 0000000000..0b9bc62c55 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -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; + + /** + * Emits whether or not this field is currently valid + */ + valid: Observable; + + /** + * The current suggestions for the metadatafield when editing + */ + metadataFieldSuggestions: BehaviorSubject = 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('.​'), + 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 { + 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 { + 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 { + 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 { + return this.editable.pipe( + map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable) + ); + } + + protected isNotEmpty(value): boolean { + return isNotEmpty(value); + } +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html new file mode 100644 index 0000000000..496429a3ba --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -0,0 +1,64 @@ + diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss new file mode 100644 index 0000000000..f3075702e6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -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; + } + + +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts new file mode 100644 index 0000000000..f2cd74fc2f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -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; +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'); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts new file mode 100644 index 0000000000..6b3e05c818 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -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; + /** + * 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; + + 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) => 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 = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable; + 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) => { + 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 { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + 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 { + return this.metadataFieldService.getAllMetadataFields().pipe( + getSucceededRemoteData(), + take(1), + map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); + } +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index 0f7d9a5607..e60fa0490d 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -12,7 +12,7 @@ {{'item.edit.tabs.status.labels.itemPage' | translate}}:
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts index f5aec6e287..00ea9b9f62 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -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); }); -}); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 2b2c7a2ed4..c7e3a023d1 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -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>; /** * 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.statusData = Object.assign({ - id: this.item.id, - handle: this.item.handle, - lastModified: this.item.lastModified + this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)); + this.itemRD$.pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.statusData = Object.assign({ + 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..label' + The value is supposed to be a href for the button + */ + this.operations = []; + if (item.isWithdrawn) { + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); + } else { + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); + } + if (item.isDiscoverable) { + this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); + } else { + this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); + } + this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); }); - this.statusDataKeys = Object.keys(this.statusData); - /* - The key is used to build messages - i18n example: 'item.edit.tabs.status.buttons..label' - The value is supposed to be a href for the button - */ - this.operations = []; - if (this.item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); - } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); - } - if (this.item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); - } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); - } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); } /** * Get the url to the simple item page * @returns {string} url */ - getItemPage(): string { - return this.router.url.substr(0, this.router.url.lastIndexOf('/')); + 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); } } diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts index 282f8687e1..974bc8d37f 100644 --- a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts @@ -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', diff --git a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 09d855e951..67684d44af 100644 --- a/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts +++ b/src/app/+item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts @@ -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. diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index 708bdb49c7..abcd90848d 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts @@ -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. diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index fcb724b564..6e19a50864 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -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'; diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 8c1f317bb7..ec562842aa 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -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: [ diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts index 3c1a46872c..46f14c042d 100644 --- a/src/app/+search-page/normalized-search-result.model.ts +++ b/src/app/+search-page/normalized-search-result.model.ts @@ -1,5 +1,5 @@ import { autoserialize } 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'; /** diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index fd5a75e7d1..1675dd051a 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -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'; @Component({ selector: 'ds-search-facet-filter', @@ -59,7 +60,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * Emits the result values for this filter found by the current filter query */ - filterSearchResults: Observable = observableOf([]); + filterSearchResults: Observable = observableOf([]); /** * Emits the active values for this filter @@ -266,7 +267,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { map( (rd: RemoteData>) => { return rd.payload.page.map((facet) => { - return { displayValue: this.getDisplayValue(facet, data), value: facet.value } + return { + displayValue: this.getDisplayValue(facet, data), + value: facet.value + } }) } )) diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts index b2e5eafdec..ff865610c6 100644 --- a/src/app/+search-page/search-result.model.ts +++ b/src/app/+search-page/search-result.model.ts @@ -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'; /** diff --git a/src/app/+search-page/search-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts index a597528d50..0f673f3485 100644 --- a/src/app/+search-page/search-service/facet-value.model.ts +++ b/src/app/+search-page/search-service/facet-value.model.ts @@ -1,4 +1,3 @@ - import { autoserialize, autoserializeAs } from 'cerialize'; /** diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index b6df1fac34..e37475d94c 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -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 { + public static getConstructor(type): GenericConstructor> { switch (type) { case AuthType.EPerson: { return NormalizedEPerson diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 3390997a1e..fdb372f643 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -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(status.eperson.toString()); + const person$ = this.rdbService.buildSingle(status.eperson.toString()); return person$.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts index b8dd2aa23e..a13a996604 100644 --- a/src/app/core/auth/models/normalized-auth-status.model.ts +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -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 { @autoserialize id: string; diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 868d444c26..b61b11a4f2 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -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(status.eperson.toString()); - return person$.pipe(map((eperson) => eperson.payload)); + const person$ = this.rdbService.buildSingle(status.eperson.toString()); + return person$.pipe( + map((eperson) => eperson.payload) + ); } else { throw(new Error('Not authenticated')); } diff --git a/src/app/core/cache/builders/normalized-object-build.service.ts b/src/app/core/cache/builders/normalized-object-build.service.ts index 9d97ccda75..79665fec3d 100644 --- a/src/app/core/cache/builders/normalized-object-build.service.ts +++ b/src/app/core/cache/builders/normalized-object-build.service.ts @@ -35,7 +35,7 @@ export class NormalizedObjectBuildService { * * @param {TDomain} domainModel a domain model */ - normalize(domainModel: TDomain): TNormalized { + normalize(domainModel: T): NormalizedObject { const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type); const relationships = getRelationships(normalizedConstructor) || []; diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 48490e5ecb..e30b8c9955 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -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(href$: string | Observable): Observable> { + buildSingle(href$: string | Observable): Observable> { 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(href)), + switchMap((href: string) => this.objectCache.getBySelfLink(href)), startWith(undefined)), requestEntry$.pipe( getResourceLinksFromResponse(), switchMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { - return this.objectCache.getBySelfLink(resourceSelfLinks[0]); + return this.objectCache.getBySelfLink(resourceSelfLinks[0]); } else { return observableOf(undefined); } @@ -67,8 +69,8 @@ export class RemoteDataBuildService { } }), hasValueOperator(), - map((normalized: TNormalized) => { - return this.build(normalized); + map((normalized: NormalizedObject) => { + return this.build(normalized); }), startWith(undefined), distinctUntilChanged() @@ -79,8 +81,8 @@ export class RemoteDataBuildService { toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { 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(href$: string | Observable): Observable>> { + buildList(href$: string | Observable): Observable>> { 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(normalized); + map((normList: Array>) => { + return normList.map((normalized: NormalizedObject) => { + return this.build(normalized); }); })); }), @@ -147,7 +149,7 @@ export class RemoteDataBuildService { return this.toRemoteDataObservable(requestEntry$, payload$); } - build(normalized: TNormalized): TDomain { + build(normalized: NormalizedObject): T { const links: any = {}; const relationships = getRelationships(normalized.constructor) || []; diff --git a/src/app/core/cache/models/normalized-bitstream-format.model.ts b/src/app/core/cache/models/normalized-bitstream-format.model.ts index 5d11c97107..994792d535 100644 --- a/src/app/core/cache/models/normalized-bitstream-format.model.ts +++ b/src/app/core/cache/models/normalized-bitstream-format.model.ts @@ -11,7 +11,7 @@ import { SupportLevel } from './support-level.model'; */ @mapsTo(BitstreamFormat) @inheritSerialization(NormalizedObject) -export class NormalizedBitstreamFormat extends NormalizedObject { +export class NormalizedBitstreamFormat extends NormalizedObject { /** * Short description of this Bitstream Format diff --git a/src/app/core/cache/models/normalized-bitstream.model.ts b/src/app/core/cache/models/normalized-bitstream.model.ts index 63f84add41..64a17aae84 100644 --- a/src/app/core/cache/models/normalized-bitstream.model.ts +++ b/src/app/core/cache/models/normalized-bitstream.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Bitstream) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBitstream extends NormalizedDSpaceObject { +export class NormalizedBitstream extends NormalizedDSpaceObject { /** * The size of this bitstream in bytes diff --git a/src/app/core/cache/models/normalized-bundle.model.ts b/src/app/core/cache/models/normalized-bundle.model.ts index 5535ab57e5..342b13629f 100644 --- a/src/app/core/cache/models/normalized-bundle.model.ts +++ b/src/app/core/cache/models/normalized-bundle.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Bundle) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBundle extends NormalizedDSpaceObject { +export class NormalizedBundle extends NormalizedDSpaceObject { /** * The primary bitstream of this Bundle */ diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts index 5cbb2d327c..ddfcc29a2c 100644 --- a/src/app/core/cache/models/normalized-collection.model.ts +++ b/src/app/core/cache/models/normalized-collection.model.ts @@ -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 { /** * 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[]; diff --git a/src/app/core/cache/models/normalized-community.model.ts b/src/app/core/cache/models/normalized-community.model.ts index e915d2f50a..f561089949 100644 --- a/src/app/core/cache/models/normalized-community.model.ts +++ b/src/app/core/cache/models/normalized-community.model.ts @@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type'; */ @mapsTo(Community) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCommunity extends NormalizedDSpaceObject { +export class NormalizedCommunity extends NormalizedDSpaceObject { /** * A string representing the unique handle of this Community diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts index 87bd4b4369..2248b62509 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -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 extends NormalizedObject { /** * 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; /** @@ -35,31 +35,31 @@ export class NormalizedDSpaceObject extends NormalizedObject { * 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 +68,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 } diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts index 7d518bd048..9e8c034e81 100644 --- a/src/app/core/cache/models/normalized-item.model.ts +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -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 { /** * 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[]; diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 786b5b3de3..9a9c948a85 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -17,9 +17,10 @@ import { SubmissionDefinitionsModel } from '../../config/models/config-submissio 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'; export class NormalizedObjectFactory { - public static getConstructor(type: ResourceType): GenericConstructor { + public static getConstructor(type: ResourceType): GenericConstructor> { switch (type) { case ResourceType.Bitstream: { return NormalizedBitstream diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts index e98081d68a..de04572dcd 100644 --- a/src/app/core/cache/models/normalized-object.model.ts +++ b/src/app/core/cache/models/normalized-object.model.ts @@ -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 implements CacheableObject { /** * The link to the rest endpoint where this object can be found diff --git a/src/app/core/cache/models/normalized-resource-policy.model.ts b/src/app/core/cache/models/normalized-resource-policy.model.ts index d0ba3d3f68..02e8352622 100644 --- a/src/app/core/cache/models/normalized-resource-policy.model.ts +++ b/src/app/core/cache/models/normalized-resource-policy.model.ts @@ -12,7 +12,7 @@ import { ActionType } from './action-type.model'; */ @mapsTo(ResourcePolicy) @inheritSerialization(NormalizedObject) -export class NormalizedResourcePolicy extends NormalizedObject { +export class NormalizedResourcePolicy extends NormalizedObject { /** * The action that is allowed by this Resource Policy diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index af30646f53..d4d52b404f 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -78,7 +78,7 @@ export class ObjectCacheService { * @return Observable * An observable of the requested object */ - getByUUID(uuid: string): Observable { + getByUUID(uuid: string): Observable> { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), mergeMap((selfLink: string) => this.getBySelfLink(selfLink) @@ -86,7 +86,7 @@ export class ObjectCacheService { ) } - getBySelfLink(selfLink: string): Observable { + getBySelfLink(selfLink: string): Observable> { 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 = NormalizedObjectFactory.getConstructor(entry.data.type); - return Object.assign(new type(), entry.data) as T + const type: GenericConstructor> = NormalizedObjectFactory.getConstructor(entry.data.type); + return Object.assign(new type(), entry.data) as NormalizedObject }) ); } @@ -145,7 +145,7 @@ export class ObjectCacheService { * The type of the objects to get * @return Observable> */ - getList(selfLinks: string[]): Observable { + getList(selfLinks: string[]): Observable>> { return observableCombineLatest( selfLinks.map((selfLink: string) => this.getBySelfLink(selfLink)) ); diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e3715d186..c86a0d5654 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -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: [] }; /** diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 7863ba8a69..bb25c49a7a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -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 ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 7106d28b3d..6aa58f791e 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -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'; const IMPORTS = [ CommonModule, @@ -154,8 +156,10 @@ const PROVIDERS = [ FileService, DSpaceObjectDataService, DSOChangeAnalyzer, + DefaultChangeAnalyzer, CSSVariableService, MenuService, + ObjectUpdatesService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c4bcdb20ab..ebfe578a6d 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -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 = { 'cache/object': objectCacheReducer, 'cache/syncbuffer': serverSyncBufferReducer, + 'cache/object-updates': objectUpdatesReducer, 'data/request': requestReducer, 'index': indexReducer, 'auth': authReducer, diff --git a/src/app/core/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts index caf9e38c7c..6b5a69259b 100644 --- a/src/app/core/data/change-analyzer.ts +++ b/src/app/core/data/change-analyzer.ts @@ -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 { +export interface ChangeAnalyzer { /** * Compare two objects and return their differences as a @@ -16,5 +17,5 @@ export interface ChangeAnalyzer { * @param {NormalizedObject} object2 * The second object to compare */ - diff(object1: TNormalized, object2: TNormalized): Operation[]; + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[]; } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 55642a181c..3d03b9397d 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -15,7 +15,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() -export class CollectionDataService extends ComColDataService { +export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; protected forceBypassCache = false; @@ -29,7 +29,7 @@ export class CollectionDataService extends ComColDataService ) { super(); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 52210164c7..cf7b6185ea 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -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 { } -class TestService extends ComColDataService { +class TestService extends ComColDataService { protected forceBypassCache = false; constructor( @@ -39,7 +41,7 @@ class TestService extends ComColDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, + protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { super(); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 8a1ea51bb3..693b8af58b 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -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 extends DataService { +export abstract class ComColDataService extends DataService { protected abstract cds: CommunityDataService; protected abstract objectCache: ObjectCacheService; protected abstract halService: HALEndpointService; diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 89895aea11..75ef58b06b 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -21,7 +21,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() -export class CommunityDataService extends ComColDataService { +export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected topLinkPath = 'communities/search/top'; protected cds = this; @@ -36,7 +36,7 @@ export class CommunityDataService extends ComColDataService ) { super(); } @@ -55,6 +55,6 @@ export class CommunityDataService extends ComColDataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } } diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 3b2c6a975a..910506bc29 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -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 { } -class TestService extends DataService { +class TestService extends DataService { protected forceBypassCache = false; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 945c881bba..984495078b 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -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 { +export abstract class DataService { protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; protected abstract dataBuildService: NormalizedObjectBuildService; @@ -56,7 +49,7 @@ export abstract class DataService; + protected abstract comparator: ChangeAnalyzer; public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable @@ -106,7 +99,7 @@ export abstract class DataService>> { + findAll(options: FindAllOptions = {}): Observable>> { const hrefObs = this.getFindAllHref(options); hrefObs.pipe( @@ -116,7 +109,7 @@ export abstract class DataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } /** @@ -128,7 +121,7 @@ export abstract class DataService> { + findById(id: string): Observable> { const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, id))); @@ -139,12 +132,12 @@ export abstract class DataService(hrefObs); + return this.rdbService.buildSingle(hrefObs); } - findByHref(href: string, options?: HttpOptions): Observable> { + findByHref(href: string, options?: HttpOptions): Observable> { this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href, null, options), this.forceBypassCache); - return this.rdbService.buildSingle(href); + return this.rdbService.buildSingle(href); } protected getSearchEndpoint(searchMethod: string): Observable { @@ -153,7 +146,7 @@ export abstract class DataService `${href}/${searchMethod}`)); } - protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { + protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { const hrefObs = this.getSearchByHref(searchMethod, options); @@ -164,7 +157,7 @@ export abstract class DataService(hrefObs) as Observable>>; + return this.rdbService.buildList(hrefObs) as Observable>>; } /** @@ -181,11 +174,10 @@ export abstract class DataService> { + update(object: T): Observable> { const oldVersion$ = this.objectCache.getBySelfLink(object.self); - return oldVersion$.pipe(first(), mergeMap((oldVersion: TNormalized) => { - const newVersion = this.dataBuildService.normalize(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> { + create(dso: T, parentUUID: string): Observable> { const requestId = this.requestService.generateRequestId(); const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -212,7 +204,7 @@ export abstract class DataService parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint) ); - const normalizedObject: TNormalized = this.dataBuildService.normalize(dso); + const normalizedObject: NormalizedObject = this.dataBuildService.normalize(dso); const serializedDso = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(dso.type)).serialize(normalizedObject); const request$ = endpoint$.pipe( @@ -253,7 +245,7 @@ export abstract class DataService { + delete(dso: T): Observable { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -273,4 +265,12 @@ export abstract class DataService implements ChangeAnalyzer { + + /** + * 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, object2: T | NormalizedObject): Operation[] { + return compare(object1, object2); + } +} diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index a47359e5c0..dd3487d3d0 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -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 { +export class DSOChangeAnalyzer implements ChangeAnalyzer { /** * Compare the metadata of two DSpaceObjects and return the differences as @@ -20,7 +21,7 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer * @param {NormalizedDSpaceObject} object2 * The second object to compare */ - diff(object1: NormalizedDSpaceObject, object2: NormalizedDSpaceObject): Operation[] { + diff(object1: T | NormalizedDSpaceObject, object2: T | NormalizedDSpaceObject): Operation[] { return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); } } diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 54933ac823..eb95cdae8a 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -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(data.payload, request.uuid); + processRequestDTO = this.process, ResourceType>(data.payload, request.uuid); } let objectList = processRequestDTO; diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 02f64ae9be..4f0653f416 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -17,7 +16,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { +class DataServiceImpl extends DataService { protected linkPath = 'dso'; protected forceBypassCache = false; @@ -30,7 +29,7 @@ class DataServiceImpl extends DataService protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer) { super(); } @@ -56,7 +55,7 @@ export class DSpaceObjectDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer) { this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); } diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c98037624b..f6adbb23c2 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,11 +1,10 @@ -import {distinctUntilChanged, map, filter} from 'rxjs/operators'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { isNotEmpty } from '../../shared/empty.util'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedItem } from '../cache/models/normalized-item.model'; import { CoreState } from '../core.reducers'; import { Item } from '../shared/item.model'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -13,16 +12,17 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteRequest, FindAllOptions, PatchRequest, RestRequest } from './request.models'; +import { FindAllOptions, PatchRequest, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { configureRequest, getResponseFromEntry } from '../shared/operators'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { HttpClient } from '@angular/common/http'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; +import { RequestEntry } from './request.reducer'; @Injectable() -export class ItemDataService extends DataService { +export class ItemDataService extends DataService { protected linkPath = 'items'; protected forceBypassCache = false; @@ -36,7 +36,7 @@ export class ItemDataService extends DataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer) { super(); } @@ -92,7 +92,9 @@ export class ItemDataService extends DataService { new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - getResponseFromEntry() + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) ); } @@ -111,7 +113,9 @@ export class ItemDataService extends DataService { new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - getResponseFromEntry() + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) ); } } diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 919bc11913..1d2bf3b221 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -10,17 +10,16 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadataschema.model'; -import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { ChangeAnalyzer } from './change-analyzer'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ @Injectable() -export class MetadataSchemaDataService extends DataService { +export class MetadataSchemaDataService extends DataService { protected linkPath = 'metadataschemas'; protected forceBypassCache = false; @@ -30,10 +29,10 @@ export class MetadataSchemaDataService extends DataService, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, + protected comparator: DefaultChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, - protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: ChangeAnalyzer) { + protected notificationsService: NotificationsService) { super(); } diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts new file mode 100644 index 0000000000..6cd74b2626 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -0,0 +1,245 @@ +import { type } from '../../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +import { Identifiable } from './object-updates.reducer'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +/** + * The list of ObjectUpdatesAction type definitions + */ +export const ObjectUpdatesActionTypes = { + INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), + SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), + SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), + ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), + DISCARD: type('dspace/core/cache/object-updates/DISCARD'), + REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), + REMOVE: type('dspace/core/cache/object-updates/REMOVE'), + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store + */ +export enum FieldChangeType { + UPDATE = 0, + ADD = 1, + REMOVE = 2 +} + +/** + * An ngrx action to initialize a new page's fields in the ObjectUpdates state + */ +export class InitializeFieldsAction implements Action { + type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS; + payload: { + url: string, + fields: Identifiable[], + lastModified: Date + }; + + /** + * Create a new InitializeFieldsAction + * + * @param url + * the unique url of the page for which the fields are being initialized + * @param fields The identifiable fields of which the updates are kept track of + * @param lastModified The last modified date of the object that belongs to the page + */ + constructor( + url: string, + fields: Identifiable[], + lastModified: Date + ) { + this.payload = { url, fields, lastModified }; + } +} + +/** + * An ngrx action to add a new field update in the ObjectUpdates state for a certain page url + */ +export class AddFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.ADD_FIELD; + payload: { + url: string, + field: Identifiable, + changeType: FieldChangeType, + }; + + /** + * Create a new AddFieldUpdateAction + * + * @param url + * the unique url of the page for which a field update is added + * @param field The identifiable field of which a new update is added + * @param changeType The update's change type + */ + constructor( + url: string, + field: Identifiable, + changeType: FieldChangeType) { + this.payload = { url, field, changeType }; + } +} + +/** + * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetEditableFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD; + payload: { + url: string, + uuid: string, + editable: boolean, + }; + + /** + * Create a new SetEditableFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param editable The new editable value for the field + */ + constructor( + url: string, + fieldUUID: string, + editable: boolean) { + this.payload = { url, uuid: fieldUUID, editable }; + } +} + +/** + * An ngrx action to set the isValid state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetValidFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_VALID_FIELD; + payload: { + url: string, + uuid: string, + isValid: boolean, + }; + + /** + * Create a new SetValidFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param isValid The new isValid value for the field + */ + constructor( + url: string, + fieldUUID: string, + isValid: boolean) { + this.payload = { url, uuid: fieldUUID, isValid }; + } +} + +/** + * An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url + */ +export class DiscardObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.DISCARD; + payload: { + url: string, + notification: INotification + }; + + /** + * Create a new DiscardObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be discarded + * @param notification The notification that is raised when changes are discarded + */ + constructor( + url: string, + notification: INotification + ) { + this.payload = { url, notification }; + } +} + +/** + * An ngrx action to reinstate all previously discarded updates in the ObjectUpdates state for a certain page url + */ +export class ReinstateObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REINSTATE; + payload: { + url: string + }; + + /** + * Create a new ReinstateObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be reinstated + */ + constructor( + url: string + ) { + this.payload = { url }; + } +} + +/** + * An ngrx action to remove all previously discarded updates in the ObjectUpdates state for a certain page url + */ +export class RemoveObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE; + payload: { + url: string + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which the changes should be removed + */ + constructor( + url: string + ) { + this.payload = { url }; + } +} + +/** + * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid + */ +export class RemoveFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE_FIELD; + payload: { + url: string, + uuid: string + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which a field's change should be removed + * @param uuid The UUID of the field for which the change should be removed + */ + constructor( + url: string, + uuid: string + ) { + this.payload = { url, uuid }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all ObjectUpdatesActions + */ +export type ObjectUpdatesAction + = AddFieldUpdateAction + | InitializeFieldsAction + | DiscardObjectUpdatesAction + | ReinstateObjectUpdatesAction + | RemoveObjectUpdatesAction + | RemoveFieldUpdateAction; diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts new file mode 100644 index 0000000000..79b1b2df72 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -0,0 +1,122 @@ +import { async, TestBed } from '@angular/core/testing'; +import { Observable, Subject } from 'rxjs'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { cold, hot } from 'jasmine-marbles'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectUpdatesEffects } from './object-updates.effects'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { filter } from 'rxjs/operators'; +import { hasValue } from '../../../shared/empty.util'; + +describe('ObjectUpdatesEffects', () => { + let updatesEffects: ObjectUpdatesEffects; + let actions: Observable; + let testURL = 'www.dspace.org/dspace7'; + let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + ObjectUpdatesEffects, + provideMockActions(() => actions), + { + provide: NotificationsService, + useValue: { + remove: (notification) => { /* empty */ + } + } + }, + ], + }); + })); + + beforeEach(() => { + testURL = 'www.dspace.org/dspace7'; + testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + updatesEffects = TestBed.get(ObjectUpdatesEffects); + (updatesEffects as any).actionMap[testURL] = new Subject(); + }); + + describe('mapLastActions$', () => { + describe('When any ObjectUpdatesAction is triggered', () => { + let action; + let emittedAction; + beforeEach(() => { + action = new RemoveObjectUpdatesAction(testURL); + }); + it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { + action = new RemoveObjectUpdatesAction(testURL); + actions = hot('--a-', { a: action }); + (updatesEffects as any).actionMap[testURL].subscribe((act) => emittedAction = act); + const expected = cold('--b-', { b: undefined }); + + expect(updatesEffects.mapLastActions$).toBeObservable(expected); + expect(emittedAction).toBe(action); + }); + }); + }); + + describe('removeAfterDiscardOrReinstateOnUndo$', () => { + describe('When an ObjectUpdatesActionTypes.DISCARD action is triggered', () => { + let infoNotification: INotification; + let removeAction; + describe('When there is no user interactions before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 0; + removeAction = new RemoveObjectUpdatesAction(testURL) + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + filter(((action) => hasValue(action)))) + .subscribe((t) => { + expect(t).toEqual(removeAction); + } + ) + ; + }); + }); + + describe('When there a REINSTATE action is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return an action with type NO_ACTION', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { + expect(t).toEqual({ type: 'NO_ACTION' }); + } + ); + }); + }); + + describe('When there any ObjectUpdates action - other than REINSTATE - is fired before the timeout is finished', () => { + beforeEach(() => { + infoNotification = new Notification('id', NotificationType.Info, 'info'); + infoNotification.options.timeOut = 10; + }); + it('should return a RemoveObjectUpdatesAction', () => { + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => + expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) + ); + }); + }); + }); + }); +}); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts new file mode 100644 index 0000000000..ae49071dc1 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { + DiscardObjectUpdatesAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + RemoveObjectUpdatesAction +} from './object-updates.actions'; +import { delay, map, switchMap, take, tap } from 'rxjs/operators'; +import { of as observableOf, race as observableRace, Subject } from 'rxjs'; +import { hasNoValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +/** + * NGRX effects for ObjectUpdatesActions + */ +@Injectable() +export class ObjectUpdatesEffects { + /** + * Map that keeps track of the latest ObjectUpdatesAction for each page's url + */ + private actionMap: { + /* Use Subject instead of BehaviorSubject: + we only want Actions that are fired while we're listening + actions that were previously fired do not matter anymore + */ + [url: string]: Subject + } = {}; + + /** + * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key + */ + @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + .pipe( + ofType(...Object.values(ObjectUpdatesActionTypes)), + map((action: DiscardObjectUpdatesAction) => { + const url: string = action.payload.url; + if (hasNoValue(this.actionMap[url])) { + this.actionMap[url] = new Subject(); + } + this.actionMap[url].next(action); + } + ) + ); + + /** + * Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction + * When no ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned + * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned + */ + @Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$ + .pipe( + ofType(ObjectUpdatesActionTypes.DISCARD), + switchMap((action: DiscardObjectUpdatesAction) => { + const url: string = action.payload.url; + const notification: INotification = action.payload.notification; + const timeOut = notification.options.timeOut; + return observableRace( + // Either wait for the delay and perform a remove action + observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)), + // Or wait for a a user action + this.actionMap[url].pipe( + take(1), + tap(() => this.notificationsService.remove(notification)), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return { type: 'NO_ACTION' } + } else { + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return new RemoveObjectUpdatesAction(action.payload.url); + } + }) + ) + ) + } + ) + ); + + constructor(private actions$: Actions, + private notificationsService: NotificationsService) { + + } + +} diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts new file mode 100644 index 0000000000..f5698b9b78 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -0,0 +1,274 @@ +import * as deepFreeze from 'deep-freeze'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; + +class NullAction extends RemoveFieldUpdateAction { + type = null; + payload = null; + + constructor() { + super(null, null); + } +} + +const identifiable1 = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, John' +}; + +const identifiable1update = { + uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', + key: 'dc.contributor.author', + language: null, + value: 'Smith, James' +}; +const identifiable2 = { + uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', + key: 'dc.title', + language: null, + value: 'New title' +}; +const identifiable3 = { + uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', + key: 'dc.description.abstract', + language: null, + value: 'Unchanged value' +}; + +const modDate = new Date(2010, 2, 11); +const uuid = identifiable1.uuid; +const url = 'test-object.url/edit'; +describe('objectUpdatesReducer', () => { + const testState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.titl', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + const discardedTestState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + }, + lastModified: modDate + }, + [url + OBJECT_UPDATES_TRASH_PATH]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false, + isValid: true + }, + [identifiable2.uuid]: { + editable: false, + isNew: true, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.titl', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = objectUpdatesReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty object', () => { + const action = new NullAction(); + const initialState = objectUpdatesReducer(undefined, action); + + expect(initialState).toEqual({}); + }); + + it('should perform the INITIALIZE_FIELDS action without affecting the previous state', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { + const action = new SetEditableFieldUpdateAction(url, uuid, false); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the ADD_FIELD action without affecting the previous state', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the DISCARD action without affecting the previous state', () => { + const action = new DiscardObjectUpdatesAction(url, null); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REINSTATE action without affecting the previous state', () => { + const action = new ReinstateObjectUpdatesAction(url); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should perform the REMOVE_FIELD action without affecting the previous state', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); + + const expectedState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false, + isValid: true + }, + }, + fieldUpdates: {}, + lastModified: modDate + } + }; + const newState = objectUpdatesReducer(testState, action); + expect(newState).toEqual(expectedState); + }); + + it('should set the given field\'s fieldStates when the SET_EDITABLE_FIELD action is dispatched, based on the payload', () => { + const action = new SetEditableFieldUpdateAction(url, identifiable3.uuid, true); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy(); + }); + + it('should set the given field\'s fieldStates when the SET_VALID_FIELD action is dispatched, based on the payload', () => { + const action = new SetValidFieldUpdateAction(url, identifiable3.uuid, false); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].isValid).toBeFalsy(); + }); + + it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => { + const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[identifiable1.uuid].field).toEqual(identifiable1update); + expect(newState[url].fieldUpdates[identifiable1.uuid].changeType).toEqual(FieldChangeType.UPDATE); + }); + + it('should discard a given url\'s updates from the state when the DISCARD action is dispatched, based on the payload', () => { + const action = new DiscardObjectUpdatesAction(url, null); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates).toEqual({}); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toEqual(testState[url]); + }); + + it('should reinstate a given url\'s updates from the state when the REINSTATE action is dispatched, based on the payload', () => { + const action = new ReinstateObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState).toEqual(testState); + }); + + it('should remove a given url\'s updates from the state when the REMOVE action is dispatched, based on the payload', () => { + const action = new RemoveObjectUpdatesAction(url); + + const newState = objectUpdatesReducer(discardedTestState, action); + expect(newState[url].fieldUpdates).toBeUndefined(); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); + }); + + it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => { + const action = new RemoveFieldUpdateAction(url, uuid); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); + }); +}); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts new file mode 100644 index 0000000000..c0f10ff92a --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -0,0 +1,332 @@ +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ObjectUpdatesAction, + ObjectUpdatesActionTypes, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; + +/** + * Path where discarded objects are saved + */ +export const OBJECT_UPDATES_TRASH_PATH = '/trash'; + +/** + * The state for a single field + */ +export interface FieldState { + editable: boolean, + isNew: boolean, + isValid: boolean +} + +/** + * A list of states for all the fields for a single page, mapped by uuid + */ +export interface FieldStates { + [uuid: string]: FieldState; +} + +/** + * Represents every object that has a UUID + */ +export interface Identifiable { + uuid: string +} + +/** + * The state of a single field update + */ +export interface FieldUpdate { + field: Identifiable, + changeType: FieldChangeType +} + +/** + * The states of all field updates available for a single page, mapped by uuid + */ +export interface FieldUpdates { + [uuid: string]: FieldUpdate; +} + +/** + * The updated state of a single page + */ +export interface ObjectUpdatesEntry { + fieldStates: FieldStates; + fieldUpdates: FieldUpdates + lastModified: Date; +} + +/** + * The updated state of all pages, mapped by the page URL + */ +export interface ObjectUpdatesState { + [url: string]: ObjectUpdatesEntry; +} + +/** + * Initial state for an existing initialized field + */ +const initialFieldState = { editable: false, isNew: false, isValid: true }; + +/** + * Initial state for a newly added field + */ +const initialNewFieldState = { editable: true, isNew: true, isValid: undefined }; + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState = Object.create(null); + +/** + * Reducer method to calculate the next ObjectUpdates state, based on the current state and the ObjectUpdatesAction + * @param state The current state + * @param action The action to perform on the current state + */ +export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState { + switch (action.type) { + case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { + return initializeFieldsUpdate(state, action as InitializeFieldsAction); + } + case ObjectUpdatesActionTypes.ADD_FIELD: { + return addFieldUpdate(state, action as AddFieldUpdateAction); + } + case ObjectUpdatesActionTypes.DISCARD: { + return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REINSTATE: { + return reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REMOVE: { + return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); + } + case ObjectUpdatesActionTypes.REMOVE_FIELD: { + return removeFieldUpdate(state, action as RemoveFieldUpdateAction); + } + case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: { + return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction); + } + case ObjectUpdatesActionTypes.SET_VALID_FIELD: { + return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); + } + default: { + return state; + } + } +} + +/** + * Initialize the state for a specific url and store all its fields in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { + const url: string = action.payload.url; + const fields: Identifiable[] = action.payload.fields; + const lastModifiedServer: Date = action.payload.lastModified; + const fieldStates = createInitialFieldStates(fields); + const newPageState = Object.assign( + {}, + state[url], + { fieldStates: fieldStates }, + { fieldUpdates: {} }, + { lastModified: lastModifiedServer } + ); + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Add a new update for a specific field to the store + * @param state The current state + * @param action The action to perform on the current state + */ +function addFieldUpdate(state: any, action: AddFieldUpdateAction) { + const url: string = action.payload.url; + const field: Identifiable = action.payload.field; + const changeType: FieldChangeType = action.payload.changeType; + const pageState: ObjectUpdatesEntry = state[url] || {}; + + let states = pageState.fieldStates; + if (changeType === FieldChangeType.ADD) { + states = Object.assign({}, { [field.uuid]: initialNewFieldState }, pageState.fieldStates) + } + + let fieldUpdate: any = pageState.fieldUpdates[field.uuid] || {}; + const newChangeType = determineChangeType(fieldUpdate.changeType, changeType); + + fieldUpdate = Object.assign({}, { field, changeType: newChangeType }); + + const fieldUpdates = Object.assign({}, pageState.fieldUpdates, { [field.uuid]: fieldUpdate }); + + const newPageState = Object.assign({}, pageState, + { fieldStates: states }, + { fieldUpdates: fieldUpdates }); + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Discard all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { + const url: string = action.payload.url; + const pageState: ObjectUpdatesEntry = state[url]; + const newFieldStates = {}; + Object.keys(pageState.fieldStates).forEach((uuid: string) => { + const fieldState: FieldState = pageState.fieldStates[uuid]; + if (!fieldState.isNew) { + /* After discarding we don't want the reset fields to stay editable or invalid */ + newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false, isValid: true }); + } + }); + + const discardedPageState = Object.assign({}, pageState, { + fieldUpdates: {}, + fieldStates: newFieldStates + }); + return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); +} + +/** + * Reinstate all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) { + const url: string = action.payload.url; + const trashState = state[url + OBJECT_UPDATES_TRASH_PATH]; + + const newState = Object.assign({}, state, { [url]: trashState }); + delete newState[url + OBJECT_UPDATES_TRASH_PATH]; + return newState; +} + +/** + * Remove all updates for a specific action's url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) { + const url: string = action.payload.url; + return removeObjectUpdatesByURL(state, url); +} + +/** + * Remove all updates for a specific url in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeObjectUpdatesByURL(state: any, url: string) { + const newState = Object.assign({}, state); + delete newState[url + OBJECT_UPDATES_TRASH_PATH]; + return newState; +} + +/** + * Discard the update for a specific action's url and field UUID in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + let newPageState: ObjectUpdatesEntry = state[url]; + if (hasValue(newPageState)) { + const newUpdates: FieldUpdates = Object.assign({}, newPageState.fieldUpdates); + if (hasValue(newUpdates[uuid])) { + delete newUpdates[uuid]; + } + const newFieldStates: FieldStates = Object.assign({}, newPageState.fieldStates); + if (hasValue(newFieldStates[uuid])) { + /* When resetting, make field not editable */ + if (newFieldStates[uuid].isNew) { + /* If this field was added, just throw it away */ + delete newFieldStates[uuid]; + } else { + newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false, isValid: true }); + } + } + newPageState = Object.assign({}, state[url], { + fieldUpdates: newUpdates, + fieldStates: newFieldStates + }); + } + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Determine the most prominent FieldChangeType, ordered as follows: + * undefined < UPDATE < ADD < REMOVE + * @param oldType The current type + * @param newType The new type that should possibly override the new type + */ +function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType): FieldChangeType { + if (hasNoValue(newType)) { + return oldType; + } + if (hasNoValue(oldType)) { + return newType; + } + return oldType.valueOf() > newType.valueOf() ? oldType : newType; +} + +/** + * Set the editable state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const editable: boolean = action.payload.editable; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { editable }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Set the isValid state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setValidFieldUpdate(state: any, action: SetValidFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const isValid: boolean = action.payload.isValid; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { isValid }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + +/** + * Method to create an initial FieldStates object based on a list of Identifiable objects + * @param fields Identifiable objects + */ +function createInitialFieldStates(fields: Identifiable[]) { + const uuids = fields.map((field: Identifiable) => field.uuid); + const fieldStates = {}; + uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); + return fieldStates; +} diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts new file mode 100644 index 0000000000..e9fc4652b0 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -0,0 +1,254 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { ObjectUpdatesService } from './object-updates.service'; +import { + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SetEditableFieldUpdateAction +} from './object-updates.actions'; +import { of as observableOf } from 'rxjs'; +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; + +describe('ObjectUpdatesService', () => { + let service: ObjectUpdatesService; + let store: Store; + const value = 'test value'; + const url = 'test-url.com/dspace'; + const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320' }; + const identifiable1Updated = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', value: value }; + const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; + const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; + const identifiables = [identifiable1, identifiable2]; + + const fieldUpdates = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + const modDate = new Date(2010, 2, 11); + + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate + }; + store = new Store(undefined, undefined, undefined); + spyOn(store, 'dispatch'); + service = new ObjectUpdatesService(store); + + spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); + spyOn(service as any, 'getFieldState').and.callFake((uuid) => { + return observableOf(fieldStates[uuid]); + }); + spyOn(service as any, 'saveFieldUpdate'); + }); + + describe('initialize', () => { + it('should dispatch an INITIALIZE action with the correct URL, initial identifiables and the last modified date', () => { + service.initialize(url, identifiables, modDate); + expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate)); + }); + }); + + describe('getFieldUpdates', () => { + it('should return the list of all fields, including their update if there is one', () => { + const result$ = service.getFieldUpdates(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('isEditable', () => { + it('should return false if this identifiable is currently not editable in the store', () => { + const result$ = service.isEditable(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently editable in the store', () => { + const result$ = service.isEditable(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('isValid', () => { + it('should return false if this identifiable is currently not valid in the store', () => { + const result$ = service.isValid(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently valid in the store', () => { + const result$ = service.isValid(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('saveAddFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.ADD', () => { + service.saveAddFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.ADD); + }); + }); + + describe('saveRemoveFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.REMOVE', () => { + service.saveRemoveFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.REMOVE); + }); + }); + + describe('saveChangeFieldUpdate', () => { + it('should call saveFieldUpdate on the service with FieldChangeType.UPDATE', () => { + service.saveChangeFieldUpdate(url, identifiable1); + expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.UPDATE); + }); + }); + + describe('setEditableFieldUpdate', () => { + it('should dispatch a SetEditableFieldUpdateAction action with the correct URL, uuid and true when true was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, true)); + }); + + it('should dispatch an SetEditableFieldUpdateAction action with the correct URL, uuid and false when false was set', () => { + service.setEditableFieldUpdate(url, identifiable1.uuid, false); + expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, false)); + }); + }); + + describe('discardFieldUpdates', () => { + it('should dispatch a DiscardObjectUpdatesAction action with the correct URL and passed notification ', () => { + const undoNotification = new Notification('id', NotificationType.Info, 'undo'); + service.discardFieldUpdates(url, undoNotification); + expect(store.dispatch).toHaveBeenCalledWith(new DiscardObjectUpdatesAction(url, undoNotification)); + }); + }); + + describe('reinstateFieldUpdates', () => { + it('should dispatch a ReinstateObjectUpdatesAction action with the correct URL ', () => { + service.reinstateFieldUpdates(url); + expect(store.dispatch).toHaveBeenCalledWith(new ReinstateObjectUpdatesAction(url)); + }); + }); + + describe('removeSingleFieldUpdate', () => { + it('should dispatch a RemoveFieldUpdateAction action with the correct URL and uuid', () => { + service.removeSingleFieldUpdate(url, identifiable1.uuid); + expect(store.dispatch).toHaveBeenCalledWith(new RemoveFieldUpdateAction(url, identifiable1.uuid)); + }); + }); + + describe('getUpdatedFields', () => { + it('should return the list of all metadata fields with their new values', () => { + const result$ = service.getUpdatedFields(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = [identifiable1Updated, identifiable2, identifiable3]; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('hasUpdates', () => { + it('should return true when there are updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + describe('when updates are emtpy', () => { + beforeEach(() => { + (service as any).getObjectEntry.and.returnValue(observableOf({})) + }); + + it('should return false when there are no updates', () => { + const result$ = service.hasUpdates(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('isReinstatable', () => { + + describe('when updates are not emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(true)); + }); + + it('should return true', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + + const expectedResult = true; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('when updates are emtpy', () => { + beforeEach(() => { + spyOn(service, 'hasUpdates').and.returnValue(observableOf(false)); + }); + + it('should return false', () => { + const result$ = service.isReinstatable(url); + expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH); + const expectedResult = false; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + }); + + describe('getLastModified', () => { + it('should return true when hasUpdates returns true', () => { + const result$ = service.getLastModified(url); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = modDate; + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + }); + }); + }); + +}); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts new file mode 100644 index 0000000000..85e17b5b2f --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -0,0 +1,268 @@ +import { Injectable } from '@angular/core'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { coreSelector, CoreState } from '../../core.reducers'; +import { + FieldState, + FieldUpdates, + Identifiable, OBJECT_UPDATES_TRASH_PATH, + ObjectUpdatesEntry, + ObjectUpdatesState +} from './object-updates.reducer'; +import { Observable } from 'rxjs'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, + SetEditableFieldUpdateAction, SetValidFieldUpdateAction +} from './object-updates.actions'; +import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; + +function objectUpdatesStateSelector(): MemoizedSelector { + return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); +} + +function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector { + return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]); +} + +function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); +} + +/** + * Service that dispatches and reads from the ObjectUpdates' state in the store + */ +@Injectable() +export class ObjectUpdatesService { + constructor(private store: Store) { + + } + + /** + * Method to dispatch an InitializeFieldsAction to the store + * @param url The page's URL for which the changes are being mapped + * @param fields The initial fields for the page's object + * @param lastModified The date the object was last modified + */ + initialize(url, fields: Identifiable[], lastModified: Date): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); + } + + /** + * Method to dispatch an AddFieldUpdateAction to the store + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + * @param changeType The last type of change applied to this field + */ + private saveFieldUpdate(url: string, field: Identifiable, changeType: FieldChangeType) { + this.store.dispatch(new AddFieldUpdateAction(url, field, changeType)) + } + + /** + * Request the ObjectUpdatesEntry state for a specific URL + * @param url The URL to filter by + */ + private getObjectEntry(url: string): Observable { + return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); + } + + /** + * Request the getFieldState state for a specific URL and UUID + * @param url The URL to filter by + * @param uuid The field's UUID to filter by + */ + private getFieldState(url: string, uuid: string): Observable { + return this.store.pipe(select(filterByUrlAndUUIDFieldStateSelector(url, uuid))); + } + + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ + getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + let fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (isEmpty(fieldUpdate)) { + const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); + fieldUpdate = { field: identifiable, changeType: undefined }; + } + fieldUpdates[uuid] = fieldUpdate; + }); + return fieldUpdates; + })) + } + + /** + * Method to check if a specific field is currently editable in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isEditable(url: string, uuid: string): Observable { + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.editable), + distinctUntilChanged() + ) + } + + /** + * Method to check if a specific field is currently valid in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isValid(url: string, uuid: string): Observable { + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.isValid), + distinctUntilChanged() + ) + } + + /** + * Method to check if a specific page is currently valid in the store + * @param url The URL of the page + */ + isValidPage(url: string): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe( + map((entry: ObjectUpdatesEntry) => { + return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 + }), + distinctUntilChanged() + ) + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.ADD + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveAddFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.ADD); + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.REMOVE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveRemoveFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.REMOVE); + } + + /** + * Calls the saveFieldUpdate method with FieldChangeType.UPDATE + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + */ + saveChangeFieldUpdate(url: string, field: Identifiable) { + this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); + } + + /** + * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param editable The new value of editable in the store for this field + */ + setEditableFieldUpdate(url: string, uuid: string, editable: boolean) { + this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable)); + } + + /** + * Dispatches a SetValidFieldUpdateAction to the store to set a field's isValid state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param valid The new value of isValid in the store for this field + */ + setValidFieldUpdate(url: string, uuid: string, valid: boolean) { + this.store.dispatch(new SetValidFieldUpdateAction(url, uuid, valid)); + } + + /** + * Method to dispatch an DiscardObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be discarded + * @param undoNotification The notification which is should possibly be canceled + */ + discardFieldUpdates(url: string, undoNotification: INotification) { + this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification)); + } + + /** + * Method to dispatch an ReinstateObjectUpdatesAction to the store + * @param url The page's URL for which the changes should be reinstated + */ + reinstateFieldUpdates(url: string) { + this.store.dispatch(new ReinstateObjectUpdatesAction(url)); + } + + /** + * Method to dispatch an RemoveFieldUpdateAction to the store + * @param url The page's URL for which the changes should be removed + */ + removeSingleFieldUpdate(url: string, uuid) { + this.store.dispatch(new RemoveFieldUpdateAction(url, uuid)); + } + + /** + * Method that combines the state's updates with the initial values (when there's no update) to create + * a list of updates fields + * @param url The URL of the page for which the updated fields should be requested + * @param initialFields The initial values of the fields + */ + getUpdatedFields(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fields: Identifiable[] = []; + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + const fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (hasNoValue(fieldUpdate) || fieldUpdate.changeType !== FieldChangeType.REMOVE) { + let field; + if (isNotEmpty(fieldUpdate)) { + field = fieldUpdate.field; + } else { + field = initialFields.find((object: Identifiable) => object.uuid === uuid); + } + fields.push(field); + } + }); + return fields; + })) + } + + /** + * Checks if the page currently has updates in the store or not + * @param url The page's url to check for in the store + */ + hasUpdates(url: string): Observable { + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); + } + + /** + * Checks if the page currently is reinstatable in the store or not + * @param url The page's url to check for in the store + */ + isReinstatable(url: string): Observable { + return this.hasUpdates(url + OBJECT_UPDATES_TRASH_PATH) + } + + /** + * Request the current lastModified date stored for the updates in the store + * @param url The page's url to check for in the store + */ + getLastModified(url: string): Observable { + return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); + } +} diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 0e6ac0fd05..0ca793c5ae 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -7,7 +7,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { hasValue } from '../../shared/empty.util'; import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; -import { MetadataMap, MetadataValue } from '../shared/metadata.interfaces'; +import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @Injectable() export class SearchResponseParsingService implements ResponseParsingService { @@ -22,7 +22,7 @@ export class SearchResponseParsingService implements ResponseParsingService { const mdMap: MetadataMap = {}; if (hhObject) { for (const key of Object.keys(hhObject)) { - const value: MetadataValue = { value: hhObject[key].join('...'), language: null }; + const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), language: null }); mdMap[key] = [ value ]; } } diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 45d26761b0..7d2138b633 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -19,4 +19,8 @@ export class EPerson extends DSpaceObject { public selfRegistered: boolean; + /** Getter to retrieve the EPerson's full name as a string */ + get name(): string { + return this.firstMetadataValue('eperson.firstname') + ' ' + this.firstMetadataValue('eperson.lastname'); + } } diff --git a/src/app/core/eperson/models/normalized-eperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts index 4f2a5edcd4..1032c84db1 100644 --- a/src/app/core/eperson/models/normalized-eperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -9,7 +9,7 @@ import { NormalizedGroup } from './normalized-group.model'; @mapsTo(EPerson) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; diff --git a/src/app/core/eperson/models/normalized-group.model.ts b/src/app/core/eperson/models/normalized-group.model.ts index 3189adbc8f..34a0da7adf 100644 --- a/src/app/core/eperson/models/normalized-group.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -7,7 +7,7 @@ import { Group } from './group.model'; @mapsTo(Group) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index d6aec339a1..cfb5a0751d 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -37,7 +37,7 @@ import { HttpClient } from '@angular/common/http'; import { EmptyError } from 'rxjs/internal-compatibility'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; -import { MetadataValue } from '../shared/metadata.interfaces'; +import { MetadataValue } from '../shared/metadata.models'; /* tslint:disable:max-classes-per-file */ @Component({ diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts index f9b5155649..ba28b59d0e 100644 --- a/src/app/core/metadata/metadatafield.model.ts +++ b/src/app/core/metadata/metadatafield.model.ts @@ -1,6 +1,7 @@ import { MetadataSchema } from './metadataschema.model'; import { autoserialize } from 'cerialize'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { isNotEmpty } from '../../shared/empty.util'; export class MetadataField implements ListableObject { @autoserialize @@ -20,4 +21,12 @@ export class MetadataField implements ListableObject { @autoserialize schema: MetadataSchema; + + toString(separator: string = '.'): string { + let key = this.schema.prefix + separator + this.element; + if (isNotEmpty(this.qualifier)) { + key += separator + this.qualifier; + } + return key; + } } diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts index 844efd232f..c121938940 100644 --- a/src/app/core/metadata/normalized-metadata-schema.model.ts +++ b/src/app/core/metadata/normalized-metadata-schema.model.ts @@ -1,7 +1,6 @@ import { autoserialize } from 'cerialize'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { mapsTo } from '../cache/builders/build-decorators'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { MetadataSchema } from './metadataschema.model'; @@ -9,7 +8,7 @@ import { MetadataSchema } from './metadataschema.model'; * Normalized class for a DSpace MetadataSchema */ @mapsTo(MetadataSchema) -export class NormalizedMetadataSchema extends NormalizedObject implements CacheableObject, ListableObject { +export class NormalizedMetadataSchema extends NormalizedObject implements ListableObject { /** * The unique identifier for this schema */ diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 969024e330..8fa1ca893a 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -29,18 +29,24 @@ import { import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasValue, hasNoValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; -import { configureRequest, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; +import { + configureRequest, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; import { MetadataRegistryCancelFieldAction, - MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, MetadataRegistryDeselectAllSchemaAction, + MetadataRegistryCancelSchemaAction, + MetadataRegistryDeselectAllFieldAction, + MetadataRegistryDeselectAllSchemaAction, MetadataRegistryDeselectFieldAction, MetadataRegistryDeselectSchemaAction, MetadataRegistryEditFieldAction, @@ -167,6 +173,47 @@ export class RegistryService { return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } + /** + * Retrieve all existing metadata fields as a paginated list + * @param pagination Pagination options to determine which page of metadata fields should be requested + * When no pagination is provided, all metadata fields are requested in one large page + * @returns an observable that emits a remote data object with a page of metadata fields + */ + public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable>> { + if (hasNoValue(pagination)) { + pagination = { currentPage: 1, pageSize: 10000 } as any; + } + const requestObs = this.getMetadataFieldsRequestObs(pagination); + + const requestEntryObs = requestObs.pipe( + flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) + ); + + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) + ); + + const metadatafieldsObs: Observable = rmrObs.pipe( + map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), + map((metadataFields: MetadataField[]) => metadataFields) + ); + + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + + map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo) + ); + + const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe( + map(([metadatafields, pageInfo]) => { + return new PaginatedList(pageInfo, metadatafields); + }) + ); + + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + } + public getBitstreamFormats(pagination: PaginationComponentOptions): Observable>> { const requestObs = this.getBitstreamFormatsRequestObs(pagination); @@ -239,6 +286,26 @@ export class RegistryService { ); } + private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable { + return this.halService.getEndpoint(this.metadataFieldsPath).pipe( + map((url: string) => { + const args: string[] = []; + args.push(`size=${pagination.pageSize}`); + args.push(`page=${pagination.currentPage - 1}`); + if (isNotEmpty(args)) { + url = new URLCombiner(url, `?${args.join('&')}`).toString(); + } + const request = new GetRequest(this.requestService.generateRequestId(), url); + return Object.assign(request, { + getResponseParser(): GenericConstructor { + return RegistryMetadatafieldsResponseParsingService; + } + }); + }), + tap((request: RestRequest) => this.requestService.configure(request)), + ); + } + private getBitstreamFormatsRequestObs(pagination: PaginationComponentOptions): Observable { return this.halService.getEndpoint(this.bitstreamFormatsPath).pipe( map((url: string) => { @@ -495,4 +562,21 @@ export class RegistryService { } }); } + + /** + * Retrieve a filtered paginated list of metadata fields + * @param query {string} The query to filter the field names by + * @returns an observable that emits a remote data object with a page of metadata fields that match the query + */ + queryMetadataFields(query: string): Observable>> { + return this.getAllMetadataFields().pipe( + map((rd: RemoteData>) => { + const filteredFields: MetadataField[] = rd.payload.page.filter( + (field: MetadataField) => field.toString().indexOf(query) >= 0 + ); + const page: PaginatedList = new PaginatedList(new PageInfo(), filteredFields) + return Object.assign({}, rd, { payload: page }); + }) + ); + } } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 100d4da557..71c6ee7837 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,12 +1,12 @@ -import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces'; -import { Metadata } from './metadata.model'; -import { isEmpty, isNotEmpty, isUndefined } from '../../shared/empty.util'; +import { Observable } from 'rxjs'; + +import { MetadataMap, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models'; +import { Metadata } from './metadata.utils'; +import { isUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { Observable } from 'rxjs'; -import { autoserialize } from 'cerialize'; /** * An abstract model class for a DSpaceObject. @@ -20,13 +20,11 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * The human-readable identifier of this DSpaceObject */ - @autoserialize id: string; /** * The universally unique identifier of this DSpaceObject */ - @autoserialize uuid: string; /** @@ -51,9 +49,15 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * All metadata of this DSpaceObject */ - @autoserialize metadata: MetadataMap; + /** + * Retrieve the current metadata as a list of MetadatumViewModels + */ + get metadataAsList(): MetadatumViewModel[] { + return Metadata.toViewModelList(this.metadata); + } + /** * An array of DSpaceObjects that are direct parents of this DSpaceObject */ diff --git a/src/app/core/shared/metadata.interfaces.ts b/src/app/core/shared/metadata.interfaces.ts deleted file mode 100644 index 3590117ce8..0000000000 --- a/src/app/core/shared/metadata.interfaces.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** A map of metadata keys to an ordered list of MetadataValue objects. */ -export interface MetadataMap { - [ key: string ]: MetadataValue[]; -} - -/** A single metadata value and its properties. */ -export interface MetadataValue { - - /** The language. */ - language: string; - - /** The string value. */ - value: string; -} - -/** Constraints for matching metadata values. */ -export interface MetadataValueFilter { - - /** The language constraint. */ - language?: string; - - /** The value constraint. */ - value?: string; - - /** Whether the value constraint should match without regard to case. */ - ignoreCase?: boolean; - - /** Whether the value constraint should match as a substring. */ - substring?: boolean; -} diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts new file mode 100644 index 0000000000..b72eb340d3 --- /dev/null +++ b/src/app/core/shared/metadata.models.ts @@ -0,0 +1,78 @@ +import * as uuidv4 from 'uuid/v4'; +import { autoserialize, Serialize, Deserialize } from 'cerialize'; +/* tslint:disable:max-classes-per-file */ + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export class MetadataMap { + [key: string]: MetadataValue[]; +} + +/** A single metadata value and its properties. */ + +export class MetadataValue { + /** The uuid. */ + uuid: string = uuidv4(); + + /** The language. */ + @autoserialize + language: string; + + /** The string value. */ + @autoserialize + value: string; +} + +/** Constraints for matching metadata values. */ +export interface MetadataValueFilter { + /** The language constraint. */ + language?: string; + + /** The value constraint. */ + value?: string; + + /** Whether the value constraint should match without regard to case. */ + ignoreCase?: boolean; + + /** Whether the value constraint should match as a substring. */ + substring?: boolean; +} + +export class MetadatumViewModel { + /** The uuid. */ + uuid: string = uuidv4(); + + /** The metadatafield key. */ + key: string; + + /** The language. */ + language: string; + + /** The string value. */ + value: string; + + /** The order. */ + order: number; +} + +/** Serializer used for MetadataMaps. + * This is necessary because Cerialize has trouble instantiating the MetadataValues using their constructor + * when they are inside arrays which also represent the values in a map. + */ +export const MetadataMapSerializer = { + Serialize(map: MetadataMap): any { + const json = {}; + Object.keys(map).forEach((key: string) => { + json[key] = Serialize(map[key], MetadataValue); + }); + return json; + }, + + Deserialize(json: any): MetadataMap { + const metadataMap: MetadataMap = {}; + Object.keys(json).forEach((key: string) => { + metadataMap[key] = Deserialize(json[key], MetadataValue); + }); + return metadataMap; + } +}; +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/shared/metadata.model.spec.ts b/src/app/core/shared/metadata.utils.spec.ts similarity index 55% rename from src/app/core/shared/metadata.model.spec.ts rename to src/app/core/shared/metadata.utils.spec.ts index dfeff8d600..7fbea14b13 100644 --- a/src/app/core/shared/metadata.model.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -1,10 +1,16 @@ import { isUndefined } from '../../shared/empty.util'; -import { MetadataValue, MetadataValueFilter } from './metadata.interfaces'; -import { Metadata } from './metadata.model'; +import * as uuidv4 from 'uuid/v4'; +import { + MetadataMap, + MetadataValue, + MetadataValueFilter, + MetadatumViewModel +} from './metadata.models'; +import { Metadata } from './metadata.utils'; const mdValue = (value: string, language?: string): MetadataValue => { - return { value: value, language: isUndefined(language) ? null : language }; -} + return { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language }; +}; const dcDescription = mdValue('Some description'); const dcAbstract = mdValue('Some abstract'); @@ -13,19 +19,27 @@ const dcTitle1 = mdValue('Title 1'); const dcTitle2 = mdValue('Title 2', 'en_US'); const bar = mdValue('Bar'); -const singleMap = { 'dc.title': [ dcTitle0 ] }; +const singleMap = { 'dc.title': [dcTitle0] }; const multiMap = { - 'dc.description': [ dcDescription ], - 'dc.description.abstract': [ dcAbstract ], - 'dc.title': [ dcTitle1, dcTitle2 ], - 'foo': [ bar ] + 'dc.description': [dcDescription], + 'dc.description.abstract': [dcAbstract], + 'dc.title': [dcTitle1, dcTitle2], + 'foo': [bar] }; +const multiViewModelList = [ + { key: 'dc.description', ...dcDescription, order: 0 }, + { key: 'dc.description.abstract', ...dcAbstract, order: 0 }, + { key: 'dc.title', ...dcTitle1, order: 0 }, + { key: 'dc.title', ...dcTitle2, order: 1 }, + { key: 'foo', ...bar, order: 0 } +]; + const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { - const keys = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ]; + const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) - + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { + + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { const result = fn(mapOrMaps, keys, filter); let shouldReturn; if (resultKind === 'boolean') { @@ -34,7 +48,7 @@ const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => shouldReturn = 'undefined'; } else if (expected instanceof Array) { shouldReturn = 'an array with ' + expected.length + ' ' + (expected.length > 1 ? 'ordered ' : '') - + resultKind + (expected.length !== 1 ? 's' : ''); + + resultKind + (expected.length !== 1 ? 's' : ''); } else { shouldReturn = 'a ' + resultKind; } @@ -57,30 +71,30 @@ describe('Metadata', () => { }); describe('with singleMap', () => { testAll(singleMap, 'foo', []); - testAll(singleMap, '*', [ dcTitle0 ]); + testAll(singleMap, '*', [dcTitle0]); testAll(singleMap, '*', [], { value: 'baz' }); - testAll(singleMap, 'dc.title', [ dcTitle0 ]); - testAll(singleMap, 'dc.*', [ dcTitle0 ]); + testAll(singleMap, 'dc.title', [dcTitle0]); + testAll(singleMap, 'dc.*', [dcTitle0]); }); describe('with multiMap', () => { - testAll(multiMap, 'foo', [ bar ]); - testAll(multiMap, '*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2, bar ]); - testAll(multiMap, 'dc.title', [ dcTitle1, dcTitle2 ]); - testAll(multiMap, 'dc.*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2 ]); - testAll(multiMap, [ 'dc.title', 'dc.*' ], [ dcTitle1, dcTitle2, dcDescription, dcAbstract ]); + testAll(multiMap, 'foo', [bar]); + testAll(multiMap, '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll(multiMap, 'dc.title', [dcTitle1, dcTitle2]); + testAll(multiMap, 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll(multiMap, ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); describe('with [ singleMap, multiMap ]', () => { - testAll([ singleMap, multiMap ], 'foo', [ bar ]); - testAll([ singleMap, multiMap ], '*', [ dcTitle0 ]); - testAll([ singleMap, multiMap ], 'dc.title', [ dcTitle0 ]); - testAll([ singleMap, multiMap ], 'dc.*', [ dcTitle0 ]); + testAll([singleMap, multiMap], 'foo', [bar]); + testAll([singleMap, multiMap], '*', [dcTitle0]); + testAll([singleMap, multiMap], 'dc.title', [dcTitle0]); + testAll([singleMap, multiMap], 'dc.*', [dcTitle0]); }); describe('with [ multiMap, singleMap ]', () => { - testAll([ multiMap, singleMap ], 'foo', [ bar ]); - testAll([ multiMap, singleMap ], '*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2, bar ]); - testAll([ multiMap, singleMap ], 'dc.title', [ dcTitle1, dcTitle2 ]); - testAll([ multiMap, singleMap ], 'dc.*', [ dcDescription, dcAbstract, dcTitle1, dcTitle2 ]); - testAll([ multiMap, singleMap ], [ 'dc.title', 'dc.*' ], [ dcTitle1, dcTitle2, dcDescription, dcAbstract ]); + testAll([multiMap, singleMap], 'foo', [bar]); + testAll([multiMap, singleMap], '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll([multiMap, singleMap], 'dc.title', [dcTitle1, dcTitle2]); + testAll([multiMap, singleMap], 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll([multiMap, singleMap], ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); }); @@ -93,10 +107,10 @@ describe('Metadata', () => { testAllValues({}, '*', []); }); describe('with singleMap', () => { - testAllValues([ singleMap, multiMap ], '*', [ dcTitle0.value ]); + testAllValues([singleMap, multiMap], '*', [dcTitle0.value]); }); describe('with [ multiMap, singleMap ]', () => { - testAllValues([ multiMap, singleMap ], '*', [ dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value ]); + testAllValues([multiMap, singleMap], '*', [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); }); }); @@ -112,7 +126,7 @@ describe('Metadata', () => { testFirst(singleMap, '*', dcTitle0); }); describe('with [ multiMap, singleMap ]', () => { - testFirst([ multiMap, singleMap ], '*', dcDescription); + testFirst([multiMap, singleMap], '*', dcDescription); }); }); @@ -128,7 +142,7 @@ describe('Metadata', () => { testFirstValue(singleMap, '*', dcTitle0.value); }); describe('with [ multiMap, singleMap ]', () => { - testFirstValue([ multiMap, singleMap ], '*', dcDescription.value); + testFirstValue([multiMap, singleMap], '*', dcDescription.value); }); }); @@ -145,7 +159,7 @@ describe('Metadata', () => { testHas(singleMap, '*', false, { value: 'baz' }); }); describe('with [ multiMap, singleMap ]', () => { - testHas([ multiMap, singleMap ], '*', true); + testHas([multiMap, singleMap], '*', true); }); }); @@ -153,7 +167,7 @@ describe('Metadata', () => { const testValueMatches = (value: MetadataValue, expected: boolean, filter?: MetadataValueFilter) => { describe('with value ' + JSON.stringify(value) + ' and filter ' - + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => { + + (isUndefined(filter) ? 'undefined' : JSON.stringify(filter)), () => { const result = Metadata.valueMatches(value, filter); it('should return ' + expected, () => { expect(result).toEqual(expected); @@ -172,4 +186,32 @@ describe('Metadata', () => { testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' }); }); + describe('toViewModelList method', () => { + + const testToViewModelList = (map: MetadataMap, expected: MetadatumViewModel[]) => { + describe('with map ' + JSON.stringify(map), () => { + const result = Metadata.toViewModelList(map); + it('should return ' + JSON.stringify(expected), () => { + expect(result).toEqual(expected); + }); + }); + }; + + testToViewModelList(multiMap, multiViewModelList); + }); + + describe('toMetadataMap method', () => { + + const testToMetadataMap = (metadatumList: MetadatumViewModel[], expected: MetadataMap) => { + describe('with metadatum list ' + JSON.stringify(metadatumList), () => { + const result = Metadata.toMetadataMap(metadatumList); + it('should return ' + JSON.stringify(expected), () => { + expect(result).toEqual(expected); + }); + }); + }; + + testToMetadataMap(multiViewModelList, multiMap); + }); + }); diff --git a/src/app/core/shared/metadata.model.ts b/src/app/core/shared/metadata.utils.ts similarity index 78% rename from src/app/core/shared/metadata.model.ts rename to src/app/core/shared/metadata.utils.ts index 2b29659252..1e9446912d 100644 --- a/src/app/core/shared/metadata.model.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,5 +1,11 @@ import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; -import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces'; +import { + MetadataMap, + MetadataValue, + MetadataValueFilter, + MetadatumViewModel +} from './metadata.models'; +import { groupBy, sortBy } from 'lodash'; /** * Utility class for working with DSpace object metadata. @@ -27,7 +33,7 @@ export class Metadata { */ public static all(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], filter?: MetadataValueFilter): MetadataValue[] { - const mdMaps: MetadataMap[] = mapOrMaps instanceof Array ? mapOrMaps : [ mapOrMaps ]; + const mdMaps: MetadataMap[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; const matches: MetadataValue[] = []; for (const mdMap of mdMaps) { for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) { @@ -71,7 +77,7 @@ export class Metadata { */ public static first(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], filter?: MetadataValueFilter): MetadataValue { - const mdMaps: MetadataMap[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [ mdMapOrMaps ]; + const mdMaps: MetadataMap[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; for (const mdMap of mdMaps) { for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { const values: MetadataValue[] = mdMap[key]; @@ -143,8 +149,8 @@ export class Metadata { * @param {MetadataMap} mdMap The source map. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. */ - private static resolveKeys(mdMap: MetadataMap, keyOrKeys: string | string[]): string[] { - const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [ keyOrKeys ]; + private static resolveKeys(mdMap: MetadataMap = {}, keyOrKeys: string | string[]): string[] { + const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; const outputKeys: string[] = []; for (const inputKey of inputKeys) { if (inputKey.includes('*')) { @@ -160,4 +166,53 @@ export class Metadata { } return outputKeys; } + + /** + * Creates an array of MetadatumViewModels from an existing MetadataMap. + * + * @param {MetadataMap} mdMap The source map. + * @returns {MetadatumViewModel[]} List of metadata view models based on the source map. + */ + public static toViewModelList(mdMap: MetadataMap): MetadatumViewModel[] { + let metadatumList: MetadatumViewModel[] = []; + Object.keys(mdMap) + .sort() + .forEach((key: string) => { + const fields = mdMap[key].map( + (metadataValue: MetadataValue, index: number) => + Object.assign( + {}, + metadataValue, + { + order: index, + key + })); + metadatumList = [...metadatumList, ...fields]; + }); + return metadatumList; + } + + /** + * Creates an MetadataMap from an existing array of MetadatumViewModels. + * + * @param {MetadatumViewModel[]} viewModelList The source list. + * @returns {MetadataMap} Map with metadata values based on the source list. + */ + public static toMetadataMap(viewModelList: MetadatumViewModel[]): MetadataMap { + const metadataMap: MetadataMap = {}; + const groupedList = groupBy(viewModelList, (viewModel) => viewModel.key); + Object.keys(groupedList) + .sort() + .forEach((key: string) => { + const orderedValues = sortBy(groupedList[key], ['order']); + metadataMap[key] = orderedValues.map((value: MetadataValue) => { + const val = Object.assign({}, value); + delete (val as any).order; + delete (val as any).key; + return val; + } + ) + }); + return metadataMap; + } } diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 0f426ba2c3..2eb47507b2 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -64,7 +64,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(getRequestFromRequestHref(requestService)).subscribe()); scheduler.flush(); - expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref) + expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref); }); it('shouldn\'t return anything if there is no request matching the self link', () => { @@ -159,6 +159,22 @@ describe('Core Module - RxJS Operators', () => { }); }); + describe('getResponseFromEntry', () => { + it('should return the response for all not empty request entries, when they have a value', () => { + const source = hot('abcdefg', testRCEs); + const result = source.pipe(getResponseFromEntry()); + const expected = cold('abcde--', { + a: testRCEs.a.response, + b: testRCEs.b.response, + c: testRCEs.c.response, + d: testRCEs.d.response, + e: testRCEs.e.response + }); + + expect(result).toBeObservable(expected) + }); + }); + describe('getSucceededRemoteData', () => { it('should return the first() hasSucceeded RemoteData Observable', () => { const testRD = { diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 9a429df5e1..5a325509bf 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -60,7 +60,7 @@ export const getRemoteDataPayload = () => export const getSucceededRemoteData = () => (source: Observable>): Observable> => - source.pipe(find((rd: RemoteData) => rd.hasSucceeded), hasValueOperator()); + source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); export const getFinishedRemoteData = () => (source: Observable>): Observable> => diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 56a62c0fbd..97c3e39353 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -14,7 +14,7 @@
    - +
diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html index 720ad0c1cf..6c67937063 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html @@ -1,3 +1,3 @@ + [formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"> diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts index cc1e2063ff..3f52f0e46a 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts @@ -50,10 +50,7 @@ describe('ComColFormComponent', () => { ]; /* tslint:disable:no-empty */ - const locationStub = { - back: () => { - } - }; + const locationStub = jasmine.createSpyObj('location', ['back']); /* tslint:enable:no-empty */ beforeEach(async(() => { @@ -112,4 +109,11 @@ describe('ComColFormComponent', () => { ); }) }); + + describe('onCancel', () => { + it('should call the back method on the Location service', () => { + comp.onCancel(); + expect(locationStub.back).toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index 0e0195aaaa..e24676a646 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -8,7 +8,7 @@ import { FormGroup } from '@angular/forms'; import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; import { TranslateService } from '@ngx-translate/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.interfaces'; +import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; import { isNotEmpty } from '../../empty.util'; import { ResourceType } from '../../../core/shared/resource-type'; @@ -83,11 +83,14 @@ export class ComColFormComponent implements OnInit { onSubmit() { const formMetadata = new Object() as MetadataMap; this.formModel.forEach((fieldModel: DynamicInputModel) => { - const value: MetadataValue = { value: fieldModel.value as string, language: null }; + const value: MetadataValue = { + value: fieldModel.value as string, + language: null + } as any; if (formMetadata.hasOwnProperty(fieldModel.name)) { formMetadata[fieldModel.name].push(value); } else { - formMetadata[fieldModel.name] = [ value ]; + formMetadata[fieldModel.name] = [value]; } }); @@ -117,4 +120,8 @@ export class ComColFormComponent implements OnInit { } ); } + + onCancel() { + this.location.back(); + } } diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts index fd3464ba5e..4dad4a703f 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts @@ -11,13 +11,12 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { CreateComColPageComponent } from './create-comcol-page.component'; import { DataService } from '../../../core/data/data.service'; describe('CreateComColPageComponent', () => { - let comp: CreateComColPageComponent; - let fixture: ComponentFixture>; + let comp: CreateComColPageComponent; + let fixture: ComponentFixture>; let communityDataService: CommunityDataService; let dsoDataService: CommunityDataService; let routeService: RouteService; diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index fc7ee3ee70..c9fcfecb97 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -10,7 +10,6 @@ import { take } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DataService } from '../../../core/data/data.service'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; /** * Component representing the create page for communities and collections @@ -19,7 +18,7 @@ import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-ds selector: 'ds-create-comcol', template: '' }) -export class CreateComColPageComponent implements OnInit { +export class CreateComColPageComponent implements OnInit { /** * Frontend endpoint for this type of DSO */ @@ -36,7 +35,7 @@ export class CreateComColPageComponent>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: DataService, protected parentDataService: CommunityDataService, protected routeService: RouteService, protected router: Router diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts index c1444ec25b..3b39d36008 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts @@ -9,15 +9,14 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { DataService } from '../../../core/data/data.service'; import { DeleteComColPageComponent } from './delete-comcol-page.component'; import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; describe('DeleteComColPageComponent', () => { - let comp: DeleteComColPageComponent; - let fixture: ComponentFixture>; + let comp: DeleteComColPageComponent; + let fixture: ComponentFixture>; let dsoDataService: CommunityDataService; let router: Router; diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts index 6e3a826e87..e2e73bae14 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts @@ -1,13 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; -import { RouteService } from '../../services/route.service'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotUndefined } from '../../empty.util'; import { first, map } from 'rxjs/operators'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DataService } from '../../../core/data/data.service'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { NotificationsService } from '../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -19,7 +15,7 @@ import { TranslateService } from '@ngx-translate/core'; selector: 'ds-delete-comcol', template: '' }) -export class DeleteComColPageComponent implements OnInit { +export class DeleteComColPageComponent implements OnInit { /** * Frontend endpoint for this type of DSO */ @@ -30,7 +26,7 @@ export class DeleteComColPageComponent>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: DataService, protected router: Router, protected route: ActivatedRoute, protected notifications: NotificationsService, diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts index 88c11a0b4d..75b7fe40e7 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts @@ -10,13 +10,12 @@ import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { EditComColPageComponent } from './edit-comcol-page.component'; import { DataService } from '../../../core/data/data.service'; describe('EditComColPageComponent', () => { - let comp: EditComColPageComponent; - let fixture: ComponentFixture>; + let comp: EditComColPageComponent; + let fixture: ComponentFixture>; let dsoDataService: CommunityDataService; let router: Router; diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index b669fcea54..24181b5e61 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -6,7 +6,6 @@ import { isNotUndefined } from '../../empty.util'; import { first, map } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DataService } from '../../../core/data/data.service'; -import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; /** @@ -16,7 +15,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; selector: 'ds-edit-comcol', template: '' }) -export class EditComColPageComponent implements OnInit { +export class EditComColPageComponent implements OnInit { /** * Frontend endpoint for this type of DSO */ @@ -27,7 +26,7 @@ export class EditComColPageComponent>; public constructor( - protected dsoDataService: DataService, + protected dsoDataService: DataService, protected router: Router, protected route: ActivatedRoute ) { diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html index bbe090dac0..49e4c0a1d5 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.html +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -1,21 +1,22 @@ -
- + +
\ No newline at end of file diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss index bea74cf7af..f2587e1b6f 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.scss +++ b/src/app/shared/input-suggestions/input-suggestions.component.scss @@ -1,8 +1,11 @@ +@import "../../../styles/_variables.scss"; + .autocomplete { width: 100%; .dropdown-item { white-space: normal; word-break: break-word; + padding: $input-padding-y $input-padding-x; &:focus { outline: none; } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts index 8b6cdd2aa5..1f16a84b2c 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts @@ -77,7 +77,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the last element ', () => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(lastLink.nativeElement); }); @@ -103,7 +103,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the first element ', () => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(firstLink.nativeElement); }); @@ -117,7 +117,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second element', () => { - const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a')); + const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLink.nativeElement); }); @@ -126,7 +126,7 @@ describe('InputSuggestionsComponent', () => { describe('when the first element is in focus', () => { beforeEach(() => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); firstLink.nativeElement.focus(); comp.selectedIndex = 0; fixture.detectChanges(); @@ -140,7 +140,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the last element ', () => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(lastLink.nativeElement); }); @@ -153,7 +153,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second element ', () => { - const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a')); + const secondLink = de.query(By.css('.dropdown-list > div:nth-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLink.nativeElement); }); @@ -162,7 +162,7 @@ describe('InputSuggestionsComponent', () => { describe('when the last element is in focus', () => { beforeEach(() => { - const lastLink = de.query(By.css('.list-unstyled > li:last-child a')); + const lastLink = de.query(By.css('.dropdown-list > div:last-child a')); lastLink.nativeElement.focus(); comp.selectedIndex = suggestions.length - 1; fixture.detectChanges(); @@ -176,7 +176,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the second last element ', () => { - const secondLastLink = de.query(By.css('.list-unstyled > li:nth-last-child(2) a')); + const secondLastLink = de.query(By.css('.dropdown-list > div:nth-last-child(2) a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(secondLastLink.nativeElement); }); @@ -189,7 +189,7 @@ describe('InputSuggestionsComponent', () => { }); it('should put the focus on the first element ', () => { - const firstLink = de.query(By.css('.list-unstyled > li:first-child a')); + const firstLink = de.query(By.css('.dropdown-list > div:first-child a')); const activeElement = el.ownerDocument.activeElement; expect(activeElement).toEqual(firstLink.nativeElement); }); @@ -294,7 +294,7 @@ describe('InputSuggestionsComponent', () => { const clickedIndex = 0; beforeEach(() => { spyOn(comp, 'onClickSuggestion'); - const clickedLink = de.query(By.css('.list-unstyled > li:nth-child(' + (clickedIndex + 1) + ') a')); + const clickedLink = de.query(By.css('.dropdown-list > div:nth-child(' + (clickedIndex + 1) + ') a')); clickedLink.triggerEventHandler('click', {} ); fixture.detectChanges(); }); diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 8a8069d71e..727421c83e 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -1,29 +1,44 @@ import { Component, - ElementRef, EventEmitter, + ElementRef, + EventEmitter, + forwardRef, Input, + OnChanges, Output, - QueryList, SimpleChanges, + QueryList, + SimpleChanges, ViewChild, ViewChildren } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { hasValue, isNotEmpty } from '../empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../empty.util'; +import { InputSuggestion } from './input-suggestions.model'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ selector: 'ds-input-suggestions', styleUrls: ['./input-suggestions.component.scss'], - templateUrl: './input-suggestions.component.html' + templateUrl: './input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => InputSuggestionsComponent), + multi: true + } + ] }) /** * Component representing a form with a autocomplete functionality */ -export class InputSuggestionsComponent { +export class InputSuggestionsComponent implements ControlValueAccessor, OnChanges { /** * The suggestions that should be shown */ - @Input() suggestions: any[] = []; + @Input() suggestions: InputSuggestion[] = []; /** * The time waited to detect if any other input will follow before requesting the suggestions @@ -46,14 +61,9 @@ export class InputSuggestionsComponent { @Input() name; /** - * Value of the input field + * Whether or not the current input is valid */ - @Input() ngModel; - - /** - * Output for when the input field's value changes - */ - @Output() ngModelChange = new EventEmitter(); + @Input() valid = true; /** * Output for when the form is submitted @@ -65,6 +75,11 @@ export class InputSuggestionsComponent { */ @Output() clickSuggestion = new EventEmitter(); + /** + * Output for when something is typed in the input field + */ + @Output() typeSuggestion = new EventEmitter(); + /** * Output for when new suggestions should be requested */ @@ -94,12 +109,26 @@ export class InputSuggestionsComponent { */ @ViewChildren('suggestion') resultViews: QueryList; + /** + * Value of the input field + */ + _value: string; + + /** Fields needed to add ngModel */ + @Input() disabled = false; + propagateChange = (_: any) => { + /* Empty implementation */ + }; + propagateTouch = (_: any) => { + /* Empty implementation */ + }; + /** * When any of the inputs change, check if we should still show the suggestions */ ngOnChanges(changes: SimpleChanges) { if (hasValue(changes.suggestions)) { - this.show.next(isNotEmpty(changes.suggestions.currentValue)); + this.show.next(isNotEmpty(changes.suggestions.currentValue) && !changes.suggestions.firstChange); } } @@ -170,6 +199,7 @@ export class InputSuggestionsComponent { * Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field */ onClickSuggestion(data) { + this.value = data; this.clickSuggestion.emit(data); this.close(); this.blockReopen = true; @@ -184,8 +214,40 @@ export class InputSuggestionsComponent { find(data) { if (!this.blockReopen) { this.findSuggestions.emit(data); + this.typeSuggestion.emit(data); } this.blockReopen = false; } + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + /* START - Method's needed to add ngModel (ControlValueAccessor) to a component */ + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + this.propagateTouch = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + this.value = value; + } + + get value() { + return this._value; + } + + set value(val) { + this._value = val; + this.propagateChange(this._value); + } + /* END - Method's needed to add ngModel to a component */ } diff --git a/src/app/shared/input-suggestions/input-suggestions.model.ts b/src/app/shared/input-suggestions/input-suggestions.model.ts new file mode 100644 index 0000000000..1ccdbbe566 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.model.ts @@ -0,0 +1,14 @@ +/** + * Interface representing a single suggestion for the input suggestions component + */ +export interface InputSuggestion { + /** + * The displayed value of the suggestion + */ + displayValue: string, + + /** + * The actual value of the suggestion + */ + value: string +} diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts index b8a10da1c7..4a8d5fb912 100644 --- a/src/app/shared/notifications/notifications.service.ts +++ b/src/app/shared/notifications/notifications.service.ts @@ -29,27 +29,30 @@ export class NotificationsService { success(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Success, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Success, title, content, notificationOptions, html); this.add(notification); return notification; } error(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Error, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Error, title, content, notificationOptions, html); this.add(notification); return notification; } info(title: any = observableOf(''), content: any = observableOf(''), - options: NotificationOptions = this.getDefaultOptions(), + options: Partial = {}, html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Info, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Info, title, content, notificationOptions, html); this.add(notification); return notification; } @@ -58,7 +61,8 @@ export class NotificationsService { content: any = observableOf(''), options: NotificationOptions = this.getDefaultOptions(), html: boolean = false): INotification { - const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, options, html); + const notificationOptions = { ...this.getDefaultOptions(), ...options }; + const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, notificationOptions, html); this.add(notification); return notification; } diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index 0901b7b8cc..844d0bc165 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -6,7 +6,7 @@ import { AbstractListableElementComponent } from '../../object-collection/shared import { ListableObject } from '../../object-collection/shared/listable-object.model'; import { TruncatableService } from '../../truncatable/truncatable.service'; import { Observable } from 'rxjs'; -import { Metadata } from '../../../core/shared/metadata.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; @Component({ selector: 'ds-search-result-grid-element', diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index 2a16b0b754..525d39e798 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -6,7 +6,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ListableObject } from '../../object-collection/shared/listable-object.model'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { TruncatableService } from '../../truncatable/truncatable.service'; -import { Metadata } from '../../../core/shared/metadata.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; @Component({ selector: 'ds-search-result-list-element', diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index b7250a6e18..87a9198b8d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -94,6 +94,9 @@ import { CreateComColPageComponent } from './comcol-forms/create-comcol-page/cre import { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { LangSwitchComponent } from './lang-switch/lang-switch.component'; +import { ObjectValuesPipe } from './utils/object-values-pipe'; +import { InListValidator } from './utils/in-list-validator.directive'; +import { AutoFocusDirective } from './utils/auto-focus.directive'; import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; const MODULES = [ @@ -129,7 +132,8 @@ const PIPES = [ CapitalizePipe, ObjectKeysPipe, ConsolePipe, - ObjNgFor + ObjNgFor, + ObjectValuesPipe ]; const COMPONENTS = [ @@ -220,7 +224,9 @@ const DIRECTIVES = [ DragClickDirective, DebounceDirective, ClickOutsideDirective, - AuthorityConfidenceStateDirective + AuthorityConfidenceStateDirective, + InListValidator, + AutoFocusDirective ]; @NgModule({ diff --git a/src/app/shared/utils/auto-focus.directive.ts b/src/app/shared/utils/auto-focus.directive.ts new file mode 100644 index 0000000000..a2d860a8e1 --- /dev/null +++ b/src/app/shared/utils/auto-focus.directive.ts @@ -0,0 +1,28 @@ +import { Directive, AfterViewInit, ElementRef, Input } from '@angular/core'; +import { isNotEmpty } from '../empty.util'; + +/** + * Directive to set focus on an element when it is rendered + */ +@Directive({ + selector: '[dsAutoFocus]' +}) +export class AutoFocusDirective implements AfterViewInit { + + /** + * Optional input to specify which element in a component should get the focus + * If left empty, the component itself will get the focus + */ + @Input() autoFocusSelector: string = undefined; + + constructor(private el: ElementRef) { + } + + ngAfterViewInit() { + if (isNotEmpty(this.autoFocusSelector)) { + return this.el.nativeElement.querySelector(this.autoFocusSelector).focus(); + } else { + return this.el.nativeElement.focus(); + } + } +} diff --git a/src/app/shared/utils/click-outside.directive.ts b/src/app/shared/utils/click-outside.directive.ts index e8efdf2d7a..b9397c65e5 100644 --- a/src/app/shared/utils/click-outside.directive.ts +++ b/src/app/shared/utils/click-outside.directive.ts @@ -16,9 +16,11 @@ export class ClickOutsideDirective { constructor(private _elementRef: ElementRef) { } - @HostListener('document:click', ['$event.target']) - public onClick(targetElement) { - const clickedInside = this._elementRef.nativeElement.contains(targetElement); + @HostListener('document:click') + public onClick() { + const hostElement = this._elementRef.nativeElement; + const focusElement = hostElement.ownerDocument.activeElement; + const clickedInside = hostElement.contains(focusElement); if (!clickedInside) { this.dsClickOutside.emit(null); } diff --git a/src/app/shared/utils/debounce.directive.ts b/src/app/shared/utils/debounce.directive.ts index a84a2d379e..8830679e2b 100644 --- a/src/app/shared/utils/debounce.directive.ts +++ b/src/app/shared/utils/debounce.directive.ts @@ -25,11 +25,6 @@ export class DebounceDirective implements OnInit, OnDestroy { @Input() public dsDebounce = 500; - /** - * True if no changes have been made to the input field's value - */ - private isFirstChange = true; - /** * Subject to unsubscribe from */ @@ -46,11 +41,9 @@ export class DebounceDirective implements OnInit, OnDestroy { this.model.valueChanges.pipe( takeUntil(this.subject), debounceTime(this.dsDebounce), - distinctUntilChanged(),) + distinctUntilChanged()) .subscribe((modelValue) => { - if (this.isFirstChange) { - this.isFirstChange = false; - } else { + if (this.model.dirty) { this.onDebounce.emit(modelValue); } }); diff --git a/src/app/shared/utils/in-list-validator.directive.ts b/src/app/shared/utils/in-list-validator.directive.ts new file mode 100644 index 0000000000..42ff7da1fd --- /dev/null +++ b/src/app/shared/utils/in-list-validator.directive.ts @@ -0,0 +1,29 @@ +import { Directive, Input } from '@angular/core'; +import { FormControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; +import { inListValidator } from './validator.functions'; + +/** + * Directive for validating if a ngModel value is in a given list + */ +@Directive({ + selector: '[ngModel][dsInListValidator]', + // We add our directive to the list of existing validators + providers: [ + { provide: NG_VALIDATORS, useExisting: InListValidator, multi: true } + ] +}) +export class InListValidator implements Validator { + /** + * The list to look in + */ + @Input() + dsInListValidator: string[]; + + /** + * The function that checks if the form control's value is currently valid + * @param c The FormControl + */ + validate(c: FormControl): ValidationErrors | null { + return inListValidator(this.dsInListValidator)(c); + } +} diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts new file mode 100644 index 0000000000..79efd1cb76 --- /dev/null +++ b/src/app/shared/utils/object-values-pipe.ts @@ -0,0 +1,18 @@ +import { PipeTransform, Pipe } from '@angular/core'; + +@Pipe({name: 'dsObjectValues'}) +/** + * Pipe for parsing all values of an object to an array of values + */ +export class ObjectValuesPipe implements PipeTransform { + + /** + * @param value An object + * @returns {any} Array with all values of the input object + */ + transform(value, args:string[]): any { + const values = []; + Object.values(value).forEach((v) => values.push(v)); + return values; + } +} diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts new file mode 100644 index 0000000000..464a4f5487 --- /dev/null +++ b/src/app/shared/utils/validator.functions.ts @@ -0,0 +1,17 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { isNotEmpty } from '../empty.util'; + +/** + * Returns a validator function to check if the control's value is in a given list + * @param list The list to look in + */ +export function inListValidator(list: string[]): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => { + const hasValue = isNotEmpty(control.value); + let inList = true; + if (isNotEmpty(list)) { + inList = list.indexOf(control.value) > -1; + } + return (hasValue && inList) ? null : { inList: { value: control.value } } + }; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index b449a18be4..5bd2c27ae4 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -6,6 +6,7 @@ import { INotificationBoardOptions } from './notifications-config.interfaces'; import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; +import { ItemPageConfig } from './item-page-config.interface'; export interface GlobalConfig extends Config { ui: ServerConfig; @@ -21,4 +22,5 @@ export interface GlobalConfig extends Config { debug: boolean; defaultLanguage: string; languages: LangConfig[]; + item: ItemPageConfig; } diff --git a/src/config/item-page-config.interface.ts b/src/config/item-page-config.interface.ts new file mode 100644 index 0000000000..c76d2cdb01 --- /dev/null +++ b/src/config/item-page-config.interface.ts @@ -0,0 +1,7 @@ +import { Config } from './config.interface'; + +export interface ItemPageConfig extends Config { + edit: { + undoTimeout: number; + } +} diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 2739503284..7be76ff5d3 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -19,12 +19,11 @@ $gray-700: lighten($gray-base, 46.6%) !default; // #777 $gray-600: lighten($gray-base, 73.3%) !default; // #bbb $gray-100: lighten($gray-base, 93.5%) !default; // #eee - /* Reassign color vars to semantic color scheme */ $blue: #2B4E72 !default; $green: #94BA65 !default; $cyan: #2790B0 !default; -$yellow: #EBBB54 !default; +$yellow: #ec9433 !default; $red: #CF4444 !default; $dark: darken($blue, 17%) !default; @@ -56,3 +55,4 @@ $grid-breakpoints: ( xl: (1200px - $collapsed-sidebar-width) ) !default; +$yiq-contrasted-threshold: 165 !default; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 10282132c2..716002327a 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,6 +1,7 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); + $card-height-percentage:98%; $card-thumbnail-height:240px; $dropdown-menu-max-height: 200px; @@ -27,3 +28,7 @@ $dark-scrollbar-background: $admin-sidebar-active-bg; $dark-scrollbar-foreground: #47495d; $submission-sections-margin-bottom: .5rem !default; + +$edit-item-button-min-width: 100px; +$edit-item-metadata-field-width: 190px; +$edit-item-language-field-width: 43px;