From b1a25718584c41a2def85890d24309bff60dfee8 Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 25 Jan 2019 13:27:18 +0100 Subject: [PATCH 01/18] 59334: basic edit metadata implementation --- resources/i18n/en.json | 3 + .../edit-item-page.component.html | 2 +- .../edit-item-page/edit-item-page.module.ts | 6 +- .../edit-in-place-field.component.html | 42 ++++++++++++++ .../edit-in-place-field.component.scss | 3 + .../edit-in-place-field.component.ts | 41 +++++++++++++ .../item-metadata.component.html | 12 ++++ .../item-metadata/item-metadata.component.ts | 37 ++++++++++++ .../builders/remote-data-build.service.ts | 19 ++----- src/app/core/registry/registry.service.ts | 57 ++++++++++++++++++- src/app/core/shared/metadatum.model.ts | 1 - src/app/core/shared/operators.ts | 2 +- 12 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html create mode 100644 src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss create mode 100644 src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html create mode 100644 src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 95b39fdb15..f8aa8eddca 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -159,6 +159,9 @@ "cancel": "Cancel", "success": "The item has been deleted", "error": "An error occured while deleting the item" + }, + "metadata": { + "add-button": "Add new metadata" } } }, 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..46a8126b05 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 @@ -16,7 +16,7 @@ - + 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..adbcf6b0df 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 @@ -12,6 +12,8 @@ import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-s 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 { EditInPlaceComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -32,7 +34,9 @@ import {ItemDeleteComponent} from './item-delete/item-delete.component'; ItemPrivateComponent, ItemPublicComponent, ItemDeleteComponent, - ItemStatusComponent + ItemStatusComponent, + ItemMetadataComponent, + EditInPlaceComponent ] }) export class EditItemPageModule { 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..5a5e16383a --- /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,42 @@ + + + {{metadata.key}} + + + + + + + + + + + + + + +
+ {{metadata.value}} +
+
+ +
+ + +
+ {{metadata.language}} +
+
+ +
+ + +
+ + +
+
+ + +
+ 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..58c24635e6 --- /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,3 @@ +textarea, input, select { + width: 100%; +} \ 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.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..1139415ecc --- /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,41 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { Metadatum } from '../../../../core/shared/metadatum.model'; +import { RegistryService } from '../../../../core/registry/registry.service'; + +@Component({ + selector: 'ds-edit-in-place-field.', + styleUrls: ['./edit-in-place-field.component.scss'], + templateUrl: './edit-in-place-field.component.html', +}) +/** + * Component for displaying an item's status + */ +export class EditInPlaceComponent { + + /** + * The value to display + */ + @Input() metadata: Metadatum; + @Output() mdUpdate: EventEmitter = new EventEmitter(); + @Output() mdRemove: EventEmitter = new EventEmitter(); + editable = false; + + constructor( + private metadataFieldService: RegistryService, + ) { + + } + + isNotEmpty(value) { + return isNotEmpty(value); + } + + update() { + this.mdUpdate.emit(); + } + + remove() { + this.mdRemove.emit() + } +} 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..bf32358d37 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -0,0 +1,12 @@ +
+ +
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..cf9420658d --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -0,0 +1,37 @@ +import { Component, Input } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Metadatum } from '../../../core/shared/metadatum.model'; + +@Component({ + selector: 'ds-item-metadata', + templateUrl: './item-metadata.component.html', +}) +/** + * Component for displaying an item's status + */ +export class ItemMetadataComponent { + + /** + * The item to display the metadata for + */ + @Input() item: Item; + + constructor(private itemService: ItemDataService) { + + } + + update() { + this.itemService.update(this.item); + } + + removeMetadata(i: number) { + this.item.metadata = this.item.metadata.filter((metadatum: Metadatum, index: number) => index !== i); + this.update(); + } + + addMetadata() { + this.item.metadata = [new Metadatum(), ...this.item.metadata]; + this.update(); + } +} 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 62a4992787..96945cea81 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -5,15 +5,7 @@ import { race as observableRace } from 'rxjs'; import { Injectable } from '@angular/core'; -import { - distinctUntilChanged, - first, - flatMap, - map, - startWith, - switchMap, - take -} from 'rxjs/operators'; +import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; @@ -29,7 +21,8 @@ import { getMapsTo, getRelationMetadata, getRelationships } from './build-decora import { PageInfo } from '../../shared/page-info.model'; import { filterSuccessfulResponses, - getRequestFromRequestHref, getRequestFromRequestUUID, + getRequestFromRequestHref, + getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators'; @@ -51,8 +44,6 @@ export class RemoteDataBuildService { const requestEntry$ = observableRace( href$.pipe(getRequestFromRequestHref(this.requestService)), requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), - ).pipe( - take(1) ); // always use self link if that is cached, only if it isn't, get it via the response. @@ -94,8 +85,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)) { diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 6cd3a7fa28..4bd0dabb70 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -28,7 +28,7 @@ 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'; @@ -166,6 +166,41 @@ export class RegistryService { return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } + public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable>> { + if (hasNoValue(pagination)) { + pagination = { currentPage: 1, pageSize: Number.MAX_VALUE } 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); @@ -238,6 +273,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) => { diff --git a/src/app/core/shared/metadatum.model.ts b/src/app/core/shared/metadatum.model.ts index a3c5830608..ca1f3d8591 100644 --- a/src/app/core/shared/metadatum.model.ts +++ b/src/app/core/shared/metadatum.model.ts @@ -19,5 +19,4 @@ export class Metadatum { */ @autoserialize value: string; - } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 6fe510c1aa..4540bed0c4 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 getAllSucceededRemoteData = () => (source: Observable>): Observable> => From 58b45801b77090ef9d1f4edf8b6c7ffc36112197 Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 25 Jan 2019 16:40:37 +0100 Subject: [PATCH 02/18] 59334: added reducer for editing items --- .../item-metadata/item-metadata.component.ts | 2 +- .../models/normalized-collection.model.ts | 10 +- .../models/normalized-dspace-object.model.ts | 2 +- .../cache/models/normalized-item.model.ts | 10 +- src/app/core/data/data.service.ts | 2 +- .../core/data/metadata-schema-data.service.ts | 11 +- .../object-updates/object-updates.actions.ts | 101 ++++++++++++++++ .../object-updates/object-updates.reducer.ts | 112 ++++++++++++++++++ src/app/shared/utils/debounce.directive.ts | 1 + 9 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 src/app/core/data/object-updates/object-updates.actions.ts create mode 100644 src/app/core/data/object-updates/object-updates.reducer.ts 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 index cf9420658d..614f342b26 100644 --- 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 @@ -22,7 +22,7 @@ export class ItemMetadataComponent { } update() { - this.itemService.update(this.item); + this.itemService.update(this.item).subscribe(); } removeMetadata(i: number) { diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts index a2c634c3e5..07e741a88b 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'; @@ -21,28 +21,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-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts index efdfa6dd74..030e17364a 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -18,7 +18,7 @@ export class NormalizedDSpaceObject extends NormalizedObject { * Repeated here to make the serialization work, * inheritSerialization doesn't seem to work for more than one level */ - @autoserialize + @deserialize self: 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..3065e37796 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'; @@ -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/data/data.service.ts b/src/app/core/data/data.service.ts index 440013c31c..6d1359d833 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -139,7 +139,7 @@ export abstract class DataService> { const oldVersion$ = this.objectCache.getBySelfLink(object.self); - return oldVersion$.pipe(first(), mergeMap((oldVersion: TNormalized) => { + return oldVersion$.pipe(take(1), mergeMap((oldVersion: TNormalized) => { const newVersion = this.dataBuildService.normalize(object); const operations = this.comparator.diff(oldVersion, newVersion); if (isNotEmpty(operations)) { diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index d568bd01f7..8dd0c5d24f 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -12,6 +12,10 @@ import { FindAllOptions, GetRequest, RestRequest } 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 { 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'; @Injectable() export class MetadataSchemaDataService extends DataService { @@ -23,7 +27,11 @@ export class MetadataSchemaDataService extends DataService, private bs: BrowseService, protected halService: HALEndpointService, - protected objectCache: ObjectCacheService) { + protected objectCache: ObjectCacheService, + protected comparator: ChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected http: HttpClient, + protected notificationsService: NotificationsService) { super(); } @@ -37,4 +45,5 @@ export class MetadataSchemaDataService extends DataService Date: Tue, 29 Jan 2019 16:03:50 +0100 Subject: [PATCH 03/18] got rid of TNormalized everywhere --- .../create-collection-page.component.ts | 3 +- .../delete-collection-page.component.ts | 6 +- .../edit-collection-page.component.ts | 2 +- .../create-community-page.component.ts | 3 +- .../delete-community-page.component.ts | 3 +- .../edit-community-page.component.ts | 3 +- .../item-metadata.component.html | 4 +- .../item-metadata/item-metadata.component.ts | 29 ++++--- src/app/core/auth/auth-object-factory.ts | 4 +- src/app/core/auth/auth.service.ts | 4 +- .../models/normalized-auth-status.model.ts | 2 +- src/app/core/auth/server-auth.service.ts | 3 +- .../normalized-object-build.service.ts | 2 +- .../builders/remote-data-build.service.ts | 19 ++-- .../normalized-bitstream-format.model.ts | 2 +- .../models/normalized-bitstream.model.ts | 2 +- .../cache/models/normalized-bundle.model.ts | 2 +- .../models/normalized-collection.model.ts | 2 +- .../models/normalized-community.model.ts | 2 +- .../models/normalized-dspace-object.model.ts | 2 +- .../cache/models/normalized-item.model.ts | 2 +- .../cache/models/normalized-object-factory.ts | 3 +- .../cache/models/normalized-object.model.ts | 2 +- .../normalized-resource-policy.model.ts | 2 +- src/app/core/cache/object-cache.service.ts | 10 +-- src/app/core/data/change-analyzer.ts | 5 +- src/app/core/data/collection-data.service.ts | 4 +- src/app/core/data/comcol-data.service.spec.ts | 8 +- src/app/core/data/comcol-data.service.ts | 17 +--- src/app/core/data/community-data.service.ts | 6 +- src/app/core/data/data.service.spec.ts | 5 +- src/app/core/data/data.service.ts | 29 +++---- .../core/data/dso-change-analyzer.service.ts | 5 +- .../core/data/dso-response-parsing.service.ts | 3 +- .../core/data/dspace-object-data.service.ts | 7 +- src/app/core/data/item-data.service.ts | 8 +- .../core/data/metadata-schema-data.service.ts | 8 +- .../object-updates/object-updates.actions.ts | 60 +++++++++---- .../object-updates/object-updates.reducer.ts | 87 ++++++++++++------- src/app/core/eperson/models/eperson.model.ts | 3 + .../models/normalized-eperson.model.ts | 2 +- .../eperson/models/normalized-group.model.ts | 2 +- .../normalized-metadata-schema.model.ts | 2 +- .../auth-nav-menu.component.html | 2 +- .../create-comcol-page.component.spec.ts | 5 +- .../create-comcol-page.component.ts | 5 +- .../delete-comcol-page.component.spec.ts | 5 +- .../delete-comcol-page.component.ts | 8 +- .../edit-comcol-page.component.spec.ts | 5 +- .../edit-comcol-page.component.ts | 5 +- 50 files changed, 223 insertions(+), 191 deletions(-) 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/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html index bf32358d37..f1c45ddda7 100644 --- 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 @@ -1,10 +1,10 @@
\ 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.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 8a8069d71e..b9f068b30f 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -1,6 +1,6 @@ import { Component, - ElementRef, EventEmitter, + ElementRef, EventEmitter, forwardRef, Input, Output, QueryList, SimpleChanges, @@ -9,21 +9,30 @@ import { } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { hasValue, isNotEmpty } 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, + useExisting: forwardRef(() => InputSuggestionsComponent), + multi: true + } + ] }) /** * Component representing a form with a autocomplete functionality */ -export class InputSuggestionsComponent { +export class InputSuggestionsComponent implements ControlValueAccessor { /** * 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 @@ -45,16 +54,6 @@ export class InputSuggestionsComponent { */ @Input() name; - /** - * Value of the input field - */ - @Input() ngModel; - - /** - * Output for when the input field's value changes - */ - @Output() ngModelChange = new EventEmitter(); - /** * Output for when the form is submitted */ @@ -94,6 +93,15 @@ export class InputSuggestionsComponent { */ @ViewChildren('suggestion') resultViews: QueryList; + /** + * Value of the input field + */ + _value: string; + + propagateChange = (_: any) => { + /* Empty implementation */ + }; + /** * When any of the inputs change, check if we should still show the suggestions */ @@ -170,6 +178,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; @@ -188,4 +197,31 @@ export class InputSuggestionsComponent { this.blockReopen = false; } + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + } + + writeValue(value: any): void { + this.value = value; + } + + get value() { + return this._value; + } + + set value(val) { + this._value = val; + this.propagateChange(this._value); + } } 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..34ab769587 --- /dev/null +++ b/src/app/shared/input-suggestions/input-suggestions.model.ts @@ -0,0 +1,4 @@ +export interface InputSuggestion { + displayValue: string, + value: string +} diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts index 55df6a4f7f..bb57aebd78 100644 --- a/src/app/shared/notifications/notifications.service.ts +++ b/src/app/shared/notifications/notifications.service.ts @@ -27,27 +27,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; } @@ -56,7 +59,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/shared.module.ts b/src/app/shared/shared.module.ts index 53cf15ab6e..c3e7051304 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -91,6 +91,7 @@ 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'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -124,6 +125,7 @@ const PIPES = [ EmphasizePipe, CapitalizePipe, ObjectKeysPipe, + ObjectValuesPipe, ConsolePipe ]; 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/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 2739503284..d24811b382 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -24,7 +24,7 @@ $gray-100: lighten($gray-base, 93.5%) !default; // #eee $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 +56,4 @@ $grid-breakpoints: ( xl: (1200px - $collapsed-sidebar-width) ) !default; +$yiq-contrasted-threshold: 165 !default; From 643c0d6e1a382c8b03ac6e4de5511b5b209a1780 Mon Sep 17 00:00:00 2001 From: lotte Date: Mon, 11 Feb 2019 15:45:41 +0100 Subject: [PATCH 05/18] finished tests for item metadata updates --- .../edit-item-page/edit-item-page.module.ts | 28 +- .../edit-in-place-field.component.spec.ts | 142 ++++++++++ .../edit-in-place-field.component.ts | 4 +- .../item-metadata.component.spec.ts | 5 +- .../item-metadata/item-metadata.component.ts | 3 +- src/app/core/data/data.service.ts | 9 + .../object-updates/object-updates.actions.ts | 77 +++++- .../object-updates.effects.spec.ts | 113 ++++++-- .../object-updates/object-updates.effects.ts | 78 ++++-- .../object-updates.reducer.spec.ts | 256 ++++++++++++++++++ .../object-updates/object-updates.reducer.ts | 84 ++++-- .../object-updates.service.spec.ts | 233 ++++++++++++++++ .../object-updates/object-updates.service.ts | 86 +++++- .../input-suggestions.component.html | 2 +- .../input-suggestions.component.spec.ts | 20 +- .../input-suggestions.component.ts | 2 + 16 files changed, 1037 insertions(+), 105 deletions(-) create mode 100644 src/app/core/data/object-updates/object-updates.reducer.spec.ts create mode 100644 src/app/core/data/object-updates/object-updates.service.spec.ts 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 1c0c747ffb..98904517f9 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,17 @@ -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'; 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 index 0d7006b85c..aad94931dd 100644 --- 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 @@ -105,6 +105,36 @@ describe('EditInPlaceFieldComponent', () => { }); }); + describe('changeType is UPDATE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.UPDATE; + fixture.detectChanges(); + }); + it('the div should have class table-warning', () => { + expect(el.classList).toContain('table-warning'); + }); + }); + + describe('changeType is ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + fixture.detectChanges(); + }); + it('the div should have class table-success', () => { + expect(el.classList).toContain('table-success'); + }); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class table-danger', () => { + expect(el.classList).toContain('table-danger'); + }); + }); + describe('setEditable', () => { const editable = false; beforeEach(() => { @@ -116,6 +146,30 @@ describe('EditInPlaceFieldComponent', () => { }); }); + 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('remove', () => { beforeEach(() => { comp.remove(); @@ -225,10 +279,98 @@ describe('EditInPlaceFieldComponent', () => { }); }); + describe('when canSetEditable emits true', () => { + beforeEach(() => { + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); + }); + it('the div should contain a edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')); + expect(editIcon).not.toBeNull(); + }); + }); + + describe('when canSetEditable emits false', () => { + beforeEach(() => { + spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')); + expect(editIcon).toBeNull(); + }); + }); + + describe('when canSetUneditable emits true', () => { + beforeEach(() => { + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should contain a check icon', () => { + const checkIcon = de.query(By.css('i.fa-check')); + expect(checkIcon).not.toBeNull(); + }); + }); + + describe('when canSetUneditable emits false', () => { + beforeEach(() => { + spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a check icon', () => { + const checkIcon = de.query(By.css('i.fa-check')); + expect(checkIcon).toBeNull(); + }); + }); + + describe('when canRemove emits true', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should contain a trash icon', () => { + const trashIcon = de.query(By.css('i.fa-trash-alt')); + expect(trashIcon).not.toBeNull(); + }); + }); + + describe('when canRemove emits false', () => { + beforeEach(() => { + spyOn(comp, 'canRemove').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a trash icon', () => { + const trashIcon = de.query(By.css('i.fa-trash-alt')); + expect(trashIcon).toBeNull(); + }); + }); + + describe('when canUndo emits true', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + it('the div should contain a undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')); + expect(undoIcon).not.toBeNull(); + }); + }); + + describe('when canUndo emits false', () => { + beforeEach(() => { + spyOn(comp, 'canUndo').and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + it('the div should not contain a undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')); + expect(undoIcon).toBeNull(); + }); + }); + describe('canRemove', () => { describe('when editable is currently true', () => { beforeEach(() => { comp.editable = observableOf(true); + fixture.detectChanges(); }); it('canRemove should return an observable emitting false', () => { const expected = '(a|)'; 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 index 8902125985..38470c54c4 100644 --- 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 @@ -3,14 +3,14 @@ import { isNotEmpty } from '../../../../shared/empty.util'; import { Metadatum } from '../../../../core/shared/metadatum.model'; import { RegistryService } from '../../../../core/registry/registry.service'; import { cloneDeep } from 'lodash'; -import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { BehaviorSubject, Observable } 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 { of as observableOf } from 'rxjs'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; @Component({ selector: 'ds-edit-in-place-field', 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 index bbeda39716..b6c53fdbf1 100644 --- 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 @@ -9,7 +9,7 @@ import { SharedModule } from '../../../shared/shared.module'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TranslateModule } from '@ngx-translate/core'; import { ItemDataService } from '../../../core/data/item-data.service'; import { By } from '@angular/platform-browser'; import { @@ -81,7 +81,8 @@ describe('ItemMetadataComponent', () => { beforeEach(async(() => { item = Object.assign(new Item(), { metadata: [metadatum1, metadatum2, metadatum3] }, { lastModified: date }); itemService = jasmine.createSpyObj('itemService', { - update: observableOf(new RemoteData(false, false, true, undefined, item)) + update: observableOf(new RemoteData(false, false, true, undefined, item)), + commitUpdates: {} }); scheduler = getTestScheduler(); objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', 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 index 6c7b310ce2..d2231e615d 100644 --- 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 @@ -11,7 +11,7 @@ import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer'; import { Metadatum } from '../../../core/shared/metadatum.model'; -import { first, switchMap } from 'rxjs/operators'; +import { first, switchMap, tap } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -121,6 +121,7 @@ export class ItemMetadataComponent implements OnInit { const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); return this.itemService.update(updatedItem); }), + tap(() => this.itemService.commitUpdates()), getSucceededRemoteData() ).subscribe( (rd: RemoteData) => { diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ac56436539..59605da4d8 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -41,6 +41,7 @@ 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 { protected abstract requestService: RequestService; @@ -228,4 +229,12 @@ export abstract class DataService { ); } + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod) { + this.requestService.commit(method); + } + } diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 96b85b9179..5f76d6dde9 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -2,6 +2,9 @@ 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'), @@ -14,12 +17,19 @@ export const ObjectUpdatesActionTypes = { }; /* 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: { @@ -28,6 +38,14 @@ export class InitializeFieldsAction implements Action { 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[], @@ -37,6 +55,9 @@ export class InitializeFieldsAction implements Action { } } +/** + * 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: { @@ -45,6 +66,14 @@ export class AddFieldUpdateAction implements Action { 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, @@ -53,6 +82,9 @@ export class AddFieldUpdateAction implements Action { } } +/** + * 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: { @@ -61,6 +93,14 @@ export class SetEditableFieldUpdateAction implements Action { 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, @@ -69,13 +109,23 @@ export class SetEditableFieldUpdateAction implements Action { } } +/** + * 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 + 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 @@ -84,12 +134,21 @@ export class DiscardObjectUpdatesAction implements Action { } } +/** + * 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 ) { @@ -97,12 +156,21 @@ export class ReinstateObjectUpdatesAction implements Action { } } +/** + * 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 ) { @@ -117,6 +185,13 @@ export class RemoveFieldUpdateAction implements Action { 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 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 index a81a0665bf..79b1b2df72 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -1,55 +1,122 @@ -import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; +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 { RemoveObjectUpdatesAction } from './object-updates.actions'; +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'; -fdescribe('ObjectUpdatesEffects', () => { +describe('ObjectUpdatesEffects', () => { let updatesEffects: ObjectUpdatesEffects; let actions: Observable; - const testURL = 'www.dspace.org/dspace7'; - beforeEach(() => { + let testURL = 'www.dspace.org/dspace7'; + let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + beforeEach(async(() => { TestBed.configureTestingModule({ providers: [ ObjectUpdatesEffects, provideMockActions(() => actions), { - provide: NotificationsService, useClass: { + provide: NotificationsService, + useValue: { remove: (notification) => { /* empty */ } } }, - // other providers ], }); + })); + 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', () => { - const action = new RemoveObjectUpdatesAction(testURL); + 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 }); - const expected = cold('--b-', { b: action }); - - expect((updatesEffects as any).actionMap[testURL]).toBeObservable(expected); + expect(updatesEffects.mapLastActions$).toBeObservable(expected); + expect(emittedAction).toBe(action); }); }); }); - // describe('removeAfterDiscardOrReinstateOnUndo$', () => { - // - // it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => { - // actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } }); - // - // const expected = cold('--b-', { b: new CollapseMenuAction(MenuID.PUBLIC) }); - // - // expect(updatesEffects.routeChange$).toBeObservable(expected); - // }); - // - // }); + 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 index f89700f6fe..ae49071dc1 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -3,15 +3,23 @@ import { Actions, Effect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, - ObjectUpdatesActionTypes + ObjectUpdatesActionTypes, + RemoveObjectUpdatesAction } from './object-updates.actions'; -import { map } from 'rxjs/operators'; -import { Subject } from 'rxjs'; +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 @@ -20,6 +28,9 @@ export class ObjectUpdatesEffects { [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)), @@ -33,31 +44,44 @@ export class ObjectUpdatesEffects { ) ); - // @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 reinstate action and perform no action - // this.actionMap[url].pipe( - // // filter((updateAction: ObjectUpdatesAction) => updateAction.type === ObjectUpdatesActionTypes.REINSTATE), - // tap(() => this.notificationsService.remove(notification)), - // map(() => { - // return { type: 'NO_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) { - 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..377b3ab1b4 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -0,0 +1,256 @@ +import * as deepFreeze from 'deep-freeze'; +import { + AddFieldUpdateAction, + DiscardObjectUpdatesAction, + FieldChangeType, + InitializeFieldsAction, + ReinstateObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction +} 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 + }, + [identifiable2.uuid]: { + editable: false, + isNew: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.title', + language: null, + value: 'New title' + }, + changeType: FieldChangeType.ADD + } + }, + lastModified: modDate + } + }; + + const discardedTestState = { + [url]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false + }, + [identifiable2.uuid]: { + editable: false, + isNew: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + lastModified: modDate + }, + [url + OBJECT_UPDATES_TRASH_PATH]: { + fieldStates: { + [identifiable1.uuid]: { + editable: true, + isNew: false + }, + [identifiable2.uuid]: { + editable: false, + isNew: true + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + fieldUpdates: { + [identifiable2.uuid]: { + field: { + uuid: identifiable2.uuid, + key: 'dc.title', + 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 + }, + [identifiable3.uuid]: { + editable: false, + isNew: false + }, + }, + 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 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 index 4345373bfa..c46438bd90 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -52,45 +52,45 @@ const initialNewFieldState = { editable: true, isNew: true }; // 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 { - let newState = state; switch (action.type) { case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { - newState = initializeFieldsUpdate(state, action as InitializeFieldsAction); - break; + return initializeFieldsUpdate(state, action as InitializeFieldsAction); } case ObjectUpdatesActionTypes.ADD_FIELD: { - newState = addFieldUpdate(state, action as AddFieldUpdateAction); - break; + return addFieldUpdate(state, action as AddFieldUpdateAction); } case ObjectUpdatesActionTypes.DISCARD: { - newState = discardObjectUpdates(state, action as DiscardObjectUpdatesAction); - break; + return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); } case ObjectUpdatesActionTypes.REINSTATE: { - newState = reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); - break; + return reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction); } case ObjectUpdatesActionTypes.REMOVE: { - newState = removeObjectUpdates(state, action as RemoveObjectUpdatesAction); - break; + return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); } case ObjectUpdatesActionTypes.REMOVE_FIELD: { - newState = removeFieldUpdate(state, action as RemoveFieldUpdateAction); - break; + return removeFieldUpdate(state, action as RemoveFieldUpdateAction); } case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: { - // return directly, no need to change the lastModified date return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction); } default: { return state; } } - // return setUpdated(newState, action.payload.url); - return newState; } +/** + * 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; @@ -101,11 +101,16 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, - { lastServerUpdate: lastModifiedServer } + { 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; @@ -130,6 +135,11 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { 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]; @@ -149,6 +159,11 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { 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]; @@ -158,17 +173,32 @@ function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction 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; @@ -196,11 +226,12 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { return Object.assign({}, state, { [url]: newPageState }); } -function setUpdated(state: any, url: string) { - const newPageState = Object.assign({}, state[url] || {}, { lastUpdated: Date.now() }); - 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; @@ -211,6 +242,11 @@ function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType) return oldType.valueOf() > newType.valueOf() ? oldType : newType; } +/** + * Set the 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; @@ -228,6 +264,10 @@ function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction 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 = {}; 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..4f0393c641 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -0,0 +1,233 @@ +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 }, + [identifiable2.uuid]: { editable: true, isNew: false }, + [identifiable3.uuid]: { editable: true, isNew: 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, '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).getObjectEntry).toHaveBeenCalledWith(url); + 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).getObjectEntry).toHaveBeenCalledWith(url); + 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 index 2f3e31eb7c..4b6c7def0d 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -29,24 +29,49 @@ function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector state[url]); } +/** + * 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))); } + /** + * 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) => { @@ -63,6 +88,11 @@ export class ObjectUpdatesService { })) } + /** + * 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 objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe( @@ -72,34 +102,74 @@ export class ObjectUpdatesService { ) } + /** + * 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)); } + /** + * 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) => { @@ -120,14 +190,26 @@ export class ObjectUpdatesService { })) } + /** + * 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))); } - isReinstatable(route: string): Observable { - return this.hasUpdates(route + OBJECT_UPDATES_TRASH_PATH) + /** + * 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/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html index 2ee80e576a..b620f4b79a 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.html +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -10,7 +10,7 @@ [ngModelOptions]="{standalone: true}" autocomplete="off"/>