From b1a25718584c41a2def85890d24309bff60dfee8 Mon Sep 17 00:00:00 2001 From: lotte Date: Fri, 25 Jan 2019 13:27:18 +0100 Subject: [PATCH 01/63] 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/63] 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/63] 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 30c0bd8ebbf853beb6b1a99e271dfcd98d41e7d3 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 11 Feb 2019 13:11:47 +0100 Subject: [PATCH 05/63] 59695: Browse by date base --- resources/i18n/en.json | 3 +- .../browse-by-date-page.component.html | 11 ++++++ .../browse-by-date-page.component.scss | 0 .../browse-by-date-page.component.ts | 39 +++++++++++++++++++ .../browse-by-metadata-page.component.ts | 9 ++--- .../+browse-by/browse-by-routing.module.ts | 2 + src/app/+browse-by/browse-by.module.ts | 4 +- .../browse-entry-search-options.model.ts | 1 + src/app/core/browse/browse.service.ts | 3 ++ src/app/core/shared/operators.ts | 2 +- 10 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.html create mode 100644 src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.scss create mode 100644 src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 954a8ca087..b4a421baf4 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -284,7 +284,8 @@ "metadata": { "title": "Title", "author": "Author", - "subject": "Subject" + "subject": "Subject", + "dateissued": "Issue Date" }, "comcol": { "head": "Browse", diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.html b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.html new file mode 100644 index 0000000000..0ba72e2e14 --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.html @@ -0,0 +1,11 @@ +
+ +
+ diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.scss b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts new file mode 100644 index 0000000000..cf3e189a57 --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -0,0 +1,39 @@ +import { Component } from '@angular/core'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; + +@Component({ + selector: 'ds-browse-by-date-page', + styleUrls: ['./browse-by-date-page.component.scss'], + templateUrl: './browse-by-date-page.component.html' +}) +/** + * Component for browsing items by metadata definition of type 'date' + * A metadata definition is a short term used to describe one or multiple metadata fields. + * An example would be 'dateissued' for 'dc.date.issued' + */ +export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { + + ngOnInit(): void { + this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); + }) + .subscribe((params) => { + this.metadata = params.metadata || this.defaultMetadata; + const searchOptions = browseParamsToOptions(params, Object.assign({}), this.sortConfig, this.metadata); + this.updatePageWithItems(searchOptions, this.value); + this.updateParent(params.scope); + })); + } + +} diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index 87ccb20c0b..310c5b509e 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -10,8 +10,6 @@ import { BrowseService } from '../../core/browse/browse.service'; import { BrowseEntry } from '../../core/shared/browse-entry.model'; import { Item } from '../../core/shared/item.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { Community } from '../../core/shared/community.model'; -import { Collection } from '../../core/shared/collection.model'; import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; @@ -79,9 +77,9 @@ export class BrowseByMetadataPageComponent implements OnInit { */ value = ''; - public constructor(private route: ActivatedRoute, - private browseService: BrowseService, - private dsoService: DSpaceObjectDataService) { + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService) { } ngOnInit(): void { @@ -177,6 +175,7 @@ export function browseParamsToOptions(params: any, field: params.sortField || sortConfig.field } ), + +params.startsWith || params.startsWith, params.scope ); } diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index 38915fffca..9295209d11 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -2,11 +2,13 @@ import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; @NgModule({ imports: [ RouterModule.forChild([ { path: 'title', component: BrowseByTitlePageComponent }, + { path: 'dateissued', component: BrowseByDatePageComponent, data: { metadata: 'dateissued' } }, { path: ':metadata', component: BrowseByMetadataPageComponent } ]) ] diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts index 38e5001b80..706d20d12e 100644 --- a/src/app/+browse-by/browse-by.module.ts +++ b/src/app/+browse-by/browse-by.module.ts @@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module'; import { BrowseByRoutingModule } from './browse-by-routing.module'; import { BrowseService } from '../core/browse/browse.service'; import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse-by-metadata-page.component'; +import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date-page.component'; @NgModule({ imports: [ @@ -15,7 +16,8 @@ import { BrowseByMetadataPageComponent } from './+browse-by-metadata-page/browse ], declarations: [ BrowseByTitlePageComponent, - BrowseByMetadataPageComponent + BrowseByMetadataPageComponent, + BrowseByDatePageComponent ], providers: [ ItemDataService, diff --git a/src/app/core/browse/browse-entry-search-options.model.ts b/src/app/core/browse/browse-entry-search-options.model.ts index a4911a33f1..417bf7ce75 100644 --- a/src/app/core/browse/browse-entry-search-options.model.ts +++ b/src/app/core/browse/browse-entry-search-options.model.ts @@ -12,6 +12,7 @@ export class BrowseEntrySearchOptions { constructor(public metadataDefinition: string, public pagination?: PaginationComponentOptions, public sort?: SortOptions, + public startsWith?: string, public scope?: string) { } } diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 815570f348..56ef72e6b7 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -161,6 +161,9 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(filterValue)) { args.push(`filterValue=${filterValue}`); } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 5434a4f04c..a9294b2fc9 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -78,7 +78,7 @@ export const getBrowseDefinitionLinks = (definitionID: string) => source.pipe( getRemoteDataPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + .find((def: BrowseDefinition) => def.id === definitionID) ), map((def: BrowseDefinition) => { if (isNotEmpty(def)) { From 643c0d6e1a382c8b03ac6e4de5511b5b209a1780 Mon Sep 17 00:00:00 2001 From: lotte Date: Mon, 11 Feb 2019 15:45:41 +0100 Subject: [PATCH 06/63] 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"/> diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index 310c5b509e..ed7029bd2d 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -4,7 +4,7 @@ import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { BrowseService } from '../../core/browse/browse.service'; import { BrowseEntry } from '../../core/shared/browse-entry.model'; @@ -13,6 +13,7 @@ import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search- import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { take } from 'rxjs/operators'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -77,9 +78,12 @@ export class BrowseByMetadataPageComponent implements OnInit { */ value = ''; + startsWith: string; + public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, - protected dsoService: DSpaceObjectDataService) { + protected dsoService: DSpaceObjectDataService, + protected router: Router) { } ngOnInit(): void { @@ -94,6 +98,7 @@ export class BrowseByMetadataPageComponent implements OnInit { .subscribe((params) => { this.metadata = params.metadata || this.defaultMetadata; this.value = +params.value || params.value || ''; + this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata); if (isNotEmpty(this.value)) { this.updatePageWithItems(searchOptions, this.value); @@ -142,6 +147,32 @@ export class BrowseByMetadataPageComponent implements OnInit { } } + goPrev() { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getPrevBrowseItems(items); + }); + } + + goNext() { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getNextBrowseItems(items); + }); + } + + pageSizeChange(size) { + this.router.navigate([], { + queryParams: Object.assign({ pageSize: size }), + queryParamsHandling: 'merge' + }); + } + + sortDirectionChange(direction) { + this.router.navigate([], { + queryParams: Object.assign({ sortDirection: direction }), + queryParamsHandling: 'merge' + }); + } + ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 51c59cdf5f..94c13c7d05 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -108,6 +108,9 @@ export class BrowseService { args.push(`page=${options.pagination.currentPage - 1}`); args.push(`size=${options.pagination.pageSize}`); } + if (isNotEmpty(options.startsWith)) { + args.push(`startsWith=${options.startsWith}`); + } if (isNotEmpty(args)) { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } From b956dbfe08b2a2c9e41bbab4df7213c42fc9fed4 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 11 Feb 2019 17:30:05 +0100 Subject: [PATCH 10/63] 59695: Browse-By-Starts-With components and switcher --- .../browse-by-date-page.component.ts | 2 ++ .../browse-by-metadata-page.component.html | 2 ++ .../browse-by-metadata-page.component.ts | 5 +++ ...rowse-by-starts-with-abstract.component.ts | 6 ++++ .../browse-by-starts-with-decorator.ts | 16 ++++++++++ .../browse-by-starts-with-date.component.html | 4 +++ .../browse-by-starts-with-date.component.scss | 0 .../browse-by-starts-with-date.component.ts | 14 +++++++++ .../browse-by-starts-with-text.component.html | 4 +++ .../browse-by-starts-with-text.component.scss | 0 .../browse-by-starts-with-text.component.ts | 14 +++++++++ .../shared/browse-by/browse-by.component.html | 1 + .../shared/browse-by/browse-by.component.ts | 31 +++++++++++++++++-- src/app/shared/shared.module.ts | 6 +++- 14 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts create mode 100644 src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts create mode 100644 src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html create mode 100644 src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss create mode 100644 src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts create mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html create mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss create mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index dd7380f226..3856d45da0 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -5,6 +5,7 @@ import { } from '../+browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; @Component({ selector: 'ds-browse-by-date-page', @@ -19,6 +20,7 @@ import { combineLatest as observableCombineLatest } from 'rxjs/internal/observab export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { ngOnInit(): void { + this.startsWithType = BrowseByStartsWithType.date; this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html index c4ad1037ed..1cf5c51d7c 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -5,6 +5,8 @@ [objects$]="(items$ !== undefined)? items$ : browseEntries$" [paginationConfig]="paginationConfig" [sortConfig]="sortConfig" + [type]="startsWithType" + [startsWithOptions]="startsWithOptions" [enableArrows]="startsWith" (prev)="goPrev()" (next)="goNext()" diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index ed7029bd2d..7b4e34cf6d 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -14,6 +14,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { take } from 'rxjs/operators'; +import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -71,6 +72,10 @@ export class BrowseByMetadataPageComponent implements OnInit { */ metadata = this.defaultMetadata; + startsWithType = BrowseByStartsWithType.text; + + startsWithOptions = []; + /** * The value we're browing items for * - When the value is not empty, we're browsing items diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts new file mode 100644 index 0000000000..c8651dba21 --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts @@ -0,0 +1,6 @@ +import { Inject } from '@angular/core'; + +export class BrowseByStartsWithAbstractComponent { + public constructor(@Inject('startsWithOptions') public startsWithOptions: any[]) { + } +} diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts new file mode 100644 index 0000000000..eb16254100 --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts @@ -0,0 +1,16 @@ +import { BrowseByStartsWithType } from '../browse-by.component'; + +const startsWithMap = new Map(); + +export function renderStartsWithFor(type: BrowseByStartsWithType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + startsWithMap.set(type, objectElement); + }; +} + +export function getStartsWithComponent(type: BrowseByStartsWithType) { + return startsWithMap.get(type); +} diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html new file mode 100644 index 0000000000..c2221ed257 --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html @@ -0,0 +1,4 @@ +

Test Starts-With Date

+

+ {{element}}, +

diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts new file mode 100644 index 0000000000..b527ef77fb --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; +import { BrowseByStartsWithType } from '../../browse-by.component'; +import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; + +@Component({ + selector: 'ds-browse-by-starts-with-date', + styleUrls: ['./browse-by-starts-with-date.component.scss'], + templateUrl: './browse-by-starts-with-date.component.html' +}) +@renderStartsWithFor(BrowseByStartsWithType.date) +export class BrowseByStartsWithDateComponent extends BrowseByStartsWithAbstractComponent { + +} diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html new file mode 100644 index 0000000000..b2144ed9d3 --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html @@ -0,0 +1,4 @@ +

Test Starts-With Text

+

+ {{element}}, +

diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts new file mode 100644 index 0000000000..db0fac33fd --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts @@ -0,0 +1,14 @@ +import { Component, Inject } from '@angular/core'; +import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; +import { BrowseByStartsWithType } from '../../browse-by.component'; +import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; + +@Component({ + selector: 'ds-browse-by-starts-with-text', + styleUrls: ['./browse-by-starts-with-text.component.scss'], + templateUrl: './browse-by-starts-with-text.component.html' +}) +@renderStartsWithFor(BrowseByStartsWithType.text) +export class BrowseByStartsWithTextComponent extends BrowseByStartsWithAbstractComponent { + +} diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index 42d5f95c9e..a047363194 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,5 +1,6 @@

{{title | translate}}

+
(); + objectInjector: Injector; + /** * Declare SortDirection enumeration to use it in the template */ public sortDirections = SortDirection; + public constructor(private injector: Injector) { + + } + goPrev() { this.prev.emit(true); } @@ -75,4 +91,15 @@ export class BrowseByComponent { this.sortDirectionChange.emit(direction); } + getStartsWithComponent() { + return getStartsWithComponent(this.type); + } + + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [{ provide: 'startsWithOptions', useFactory: () => (this.startsWithOptions), deps:[] }], + parent: this.injector + }); + } + } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 5a7541018b..b8530101f9 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -88,6 +88,8 @@ import { MomentModule } from 'ngx-moment'; import { MenuModule } from './menu/menu.module'; import {LangSwitchComponent} from './lang-switch/lang-switch.component'; import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; +import { BrowseByStartsWithDateComponent } from './browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component'; +import { BrowseByStartsWithTextComponent } from './browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -177,7 +179,9 @@ const ENTRY_COMPONENTS = [ CollectionGridElementComponent, CommunityGridElementComponent, SearchResultGridElementComponent, - BrowseEntryListElementComponent + BrowseEntryListElementComponent, + BrowseByStartsWithDateComponent, + BrowseByStartsWithTextComponent ]; const PROVIDERS = [ From d80233074f90c877cce4ce54abdd2912c9eccfe1 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 11 Feb 2019 17:55:27 +0100 Subject: [PATCH 11/63] 59695: Intermediate date jump options test --- .../browse-by-date-page.component.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index 3856d45da0..0d07bcc073 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -19,6 +19,9 @@ import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.compone */ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { + oneYearLimit = 10; + fiveYearLimit = 30; + ngOnInit(): void { this.startsWithType = BrowseByStartsWithType.date; this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); @@ -37,6 +40,23 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { this.updatePageWithItems(searchOptions, this.value); this.updateParent(params.scope); })); + const options = []; + const currentYear = new Date().getFullYear(); + const oneYearBreak = Math.floor((currentYear - this.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((currentYear - this.fiveYearLimit) / 10) * 10; + const lowerLimit = 1900; // Hardcoded atm, this should be the lowest date issued + let i = currentYear; + while (i > lowerLimit) { + options.push(i); + if (i <= fiveYearBreak) { + i -= 10; + } else if (i <= oneYearBreak) { + i -= 5; + } else { + i--; + } + } + console.log(options); } } From cdcacedfaecb911530a84477ce7a010864e7e250 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 10:40:47 +0100 Subject: [PATCH 12/63] 59695: Starts-with implementation for browse-by date --- config/environment.default.js | 11 ++- resources/i18n/en.json | 5 ++ .../browse-by-date-page.component.ts | 79 ++++++++++++++----- .../+browse-by/browse-by-routing.module.ts | 2 +- src/app/core/browse/browse.service.ts | 24 +++++- ...rowse-by-starts-with-abstract.component.ts | 56 ++++++++++++- .../browse-by-starts-with-date.component.html | 26 +++++- .../browse-by-starts-with-date.component.scss | 7 ++ .../browse-by-starts-with-text.component.html | 5 +- src/config/browse-by-config.interface.ts | 7 ++ src/config/global-config.interface.ts | 2 + 11 files changed, 190 insertions(+), 34 deletions(-) create mode 100644 src/config/browse-by-config.interface.ts diff --git a/config/environment.default.js b/config/environment.default.js index d3758e66bd..b99f027c08 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -72,5 +72,14 @@ module.exports = { code: 'nl', label: 'Nederlands', active: false, - }] + }], + // Browse-By Pages + browseBy: { + // Amount of years to display using jumps of one year (current year - oneYearLimit) + oneYearLimit: 10, + // Limit for years to display using jumps of five years (current year - fiveYearLimit) + fiveYearLimit: 30, + // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) + defaultLowerLimit: 1900 + } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index b4a421baf4..a357a9c622 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -281,6 +281,11 @@ }, "browse": { "title": "Browsing {{ collection }} by {{ field }} {{ value }}", + "startsWith": { + "choose_year": "(Choose year)", + "type_year": "Or type in a year:", + "submit": "Go" + }, "metadata": { "title": "Title", "author": "Author", diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index 0d07bcc073..9ae8ea56df 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { BrowseByMetadataPageComponent, browseParamsToOptions @@ -6,6 +6,14 @@ import { import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { Item } from '../../core/shared/item.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @Component({ selector: 'ds-browse-by-date-page', @@ -19,8 +27,15 @@ import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.compone */ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { - oneYearLimit = 10; - fiveYearLimit = 30; + defaultMetadataField = 'dc.date.issued'; + + public constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig, + protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router) { + super(route, browseService, dsoService, router); + } ngOnInit(): void { this.startsWithType = BrowseByStartsWithType.date; @@ -34,29 +49,53 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { return Object.assign({}, params, queryParams, data); }) .subscribe((params) => { + const metadataField = params.metadataField || this.defaultMetadataField; this.metadata = params.metadata || this.defaultMetadata; this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, Object.assign({}), this.sortConfig, this.metadata); this.updatePageWithItems(searchOptions, this.value); this.updateParent(params.scope); + this.updateStartsWithOptions(this.metadata, metadataField, params.scope); })); - const options = []; - const currentYear = new Date().getFullYear(); - const oneYearBreak = Math.floor((currentYear - this.oneYearLimit) / 5) * 5; - const fiveYearBreak = Math.floor((currentYear - this.fiveYearLimit) / 10) * 10; - const lowerLimit = 1900; // Hardcoded atm, this should be the lowest date issued - let i = currentYear; - while (i > lowerLimit) { - options.push(i); - if (i <= fiveYearBreak) { - i -= 10; - } else if (i <= oneYearBreak) { - i -= 5; - } else { - i--; - } - } - console.log(options); + } + + updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { + this.subs.push( + this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData>) => { + let lowerLimit = this.config.browseBy.defaultLowerLimit; + if (firstItemRD.payload.page.length > 0) { + const date = firstItemRD.payload.page[0].findMetadata(metadataField); + if (hasValue(date) && hasValue(+date.split('-')[0])) { + lowerLimit = +date.split('-')[0]; + } + } + const options = []; + const currentYear = new Date().getFullYear(); + const oneYearBreak = Math.floor((currentYear - this.config.browseBy.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((currentYear - this.config.browseBy.fiveYearLimit) / 10) * 10; + if (lowerLimit <= fiveYearBreak) { + lowerLimit -= 10; + } else if (lowerLimit <= oneYearBreak) { + lowerLimit -= 5; + } else { + lowerLimit -= 1; + } + let i = currentYear; + while (i > lowerLimit) { + options.push(i); + if (i <= fiveYearBreak) { + i -= 10; + } else if (i <= oneYearBreak) { + i -= 5; + } else { + i--; + } + } + if (isNotEmpty(options)) { + this.startsWithOptions = options; + } + }) + ); } } diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index 9295209d11..f4c8b688f6 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -8,7 +8,7 @@ import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date imports: [ RouterModule.forChild([ { path: 'title', component: BrowseByTitlePageComponent }, - { path: 'dateissued', component: BrowseByDatePageComponent, data: { metadata: 'dateissued' } }, + { path: 'dateissued', component: BrowseByDatePageComponent, data: { metadata: 'dateissued', metadataField: 'dc.date.issued' } }, { path: ':metadata', component: BrowseByMetadataPageComponent } ]) ] diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 94c13c7d05..5388721218 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, map, startWith, take } from 'rxjs/operators'; import { - ensureArrayHasValue, + ensureArrayHasValue, hasValue, hasValueOperator, isEmpty, isNotEmpty, @@ -162,6 +162,28 @@ export class BrowseService { ); } + getFirstItemFor(definition: string, scope?: string): Observable>> { + return this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(definition), + hasValueOperator(), + map((_links: any) => _links.items), + hasValueOperator(), + map((href: string) => { + const args = []; + if (hasValue(scope)) { + args.push(`scope=${scope}`); + } + args.push('page=0'); + args.push('size=1'); + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) + ); + } + getPrevBrowseItems(items: RemoteData>): Observable>> { return observableOf(items.payload.prev).pipe( getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts index c8651dba21..ff113f1773 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts @@ -1,6 +1,56 @@ -import { Inject } from '@angular/core'; +import { Inject, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { hasValue } from '../../empty.util'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { FormControl, FormGroup } from '@angular/forms'; -export class BrowseByStartsWithAbstractComponent { - public constructor(@Inject('startsWithOptions') public startsWithOptions: any[]) { +export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { + startsWith: string; + + formData: FormGroup; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + public constructor(@Inject('startsWithOptions') public startsWithOptions: any[], + protected route: ActivatedRoute, + protected router: Router) { + } + + ngOnInit(): void { + this.subs.push( + this.route.queryParams.subscribe((params) => { + this.startsWith = params.startsWith; + }) + ); + this.formData = new FormGroup({ + startsWith: new FormControl() + }); + } + + setStartsWith(event: Event) { + this.startsWith = (event.target as HTMLInputElement).value; + this.setStartsWithParam(); + } + + setStartsWithParam() { + if (this.startsWith === '-1') { + this.startsWith = undefined; + } + this.router.navigate([], { + queryParams: Object.assign({ startsWith: this.startsWith }), + queryParamsHandling: 'merge' + }); + } + + submitForm(data) { + this.startsWith = data.startsWith; + this.setStartsWithParam(); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } } diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html index c2221ed257..768d6c4049 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html @@ -1,4 +1,22 @@ -

Test Starts-With Date

-

- {{element}}, -

+
+
+
+ +
+ + + + +
+
+
+
diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss index e69de29bb2..e516151d57 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss @@ -0,0 +1,7 @@ +@import '../../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html index b2144ed9d3..7a1b58e832 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html +++ b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html @@ -1,4 +1 @@ -

Test Starts-With Text

-

- {{element}}, -

+ diff --git a/src/config/browse-by-config.interface.ts b/src/config/browse-by-config.interface.ts new file mode 100644 index 0000000000..1bc804c3b8 --- /dev/null +++ b/src/config/browse-by-config.interface.ts @@ -0,0 +1,7 @@ +import { Config } from './config.interface'; + +export interface BrowseByConfig extends Config { + oneYearLimit: number; + fiveYearLimit: number; + defaultLowerLimit: number; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 6d1d4da6f9..38532941a8 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -5,6 +5,7 @@ import { UniversalConfig } from './universal-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; +import { BrowseByConfig } from './browse-by-config.interface'; export interface GlobalConfig extends Config { ui: ServerConfig; @@ -19,4 +20,5 @@ export interface GlobalConfig extends Config { debug: boolean; defaultLanguage: string; languages: LangConfig[]; + browseBy: BrowseByConfig; } From c49f9f2ef6b0f6317281190101b7a61440a031f1 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 11:27:24 +0100 Subject: [PATCH 13/63] 59695: Browse-by-date style finetuning + navigation + parsing service fix for empty pages --- resources/i18n/en.json | 2 ++ .../browse-items-response-parsing-service.ts | 4 +++- src/app/navbar/navbar.component.ts | 11 ++++++++++ .../browse-by-starts-with-date.component.html | 21 +++++++++++-------- .../comcol-page-browse-by.component.html | 1 + 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index a357a9c622..031a30945a 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -282,6 +282,7 @@ "browse": { "title": "Browsing {{ collection }} by {{ field }} {{ value }}", "startsWith": { + "jump": "Jump to a point in the index:", "choose_year": "(Choose year)", "type_year": "Or type in a year:", "submit": "Go" @@ -296,6 +297,7 @@ "head": "Browse", "by": { "title": "By Title", + "dateissued": "By Issue Date", "author": "By Author", "subject": "By Subject" } diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index e513ad0898..9228204b32 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ErrorResponse, @@ -45,6 +45,8 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic const serializer = new DSpaceRESTv2Serializer(DSpaceObject); const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload)); + } else if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) { + return new GenericSuccessResponse([], data.statusCode, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index df6226aedc..008a86599d 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -73,6 +73,17 @@ export class NavbarComponent extends MenuComponent implements OnInit { link: '/browse/title' } as LinkMenuItemModel, }, + { + id: 'browse_global_global_by_issue_date', + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.browse_global_by_issue_date', + link: '/browse/dateissued' + } as LinkMenuItemModel, + }, { id: 'browse_global_by_author', parentID: 'browse_global', diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html index 768d6c4049..43e53ea54d 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html @@ -1,7 +1,10 @@
-
-
- @@ -11,12 +14,12 @@ {{option}} -
- - - - -
+
+
+ + + +
diff --git a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html index 653bd1ed53..f9ef4e5232 100644 --- a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html +++ b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.html @@ -1,6 +1,7 @@

{{'browse.comcol.head' | translate}}

From b1b239d45142701212f2a8db28352446bbae7fc1 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 11:50:35 +0100 Subject: [PATCH 14/63] 59695: Wait for startsWithOptions to prevent empty dropdown + loading message --- resources/i18n/en.json | 3 ++- .../+browse-by-date-page/browse-by-date-page.component.ts | 6 ++++-- .../browse-by-metadata-page.component.html | 3 ++- .../browse-by-metadata-page.component.ts | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 031a30945a..a9a456df33 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -444,7 +444,8 @@ "item": "Loading item...", "objects": "Loading...", "search-results": "Loading search results...", - "browse-by": "Loading items..." + "browse-by": "Loading items...", + "browse-by-page": "Loading page..." }, "error": { "default": "Error", diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index 9ae8ea56df..f40683f387 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject } from '@angular/core'; +import { ChangeDetectorRef, Component, Inject } from '@angular/core'; import { BrowseByMetadataPageComponent, browseParamsToOptions @@ -33,7 +33,8 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { protected route: ActivatedRoute, protected browseService: BrowseService, protected dsoService: DSpaceObjectDataService, - protected router: Router) { + protected router: Router, + protected cdRef: ChangeDetectorRef) { super(route, browseService, dsoService, router); } @@ -93,6 +94,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { } if (isNotEmpty(options)) { this.startsWithOptions = options; + this.cdRef.detectChanges(); } }) ); diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html index 1cf5c51d7c..9ccc466d90 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -1,6 +1,6 @@
diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index 7b4e34cf6d..f2e25d84d9 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -74,7 +74,7 @@ export class BrowseByMetadataPageComponent implements OnInit { startsWithType = BrowseByStartsWithType.text; - startsWithOptions = []; + startsWithOptions; /** * The value we're browing items for @@ -112,6 +112,7 @@ export class BrowseByMetadataPageComponent implements OnInit { } this.updateParent(params.scope); })); + this.startsWithOptions = []; } /** From 714811dc0761db42f029466d395d909deeced547 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 12 Feb 2019 12:48:32 +0100 Subject: [PATCH 15/63] 59334: added validation --- resources/i18n/en.json | 7 +++ .../edit-in-place-field.component.html | 16 +++++-- .../edit-in-place-field.component.scss | 1 + .../edit-in-place-field.component.ts | 39 +++++++++++---- .../item-metadata.component.scss | 5 ++ .../item-metadata/item-metadata.component.ts | 47 ++++++++++++------- .../+item-page/item-page-routing.module.ts | 2 +- src/app/core/core.reducers.ts | 6 +-- .../object-updates/object-updates.actions.ts | 28 +++++++++++ .../object-updates/object-updates.reducer.ts | 42 +++++++++++++---- .../object-updates/object-updates.service.ts | 40 +++++++++++++++- .../comcol-form/comcol-form.component.html | 2 +- .../comcol-form/comcol-form.component.spec.ts | 12 +++-- .../comcol-form/comcol-form.component.ts | 4 ++ .../input-suggestions.component.ts | 18 +++++-- src/app/shared/utils/validator.functions.ts | 7 +++ src/styles/_custom_variables.scss | 2 + 17 files changed, 226 insertions(+), 52 deletions(-) create mode 100644 src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss create mode 100644 src/app/shared/utils/validator.functions.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index cd7e1276f1..795b3cc747 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -233,6 +233,9 @@ "language": "Lang", "edit": "Edit" }, + "metadatafield": { + "invalid": "Please choose a valid metadata field" + }, "notifications": { "outdated": { "title": "Changed outdated", @@ -241,6 +244,10 @@ "discarded": { "title": "Changed discarded", "content": "Your changes were discarded. To reinstate your changes click the 'Undo' button" + }, + "invalid": { + "title": "Metadata invalid", + "content": "Please make sure all fields are valid" } } } 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 index 279552565c..51f4c650af 100644 --- 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 @@ -13,10 +13,14 @@ [(ngModel)]="metadata.key" (submitSuggestion)="update()" (clickSuggestion)="update()" + (typeSuggestion)="update()" (findSuggestions)="findMetadataFieldSuggestions($event)" + [formControl]="formControl" ngDefaultControl >
+ {{"item.edit.metadata.metadatafield.invalid" | translate}}
@@ -38,10 +42,14 @@
- - - - + + + +
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss index e69de29bb2..3575cae797 100644 --- 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 @@ -0,0 +1 @@ +@import '../../../../../styles/variables.scss'; 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 38470c54c4..b85b558cfd 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 @@ -11,6 +11,9 @@ import { FieldChangeType } from '../../../../core/data/object-updates/object-upd 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'; +import { inListValidator } from '../../../../shared/utils/validator.functions'; +import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { FormControl, ValidationErrors, ValidatorFn } from '@angular/forms'; @Component({ selector: 'ds-edit-in-place-field', @@ -21,7 +24,6 @@ import { ObjectUpdatesService } from '../../../../core/data/object-updates/objec * Component that displays a single metadatum of an item on the edit page */ export class EditInPlaceFieldComponent implements OnInit, OnChanges { - /** * The current field, value and state of the metadatum */ @@ -39,22 +41,43 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { */ editable: Observable; + /** + * Emits whether or not this field is currently valid + */ + valid: Observable; + /** * The current suggestions for the metadatafield when editing */ metadataFieldSuggestions: BehaviorSubject = new BehaviorSubject([]); + formControl: FormControl; + constructor( private metadataFieldService: RegistryService, private objectUpdatesService: ObjectUpdatesService, ) { } + /** + * Sets up an observable that keeps track of the current editable and valid state of this field + * Also creates a form control object for the input suggestions + */ + ngOnInit(): void { + this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid); + this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid); + this.findMetadataFields().pipe(take(1)).subscribe((metadataFields: string[]) => { + const validator = inListValidator(metadataFields); + this.formControl = new FormControl('', validator); + }); + } + /** * Sends a new change update for this field to the object updates service */ update() { this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata); + this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, this.formControl.valid); } /** @@ -79,13 +102,6 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { this.objectUpdatesService.removeSingleFieldUpdate(this.route, this.metadata.uuid); } - /** - * Sets up an observable that keeps track of the current editable state of this field - */ - ngOnInit(): void { - this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid); - } - /** * Sets the current metadatafield based on the fieldUpdate input field */ @@ -115,6 +131,13 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { ); } + findMetadataFields(): Observable { + return this.metadataFieldService.getAllMetadataFields().pipe( + getSucceededRemoteData(), + take(1), + map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); + } + /** * Check if a user should be allowed to edit this field * @return an observable that emits true when the user should be able to edit this field and false when they should not diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss new file mode 100644 index 0000000000..b2994dcec7 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -0,0 +1,5 @@ +@import '../../../../styles/variables.scss'; + +.button-row .btn { + min-width: $button-min-width; +} \ No newline at end of file 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 d2231e615d..ae32028486 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, tap } from 'rxjs/operators'; +import { first, map, 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'; @@ -20,6 +20,7 @@ import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'ds-item-metadata', + styleUrls: ['./item-metadata.component.scss'], templateUrl: './item-metadata.component.html', }) /** @@ -114,22 +115,30 @@ export class ItemMetadataComponent implements OnInit { * Makes sure the new version of the item is rendered on the page */ submit() { - const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable; - metadata$.pipe( - first(), - switchMap((metadata: Metadatum[]) => { - const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); - return this.itemService.update(updatedItem); - }), - tap(() => this.itemService.commitUpdates()), - getSucceededRemoteData() - ).subscribe( - (rd: RemoteData) => { - this.item = rd.payload; - this.initializeOriginalFields(); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata); - } - ) + this.isValid().pipe(first()).subscribe((isValid) => { + if (isValid) { + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable; + metadata$.pipe( + first(), + switchMap((metadata: Metadatum[]) => { + const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); + return this.itemService.update(updatedItem); + }), + tap(() => this.itemService.commitUpdates()), + getSucceededRemoteData() + ).subscribe( + (rd: RemoteData) => { + this.item = rd.payload; + this.initializeOriginalFields(); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata); + } + ) + } else { + const title = this.translateService.instant('item.edit.metadata.notifications.invalid.title'); + const content = this.translateService.instant('item.edit.metadata.notifications.invalid.content'); + this.notificationsService.error(title, content); + } + }); } /** @@ -163,4 +172,8 @@ export class ItemMetadataComponent implements OnInit { } ); } + + private isValid() { + return this.objectUpdatesService.isValidPage(this.route); + } } diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 1ce4f3ad88..0f2f0817f6 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -38,7 +38,7 @@ const ITEM_EDIT_PATH = ':id/edit'; { path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', - canActivate: [AuthenticatedGuard] + // canActivate: [AuthenticatedGuard] } ]) ], diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 760d3ddeaf..e0ddb4a9de 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,8 +1,6 @@ import { ActionReducerMap, createFeatureSelector, - createSelector, - MemoizedSelector } from '@ngrx/store'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; @@ -14,8 +12,6 @@ import { objectUpdatesReducer, ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; -import { hasValue } from '../shared/empty.util'; -import { AppState } from '../app.reducer'; export interface CoreState { 'cache/object': ObjectCacheState, @@ -35,4 +31,4 @@ export const coreReducers: ActionReducerMap = { 'auth': authReducer, }; -export const coreSelector = createFeatureSelector('core'); \ No newline at end of file +export const coreSelector = createFeatureSelector('core'); 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 5f76d6dde9..c1b35d07b4 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -9,6 +9,7 @@ import { INotification } from '../../../shared/notifications/models/notification export const ObjectUpdatesActionTypes = { INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), + SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), DISCARD: type('dspace/core/cache/object-updates/DISCARD'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), @@ -109,6 +110,33 @@ export class SetEditableFieldUpdateAction implements Action { } } +/** + * An ngrx action to set the isValid state of an existing field in the ObjectUpdates state for a certain page url + */ +export class SetValidFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.SET_VALID_FIELD; + payload: { + url: string, + uuid: string, + isValid: boolean, + }; + + /** + * Create a new SetEditableFieldUpdateAction + * + * @param url + * the unique url of the page + * @param fieldUUID The UUID of the field of which + * @param isValid The new isValid value for the field + */ + constructor( + url: string, + fieldUUID: string, + isValid: boolean) { + this.payload = { url, uuid: fieldUUID, isValid }; + } +} + /** * An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url */ 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 c46438bd90..dad394e84f 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -7,7 +7,7 @@ import { ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction, SetEditableFieldUpdateAction + RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; @@ -15,7 +15,8 @@ export const OBJECT_UPDATES_TRASH_PATH = '/trash'; export interface FieldState { editable: boolean, - isNew: boolean + isNew: boolean, + isValid: boolean } export interface FieldStates { @@ -46,8 +47,8 @@ export interface ObjectUpdatesState { [url: string]: ObjectUpdatesEntry; } -const initialFieldState = { editable: false, isNew: false }; -const initialNewFieldState = { editable: true, isNew: true }; +const initialFieldState = { editable: false, isNew: false, isValid: true }; +const initialNewFieldState = { editable: true, isNew: true, isValid: true }; // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) const initialState = Object.create(null); @@ -80,6 +81,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: { return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction); } + case ObjectUpdatesActionTypes.SET_VALID_FIELD: { + return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); + } default: { return state; } @@ -147,8 +151,8 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { Object.keys(pageState.fieldStates).forEach((uuid: string) => { const fieldState: FieldState = pageState.fieldStates[uuid]; if (!fieldState.isNew) { - /* After discarding we don't want the reset fields to stay editable */ - newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false }); + /* After discarding we don't want the reset fields to stay editable or invalid */ + newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false, isValid: true }); } }); @@ -215,7 +219,7 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { /* If this field was added, just throw it away */ delete newFieldStates[uuid]; } else { - newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false }); + newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false, isValid: true }); } } newPageState = Object.assign({}, state[url], { @@ -243,7 +247,7 @@ function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType) } /** - * Set the state of a specific action's url and uuid to false or true + * Set the editable state of a specific action's url and uuid to false or true * @param state The current state * @param action The action to perform on the current state */ @@ -264,6 +268,28 @@ function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction return Object.assign({}, state, { [url]: newPageState }); } +/** + * Set the isValid state of a specific action's url and uuid to false or true + * @param state The current state + * @param action The action to perform on the current state + */ +function setValidFieldUpdate(state: any, action: SetValidFieldUpdateAction) { + const url: string = action.payload.url; + const uuid: string = action.payload.uuid; + const isValid: boolean = action.payload.isValid; + + const pageState: ObjectUpdatesEntry = state[url]; + + const fieldState = pageState.fieldStates[uuid]; + const newFieldState = Object.assign({}, fieldState, { isValid }); + + const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState }); + + const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates }); + + return Object.assign({}, state, { [url]: newPageState }); +} + /** * Method to create an initial FieldStates object based on a list of Identifiable objects * @param fields Identifiable objects 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 4b6c7def0d..6136d31ac0 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { coreSelector, CoreState } from '../../core.reducers'; import { + FieldState, FieldUpdates, Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, @@ -15,7 +16,7 @@ import { InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - SetEditableFieldUpdateAction + SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { filter, map } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; @@ -102,6 +103,33 @@ export class ObjectUpdatesService { ) } + /** + * Method to check if a specific field is currently valid in the store + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field + */ + isValid(url: string, uuid: string): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe( + filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), + map((objectEntry) => objectEntry.fieldStates[uuid].isValid + ) + ) + } + + /** + * Method to check if a specific page is currently valid in the store + * @param url The URL of the page + */ + isValidPage(url: string): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe( + map((entry: ObjectUpdatesEntry) => { + return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 + }) + ) + } + /** * Calls the saveFieldUpdate method with FieldChangeType.ADD * @param url The page's URL for which the changes are saved @@ -139,6 +167,16 @@ export class ObjectUpdatesService { this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable)); } + /** + * Dispatches a SetValidFieldUpdateAction to the store to set a field's isValid state + * @param url The URL of the page on which the field resides + * @param uuid The UUID of the field that should be set + * @param valid The new value of isValid in the store for this field + */ + setValidFieldUpdate(url: string, uuid: string, valid: boolean) { + this.store.dispatch(new SetValidFieldUpdateAction(url, uuid, valid)); + } + /** * Method to dispatch an DiscardObjectUpdatesAction to the store * @param url The page's URL for which the changes should be discarded diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html index 720ad0c1cf..6c67937063 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html @@ -1,3 +1,3 @@ + [formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"> diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts index a6f5e0d45a..51c5928348 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts @@ -50,10 +50,7 @@ describe('ComColFormComponent', () => { ]; /* tslint:disable:no-empty */ - const locationStub = { - back: () => { - } - }; + const locationStub = jasmine.createSpyObj('location', ['back']); /* tslint:enable:no-empty */ beforeEach(async(() => { @@ -112,4 +109,11 @@ describe('ComColFormComponent', () => { ); }) }); + + describe('onCancel', () => { + it('should call the back method on the Location service', () => { + comp.onCancel(); + expect(locationStub.back).toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index 17710fd1c6..a7d638e791 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -112,4 +112,8 @@ export class ComColFormComponent implements OnInit { } ); } + + onCancel() { + this.location.back(); + } } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 434a285818..afb405a60a 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -1,9 +1,13 @@ import { Component, - ElementRef, EventEmitter, forwardRef, + ElementRef, + EventEmitter, + forwardRef, Input, + OnChanges, Output, - QueryList, SimpleChanges, + QueryList, + SimpleChanges, ViewChild, ViewChildren } from '@angular/core'; @@ -19,6 +23,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; providers: [ { provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref useExisting: forwardRef(() => InputSuggestionsComponent), multi: true } @@ -28,7 +34,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; /** * Component representing a form with a autocomplete functionality */ -export class InputSuggestionsComponent implements ControlValueAccessor { +export class InputSuggestionsComponent implements ControlValueAccessor, OnChanges { /** * The suggestions that should be shown */ @@ -64,6 +70,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor { */ @Output() clickSuggestion = new EventEmitter(); + /** + * Output for when something is typed in the input field + */ + @Output() typeSuggestion = new EventEmitter(); + /** * Output for when new suggestions should be requested */ @@ -195,6 +206,7 @@ export class InputSuggestionsComponent implements ControlValueAccessor { this.findSuggestions.emit(data); } this.blockReopen = false; + this.typeSuggestion.emit(data); } onSubmit(data) { diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts new file mode 100644 index 0000000000..55fe498747 --- /dev/null +++ b/src/app/shared/utils/validator.functions.ts @@ -0,0 +1,7 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; + +export function inListValidator(list: string[]): ValidatorFn { + return (control: AbstractControl): {[key: string]: any} | null => { + const contains = list.indexOf(control.value) > 0; + return contains ? null : {inList: {value: control.value}} }; +} diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index e8d839826d..dda018ad2c 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,6 +1,8 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); +$button-min-width: 100px; + $card-height-percentage:98%; $card-thumbnail-height:240px; $dropdown-menu-max-height: 200px; From bd211ce0f4b79ee85f5d1ba7caad9dfb7bfaf02e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 12:53:23 +0100 Subject: [PATCH 16/63] 59695: Browse-By-Title-Page refactoring --- .../browse-by-title-page.component.ts | 88 +++++-------------- .../+browse-by/browse-by-routing.module.ts | 2 +- 2 files changed, 25 insertions(+), 65 deletions(-) diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index 6ba43c8f10..a7a35f49cc 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -1,80 +1,52 @@ -import { combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; -import { Component, OnInit } from '@angular/core'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { Component } from '@angular/core'; import { ItemDataService } from '../../core/data/item-data.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { Item } from '../../core/shared/item.model'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { hasValue } from '../../shared/empty.util'; -import { Collection } from '../../core/shared/collection.model'; -import { browseParamsToOptions } from '../+browse-by-metadata-page/browse-by-metadata-page.component'; +import { + BrowseByMetadataPageComponent, + browseParamsToOptions +} from '../+browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { Community } from '../../core/shared/community.model'; -import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { BrowseService } from '../../core/browse/browse.service'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-browse-by-title-page', - styleUrls: ['./browse-by-title-page.component.scss'], - templateUrl: './browse-by-title-page.component.html' + styleUrls: ['../+browse-by-metadata-page/browse-by-metadata-page.component.scss'], + templateUrl: '../+browse-by-metadata-page/browse-by-metadata-page.component.html' }) /** * Component for browsing items by title (dc.title) */ -export class BrowseByTitlePageComponent implements OnInit { - - /** - * The list of items to display - */ - items$: Observable>>; - - /** - * The current Community or Collection we're browsing metadata/items in - */ - parent$: Observable>; - - /** - * The pagination configuration to use for displaying the list of items - */ - paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'browse-by-title-pagination', - currentPage: 1, - pageSize: 20 - }); - - /** - * The sorting configuration to use for displaying the list of items - * Sorted by title (Ascending by default) - */ - sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); - - /** - * List of subscriptions - */ - subs: Subscription[] = []; - - public constructor(private itemDataService: ItemDataService, - private route: ActivatedRoute, - private dsoService: DSpaceObjectDataService) { +export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { + public constructor(protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected router: Router, + protected itemDataService: ItemDataService) { + super(route, browseService, dsoService, router); } ngOnInit(): void { + this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( this.route.params, this.route.queryParams, - (params, queryParams, ) => { - return Object.assign({}, params, queryParams); + this.route.data, + (params, queryParams, data ) => { + return Object.assign({}, params, queryParams, data); }) .subscribe((params) => { + this.metadata = params.metadata || this.defaultMetadata; this.updatePage(browseParamsToOptions(params, this.paginationConfig, this.sortConfig)); this.updateParent(params.scope) })); + this.startsWithOptions = []; } /** @@ -92,18 +64,6 @@ export class BrowseByTitlePageComponent implements OnInit { }); } - /** - * Update the parent Community or Collection using their scope - * @param scope The UUID of the Community or Collection to fetch - */ - updateParent(scope: string) { - if (hasValue(scope)) { - this.parent$ = this.dsoService.findById(scope).pipe( - getSucceededRemoteData() - ); - } - } - ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts index f4c8b688f6..51acd19645 100644 --- a/src/app/+browse-by/browse-by-routing.module.ts +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -7,7 +7,7 @@ import { BrowseByDatePageComponent } from './+browse-by-date-page/browse-by-date @NgModule({ imports: [ RouterModule.forChild([ - { path: 'title', component: BrowseByTitlePageComponent }, + { path: 'title', component: BrowseByTitlePageComponent, data: { metadata: 'title' } }, { path: 'dateissued', component: BrowseByDatePageComponent, data: { metadata: 'dateissued', metadataField: 'dc.date.issued' } }, { path: ':metadata', component: BrowseByMetadataPageComponent } ]) From 86dc17e17d13ba9982aa457ada4b08f22b166396 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 13:00:10 +0100 Subject: [PATCH 17/63] 59695: Removal of unnecessary files after refactor --- .../browse-by-title-page.component.html | 10 ---------- .../browse-by-title-page.component.scss | 0 2 files changed, 10 deletions(-) delete mode 100644 src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html delete mode 100644 src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html deleted file mode 100644 index 84b0baf1f6..0000000000 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
- - -
-
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss deleted file mode 100644 index e69de29bb2..0000000000 From c15a7fd4bac0aaaf29eee8fd6c31a9e00bf571ef Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 13:29:31 +0100 Subject: [PATCH 18/63] 59695: Fix existing tests --- .../browse-by-metadata-page.component.spec.ts | 4 +++- .../browse-by-title-page.component.spec.ts | 10 +++++++--- src/app/shared/browse-by/browse-by.component.spec.ts | 10 +++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts index 87f9aa498d..7b22f6afe1 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.spec.ts @@ -19,6 +19,7 @@ import { SortDirection } from '../../core/cache/models/sort-options.model'; import { Item } from '../../core/shared/item.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { Community } from '../../core/shared/community.model'; +import { MockRouter } from '../../shared/mocks/mock-router'; describe('BrowseByMetadataPageComponent', () => { let comp: BrowseByMetadataPageComponent; @@ -81,7 +82,8 @@ describe('BrowseByMetadataPageComponent', () => { providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts index f99c2b0113..d7bc7397b8 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts @@ -1,5 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Item } from '../../core/shared/item.model'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { of as observableOf } from 'rxjs/internal/observable/of'; @@ -15,6 +15,8 @@ import { ItemDataService } from '../../core/data/item-data.service'; import { Community } from '../../core/shared/community.model'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { BrowseService } from '../../core/browse/browse.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; describe('BrowseByTitlePageComponent', () => { let comp: BrowseByTitlePageComponent; @@ -57,8 +59,10 @@ describe('BrowseByTitlePageComponent', () => { declarations: [BrowseByTitlePageComponent, EnumKeysPipe], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, - { provide: ItemDataService, useValue: mockItemDataService }, - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: BrowseService, useValue: {} }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() }, + { provide: ItemDataService, useValue: mockItemDataService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 2417dde7ca..83c07daf34 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -5,6 +5,10 @@ import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { SharedModule } from '../shared.module'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRouteStub } from '../testing/active-router-stub'; +import { MockRouter } from '../mocks/mock-router'; describe('BrowseByComponent', () => { let comp: BrowseByComponent; @@ -12,8 +16,12 @@ describe('BrowseByComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), SharedModule], + imports: [CommonModule, TranslateModule.forRoot(), SharedModule], declarations: [], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: Router, useValue: new MockRouter() } + ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); From 9f01361e091e02f682738c8d3870e98995fafb2a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 14:43:32 +0100 Subject: [PATCH 19/63] 59695: BrowseByDatePage tests --- .../browse-by-date-page.component.spec.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts new file mode 100644 index 0000000000..77f41be76a --- /dev/null +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts @@ -0,0 +1,95 @@ +import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BrowseService } from '../../core/browse/browse.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { MockRouter } from '../../shared/mocks/mock-router'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../core/data/remote-data'; +import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; +import { Community } from '../../core/shared/community.model'; +import { Item } from '../../core/shared/item.model'; +import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config'; +import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; +import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec'; + +describe('BrowseByDatePageComponent', () => { + let comp: BrowseByDatePageComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + + const mockCommunity = Object.assign(new Community(), { + id: 'test-uuid', + name: 'test community' + }); + + const firstItem = Object.assign(new Item(), { + id: 'first-item-id', + metadata: [ + { key: 'dc.date.issued', value: '1950-01-01' } + ] + }); + + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), + getFirstItemFor: () => toRemoteData([firstItem]) + }; + + const mockDsoService = { + findById: () => observableOf(new RemoteData(false, false, true, null, mockCommunity)) + }; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}), + data: observableOf({ metadata: 'dateissued', metadataField: 'dc.date.issued' }) + }); + + const mockCdRef = Object.assign({ + detectChanges: () => fixture.detectChanges() + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByDatePageComponent, EnumKeysPipe], + providers: [ + { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: BrowseService, useValue: mockBrowseService }, + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new MockRouter() }, + { provide: ChangeDetectorRef, useValue: mockCdRef } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByDatePageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + }); + + it('should initialize the list of items', () => { + comp.items$.subscribe((result) => { + expect(result.payload.page).toEqual([firstItem]); + }); + }); + + it('should create a list of startsWith options with the earliest year at the end (rounded down by 10)', () => { + expect(comp.startsWithOptions[comp.startsWithOptions.length - 1]).toEqual(1950); + }); + + it('should create a list of startsWith options with the current year first', () => { + expect(comp.startsWithOptions[0]).toEqual(new Date().getFullYear()); + }); +}); From 51242ecd1c6513f81c039df1477f6af079f4a6ea Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 14:51:47 +0100 Subject: [PATCH 20/63] 59695: BrowseByStartsWithDecorator test --- .../browse-by-starts-with-decorator.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts new file mode 100644 index 0000000000..8eaa9eee09 --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts @@ -0,0 +1,14 @@ +import { renderStartsWithFor } from './browse-by-starts-with-decorator'; +import { BrowseByStartsWithType } from '../browse-by.component'; + +describe('BrowseByStartsWithDecorator', () => { + const textDecorator = renderStartsWithFor(BrowseByStartsWithType.text); + const dateDecorator = renderStartsWithFor(BrowseByStartsWithType.date); + it('should have a decorator for both text and date', () => { + expect(textDecorator.length).not.toBeNull(); + expect(dateDecorator.length).not.toBeNull(); + }); + it('should have 2 separate decorators for text and date', () => { + expect(textDecorator).not.toEqual(dateDecorator); + }); +}); From e9bff44ba2ac9ed9b73e4455e68b986310644fed Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 16:08:04 +0100 Subject: [PATCH 21/63] 59695: BrowseByStartsWithDate tests --- ...owse-by-starts-with-date.component.spec.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts new file mode 100644 index 0000000000..c0812245e9 --- /dev/null +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts @@ -0,0 +1,128 @@ +import { BrowseByStartsWithDateComponent } from './browse-by-starts-with-date.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { EnumKeysPipe } from '../../../utils/enum-keys-pipe'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRouteStub } from '../../../testing/active-router-stub'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RouterStub } from '../../../testing/router-stub'; +import { By } from '@angular/platform-browser'; + +describe('BrowseByStartsWithDateComponent', () => { + let comp: BrowseByStartsWithDateComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let router: Router; + + const options = [2019, 2018, 2017, 2016, 2015]; + + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}), + queryParams: observableOf({}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [BrowseByStartsWithDateComponent, EnumKeysPipe], + providers: [ + { provide: 'startsWithOptions', useValue: options }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Router, useValue: new RouterStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByStartsWithDateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + route = (comp as any).route; + router = (comp as any).router; + }); + + it('should create a FormGroup containing a startsWith FormControl', () => { + expect(comp.formData.value.startsWith).toBeDefined(); + }); + + describe('when selecting the first option in the dropdown', () => { + let select; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + select.value = select.options[0].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to undefined', () => { + expect(comp.startsWith).toBeUndefined(); + }); + + it('should not add a startsWith query parameter', () => { + route.queryParams.subscribe((params) => { + expect(params.startsWith).toBeUndefined(); + }); + }); + }); + + describe('when selecting the second option in the dropdown', () => { + let select; + let input; + const expectedValue = '' + options[0]; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('select')).nativeElement; + input = fixture.debugElement.query(By.css('input')).nativeElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + + it('should automatically fill in the input field', () => { + expect(input.value).toEqual(expectedValue); + }); + }); + + describe('when filling in the input form', () => { + let form; + const expectedValue = '2015'; + const extras = { + queryParams: Object.assign({ startsWith: expectedValue }), + queryParamsHandling: 'merge' + }; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('form')); + comp.formData.value.startsWith = expectedValue; + form.triggerEventHandler('ngSubmit', null); + fixture.detectChanges(); + }); + + it('should set startsWith to the correct value', () => { + expect(comp.startsWith).toEqual(expectedValue); + }); + + it('should add a startsWith query parameter', () => { + expect(router.navigate).toHaveBeenCalledWith([], extras); + }); + }); + +}); From 0bdd4789f6fbee5701051a2c56ec3ea951eac210 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Feb 2019 16:36:07 +0100 Subject: [PATCH 22/63] 59695: Added BrowseService tests --- src/app/core/browse/browse.service.spec.ts | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 79df1ea88c..d17c9541f6 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -294,4 +294,39 @@ describe('BrowseService', () => { }); }); }); + + describe('getFirstItemFor', () => { + beforeEach(() => { + responseCache = initMockResponseCacheService(true); + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + service = initTestService(); + spyOn(service, 'getBrowseDefinitions').and + .returnValue(hot('--a-', { a: { + payload: browseDefinitions + }})); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + }); + + describe('when getFirstItemFor is called with a valid browse definition id', () => { + const expectedURL = browseDefinitions[1]._links.items + '?page=0&size=1'; + + it('should configure a new BrowseItemsRequest', () => { + const expected = new BrowseItemsRequest(requestService.generateRequestId(), expectedURL); + + scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getFirstItemFor(browseDefinitions[1].id); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + }); + + }); + }); + }); From a1bed63442c7097d454885f31d82a0d689b1b914 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 13 Feb 2019 09:33:51 +0100 Subject: [PATCH 23/63] 59695: Added BrowseBy tests --- .../shared/browse-by/browse-by.component.html | 8 +- .../browse-by/browse-by.component.spec.ts | 122 ++++++++++++++++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index a047363194..bad9f3fe8c 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -16,9 +16,9 @@
- + - +
@@ -29,8 +29,8 @@
- - + +
diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 83c07daf34..bae345d009 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -1,27 +1,68 @@ import { BrowseByComponent } from './browse-by.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { SharedModule } from '../shared.module'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { ActivatedRouteStub } from '../testing/active-router-stub'; -import { MockRouter } from '../mocks/mock-router'; +import { Item } from '../../core/shared/item.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { StoreModule } from '@ngrx/store'; +import { MockTranslateLoader } from '../mocks/mock-translate-loader'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; describe('BrowseByComponent', () => { let comp: BrowseByComponent; let fixture: ComponentFixture; + const mockItems = [ + Object.assign(new Item(), { + id: 'fakeId-1', + metadata: [ + { + key: 'dc.title', + value: 'First Fake Title' + } + ] + }), + Object.assign(new Item(), { + id: 'fakeId-2', + metadata: [ + { + key: 'dc.title', + value: 'Second Fake Title' + } + ] + }) + ]; + const mockItemsRD$ = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockItems))); + beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [CommonModule, TranslateModule.forRoot(), SharedModule], - declarations: [], - providers: [ - { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, - { provide: Router, useValue: new MockRouter() } + imports: [ + CommonModule, + TranslateModule.forRoot(), + SharedModule, + NgbModule.forRoot(), + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + RouterTestingModule, + BrowserAnimationsModule ], + declarations: [], + providers: [], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); @@ -49,4 +90,67 @@ describe('BrowseByComponent', () => { expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); }); + describe('when enableArrows is true and objects are defined', () => { + beforeEach(() => { + comp.enableArrows = true; + comp.objects$ = mockItemsRD$; + comp.paginationConfig = Object.assign(new PaginationComponentOptions(), { + id: 'test-pagination', + currentPage: 1, + pageSizeOptions: [5,10,15,20], + pageSize: 15 + }); + comp.sortConfig = Object.assign(new SortOptions('dc.title', SortDirection.ASC)); + fixture.detectChanges(); + }); + + describe('when clicking the previous arrow button', () => { + beforeEach(() => { + spyOn(comp.prev, 'emit'); + fixture.debugElement.query(By.css('#nav-prev')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.prev.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking the next arrow button', () => { + beforeEach(() => { + spyOn(comp.next, 'emit'); + fixture.debugElement.query(By.css('#nav-next')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.next.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different page size', () => { + beforeEach(() => { + spyOn(comp.pageSizeChange, 'emit'); + fixture.debugElement.query(By.css('.page-size-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.pageSizeChange.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking a different sort direction', () => { + beforeEach(() => { + spyOn(comp.sortDirectionChange, 'emit'); + fixture.debugElement.query(By.css('.sort-direction-change')).triggerEventHandler('click', null); + fixture.detectChanges(); + }); + + it('should emit a signal to the EventEmitter',() => { + expect(comp.sortDirectionChange.emit).toHaveBeenCalled(); + }); + }); + }); + }); From 40342af0295cd406e16f4b9e5498ed566c4455b1 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 13 Feb 2019 10:13:18 +0100 Subject: [PATCH 24/63] 59695: JSDocs --- .../browse-by-date-page.component.ts | 13 ++++ .../browse-by-metadata-page.component.ts | 25 ++++++++ src/app/core/browse/browse.service.ts | 61 ++++++++++++++++--- ...rowse-by-starts-with-abstract.component.ts | 20 ++++++ .../browse-by-starts-with-decorator.ts | 8 +++ .../browse-by-starts-with-date.component.ts | 4 ++ .../browse-by-starts-with-text.component.ts | 3 + .../shared/browse-by/browse-by.component.ts | 48 +++++++++++++++ 8 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index f40683f387..701082b6a2 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -27,6 +27,9 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; */ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { + /** + * The default metadata-field to use for determining the lower limit of the StartsWith dropdown options + */ defaultMetadataField = 'dc.date.issued'; public constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig, @@ -60,6 +63,16 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { })); } + /** + * Update the StartsWith options + * In this implementation, it creates a list of years starting from now, going all the way back to the earliest + * date found on an item within this scope. The further back in time, the bigger the change in years become to avoid + * extremely long lists with a one-year difference. + * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. + * @param definition The metadata definition to fetch the first item for + * @param metadataField The metadata field to fetch the earliest date from (expects a date field) + * @param scope The scope under which to fetch the earliest item for + */ updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { this.subs.push( this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData>) => { diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index f2e25d84d9..a8bd7086a4 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -72,8 +72,16 @@ export class BrowseByMetadataPageComponent implements OnInit { */ metadata = this.defaultMetadata; + /** + * The type of StartsWith options to render + * Defaults to text + */ startsWithType = BrowseByStartsWithType.text; + /** + * The list of StartsWith options + * Should be defined after ngOnInit is called! + */ startsWithOptions; /** @@ -83,6 +91,9 @@ export class BrowseByMetadataPageComponent implements OnInit { */ value = ''; + /** + * The current startsWith option (fetched and updated from query-params) + */ startsWith: string; public constructor(protected route: ActivatedRoute, @@ -153,18 +164,28 @@ export class BrowseByMetadataPageComponent implements OnInit { } } + /** + * Navigate to the previous page + */ goPrev() { this.items$.pipe(take(1)).subscribe((items) => { this.items$ = this.browseService.getPrevBrowseItems(items); }); } + /** + * Navigate to the next page + */ goNext() { this.items$.pipe(take(1)).subscribe((items) => { this.items$ = this.browseService.getNextBrowseItems(items); }); } + /** + * Change the page size + * @param size + */ pageSizeChange(size) { this.router.navigate([], { queryParams: Object.assign({ pageSize: size }), @@ -172,6 +193,10 @@ export class BrowseByMetadataPageComponent implements OnInit { }); } + /** + * Change the sorting direction + * @param direction + */ sortDirectionChange(direction) { this.router.navigate([], { queryParams: Object.assign({ sortDirection: direction }), diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 5388721218..40c51130d6 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -8,9 +8,7 @@ import { isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortOptions } from '../cache/models/sort-options.model'; import { GenericSuccessResponse } from '../cache/response-cache.models'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; @@ -20,7 +18,6 @@ import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest, - GetRequest, RestRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; @@ -38,8 +35,10 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { observable } from 'rxjs/internal-compatibility'; +/** + * The service handling all browse requests + */ @Injectable() export class BrowseService { protected linkPath = 'browses'; @@ -65,6 +64,9 @@ export class BrowseService { ) { } + /** + * Get all BrowseDefinitions + */ getBrowseDefinitions(): Observable> { const request$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), @@ -89,6 +91,10 @@ export class BrowseService { return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); } + /** + * Get all BrowseEntries filtered or modified by BrowseEntrySearchOptions + * @param options + */ getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable>> { return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), @@ -122,11 +128,8 @@ export class BrowseService { /** * Get all items linked to a certain metadata value - * @param {string} definitionID definition ID to define the metadata-field (e.g. author) * @param {string} filterValue metadata value to filter by (e.g. author's name) - * @param options Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } + * @param options Options to narrow down your search * @returns {Observable>>} */ getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable>> { @@ -162,6 +165,11 @@ export class BrowseService { ); } + /** + * Get the first item for a metadata definition in an optional scope + * @param definition + * @param scope + */ getFirstItemFor(definition: string, scope?: string): Observable>> { return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(definition), @@ -184,18 +192,31 @@ export class BrowseService { ); } + /** + * Get the previous page using the paginated list's prev link + * @param items + */ getPrevBrowseItems(items: RemoteData>): Observable>> { return observableOf(items.payload.prev).pipe( getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) ); } + /** + * Get the next page using the paginated list's next link + * @param items + */ getNextBrowseItems(items: RemoteData>): Observable>> { return observableOf(items.payload.next).pipe( getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) ); } + /** + * Get the browse URL by providing a metadatum key and linkPath + * @param metadatumKey + * @param linkPath + */ getBrowseURLFor(metadatumKey: string, linkPath: string): Observable { const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey); return this.getBrowseDefinitions().pipe( @@ -220,6 +241,12 @@ export class BrowseService { } +/** + * Operator for turning a href into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ export const getBrowseEntriesFor = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => (source: Observable): Observable>> => source.pipe( @@ -228,6 +255,12 @@ export const getBrowseEntriesFor = (requestService: RequestService, responseCach toRDPaginatedBrowseEntries(requestService, responseCache, rdb) ); +/** + * Operator for turning a href into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ export const getBrowseItemsFor = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => (source: Observable): Observable>> => source.pipe( @@ -236,6 +269,12 @@ export const getBrowseItemsFor = (requestService: RequestService, responseCache: toRDPaginatedBrowseItems(requestService, responseCache, rdb) ); +/** + * Operator for turning a RestRequest into a PaginatedList of Items + * @param requestService + * @param responseCache + * @param rdb + */ export const toRDPaginatedBrowseItems = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => (source: Observable): Observable>> => { const href$ = source.pipe(map((request: RestRequest) => request.href)); @@ -256,6 +295,12 @@ export const toRDPaginatedBrowseItems = (requestService: RequestService, respons return rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); }; +/** + * Operator for turning a RestRequest into a PaginatedList of BrowseEntries + * @param requestService + * @param responseCache + * @param rdb + */ export const toRDPaginatedBrowseEntries = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => (source: Observable): Observable>> => { const href$ = source.pipe(map((request: RestRequest) => request.href)); diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts index ff113f1773..5486deb057 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts @@ -4,9 +4,18 @@ import { hasValue } from '../../empty.util'; import { Subscription } from 'rxjs/internal/Subscription'; import { FormControl, FormGroup } from '@angular/forms'; +/** + * An abstract component to render StartsWith options + */ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { + /** + * The currently selected startsWith in string format + */ startsWith: string; + /** + * The formdata controlling the StartsWith input + */ formData: FormGroup; /** @@ -30,11 +39,18 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { }); } + /** + * Set the startsWith by event + * @param event + */ setStartsWith(event: Event) { this.startsWith = (event.target as HTMLInputElement).value; this.setStartsWithParam(); } + /** + * Add/Change the url query parameter startsWith using the local variable + */ setStartsWithParam() { if (this.startsWith === '-1') { this.startsWith = undefined; @@ -45,6 +61,10 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { }); } + /** + * Submit the form data. Called when clicking a submit button on the form. + * @param data + */ submitForm(data) { this.startsWith = data.startsWith; this.setStartsWithParam(); diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts index eb16254100..88f07c766f 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts @@ -2,6 +2,10 @@ import { BrowseByStartsWithType } from '../browse-by.component'; const startsWithMap = new Map(); +/** + * Fetch a decorator to render a StartsWith component for type + * @param type + */ export function renderStartsWithFor(type: BrowseByStartsWithType) { return function decorator(objectElement: any) { if (!objectElement) { @@ -11,6 +15,10 @@ export function renderStartsWithFor(type: BrowseByStartsWithType) { }; } +/** + * Get the correct component depending on the StartsWith type + * @param type + */ export function getStartsWithComponent(type: BrowseByStartsWithType) { return startsWithMap.get(type); } diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts index b527ef77fb..78551270d6 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts @@ -3,6 +3,10 @@ import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; import { BrowseByStartsWithType } from '../../browse-by.component'; import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; +/** + * A switchable component rendering StartsWith options for the type "Date". + * The options are rendered in a dropdown with an input field (of type number) next to it. + */ @Component({ selector: 'ds-browse-by-starts-with-date', styleUrls: ['./browse-by-starts-with-date.component.scss'], diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts index db0fac33fd..23ecacfa34 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts @@ -3,6 +3,9 @@ import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; import { BrowseByStartsWithType } from '../../browse-by.component'; import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; +/** + * A switchable component rendering StartsWith options for the type "Text". + */ @Component({ selector: 'ds-browse-by-starts-with-text', styleUrls: ['./browse-by-starts-with-text.component.scss'], diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 1abf056cbc..4d5c35f3bc 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -8,6 +8,9 @@ import { Observable } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; import { getStartsWithComponent } from './browse-by-starts-with/browse-by-starts-with-decorator'; +/** + * An enum that defines the type of StartsWith options + */ export enum BrowseByStartsWithType { text = 'Text', date = 'Date' @@ -46,22 +49,50 @@ export class BrowseByComponent implements OnInit { */ @Input() sortConfig: SortOptions; + /** + * The type of StartsWith options used to define what component to render for the options + * Defaults to text + */ @Input() type = BrowseByStartsWithType.text; + /** + * The list of options to render for the StartsWith component + */ @Input() startsWithOptions = []; + /** + * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination + */ @Input() enableArrows = false; + /** + * If enableArrows is set to true, should it hide the options gear? + */ @Input() hideGear = false; + /** + * If enableArrows is set to true, emit when the previous button is clicked + */ @Output() prev = new EventEmitter(); + /** + * If enableArrows is set to true, emit when the next button is clicked + */ @Output() next = new EventEmitter(); + /** + * If enableArrows is set to true, emit when the page size is changed + */ @Output() pageSizeChange = new EventEmitter(); + /** + * If enableArrows is set to true, emit when the sort direction is changed + */ @Output() sortDirectionChange = new EventEmitter(); + /** + * An object injector used to inject the startsWithOptions to the switchable StartsWith component + */ objectInjector: Injector; /** @@ -73,24 +104,41 @@ export class BrowseByComponent implements OnInit { } + /** + * Go to the previous page + */ goPrev() { this.prev.emit(true); } + /** + * Go to the next page + */ goNext() { this.next.emit(true); } + /** + * Change the page size + * @param size + */ doPageSizeChange(size) { this.paginationConfig.pageSize = size; this.pageSizeChange.emit(size); } + /** + * Change the sort direction + * @param direction + */ doSortDirectionChange(direction) { this.sortConfig.direction = direction; this.sortDirectionChange.emit(direction); } + /** + * Get the switchable StartsWith component dependant on the type + */ getStartsWithComponent() { return getStartsWithComponent(this.type); } From 394a32761171e28e6ba0006faa04e9480406ef8e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 13 Feb 2019 10:41:52 +0100 Subject: [PATCH 25/63] 59695: Refactored getFirstItemFor --- .../browse-by-date-page.component.spec.ts | 2 +- .../browse-by-date-page.component.ts | 6 +++--- src/app/core/browse/browse.service.ts | 7 ++++--- src/app/core/shared/operators.ts | 9 +++++++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts index 77f41be76a..bb2187d9a6 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.spec.ts @@ -39,7 +39,7 @@ describe('BrowseByDatePageComponent', () => { const mockBrowseService = { getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), - getFirstItemFor: () => toRemoteData([firstItem]) + getFirstItemFor: () => observableOf(new RemoteData(false, false, true, undefined, firstItem)) }; const mockDsoService = { diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index 701082b6a2..c5048c9520 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -75,10 +75,10 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { */ updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { this.subs.push( - this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData>) => { + this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { let lowerLimit = this.config.browseBy.defaultLowerLimit; - if (firstItemRD.payload.page.length > 0) { - const date = firstItemRD.payload.page[0].findMetadata(metadataField); + if (hasValue(firstItemRD.payload)) { + const date = firstItemRD.payload.findMetadata(metadataField); if (hasValue(date) && hasValue(+date.split('-')[0])) { lowerLimit = +date.split('-')[0]; } diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 40c51130d6..c7ff4d3478 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -26,7 +26,7 @@ import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, getBrowseDefinitionLinks, + filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence, getRemoteDataPayload, getRequestFromSelflink, getResponseFromSelflink @@ -170,7 +170,7 @@ export class BrowseService { * @param definition * @param scope */ - getFirstItemFor(definition: string, scope?: string): Observable>> { + getFirstItemFor(definition: string, scope?: string): Observable> { return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(definition), hasValueOperator(), @@ -188,7 +188,8 @@ export class BrowseService { } return href; }), - getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) + getBrowseItemsFor(this.requestService, this.responseCache, this.rdb), + getFirstOccurrence() ); } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index a9294b2fc9..2dfe2691da 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -88,3 +88,12 @@ export const getBrowseDefinitionLinks = (definitionID: string) => } }) ); + +/** + * Get the first occurrence of an object within a paginated list + */ +export const getFirstOccurrence = () => + (source: Observable>>): Observable> => + source.pipe( + map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) + ); From 5288f817b03d43b561b0c76da31acff7c5ed2bc1 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 13 Feb 2019 11:08:01 +0100 Subject: [PATCH 26/63] 59695: Fix AoT error and console warning --- .../browse-by-starts-with-abstract.component.ts | 7 +++++++ .../date/browse-by-starts-with-date.component.html | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts index 5486deb057..a51ad5e2ea 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts +++ b/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts @@ -39,6 +39,13 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { }); } + /** + * Get startsWith as a number; + */ + getStartsWithAsNumber() { + return +this.startsWith; + } + /** * Set the startsWith by event * @param event diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html index 43e53ea54d..0403e1b94f 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html +++ b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html @@ -16,7 +16,7 @@
- + From 7eec961fa78a37d03f016eebad6cc0fe8451038c Mon Sep 17 00:00:00 2001 From: lotte Date: Wed, 13 Feb 2019 11:39:32 +0100 Subject: [PATCH 27/63] 59334: edit item metadata finished --- .../metadata-schema-form.component.spec.ts | 3 +- .../metadata-field-form.component.spec.ts | 3 +- .../edit-in-place-field.component.html | 37 ++++---- .../edit-in-place-field.component.spec.ts | 95 +++++++++++++------ .../edit-in-place-field.component.ts | 27 +++--- .../item-metadata.component.html | 4 +- .../item-metadata.component.spec.ts | 3 +- .../object-updates/object-updates.actions.ts | 2 +- .../object-updates.reducer.spec.ts | 46 ++++++--- .../object-updates.service.spec.ts | 31 +++++- .../object-updates/object-updates.service.ts | 38 +++++--- src/app/shared/shared.module.ts | 4 +- .../utils/in-list-validator.directive.ts | 32 +++++++ src/app/shared/utils/validator.functions.ts | 6 +- 14 files changed, 233 insertions(+), 98 deletions(-) create mode 100644 src/app/shared/utils/in-list-validator.directive.ts diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index 02cf168387..42b6d1f133 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -20,7 +20,8 @@ describe('MetadataSchemaFormComponent', () => { /* tslint:disable:no-empty */ const registryServiceStub = { getActiveMetadataSchema: () => observableOf(undefined), - createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema) + createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), + cancelEditMetadataSchema: () => {} }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 25502a27c8..4364b0234a 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -27,7 +27,8 @@ describe('MetadataFieldFormComponent', () => { /* tslint:disable:no-empty */ const registryServiceStub = { getActiveMetadataField: () => observableOf(undefined), - createOrUpdateMetadataField: (field: MetadataField) => observableOf(field) + createOrUpdateMetadataField: (field: MetadataField) => observableOf(field), + cancelEditMetadataSchema: () => {}, }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+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 index 51f4c650af..e4fdc60a1e 100644 --- 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 @@ -1,3 +1,4 @@ +
{{"item.edit.metadata.metadatafield.invalid" | translate}} - +
{{metadata?.value}}
@@ -40,16 +41,20 @@ (onDebounce)="update()"/>
- -
- - - - + +
+ + + +
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index aad94931dd..927a4a96b1 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 @@ -16,6 +16,7 @@ import { InputSuggestion } from '../../../../shared/input-suggestions/input-sugg import { TestScheduler } from 'rxjs/testing'; import { MetadataSchema } from '../../../../core/metadata/metadataschema.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { TranslateModule } from '@ngx-translate/core'; let comp: EditInPlaceFieldComponent; let fixture: ComponentFixture; @@ -58,20 +59,23 @@ describe('EditInPlaceFieldComponent', () => { paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); metadataFieldService = jasmine.createSpyObj({ - queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)) + queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)), + getAllMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)) }); objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { saveChangeFieldUpdate: {}, saveRemoveFieldUpdate: {}, setEditableFieldUpdate: {}, + setValidFieldUpdate: {}, removeSingleFieldUpdate: {}, - isEditable: observableOf(false) // should always return something --> its in ngOnInit + isEditable: observableOf(false), // should always return something --> its in ngOnInit + isValid: observableOf(true) // should always return something --> its in ngOnInit } ); TestBed.configureTestingModule({ - imports: [FormsModule, SharedModule], + imports: [FormsModule, SharedModule, TranslateModule.forRoot()], declarations: [EditInPlaceFieldComponent], providers: [ { provide: RegistryService, useValue: metadataFieldService }, @@ -170,13 +174,27 @@ describe('EditInPlaceFieldComponent', () => { }); }); - describe('remove', () => { + describe('isValid is true', () => { beforeEach(() => { - comp.remove(); + comp.valid = observableOf(true); + fixture.detectChanges(); }); + it('the div should not contain an error message', () => { + const errorMessages = de.queryAll(By.css('small.text-danger')); + expect(errorMessages.length).toBe(0); + + }); + }); + + describe('isValid is false', () => { + beforeEach(() => { + comp.valid = observableOf(false); + fixture.detectChanges(); + }); + it('the div should contain no input fields or textareas', () => { + const errorMessages = de.queryAll(By.css('small.text-danger')); + expect(errorMessages.length).toBeGreaterThan(0); - it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct route and metadata', () => { - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(route, metadatum); }); }); @@ -190,6 +208,16 @@ describe('EditInPlaceFieldComponent', () => { }); }); + describe('removeChangesFromField', () => { + beforeEach(() => { + comp.removeChangesFromField(); + }); + + it('it should call removeChangesFromField on the objectUpdatesService with the correct route and uuid', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid); + }); + }); + describe('findMetadataFieldSuggestions', () => { const query = 'query string'; @@ -281,44 +309,49 @@ describe('EditInPlaceFieldComponent', () => { describe('when canSetEditable emits true', () => { beforeEach(() => { + comp.editable = observableOf(false); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); + fixture.detectChanges(); }); - it('the div should contain a edit icon', () => { - const editIcon = de.query(By.css('i.fa-edit')); - expect(editIcon).not.toBeNull(); + it('the div should have an enabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(false); }); }); describe('when canSetEditable emits false', () => { beforeEach(() => { + comp.editable = observableOf(false); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); fixture.detectChanges(); }); - it('the div should not contain a edit icon', () => { - const editIcon = de.query(By.css('i.fa-edit')); - expect(editIcon).toBeNull(); + it('the div should have a disabled button with an edit icon', () => { + const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled; + expect(editIcon).toBe(true); }); }); describe('when canSetUneditable emits true', () => { beforeEach(() => { + comp.editable = observableOf(true); spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); fixture.detectChanges(); }); - it('the div should contain a check icon', () => { - const checkIcon = de.query(By.css('i.fa-check')); - expect(checkIcon).not.toBeNull(); + it('the div should have an enabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(false); }); }); describe('when canSetUneditable emits false', () => { beforeEach(() => { + comp.editable = observableOf(true); spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); fixture.detectChanges(); }); - it('the div should not contain a check icon', () => { - const checkIcon = de.query(By.css('i.fa-check')); - expect(checkIcon).toBeNull(); + it('the div should have a disabled button with a check icon', () => { + const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled; + expect(checkButtonAttrs).toBe(true); }); }); @@ -327,9 +360,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have an enabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(false); }); }); @@ -338,9 +371,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have a disabled button with a trash icon', () => { + const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled; + expect(trashButtonAttrs).toBe(true); }); }); @@ -349,9 +382,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have an enabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(false); }); }); @@ -360,9 +393,9 @@ describe('EditInPlaceFieldComponent', () => { 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(); + it('the div should have a disabled button with an undo icon', () => { + const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled; + expect(undoIcon).toBe(true); }); }); 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 b85b558cfd..d0b35b7a82 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 @@ -1,19 +1,18 @@ import { Component, Input, OnChanges, OnInit } from '@angular/core'; -import { isNotEmpty } from '../../../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../../../shared/empty.util'; import { Metadatum } from '../../../../core/shared/metadatum.model'; import { RegistryService } from '../../../../core/registry/registry.service'; import { cloneDeep } from 'lodash'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { MetadataField } from '../../../../core/metadata/metadatafield.model'; import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; -import { 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'; import { inListValidator } from '../../../../shared/utils/validator.functions'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; -import { FormControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { FormControl } from '@angular/forms'; @Component({ selector: 'ds-edit-in-place-field', @@ -51,7 +50,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { */ metadataFieldSuggestions: BehaviorSubject = new BehaviorSubject([]); - formControl: FormControl; + /** + * List of strings with all metadata field keys available + */ + metadataFields: Observable; constructor( private metadataFieldService: RegistryService, @@ -61,23 +63,21 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { /** * Sets up an observable that keeps track of the current editable and valid state of this field - * Also creates a form control object for the input suggestions */ ngOnInit(): void { this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid); this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid); - this.findMetadataFields().pipe(take(1)).subscribe((metadataFields: string[]) => { - const validator = inListValidator(metadataFields); - this.formControl = new FormControl('', validator); - }); + this.metadataFields = this.findMetadataFields() } /** * Sends a new change update for this field to the object updates service */ - update() { + update(control?: FormControl) { this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata); - this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, this.formControl.valid); + if (hasValue(control)) { + this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, control.valid); + } } /** @@ -131,6 +131,9 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { ); } + /** + * Method to request all metadata fields and convert them to a list of strings + */ findMetadataFields(): Observable { return this.metadataFieldService.getAllMetadataFields().pipe( getSucceededRemoteData(), 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 bc8e52d48b..36d18372bd 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 @@ -26,9 +26,9 @@ {{'item.edit.metadata.headers.field' | translate}} - {{'item.edit.metadata.headers.value' | translate}} + {{'item.edit.metadata.headers.value' | translate}} {{'item.edit.metadata.headers.language' | translate}} - {{'item.edit.metadata.headers.edit' | translate}} + {{'item.edit.metadata.headers.edit' | translate}} { getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]), getLastModified: observableOf(date), hasUpdates: observableOf(true), - isReinstatable: observableOf(false) // should always return something --> its in ngOnInit + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) } ); 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 c1b35d07b4..7381188892 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -2,10 +2,10 @@ import { type } from '../../../shared/ngrx/type'; import { Action } from '@ngrx/store'; import { Identifiable } from './object-updates.reducer'; import { INotification } from '../../../shared/notifications/models/notification.model'; + /** * The list of ObjectUpdatesAction type definitions */ - export const ObjectUpdatesActionTypes = { INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), 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 index 377b3ab1b4..f5698b9b78 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -6,7 +6,7 @@ import { InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, - SetEditableFieldUpdateAction + SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; @@ -54,22 +54,25 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: true, - isNew: false + isNew: false, + isValid: true }, [identifiable2.uuid]: { editable: false, - isNew: true + isNew: true, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: false }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.title', + key: 'dc.titl', language: null, value: 'New title' }, @@ -85,15 +88,18 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: true, - isNew: false + isNew: false, + isValid: true }, [identifiable2.uuid]: { editable: false, - isNew: true + isNew: true, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: true }, }, lastModified: modDate @@ -102,22 +108,25 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: true, - isNew: false + isNew: false, + isValid: true }, [identifiable2.uuid]: { editable: false, - isNew: true + isNew: true, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: false }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.title', + key: 'dc.titl', language: null, value: 'New title' }, @@ -194,11 +203,13 @@ describe('objectUpdatesReducer', () => { fieldStates: { [identifiable1.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: true }, [identifiable3.uuid]: { editable: false, - isNew: false + isNew: false, + isValid: true }, }, fieldUpdates: {}, @@ -216,6 +227,13 @@ describe('objectUpdatesReducer', () => { expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy(); }); + it('should set the given field\'s fieldStates when the SET_VALID_FIELD action is dispatched, based on the payload', () => { + const action = new SetValidFieldUpdateAction(url, identifiable3.uuid, false); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].fieldStates[identifiable3.uuid].isValid).toBeFalsy(); + }); + it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => { const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); 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 index 4f0393c641..e9fc4652b0 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -32,9 +32,9 @@ describe('ObjectUpdatesService', () => { beforeEach(() => { const fieldStates = { - [identifiable1.uuid]: { editable: false, isNew: false }, - [identifiable2.uuid]: { editable: true, isNew: false }, - [identifiable3.uuid]: { editable: true, isNew: true }, + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, }; const objectEntry = { @@ -45,6 +45,9 @@ describe('ObjectUpdatesService', () => { service = new ObjectUpdatesService(store); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); + spyOn(service as any, 'getFieldState').and.callFake((uuid) => { + return observableOf(fieldStates[uuid]); + }); spyOn(service as any, 'saveFieldUpdate'); }); @@ -75,7 +78,7 @@ describe('ObjectUpdatesService', () => { 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); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); result$.subscribe((result) => { expect(result).toEqual(false); }); @@ -83,7 +86,25 @@ describe('ObjectUpdatesService', () => { 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); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + + describe('isValid', () => { + it('should return false if this identifiable is currently not valid in the store', () => { + const result$ = service.isValid(url, identifiable2.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid); + result$.subscribe((result) => { + expect(result).toEqual(false); + }); + }); + + it('should return true if this identifiable is currently valid in the store', () => { + const result$ = service.isValid(url, identifiable1.uuid); + expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid); result$.subscribe((result) => { expect(result).toEqual(true); }); 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 6136d31ac0..85e17b5b2f 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -18,7 +18,7 @@ import { RemoveFieldUpdateAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; -import { filter, map } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; @@ -30,6 +30,10 @@ function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector state[url]); } +function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); +} + /** * Service that dispatches and reads from the ObjectUpdates' state in the store */ @@ -67,6 +71,15 @@ export class ObjectUpdatesService { return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url))); } + /** + * Request the getFieldState state for a specific URL and UUID + * @param url The URL to filter by + * @param uuid The field's UUID to filter by + */ + private getFieldState(url: string, uuid: string): Observable { + return this.store.pipe(select(filterByUrlAndUUIDFieldStateSelector(url, uuid))); + } + /** * Method that combines the state's updates with the initial values (when there's no update) to create * a FieldUpdates object @@ -95,11 +108,11 @@ export class ObjectUpdatesService { * @param uuid The UUID of the field */ isEditable(url: string, uuid: string): Observable { - const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe( - filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), - map((objectEntry) => objectEntry.fieldStates[uuid].editable - ) + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.editable), + distinctUntilChanged() ) } @@ -109,11 +122,11 @@ export class ObjectUpdatesService { * @param uuid The UUID of the field */ isValid(url: string, uuid: string): Observable { - const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe( - filter((objectEntry) => hasValue(objectEntry.fieldStates[uuid])), - map((objectEntry) => objectEntry.fieldStates[uuid].isValid - ) + const fieldState$ = this.getFieldState(url, uuid); + return fieldState$.pipe( + filter((fieldState) => hasValue(fieldState)), + map((fieldState) => fieldState.isValid), + distinctUntilChanged() ) } @@ -126,7 +139,8 @@ export class ObjectUpdatesService { return objectUpdates.pipe( map((entry: ObjectUpdatesEntry) => { return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0 - }) + }), + distinctUntilChanged() ) } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index c3e7051304..6bdeabec43 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -92,6 +92,7 @@ import { EditComColPageComponent } from './comcol-forms/edit-comcol-page/edit-co import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { ObjectValuesPipe } from './utils/object-values-pipe'; +import { InListValidator } from './utils/in-list-validator.directive'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -197,7 +198,8 @@ const DIRECTIVES = [ VarDirective, DragClickDirective, DebounceDirective, - ClickOutsideDirective + ClickOutsideDirective, + InListValidator ]; @NgModule({ diff --git a/src/app/shared/utils/in-list-validator.directive.ts b/src/app/shared/utils/in-list-validator.directive.ts new file mode 100644 index 0000000000..621ae93b83 --- /dev/null +++ b/src/app/shared/utils/in-list-validator.directive.ts @@ -0,0 +1,32 @@ +import { Directive, Input } from '@angular/core'; +import { FormControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; +import { inListValidator } from './validator.functions'; + +/** + * Directive for validating if a ngModel value is in a given list + */ +@Directive({ + selector: '[ngModel][dsInListValidator]', + // We add our directive to the list of existing validators + providers: [ + { provide: NG_VALIDATORS, useExisting: InListValidator, multi: true } + ] +}) +export class InListValidator implements Validator { + /** + * The list to look in + */ + @Input() + dsInListValidator: string[]; + + /** + * The function that checks if the form control's value is currently valid + * @param c The FormControl + */ + validate(c: FormControl): ValidationErrors | null { + if (this.dsInListValidator !== null) { + return inListValidator(this.dsInListValidator)(c); + } + return null; + } +} diff --git a/src/app/shared/utils/validator.functions.ts b/src/app/shared/utils/validator.functions.ts index 55fe498747..3ad660731a 100644 --- a/src/app/shared/utils/validator.functions.ts +++ b/src/app/shared/utils/validator.functions.ts @@ -1,7 +1,11 @@ import { AbstractControl, ValidatorFn } from '@angular/forms'; +/** + * Returns a validator function to check if the control's value is in a given list + * @param list The list to look in + */ export function inListValidator(list: string[]): ValidatorFn { return (control: AbstractControl): {[key: string]: any} | null => { - const contains = list.indexOf(control.value) > 0; + const contains = list.indexOf(control.value) > -1; return contains ? null : {inList: {value: control.value}} }; } From 86115c44ce12e1715c2855371dd090d85535d083 Mon Sep 17 00:00:00 2001 From: lotte Date: Wed, 13 Feb 2019 12:18:21 +0100 Subject: [PATCH 28/63] started navigation for tabs --- .../edit-item-page/edit-item-page.component.html | 10 +++++----- .../edit-item-page/edit-item-page.component.ts | 5 +++-- src/app/+item-page/item-page-routing.module.ts | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) 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 82a4b0c283..2cb05ab21f 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 @@ -3,13 +3,13 @@

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

- - + + - + @@ -20,12 +20,12 @@ - + - + diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index b8d3ca7957..a795dd3dee 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -1,6 +1,6 @@ import {fadeIn, fadeInOut} from '../../shared/animations/fade'; import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; -import {ActivatedRoute} from '@angular/router'; +import { ActivatedRoute, Params } from '@angular/router'; import {RemoteData} from '../../core/data/remote-data'; import {Item} from '../../core/shared/item.model'; import {Observable} from 'rxjs'; @@ -24,12 +24,13 @@ export class EditItemPageComponent implements OnInit { * The item to edit */ itemRD$: Observable>; - + params$: Observable; constructor(private route: ActivatedRoute) { } ngOnInit(): void { this.itemRD$ = this.route.data.pipe(map((data) => data.item)); + this.params$ = this.route.params; } } diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 0f2f0817f6..962f659076 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -15,7 +15,7 @@ export function getItemEditPath(id: string) { return new URLCombiner(getItemModulePath(),ITEM_EDIT_PATH.replace(/:id/, id)).toString() } -const ITEM_EDIT_PATH = ':id/edit'; +const ITEM_EDIT_PATH = ':id/edit/:page'; @NgModule({ imports: [ @@ -38,7 +38,7 @@ const ITEM_EDIT_PATH = ':id/edit'; { path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', - // canActivate: [AuthenticatedGuard] + canActivate: [AuthenticatedGuard] } ]) ], From 25b65d840c634e0ed0adf2519d879ee09d8e6680 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 19 Feb 2019 08:57:37 +0100 Subject: [PATCH 29/63] 59695: Arrow-navigation enabled by default + support for entries --- .../browse-by-metadata-page.component.html | 2 +- .../browse-by-metadata-page.component.ts | 24 ++++++++++++++----- .../browse-by-title-page.component.spec.ts | 9 ++++--- .../browse-by-title-page.component.ts | 20 ++-------------- src/app/core/browse/browse.service.ts | 24 +++++++++++++++++-- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html index 9ccc466d90..23f52e506a 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -7,7 +7,7 @@ [sortConfig]="sortConfig" [type]="startsWithType" [startsWithOptions]="startsWithOptions" - [enableArrows]="startsWith" + [enableArrows]="true" (prev)="goPrev()" (next)="goNext()" (pageSizeChange)="pageSizeChange($event)" diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index a8bd7086a4..cfd8c74107 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -168,18 +168,30 @@ export class BrowseByMetadataPageComponent implements OnInit { * Navigate to the previous page */ goPrev() { - this.items$.pipe(take(1)).subscribe((items) => { - this.items$ = this.browseService.getPrevBrowseItems(items); - }); + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getPrevBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getPrevBrowseEntries(entries); + }); + } } /** * Navigate to the next page */ goNext() { - this.items$.pipe(take(1)).subscribe((items) => { - this.items$ = this.browseService.getNextBrowseItems(items); - }); + if (this.items$) { + this.items$.pipe(take(1)).subscribe((items) => { + this.items$ = this.browseService.getNextBrowseItems(items); + }); + } else if (this.browseEntries$) { + this.browseEntries$.pipe(take(1)).subscribe((entries) => { + this.browseEntries$ = this.browseService.getNextBrowseEntries(entries); + }); + } } /** diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts index d7bc7397b8..bf62993d02 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts @@ -41,8 +41,8 @@ describe('BrowseByTitlePageComponent', () => { }) ]; - const mockItemDataService = { - findAll: () => toRemoteData(mockItems) + const mockBrowseService = { + getBrowseItemsFor: () => toRemoteData(mockItems) }; const mockDsoService = { @@ -59,10 +59,9 @@ describe('BrowseByTitlePageComponent', () => { declarations: [BrowseByTitlePageComponent, EnumKeysPipe], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, - { provide: BrowseService, useValue: {} }, + { provide: BrowseService, useValue: mockBrowseService }, { provide: DSpaceObjectDataService, useValue: mockDsoService }, - { provide: Router, useValue: new MockRouter() }, - { provide: ItemDataService, useValue: mockItemDataService } + { provide: Router, useValue: new MockRouter() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index a7a35f49cc..8703193dc5 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -25,8 +25,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, protected dsoService: DSpaceObjectDataService, - protected router: Router, - protected itemDataService: ItemDataService) { + protected router: Router) { super(route, browseService, dsoService, router); } @@ -43,27 +42,12 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { }) .subscribe((params) => { this.metadata = params.metadata || this.defaultMetadata; - this.updatePage(browseParamsToOptions(params, this.paginationConfig, this.sortConfig)); + this.updatePageWithItems(browseParamsToOptions(params, this.paginationConfig, this.sortConfig, this.metadata), undefined); this.updateParent(params.scope) })); this.startsWithOptions = []; } - /** - * Updates the current page with searchOptions - * @param searchOptions Options to narrow down your search: - * { pagination: PaginationComponentOptions, - * sort: SortOptions } - */ - updatePage(searchOptions: BrowseEntrySearchOptions) { - this.items$ = this.itemDataService.findAll({ - currentPage: searchOptions.pagination.currentPage, - elementsPerPage: searchOptions.pagination.pageSize, - sort: searchOptions.sort, - scopeID: searchOptions.scope - }); - } - ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index c7ff4d3478..91dd5aff75 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -194,7 +194,7 @@ export class BrowseService { } /** - * Get the previous page using the paginated list's prev link + * Get the previous page of items using the paginated list's prev link * @param items */ getPrevBrowseItems(items: RemoteData>): Observable>> { @@ -204,7 +204,7 @@ export class BrowseService { } /** - * Get the next page using the paginated list's next link + * Get the next page of items using the paginated list's next link * @param items */ getNextBrowseItems(items: RemoteData>): Observable>> { @@ -213,6 +213,26 @@ export class BrowseService { ); } + /** + * Get the previous page of browse-entries using the paginated list's prev link + * @param entries + */ + getPrevBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.prev).pipe( + getBrowseEntriesFor(this.requestService, this.responseCache, this.rdb) + ); + } + + /** + * Get the next page of browse-entries using the paginated list's next link + * @param entries + */ + getNextBrowseEntries(entries: RemoteData>): Observable>> { + return observableOf(entries.payload.next).pipe( + getBrowseEntriesFor(this.requestService, this.responseCache, this.rdb) + ); + } + /** * Get the browse URL by providing a metadatum key and linkPath * @param metadatumKey From 0da38dbd523b2df97ba12d49a5d636438d8c0b20 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 19 Feb 2019 09:15:47 +0100 Subject: [PATCH 30/63] 59695: Browse-By-Title test fix --- .../browse-by-title-page.component.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts index bf62993d02..acb1fe72e3 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.spec.ts @@ -42,7 +42,8 @@ describe('BrowseByTitlePageComponent', () => { ]; const mockBrowseService = { - getBrowseItemsFor: () => toRemoteData(mockItems) + getBrowseItemsFor: () => toRemoteData(mockItems), + getBrowseEntriesFor: () => toRemoteData([]) }; const mockDsoService = { @@ -50,7 +51,8 @@ describe('BrowseByTitlePageComponent', () => { }; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}) + params: observableOf({}), + data: observableOf({ metadata: 'title' }) }); beforeEach(async(() => { From bfa1e77177ee3baca7c2200eb143e5b6aa0f2a33 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 19 Feb 2019 10:13:49 +0100 Subject: [PATCH 31/63] finalised edit item page --- resources/i18n/en.json | 20 ++-- .../edit-item-page.component.html | 52 ++++------- .../edit-item-page.component.ts | 37 +++++--- .../edit-item-page.routing.module.ts | 51 ++++++++-- .../edit-in-place-field.component.html | 21 +++-- .../edit-in-place-field.component.spec.ts | 20 ++-- .../edit-in-place-field.component.ts | 42 +++++---- .../item-metadata.component.html | 35 +++---- .../item-metadata.component.scss | 12 ++- .../item-metadata.component.spec.ts | 40 ++++---- .../item-metadata/item-metadata.component.ts | 92 +++++++++++-------- .../item-status/item-status.component.spec.ts | 17 +++- .../item-status/item-status.component.ts | 68 ++++++++------ .../+item-page/item-page-routing.module.ts | 4 +- src/app/core/metadata/metadatafield.model.ts | 6 +- .../input-suggestions.component.html | 2 + .../input-suggestions.component.ts | 14 +++ src/app/shared/shared.module.ts | 4 +- src/app/shared/utils/auto-focus.directive.ts | 22 +++++ 19 files changed, 346 insertions(+), 213 deletions(-) create mode 100644 src/app/shared/utils/auto-focus.directive.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 795b3cc747..12f4547cf6 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -188,7 +188,7 @@ "confirm": "Withdraw", "cancel": "Cancel", "success": "The item was withdrawn successfully", - "error": "An error occured while withdrawing the item" + "error": "An error occurred while withdrawing the item" }, "reinstate": { "header": "Reinstate item: {{ id }}", @@ -196,7 +196,7 @@ "confirm": "Reinstate", "cancel": "Cancel", "success": "The item was reinstated successfully", - "error": "An error occured while reinstating the item" + "error": "An error occurred while reinstating the item" }, "private": { "header": "Make item private: {{ id }}", @@ -204,7 +204,7 @@ "confirm": "Make it Private", "cancel": "Cancel", "success": "The item is now private", - "error": "An error occured while making the item private" + "error": "An error occurred while making the item private" }, "public": { "header": "Make item public: {{ id }}", @@ -212,7 +212,7 @@ "confirm": "Make it Public", "cancel": "Cancel", "success": "The item is now public", - "error": "An error occured while making the item public" + "error": "An error occurred while making the item public" }, "delete": { "header": "Delete item: {{ id }}", @@ -220,7 +220,7 @@ "confirm": "Delete", "cancel": "Cancel", "success": "The item has been deleted", - "error": "An error occured while deleting the item" + "error": "An error occurred while deleting the item" }, "metadata": { "add-button": "Add", @@ -233,6 +233,14 @@ "language": "Lang", "edit": "Edit" }, + "edit": { + "buttons": { + "edit": "Edit", + "unedit": "Stop editing", + "remove": "Remove", + "undo": "Undo changes" + } + }, "metadatafield": { "invalid": "Please choose a valid metadata field" }, @@ -247,7 +255,7 @@ }, "invalid": { "title": "Metadata invalid", - "content": "Please make sure all fields are valid" + "content": "Your changes were not saved. Please make sure all fields are valid before you save." } } } 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 2cb05ab21f..f580d21d18 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.html @@ -1,37 +1,21 @@
-
-
-

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

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

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

+ +
-
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index a795dd3dee..d7ab3ea199 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -1,10 +1,11 @@ -import {fadeIn, fadeInOut} from '../../shared/animations/fade'; -import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; -import {RemoteData} from '../../core/data/remote-data'; -import {Item} from '../../core/shared/item.model'; -import {Observable} from 'rxjs'; -import {map} from 'rxjs/operators'; +import { fadeIn, fadeInOut } from '../../shared/animations/fade'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params, Router } from '@angular/router'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; @Component({ selector: 'ds-edit-item-page', @@ -24,13 +25,27 @@ export class EditItemPageComponent implements OnInit { * The item to edit */ itemRD$: Observable>; - params$: Observable; - constructor(private route: ActivatedRoute) { + + /** + * The current page outlet string + */ + currentPage: string; + + /** + * All possible page outlet strings + */ + pages: string[]; + + constructor(private route: ActivatedRoute, private router: Router) { + this.router.events.subscribe(() => { + this.currentPage = this.route.snapshot.firstChild.routeConfig.path; + }); } ngOnInit(): void { + this.pages = this.route.routeConfig.children + .map((child: any) => child.path) + .filter((path: string) => isNotEmpty(path)); // ignore reroutes this.itemRD$ = this.route.data.pipe(map((data) => data.item)); - this.params$ = this.route.params; } - } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 8ef6f43e17..8a45c42ff6 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -1,12 +1,14 @@ -import {ItemPageResolver} from '../item-page.resolver'; -import {NgModule} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {EditItemPageComponent} from './edit-item-page.component'; -import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component'; -import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'; -import {ItemPrivateComponent} from './item-private/item-private.component'; -import {ItemPublicComponent} from './item-public/item-public.component'; -import {ItemDeleteComponent} from './item-delete/item-delete.component'; +import { ItemPageResolver } from '../item-page.resolver'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EditItemPageComponent } from './edit-item-page.component'; +import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; +import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; +import { ItemPrivateComponent } from './item-private/item-private.component'; +import { ItemPublicComponent } from './item-public/item-public.component'; +import { ItemDeleteComponent } from './item-delete/item-delete.component'; +import { ItemStatusComponent } from './item-status/item-status.component'; +import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -25,7 +27,36 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; component: EditItemPageComponent, resolve: { item: ItemPageResolver - } + }, + children: [ + { + path: '', + redirectTo: 'status' + }, + { + path: 'status', + component: ItemStatusComponent + }, + { + path: 'bitstreams', + /* TODO - change when bitstreams page exists */ + component: ItemStatusComponent + }, + { + path: 'metadata', + component: ItemMetadataComponent + }, + { + path: 'view', + /* TODO - change when view page exists */ + component: ItemStatusComponent + }, + { + path: 'curate', + /* TODO - change when curate page exists */ + component: ItemStatusComponent + }, + ] }, { path: ITEM_EDIT_WITHDRAW_PATH, 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 index e4fdc60a1e..25f586d0ed 100644 --- 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 @@ -7,7 +7,7 @@
- {{metadata?.key}} + {{metadata?.key?.split('.').join('.​')}}
- - - -
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 927a4a96b1..cefbb3620a 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 @@ -44,7 +44,7 @@ const metadatum = Object.assign(new Metadatum(), { language: 'en' }); -const route = 'http://test-url.com/test-url'; +const url = 'http://test-url.com/test-url'; const fieldUpdate = { field: metadatum, changeType: undefined @@ -92,7 +92,7 @@ describe('EditInPlaceFieldComponent', () => { de = fixture.debugElement.query(By.css('div.d-flex')); el = de.nativeElement; - comp.route = route; + comp.url = url; comp.fieldUpdate = fieldUpdate; comp.metadata = metadatum; @@ -104,8 +104,8 @@ describe('EditInPlaceFieldComponent', () => { comp.update(); }); - it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct route and metadata', () => { - expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(route, metadatum); + it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum); }); }); @@ -145,8 +145,8 @@ describe('EditInPlaceFieldComponent', () => { comp.setEditable(editable); }); - it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct route and uuid and false', () => { - expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid, editable); + it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => { + expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable); }); }); @@ -203,8 +203,8 @@ describe('EditInPlaceFieldComponent', () => { comp.remove(); }); - it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct route and metadata', () => { - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(route, metadatum); + it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum); }); }); @@ -213,8 +213,8 @@ describe('EditInPlaceFieldComponent', () => { comp.removeChangesFromField(); }); - it('it should call removeChangesFromField on the objectUpdatesService with the correct route and uuid', () => { - expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid); + it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid); }); }); 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 d0b35b7a82..f24de359b8 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 @@ -10,7 +10,6 @@ import { InputSuggestion } from '../../../../shared/input-suggestions/input-sugg import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { inListValidator } from '../../../../shared/utils/validator.functions'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { FormControl } from '@angular/forms'; @@ -28,9 +27,9 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { */ @Input() fieldUpdate: FieldUpdate; /** - * The current route of this page + * The current url of this page */ - @Input() route: string; + @Input() url: string; /** * The metadatum of this field */ @@ -65,8 +64,8 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { * Sets up an observable that keeps track of the current editable and valid state of this field */ ngOnInit(): void { - this.editable = this.objectUpdatesService.isEditable(this.route, this.metadata.uuid); - this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid); + this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid); + this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid); this.metadataFields = this.findMetadataFields() } @@ -74,32 +73,41 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { * Sends a new change update for this field to the object updates service */ update(control?: FormControl) { - this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata); + this.objectUpdatesService.saveChangeFieldUpdate(this.url, this.metadata); if (hasValue(control)) { - this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, control.valid); + this.checkValidity(control); } } + /** + * Method to check the validity of a form control + * @param control The form control to check + */ + private checkValidity(control: FormControl) { + control.updateValueAndValidity(); + this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, control.valid); + } + /** * Sends a new editable state for this field to the service to change it * @param editable The new editable state for this field */ setEditable(editable: boolean) { - this.objectUpdatesService.setEditableFieldUpdate(this.route, this.metadata.uuid, editable); + this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable); } /** * Sends a new remove update for this field to the object updates service */ remove() { - this.objectUpdatesService.saveRemoveFieldUpdate(this.route, this.metadata); + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.metadata); } /** * Notifies the object updates service that the updates for the current field can be removed */ removeChangesFromField() { - this.objectUpdatesService.removeSingleFieldUpdate(this.route, this.metadata.uuid); + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid); } /** @@ -170,15 +178,7 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { * @return an observable that emits true when the user should be able to remove this field and false when they should not */ canRemove(): Observable { - return this.editable.pipe( - map((editable: boolean) => { - if (editable) { - return false; - } else { - return this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD; - } - }) - ); + return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD); } /** @@ -186,7 +186,9 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { * @return an observable that emits true when the user should be able to undo changes to this field and false when they should not */ canUndo(): Observable { - return observableOf(this.fieldUpdate.changeType >= 0); + return this.editable.pipe( + map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable) + ); } protected isNotEmpty(value): boolean { 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 36d18372bd..04896ee4a6 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 @@ -5,8 +5,7 @@ (click)="add()"> {{"item.edit.metadata.add-button" | translate}} - -
+
- - +
+
+ + + +
diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss index b2994dcec7..1ae2839606 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -1,5 +1,13 @@ @import '../../../../styles/variables.scss'; -.button-row .btn { - min-width: $button-min-width; +.button-row { + .spaced-btn-group > .btn { + margin-right: 0.5 * $spacer; + &:last-child { + margin-right: 0; + } + } + .btn { + min-width: $button-min-width; + } } \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts index e57c4af67c..a9c8c4327f 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 @@ -7,7 +7,7 @@ import { Metadatum } from '../../../core/shared/metadatum.model'; import { TestScheduler } from 'rxjs/testing'; import { SharedModule } from '../../../shared/shared.module'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateModule } from '@ngx-translate/core'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -32,6 +32,8 @@ const infoNotification: INotification = new Notification('id', NotificationType. const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); const date = new Date(); const router = new RouterStub(); +let routeStub; + let itemService; const notificationsService = jasmine.createSpyObj('notificationsService', { @@ -56,9 +58,9 @@ const metadatum3 = Object.assign(new Metadatum(), { value: 'Shakespeare, William', }); -const route = 'http://test-url.com/test-url'; +const url = 'http://test-url.com/test-url'; -router.url = route; +router.url = url; const fieldUpdate1 = { field: metadatum1, @@ -84,6 +86,11 @@ describe('ItemMetadataComponent', () => { update: observableOf(new RemoteData(false, false, true, undefined, item)), commitUpdates: {} }); + routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, item) }) + } + }; scheduler = getTestScheduler(); objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { @@ -111,6 +118,9 @@ describe('ItemMetadataComponent', () => { { provide: ItemDataService, useValue: itemService }, { provide: ObjectUpdatesService, useValue: objectUpdatesService }, { provide: Router, useValue: router }, + { + provide: ActivatedRoute, useValue: routeStub + }, { provide: NotificationsService, useValue: notificationsService }, { provide: GLOBAL_CONFIG, useValue: { notifications: { timeOut: 10 } } as any } ], schemas: [ @@ -118,16 +128,14 @@ describe('ItemMetadataComponent', () => { ] }).compileComponents(); }) - ) - ; + ); beforeEach(() => { fixture = TestBed.createComponent(ItemMetadataComponent); comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance de = fixture.debugElement.query(By.css('div.d-flex')); el = de.nativeElement; - comp.item = item; - comp.route = route; + comp.url = url; fixture.detectChanges(); }); @@ -137,8 +145,8 @@ describe('ItemMetadataComponent', () => { comp.add(md); }); - it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct route and metadata', () => { - expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(route, md); + it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md); }); }); @@ -147,8 +155,8 @@ describe('ItemMetadataComponent', () => { comp.discard(); }); - it('it should call discardFieldUpdates on the objectUpdatesService with the correct route and notification', () => { - expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(route, infoNotification); + it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); }); }); @@ -157,8 +165,8 @@ describe('ItemMetadataComponent', () => { comp.reinstate(); }); - it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct route', () => { - expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(route); + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); }); }); @@ -167,10 +175,10 @@ describe('ItemMetadataComponent', () => { comp.submit(); }); - it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct route and metadata', () => { - expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(route, comp.item.metadata); + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => { + expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadata); expect(itemService.update).toHaveBeenCalledWith(comp.item); - expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(route, comp.item.metadata); + expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadata); }); }); 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 ae32028486..380e05c334 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 @@ -2,7 +2,7 @@ import { Component, Inject, Input, OnInit } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { cloneDeep } from 'lodash'; import { Observable } from 'rxjs'; import { @@ -31,15 +31,15 @@ export class ItemMetadataComponent implements OnInit { /** * The item to display the edit page for */ - @Input() item: Item; + item: Item; /** * The current values and updates for all this item's metadata fields */ updates$: Observable; /** - * The current route of this page + * The current url of this page */ - route: string; + url: string; /** * The time span for being able to undo discarding changes */ @@ -51,16 +51,25 @@ export class ItemMetadataComponent implements OnInit { private router: Router, private notificationsService: NotificationsService, private translateService: TranslateService, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private route: ActivatedRoute ) { } ngOnInit(): void { + this.route.parent.data.pipe(map((data) => data.item)) + .pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.item = item; + }); + this.discardTimeOut = this.EnvConfig.notifications.timeOut; - this.route = this.router.url; - if (this.route.indexOf('?') > 0) { - this.route = this.route.substr(0, this.route.indexOf('?')); + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); } this.hasChanges().pipe(first()).subscribe((hasChanges) => { if (!hasChanges) { @@ -69,7 +78,8 @@ export class ItemMetadataComponent implements OnInit { this.checkLastModified(); } }); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadata); + } /** @@ -77,7 +87,8 @@ export class ItemMetadataComponent implements OnInit { * @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum */ add(metadata: Metadatum = new Metadatum()) { - this.objectUpdatesService.saveAddFieldUpdate(this.route, metadata); + this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); + } /** @@ -88,21 +99,21 @@ export class ItemMetadataComponent implements OnInit { const title = this.translateService.instant('item.edit.metadata.notifications.discarded.title'); const content = this.translateService.instant('item.edit.metadata.notifications.discarded.content'); const undoNotification = this.notificationsService.info(title, content, { timeOut: this.discardTimeOut }); - this.objectUpdatesService.discardFieldUpdates(this.route, undoNotification); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); } /** * Request the object updates service to undo discarding all changes to this item */ reinstate() { - this.objectUpdatesService.reinstateFieldUpdates(this.route); + this.objectUpdatesService.reinstateFieldUpdates(this.url); } /** * Sends all initial values of this item to the object updates service */ private initializeOriginalFields() { - this.objectUpdatesService.initialize(this.route, this.item.metadata, this.item.lastModified); + this.objectUpdatesService.initialize(this.url, this.item.metadata, this.item.lastModified); } /* Prevent unnecessary rerendering so fields don't lose focus **/ @@ -117,42 +128,42 @@ export class ItemMetadataComponent implements OnInit { submit() { this.isValid().pipe(first()).subscribe((isValid) => { if (isValid) { - const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable; - metadata$.pipe( - first(), - switchMap((metadata: Metadatum[]) => { - const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); - return this.itemService.update(updatedItem); - }), - tap(() => this.itemService.commitUpdates()), - getSucceededRemoteData() - ).subscribe( - (rd: RemoteData) => { - this.item = rd.payload; - this.initializeOriginalFields(); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata); - } - ) - } else { - const title = this.translateService.instant('item.edit.metadata.notifications.invalid.title'); - const content = this.translateService.instant('item.edit.metadata.notifications.invalid.content'); - this.notificationsService.error(title, content); - } - }); + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadata) as Observable; + metadata$.pipe( + first(), + switchMap((metadata: Metadatum[]) => { + const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata }); + return this.itemService.update(updatedItem); + }), + tap(() => this.itemService.commitUpdates()), + getSucceededRemoteData() + ).subscribe( + (rd: RemoteData) => { + this.item = rd.payload; + this.initializeOriginalFields(); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadata); + } + ) + } else { + const title = this.translateService.instant('item.edit.metadata.notifications.invalid.title'); + const content = this.translateService.instant('item.edit.metadata.notifications.invalid.content'); + this.notificationsService.error(title, content); + } + }); } /** * Checks whether or not there are currently updates for this item */ hasChanges(): Observable { - return this.objectUpdatesService.hasUpdates(this.route); + return this.objectUpdatesService.hasUpdates(this.url); } /** * Checks whether or not the item is currently reinstatable */ isReinstatable(): Observable { - return this.objectUpdatesService.isReinstatable(this.route); + return this.objectUpdatesService.isReinstatable(this.url); } /** @@ -161,7 +172,7 @@ export class ItemMetadataComponent implements OnInit { */ private checkLastModified() { const currentVersion = this.item.lastModified; - this.objectUpdatesService.getLastModified(this.route).pipe(first()).subscribe( + this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( (updateVersion: Date) => { if (updateVersion.getDate() !== currentVersion.getDate()) { const title = this.translateService.instant('item.edit.metadata.notifications.outdated.title'); @@ -173,7 +184,10 @@ export class ItemMetadataComponent implements OnInit { ); } + /** + * Check if the current page is entirely valid + */ private isValid() { - return this.objectUpdatesService.isValidPage(this.route); + return this.objectUpdatesService.isValidPage(this.url); } } diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts index 319d4c47ae..9c3049c638 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -6,11 +6,13 @@ import { CommonModule } from '@angular/common'; import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub'; import { HostWindowService } from '../../../shared/host-window.service'; import { RouterTestingModule } from '@angular/router/testing'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { RouterStub } from '../../../shared/testing/router-stub'; import { Item } from '../../../core/shared/item.model'; import { By } from '@angular/platform-browser'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; describe('ItemStatusComponent', () => { let comp: ItemStatusComponent; @@ -27,12 +29,19 @@ describe('ItemStatusComponent', () => { url: `${itemPageUrl}/edit` }); + const routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) }) + } + }; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], declarations: [ItemStatusComponent], providers: [ { provide: Router, useValue: routerStub }, + { provide: ActivatedRoute, useValue: routeStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -41,7 +50,6 @@ describe('ItemStatusComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ItemStatusComponent); comp = fixture.componentInstance; - comp.item = mockItem; fixture.detectChanges(); }); @@ -65,4 +73,5 @@ describe('ItemStatusComponent', () => { expect(statusItemPage.textContent).toContain(itemPageUrl); }); -}); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 2b2c7a2ed4..28cd23a5fe 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -1,8 +1,11 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; -import {fadeIn, fadeInOut} from '../../../shared/animations/fade'; -import {Item} from '../../../core/shared/item.model'; -import {Router} from '@angular/router'; -import {ItemOperation} from '../item-operation/itemOperation.model'; +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; +import { Item } from '../../../core/shared/item.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemOperation } from '../item-operation/itemOperation.model'; +import { first, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; @Component({ selector: 'ds-item-status', @@ -21,7 +24,7 @@ export class ItemStatusComponent implements OnInit { /** * The item to display the status for */ - @Input() item: Item; + itemRD$: Observable>; /** * The data to show in the status @@ -37,39 +40,46 @@ export class ItemStatusComponent implements OnInit { * key: id value: url to action's component */ operations: ItemOperation[]; + /** * The keys of the actions (to loop over) */ actionsKeys; - constructor(private router: Router) { + constructor(private router: Router, private route: ActivatedRoute) { } ngOnInit(): void { - this.statusData = Object.assign({ - id: this.item.id, - handle: this.item.handle, - lastModified: this.item.lastModified + this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)); + this.itemRD$.pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.statusData = Object.assign({ + id: item.id, + handle: item.handle, + lastModified: item.lastModified + }); + this.statusDataKeys = Object.keys(this.statusData); + /* + The key is used to build messages + i18n example: 'item.edit.tabs.status.buttons..label' + The value is supposed to be a href for the button + */ + this.operations = []; + if (item.isWithdrawn) { + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); + } else { + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); + } + if (item.isDiscoverable) { + this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); + } else { + this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); + } + this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); }); - this.statusDataKeys = Object.keys(this.statusData); - /* - The key is used to build messages - i18n example: 'item.edit.tabs.status.buttons..label' - The value is supposed to be a href for the button - */ - this.operations = []; - if (this.item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); - } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); - } - if (this.item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); - } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); - } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); } /** diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 962f659076..ec562842aa 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -15,7 +15,7 @@ export function getItemEditPath(id: string) { return new URLCombiner(getItemModulePath(),ITEM_EDIT_PATH.replace(/:id/, id)).toString() } -const ITEM_EDIT_PATH = ':id/edit/:page'; +const ITEM_EDIT_PATH = ':id/edit'; @NgModule({ imports: [ @@ -39,7 +39,7 @@ const ITEM_EDIT_PATH = ':id/edit/:page'; path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', canActivate: [AuthenticatedGuard] - } + }, ]) ], providers: [ diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts index 36f97d8d6f..ba28b59d0e 100644 --- a/src/app/core/metadata/metadatafield.model.ts +++ b/src/app/core/metadata/metadatafield.model.ts @@ -22,10 +22,10 @@ export class MetadataField implements ListableObject { @autoserialize schema: MetadataSchema; - toString(): string { - let key = this.schema.prefix + '.' + this.element; + toString(separator: string = '.'): string { + let key = this.schema.prefix + separator + this.element; if (isNotEmpty(this.qualifier)) { - key += '.' + this.qualifier; + key += separator + this.qualifier; } return key; } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html index b620f4b79a..1bd3cde22e 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.html +++ b/src/app/shared/input-suggestions/input-suggestions.component.html @@ -5,8 +5,10 @@ (dsClickOutside)="close()">
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.scss b/src/app/+item-page/edit-item-page/edit-item-page.component.scss new file mode 100644 index 0000000000..f22ca8f8de --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.scss @@ -0,0 +1,5 @@ +@import '../../../styles/variables.scss'; + +.btn { + min-width: $edit-item-button-min-width; +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index d7ab3ea199..efde7c52fb 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -6,6 +6,7 @@ import { Item } from '../../core/shared/item.model'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { isNotEmpty } from '../../shared/empty.util'; +import { getItemPageRoute } from '../item-page-routing.module'; @Component({ selector: 'ds-edit-item-page', @@ -48,4 +49,8 @@ export class EditItemPageComponent implements OnInit { .filter((path: string) => isNotEmpty(path)); // ignore reroutes this.itemRD$ = this.route.data.pipe(map((data) => data.item)); } + + getItemPage(item: Item): string { + return getItemPageRoute(item.id) + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 98904517f9..0c1de642ce 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -14,6 +14,7 @@ import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -36,6 +37,7 @@ import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/e ItemDeleteComponent, ItemStatusComponent, ItemMetadataComponent, + ItemBitstreamsComponent, EditInPlaceFieldComponent ] }) diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 8a45c42ff6..48fd6ceb9b 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -9,6 +9,7 @@ import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; +import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -39,8 +40,7 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; }, { path: 'bitstreams', - /* TODO - change when bitstreams page exists */ - component: ItemStatusComponent + component: ItemBitstreamsComponent }, { path: 'metadata', @@ -49,12 +49,12 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; { path: 'view', /* TODO - change when view page exists */ - component: ItemStatusComponent + component: ItemBitstreamsComponent }, { path: 'curate', /* TODO - change when curate page exists */ - component: ItemStatusComponent + component: ItemBitstreamsComponent }, ] }, diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html new file mode 100644 index 0000000000..b80e6e0678 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss new file mode 100644 index 0000000000..88eb98509a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables.scss'; \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts new file mode 100644 index 0000000000..71f25cd5cf --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-item-bitstreams', + styleUrls: ['./item-bitstreams.component.scss'], + templateUrl: './item-bitstreams.component.html', +}) +/** + * Component for displaying an item's bitstreams edit page + */ +export class ItemBitstreamsComponent { + /* TODO implement */ +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 25f586d0ed..44c6cf2258 100644 --- 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 @@ -1,11 +1,6 @@ - -
- - + + + {{"item.edit.metadata.metadatafield.invalid" | translate}} - - -
- {{metadata?.value}} -
-
+
+ + +
+ {{metadata?.value}} +
+
-
- - -
- {{metadata?.language}} -
-
- -
- - -
- - - - -
- -
\ No newline at end of file +
+ + +
+ {{metadata?.language}} +
+
+ +
+ + +
+ + + + +
+ \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss index 3575cae797..65bdbc38e0 100644 --- 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 @@ -1 +1,10 @@ @import '../../../../../styles/variables.scss'; +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.metadata-field { + width: $edit-item-metadata-field-width; +} \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index cefbb3620a..133ee19e43 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 @@ -400,66 +400,64 @@ describe('EditInPlaceFieldComponent', () => { }); describe('canRemove', () => { - describe('when editable is currently true', () => { + describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { beforeEach(() => { - comp.editable = observableOf(true); - fixture.detectChanges(); + comp.fieldUpdate.changeType = FieldChangeType.UPDATE; + }); + it('canRemove should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); + }); + }); + + describe('when the fieldUpdate\'s changeType is currently ADD', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; }); it('canRemove should return an observable emitting false', () => { const expected = '(a|)'; scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); }); - }); - - describe('when editable is currently false', () => { - beforeEach(() => { - comp.editable = observableOf(false); - }); - - describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.UPDATE; - }); - it('canRemove should return an observable emitting true', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true }); - }); - }); - - describe('when the fieldUpdate\'s changeType is currently ADD', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; - }); - it('canRemove should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false }); - }); - }) - }); + }) }); describe('canUndo', () => { - describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { + describe('when editable is currently true', () => { beforeEach(() => { - comp.fieldUpdate.changeType = FieldChangeType.ADD; + comp.editable = observableOf(true); + comp.fieldUpdate.changeType = undefined; + fixture.detectChanges(); }); - it('canUndo should return an observable emitting true', () => { const expected = '(a|)'; scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); }); }); - describe('when the fieldUpdate\'s changeType is currently undefined', () => { - beforeEach(() => { - comp.fieldUpdate.changeType = undefined; + describe('when editable is currently false', () => { + describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = FieldChangeType.ADD; + }); + + it('canUndo should return an observable emitting true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true }); + }); }); - it('canUndo should return an observable emitting false', () => { - const expected = '(a|)'; - scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); + describe('when the fieldUpdate\'s changeType is currently undefined', () => { + beforeEach(() => { + comp.fieldUpdate.changeType = undefined; + }); + + it('canUndo should return an observable emitting false', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false }); + }); }); }); + }); }); diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts index f24de359b8..7d4922de43 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 @@ -14,7 +14,8 @@ import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { FormControl } from '@angular/forms'; @Component({ - selector: 'ds-edit-in-place-field', + // tslint:disable-next-line:component-selector + selector: '[ds-edit-in-place-field]', styleUrls: ['./edit-in-place-field.component.scss'], templateUrl: './edit-in-place-field.component.html', }) @@ -131,7 +132,7 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges { (fields: MetadataField[]) => this.metadataFieldSuggestions.next( fields.map((field: MetadataField) => { return { - displayValue: field.toString(), + displayValue: field.toString().split('.').join('.​'), value: field.toString() } }) 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 04896ee4a6..bb0c188f05 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,56 +1,63 @@ -
- diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss index 1ae2839606..f3075702e6 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -1,13 +1,22 @@ @import '../../../../styles/variables.scss'; .button-row { - .spaced-btn-group > .btn { + .btn { margin-right: 0.5 * $spacer; + &:last-child { margin-right: 0; } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } } - .btn { - min-width: $button-min-width; + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; } + + } \ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 380e05c334..46098ff69d 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 @@ -57,6 +57,9 @@ export class ItemMetadataComponent implements OnInit { } + /** + * Set up and initialize all fields + */ ngOnInit(): void { this.route.parent.data.pipe(map((data) => data.item)) .pipe( @@ -116,8 +119,10 @@ export class ItemMetadataComponent implements OnInit { this.objectUpdatesService.initialize(this.url, this.item.metadata, this.item.lastModified); } - /* Prevent unnecessary rerendering so fields don't lose focus **/ - protected trackUpdate(index, update: FieldUpdate) { + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { return update && update.field ? update.field.uuid : undefined; } diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html index 0f7d9a5607..e60fa0490d 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -12,7 +12,7 @@ {{'item.edit.tabs.status.labels.itemPage' | translate}}:
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 28cd23a5fe..b9a9e4a2f3 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -6,6 +6,7 @@ import { ItemOperation } from '../item-operation/itemOperation.model'; import { first, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; +import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module'; @Component({ selector: 'ds-item-status', @@ -68,16 +69,16 @@ export class ItemStatusComponent implements OnInit { */ this.operations = []; if (item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); } if (item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); + this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); + this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); + this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); }); } @@ -86,20 +87,16 @@ export class ItemStatusComponent implements OnInit { * Get the url to the simple item page * @returns {string} url */ - getItemPage(): string { - return this.router.url.substr(0, this.router.url.lastIndexOf('/')); + getItemPage(item: Item): string { + return getItemPageRoute(item.id) } /** * Get the current url without query params * @returns {string} url */ - getCurrentUrl(): string { - if (this.router.url.indexOf('?') > -1) { - return this.router.url.substr(0, this.router.url.indexOf('?')); - } else { - return this.router.url; - } + getCurrentUrl(item: Item): string { + return getItemEditPath(item.id); } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 1039ded993..357f552074 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -68,6 +68,7 @@ import { MenuService } from '../shared/menu/menu.service'; import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; +import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; const IMPORTS = [ CommonModule, @@ -133,6 +134,7 @@ const PROVIDERS = [ UUIDService, DSpaceObjectDataService, DSOChangeAnalyzer, + DefaultChangeAnalyzer, CSSVariableService, MenuService, ObjectUpdatesService, diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts new file mode 100644 index 0000000000..1fd207d2bf --- /dev/null +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -0,0 +1,29 @@ +import { Operation } from 'fast-json-patch/lib/core'; +import { compare } from 'fast-json-patch'; +import { ChangeAnalyzer } from './change-analyzer'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; +import { Injectable } from '@angular/core'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; + +/** + * A class to determine what differs between two + * CacheableObjects + */ +@Injectable() +export class DefaultChangeAnalyzer implements ChangeAnalyzer { + + /** + * Compare the metadata of two CacheableObject and return the differences as + * a JsonPatch Operation Array + * + * @param {NormalizedObject} object1 + * The first object to compare + * @param {NormalizedObject} object2 + * The second object to compare + */ + diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[] { + return compare(object1, object2); + } +} diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 7f17ad9cf1..ab5b859530 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -11,10 +11,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadataschema.model'; -import { ChangeAnalyzer } from './change-analyzer'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { HttpClient } from '@angular/common/http'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; @Injectable() export class MetadataSchemaDataService extends DataService { @@ -27,7 +27,7 @@ export class MetadataSchemaDataService extends DataService { private bs: BrowseService, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: ChangeAnalyzer, + protected comparator: DefaultChangeAnalyzer, protected dataBuildService: NormalizedObjectBuildService, protected http: HttpClient, protected notificationsService: NotificationsService) { diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 1841fba6a0..25579a0690 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -228,6 +228,7 @@ describe('MetadataService', () => { const mockPublisher = (mockItem: Item): Item => { const publishedMockItem = Object.assign(new Item(), mockItem) as Item; publishedMockItem.metadata.push({ + uuid: 'b3826cf5-5f07-44cf-88d8-2da968354d18', key: 'dc.publisher', language: 'en_US', value: 'Mock Publisher' diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index d24811b382..7be76ff5d3 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -19,7 +19,6 @@ $gray-700: lighten($gray-base, 46.6%) !default; // #777 $gray-600: lighten($gray-base, 73.3%) !default; // #bbb $gray-100: lighten($gray-base, 93.5%) !default; // #eee - /* Reassign color vars to semantic color scheme */ $blue: #2B4E72 !default; $green: #94BA65 !default; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index dda018ad2c..05387e8740 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,7 +1,6 @@ $content-spacing: $spacer * 1.5; $button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2); -$button-min-width: 100px; $card-height-percentage:98%; $card-thumbnail-height:240px; @@ -24,3 +23,6 @@ $admin-sidebar-header-bg: darken($dark, 7%); $dark-scrollbar-background: $admin-sidebar-active-bg; $dark-scrollbar-foreground: #47495d; + +$edit-item-button-min-width: 100px; +$edit-item-metadata-field-width: 190px; \ No newline at end of file From 1cea791cba6e014d3a1283227c5286fb3768a7a2 Mon Sep 17 00:00:00 2001 From: lotte Date: Tue, 19 Feb 2019 15:59:03 +0100 Subject: [PATCH 33/63] fixed tests and added some more documentation --- .../edit-item-page.component.ts | 4 ++ .../edit-in-place-field.component.spec.ts | 38 ++------------- .../item-metadata.component.html | 4 +- .../item-metadata.component.spec.ts | 46 +++++++++++++++---- .../item-status/item-status.component.spec.ts | 9 +--- .../item-status/item-status.component.ts | 6 +-- .../object-updates/object-updates.actions.ts | 5 +- .../object-updates/object-updates.reducer.ts | 32 ++++++++++++- .../input-suggestions.component.ts | 6 +-- .../input-suggestions.model.ts | 10 ++++ 10 files changed, 100 insertions(+), 60 deletions(-) diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index efde7c52fb..4ea47f08e7 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -50,6 +50,10 @@ export class EditItemPageComponent implements OnInit { this.itemRD$ = this.route.data.pipe(map((data) => data.item)); } + /** + * Get the item page url + * @param item The item for which the url is requested + */ getItemPage(item: Item): string { return getItemPageRoute(item.id) } diff --git a/src/app/+item-page/edit-item-page/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 133ee19e43..565c720a75 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 @@ -89,7 +89,7 @@ describe('EditInPlaceFieldComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EditInPlaceFieldComponent); comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance - de = fixture.debugElement.query(By.css('div.d-flex')); + de = fixture.debugElement; el = de.nativeElement; comp.url = url; @@ -109,36 +109,6 @@ 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(() => { @@ -223,9 +193,9 @@ describe('EditInPlaceFieldComponent', () => { const metadataFieldSuggestions: InputSuggestion[] = [ - { displayValue: mdField1.toString(), value: mdField1.toString() }, - { displayValue: mdField2.toString(), value: mdField2.toString() }, - { displayValue: mdField3.toString(), value: mdField3.toString() } + { displayValue: mdField1.toString().split('.').join('.​'), value: mdField1.toString() }, + { displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() }, + { displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() } ]; beforeEach(() => { 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 bb0c188f05..03b0e143b4 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 @@ -30,8 +30,8 @@ {{'item.edit.metadata.headers.language' | translate}} {{'item.edit.metadata.headers.edit' | translate}} - +
+
+
+ +
+
+ + + + +
+
+
From a0501b0c3ca40939a20782a42bc6083c753d4162 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 09:24:07 +0100 Subject: [PATCH 35/63] 60168: Refactor browse-by-starts-with components to starts-with components + further refactoring by seperating date from text --- .../browse-by-date-page.component.ts | 4 +- .../browse-by-metadata-page.component.ts | 4 +- .../browse-by-starts-with-date.component.ts | 18 --------- .../browse-by-starts-with-text.component.scss | 0 .../browse-by-starts-with-text.component.ts | 17 --------- .../shared/browse-by/browse-by.component.ts | 12 +----- src/app/shared/shared.module.ts | 8 ++-- .../date/starts-with-date.component.html} | 2 +- .../date/starts-with-date.component.scss} | 2 +- .../date/starts-with-date.component.spec.ts} | 16 ++++---- .../date/starts-with-date.component.ts | 34 +++++++++++++++++ .../starts-with-abstract.component.ts} | 21 +++------- .../starts-with-decorator.spec.ts} | 7 ++-- .../starts-with-decorator.ts} | 14 +++++-- .../text/starts-with-text.component.html} | 2 +- .../text/starts-with-text.component.scss | 7 ++++ .../text/starts-with-text.component.ts | 38 +++++++++++++++++++ 17 files changed, 118 insertions(+), 88 deletions(-) delete mode 100644 src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts delete mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss delete mode 100644 src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts rename src/app/shared/{browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html => starts-with/date/starts-with-date.component.html} (94%) rename src/app/shared/{browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss => starts-with/date/starts-with-date.component.scss} (72%) rename src/app/shared/{browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts => starts-with/date/starts-with-date.component.spec.ts} (87%) create mode 100644 src/app/shared/starts-with/date/starts-with-date.component.ts rename src/app/shared/{browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts => starts-with/starts-with-abstract.component.ts} (80%) rename src/app/shared/{browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts => starts-with/starts-with-decorator.spec.ts} (54%) rename src/app/shared/{browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts => starts-with/starts-with-decorator.ts} (60%) rename src/app/shared/{browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html => starts-with/text/starts-with-text.component.html} (92%) create mode 100644 src/app/shared/starts-with/text/starts-with-text.component.scss create mode 100644 src/app/shared/starts-with/text/starts-with-text.component.ts diff --git a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts index c5048c9520..8e7502fec9 100644 --- a/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/+browse-by/+browse-by-date-page/browse-by-date-page.component.ts @@ -5,7 +5,6 @@ import { } from '../+browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; -import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { Item } from '../../core/shared/item.model'; @@ -14,6 +13,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by-date-page', @@ -42,7 +42,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { } ngOnInit(): void { - this.startsWithType = BrowseByStartsWithType.date; + this.startsWithType = StartsWithType.date; this.updatePage(new BrowseEntrySearchOptions(null, this.paginationConfig, this.sortConfig)); this.subs.push( observableCombineLatest( diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index 79a8ff0421..e960ac2ae9 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -14,7 +14,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { take } from 'rxjs/operators'; -import { BrowseByStartsWithType } from '../../shared/browse-by/browse-by.component'; +import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by-metadata-page', @@ -76,7 +76,7 @@ export class BrowseByMetadataPageComponent implements OnInit { * The type of StartsWith options to render * Defaults to text */ - startsWithType = BrowseByStartsWithType.text; + startsWithType = StartsWithType.text; /** * The list of StartsWith options diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts b/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts deleted file mode 100644 index 78551270d6..0000000000 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component } from '@angular/core'; -import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; -import { BrowseByStartsWithType } from '../../browse-by.component'; -import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; - -/** - * A switchable component rendering StartsWith options for the type "Date". - * The options are rendered in a dropdown with an input field (of type number) next to it. - */ -@Component({ - selector: 'ds-browse-by-starts-with-date', - styleUrls: ['./browse-by-starts-with-date.component.scss'], - templateUrl: './browse-by-starts-with-date.component.html' -}) -@renderStartsWithFor(BrowseByStartsWithType.date) -export class BrowseByStartsWithDateComponent extends BrowseByStartsWithAbstractComponent { - -} diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts b/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts deleted file mode 100644 index 23ecacfa34..0000000000 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { renderStartsWithFor } from '../browse-by-starts-with-decorator'; -import { BrowseByStartsWithType } from '../../browse-by.component'; -import { BrowseByStartsWithAbstractComponent } from '../browse-by-starts-with-abstract.component'; - -/** - * A switchable component rendering StartsWith options for the type "Text". - */ -@Component({ - selector: 'ds-browse-by-starts-with-text', - styleUrls: ['./browse-by-starts-with-text.component.scss'], - templateUrl: './browse-by-starts-with-text.component.html' -}) -@renderStartsWithFor(BrowseByStartsWithType.text) -export class BrowseByStartsWithTextComponent extends BrowseByStartsWithAbstractComponent { - -} diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 4d5c35f3bc..6c4bc78213 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -6,15 +6,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { fadeIn, fadeInOut } from '../animations/fade'; import { Observable } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { getStartsWithComponent } from './browse-by-starts-with/browse-by-starts-with-decorator'; - -/** - * An enum that defines the type of StartsWith options - */ -export enum BrowseByStartsWithType { - text = 'Text', - date = 'Date' -} +import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; @Component({ selector: 'ds-browse-by', @@ -53,7 +45,7 @@ export class BrowseByComponent implements OnInit { * The type of StartsWith options used to define what component to render for the options * Defaults to text */ - @Input() type = BrowseByStartsWithType.text; + @Input() type = StartsWithType.text; /** * The list of options to render for the StartsWith component diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index b8530101f9..18f151fccb 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -88,8 +88,8 @@ import { MomentModule } from 'ngx-moment'; import { MenuModule } from './menu/menu.module'; import {LangSwitchComponent} from './lang-switch/lang-switch.component'; import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component'; -import { BrowseByStartsWithDateComponent } from './browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component'; -import { BrowseByStartsWithTextComponent } from './browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component'; +import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component'; +import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -180,8 +180,8 @@ const ENTRY_COMPONENTS = [ CommunityGridElementComponent, SearchResultGridElementComponent, BrowseEntryListElementComponent, - BrowseByStartsWithDateComponent, - BrowseByStartsWithTextComponent + StartsWithDateComponent, + StartsWithTextComponent ]; const PROVIDERS = [ diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html similarity index 94% rename from src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html rename to src/app/shared/starts-with/date/starts-with-date.component.html index 0403e1b94f..22f59b0875 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.html +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -16,7 +16,7 @@
- + diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss b/src/app/shared/starts-with/date/starts-with-date.component.scss similarity index 72% rename from src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss rename to src/app/shared/starts-with/date/starts-with-date.component.scss index e516151d57..ceec56c8c2 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.scss +++ b/src/app/shared/starts-with/date/starts-with-date.component.scss @@ -1,4 +1,4 @@ -@import '../../../../../styles/variables.scss'; +@import '../../../../styles/variables.scss'; // temporary fix for bootstrap 4 beta btn color issue .btn-secondary { diff --git a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts similarity index 87% rename from src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts rename to src/app/shared/starts-with/date/starts-with-date.component.spec.ts index c0812245e9..e2956a2a97 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/date/browse-by-starts-with-date.component.spec.ts +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -1,20 +1,20 @@ -import { BrowseByStartsWithDateComponent } from './browse-by-starts-with-date.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { EnumKeysPipe } from '../../../utils/enum-keys-pipe'; import { ActivatedRoute, Router } from '@angular/router'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ActivatedRouteStub } from '../../../testing/active-router-stub'; import { of as observableOf } from 'rxjs/internal/observable/of'; -import { RouterStub } from '../../../testing/router-stub'; import { By } from '@angular/platform-browser'; +import { StartsWithDateComponent } from './starts-with-date.component'; +import { ActivatedRouteStub } from '../../testing/active-router-stub'; +import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; +import { RouterStub } from '../../testing/router-stub'; describe('BrowseByStartsWithDateComponent', () => { - let comp: BrowseByStartsWithDateComponent; - let fixture: ComponentFixture; + let comp: StartsWithDateComponent; + let fixture: ComponentFixture; let route: ActivatedRoute; let router: Router; @@ -28,7 +28,7 @@ describe('BrowseByStartsWithDateComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], - declarations: [BrowseByStartsWithDateComponent, EnumKeysPipe], + declarations: [StartsWithDateComponent, EnumKeysPipe], providers: [ { provide: 'startsWithOptions', useValue: options }, { provide: ActivatedRoute, useValue: activatedRouteStub }, @@ -39,7 +39,7 @@ describe('BrowseByStartsWithDateComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByStartsWithDateComponent); + fixture = TestBed.createComponent(StartsWithDateComponent); comp = fixture.componentInstance; fixture.detectChanges(); route = (comp as any).route; diff --git a/src/app/shared/starts-with/date/starts-with-date.component.ts b/src/app/shared/starts-with/date/starts-with-date.component.ts new file mode 100644 index 0000000000..ab4a26498e --- /dev/null +++ b/src/app/shared/starts-with/date/starts-with-date.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; + +/** + * A switchable component rendering StartsWith options for the type "Date". + * The options are rendered in a dropdown with an input field (of type number) next to it. + */ +@Component({ + selector: 'ds-starts-with-date', + styleUrls: ['./starts-with-date.component.scss'], + templateUrl: './starts-with-date.component.html' +}) +@renderStartsWithFor(StartsWithType.date) +export class StartsWithDateComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as a number; + */ + getStartsWith() { + return +this.startsWith; + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '-1') { + this.startsWith = undefined; + } + super.setStartsWithParam(); + } + +} diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts similarity index 80% rename from src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts rename to src/app/shared/starts-with/starts-with-abstract.component.ts index 97030f7cdf..f1137004a6 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-abstract.component.ts +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -1,13 +1,13 @@ import { Inject, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { hasValue } from '../../empty.util'; import { Subscription } from 'rxjs/internal/Subscription'; import { FormControl, FormGroup } from '@angular/forms'; +import { hasValue } from '../empty.util'; /** * An abstract component to render StartsWith options */ -export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { +export class StartsWithAbstractComponent implements OnInit, OnDestroy { /** * The currently selected startsWith in string format */ @@ -39,19 +39,11 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { }); } - getStartsWithAsText() { - if (hasValue(this.startsWith)) { - return this.startsWith; - } else { - return ''; - } - } - /** - * Get startsWith as a number; + * Get startsWith */ - getStartsWithAsNumber() { - return +this.startsWith; + getStartsWith(): any { + return this.startsWith; } /** @@ -67,9 +59,6 @@ export class BrowseByStartsWithAbstractComponent implements OnInit, OnDestroy { * Add/Change the url query parameter startsWith using the local variable */ setStartsWithParam() { - if (this.startsWith === '-1') { - this.startsWith = undefined; - } this.router.navigate([], { queryParams: Object.assign({ startsWith: this.startsWith }), queryParamsHandling: 'merge' diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts b/src/app/shared/starts-with/starts-with-decorator.spec.ts similarity index 54% rename from src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts rename to src/app/shared/starts-with/starts-with-decorator.spec.ts index 8eaa9eee09..0ba72d8ac4 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.spec.ts +++ b/src/app/shared/starts-with/starts-with-decorator.spec.ts @@ -1,9 +1,8 @@ -import { renderStartsWithFor } from './browse-by-starts-with-decorator'; -import { BrowseByStartsWithType } from '../browse-by.component'; +import { renderStartsWithFor, StartsWithType } from './starts-with-decorator'; describe('BrowseByStartsWithDecorator', () => { - const textDecorator = renderStartsWithFor(BrowseByStartsWithType.text); - const dateDecorator = renderStartsWithFor(BrowseByStartsWithType.date); + const textDecorator = renderStartsWithFor(StartsWithType.text); + const dateDecorator = renderStartsWithFor(StartsWithType.date); it('should have a decorator for both text and date', () => { expect(textDecorator.length).not.toBeNull(); expect(dateDecorator.length).not.toBeNull(); diff --git a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts b/src/app/shared/starts-with/starts-with-decorator.ts similarity index 60% rename from src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts rename to src/app/shared/starts-with/starts-with-decorator.ts index 88f07c766f..7592f00a8b 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/browse-by-starts-with-decorator.ts +++ b/src/app/shared/starts-with/starts-with-decorator.ts @@ -1,12 +1,18 @@ -import { BrowseByStartsWithType } from '../browse-by.component'; - const startsWithMap = new Map(); +/** + * An enum that defines the type of StartsWith options + */ +export enum StartsWithType { + text = 'Text', + date = 'Date' +} + /** * Fetch a decorator to render a StartsWith component for type * @param type */ -export function renderStartsWithFor(type: BrowseByStartsWithType) { +export function renderStartsWithFor(type: StartsWithType) { return function decorator(objectElement: any) { if (!objectElement) { return; @@ -19,6 +25,6 @@ export function renderStartsWithFor(type: BrowseByStartsWithType) { * Get the correct component depending on the StartsWith type * @param type */ -export function getStartsWithComponent(type: BrowseByStartsWithType) { +export function getStartsWithComponent(type: StartsWithType) { return startsWithMap.get(type); } diff --git a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html similarity index 92% rename from src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html rename to src/app/shared/starts-with/text/starts-with-text.component.html index 5255423d81..8ca2ad7565 100644 --- a/src/app/shared/browse-by/browse-by-starts-with/text/browse-by-starts-with-text.component.html +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -8,7 +8,7 @@
- + diff --git a/src/app/shared/starts-with/text/starts-with-text.component.scss b/src/app/shared/starts-with/text/starts-with-text.component.scss new file mode 100644 index 0000000000..ceec56c8c2 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/variables.scss'; + +// temporary fix for bootstrap 4 beta btn color issue +.btn-secondary { + background-color: $input-bg; + color: $input-color; +} diff --git a/src/app/shared/starts-with/text/starts-with-text.component.ts b/src/app/shared/starts-with/text/starts-with-text.component.ts new file mode 100644 index 0000000000..ef6ff87163 --- /dev/null +++ b/src/app/shared/starts-with/text/starts-with-text.component.ts @@ -0,0 +1,38 @@ +import { Component, Inject } from '@angular/core'; +import { renderStartsWithFor, StartsWithType } from '../starts-with-decorator'; +import { StartsWithAbstractComponent } from '../starts-with-abstract.component'; +import { hasValue } from '../../empty.util'; + +/** + * A switchable component rendering StartsWith options for the type "Text". + */ +@Component({ + selector: 'ds-starts-with-text', + styleUrls: ['./starts-with-text.component.scss'], + templateUrl: './starts-with-text.component.html' +}) +@renderStartsWithFor(StartsWithType.text) +export class StartsWithTextComponent extends StartsWithAbstractComponent { + + /** + * Get startsWith as text; + */ + getStartsWith() { + if (hasValue(this.startsWith)) { + return this.startsWith; + } else { + return ''; + } + } + + /** + * Add/Change the url query parameter startsWith using the local variable + */ + setStartsWithParam() { + if (this.startsWith === '0-9') { + this.startsWith = '0'; + } + super.setStartsWithParam(); + } + +} From 7ce9b01adfb1131ba826805dd2707c0b21047aec Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 09:38:00 +0100 Subject: [PATCH 36/63] 60168: 0-9 option for alphabetic startsWith --- .../browse-by-metadata-page.component.ts | 2 +- .../starts-with/date/starts-with-date.component.html | 2 +- .../starts-with/starts-with-abstract.component.ts | 11 ++++++++++- .../starts-with/text/starts-with-text.component.html | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index e960ac2ae9..47ca9e76c0 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -127,7 +127,7 @@ export class BrowseByMetadataPageComponent implements OnInit { } updateStartsWithTextOptions() { - this.startsWithOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + this.startsWithOptions = ['0-9', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; } /** diff --git a/src/app/shared/starts-with/date/starts-with-date.component.html b/src/app/shared/starts-with/date/starts-with-date.component.html index 22f59b0875..bddbb6f391 100644 --- a/src/app/shared/starts-with/date/starts-with-date.component.html +++ b/src/app/shared/starts-with/date/starts-with-date.component.html @@ -4,7 +4,7 @@ {{'browse.startsWith.jump' | translate}}
- diff --git a/src/app/shared/starts-with/starts-with-abstract.component.ts b/src/app/shared/starts-with/starts-with-abstract.component.ts index f1137004a6..f9105e2756 100644 --- a/src/app/shared/starts-with/starts-with-abstract.component.ts +++ b/src/app/shared/starts-with/starts-with-abstract.component.ts @@ -50,11 +50,20 @@ export class StartsWithAbstractComponent implements OnInit, OnDestroy { * Set the startsWith by event * @param event */ - setStartsWith(event: Event) { + setStartsWithEvent(event: Event) { this.startsWith = (event.target as HTMLInputElement).value; this.setStartsWithParam(); } + /** + * Set the startsWith by string + * @param startsWith + */ + setStartsWith(startsWith: string) { + this.startsWith = startsWith; + this.setStartsWithParam(); + } + /** * Add/Change the url query parameter startsWith using the local variable */ diff --git a/src/app/shared/starts-with/text/starts-with-text.component.html b/src/app/shared/starts-with/text/starts-with-text.component.html index 8ca2ad7565..41ab7294f1 100644 --- a/src/app/shared/starts-with/text/starts-with-text.component.html +++ b/src/app/shared/starts-with/text/starts-with-text.component.html @@ -3,7 +3,7 @@ From 9a0989f240c8b22d5c0907f5b88963a3e8960eeb Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 20 Feb 2019 10:08:12 +0100 Subject: [PATCH 37/63] 60168: Starts-With text dropdown options + styling --- resources/i18n/en.json | 1 + .../browse-by-metadata-page.component.html | 2 +- .../date/starts-with-date.component.ts | 10 ---------- .../starts-with-abstract.component.ts | 3 +++ .../text/starts-with-text.component.html | 16 ++++++++++++++-- .../text/starts-with-text.component.ts | 11 +++++++++++ 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index b1e76631f0..5778edfa6c 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -284,6 +284,7 @@ "startsWith": { "jump": "Jump to a point in the index:", "choose_year": "(Choose year)", + "choose_start": "(Choose start)", "type_year": "Or type in a year:", "type_text": "Or enter first few letters:", "submit": "Go" diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html index 23f52e506a..cf43f74eb0 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.html @@ -1,5 +1,5 @@
-