From 101f1a76dd97009b10d30f1bb96994aca6933729 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 May 2020 14:54:10 +0200 Subject: [PATCH 01/59] 70834: Refactoring registry-service pt1 - removing response-parsing services and using data-services --- .../metadata-registry.component.ts | 3 +- .../metadata-schema.component.ts | 6 +- src/app/core/cache/response.models.ts | 45 ---- src/app/core/core.module.ts | 10 +- .../core/data/metadata-field-data.service.ts | 48 ++++ .../core/data/metadata-schema-data.service.ts | 26 +- ...amformats-response-parsing.service.spec.ts | 41 ---- ...tstreamformats-response-parsing.service.ts | 25 -- ...atafields-response-parsing.service.spec.ts | 68 ------ ...metadatafields-response-parsing.service.ts | 34 --- ...taschemas-response-parsing.service.spec.ts | 50 ---- ...etadataschemas-response-parsing.service.ts | 29 --- ...egistry-bitstreamformats-response.model.ts | 24 -- .../registry-metadatafields-response.model.ts | 46 ---- ...registry-metadataschemas-response.model.ts | 14 -- src/app/core/registry/registry.service.ts | 224 +++--------------- src/app/shared/pagination/pagination.utils.ts | 14 ++ 17 files changed, 107 insertions(+), 600 deletions(-) create mode 100644 src/app/core/data/metadata-field-data.service.ts delete mode 100644 src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts delete mode 100644 src/app/core/data/registry-bitstreamformats-response-parsing.service.ts delete mode 100644 src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts delete mode 100644 src/app/core/data/registry-metadatafields-response-parsing.service.ts delete mode 100644 src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts delete mode 100644 src/app/core/data/registry-metadataschemas-response-parsing.service.ts delete mode 100644 src/app/core/registry/registry-bitstreamformats-response.model.ts delete mode 100644 src/app/core/registry/registry-metadatafields-response.model.ts delete mode 100644 src/app/core/registry/registry-metadataschemas-response.model.ts create mode 100644 src/app/shared/pagination/pagination.utils.ts diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts index 302974b5c2..ed5cefeab2 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts @@ -12,6 +12,7 @@ import { NotificationsService } from '../../../shared/notifications/notification import { Route, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; +import { toFindListOptions } from '../../../shared/pagination/pagination.utils'; @Component({ selector: 'ds-metadata-registry', @@ -57,7 +58,7 @@ export class MetadataRegistryComponent { * Update the list of schemas by fetching it from the rest api or cache */ private updateSchemas() { - this.metadataSchemas = this.registryService.getMetadataSchemas(this.config); + this.metadataSchemas = this.registryService.getMetadataSchemas(toFindListOptions(this.config)); } /** diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts index 2974c1c087..5712cde0e7 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -13,6 +13,8 @@ import { NotificationsService } from '../../../shared/notifications/notification import { TranslateService } from '@ngx-translate/core'; import { MetadataField } from '../../../core/metadata/metadata-field.model'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { toFindListOptions } from '../../../shared/pagination/pagination.utils'; @Component({ selector: 'ds-metadata-schema', @@ -85,9 +87,9 @@ export class MetadataSchemaComponent implements OnInit { * Update the list of fields by fetching it from the rest api or cache */ private updateFields() { - this.metadataSchema.subscribe((schemaData) => { + this.metadataSchema.pipe(getSucceededRemoteData()).subscribe((schemaData) => { const schema = schemaData.payload; - this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config); + this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, toFindListOptions(this.config)); this.namespace = {namespace: schemaData.payload.namespace}; }); } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3f46ecf647..b40965dd0a 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -6,9 +6,6 @@ import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../shared/search/facet-value.model'; import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; import { IntegrationModel } from '../integration/models/integration.model'; -import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; -import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; -import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -40,48 +37,6 @@ export class DSOSuccessResponse extends RestResponse { } } -/** - * A successful response containing a list of MetadataSchemas wrapped in a RegistryMetadataschemasResponse - */ -export class RegistryMetadataschemasSuccessResponse extends RestResponse { - constructor( - public metadataschemasResponse: RegistryMetadataschemasResponse, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - -/** - * A successful response containing a list of MetadataFields wrapped in a RegistryMetadatafieldsResponse - */ -export class RegistryMetadatafieldsSuccessResponse extends RestResponse { - constructor( - public metadatafieldsResponse: RegistryMetadatafieldsResponse, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - -/** - * A successful response containing a list of BitstreamFormats wrapped in a RegistryBitstreamformatsResponse - */ -export class RegistryBitstreamformatsSuccessResponse extends RestResponse { - constructor( - public bitstreamformatsResponse: RegistryBitstreamformatsResponse, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - /** * A successful response containing exactly one MetadataSchema */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 356dad5ed8..9cde79471c 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -73,9 +73,6 @@ import { MetadatafieldParsingService } from './data/metadatafield-parsing.servic import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; -import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; -import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; -import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service'; import { RelationshipTypeService } from './data/relationship-type.service'; import { RelationshipService } from './data/relationship.service'; import { ResourcePolicyService } from './data/resource-policy.service'; @@ -145,6 +142,8 @@ import { Version } from './shared/version.model'; import { VersionHistory } from './shared/version-history.model'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; +import { MetadataFieldDataService } from './data/metadata-field-data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -201,9 +200,6 @@ const PROVIDERS = [ FacetValueResponseParsingService, FacetValueMapResponseParsingService, FacetConfigResponseParsingService, - RegistryMetadataschemasResponseParsingService, - RegistryMetadatafieldsResponseParsingService, - RegistryBitstreamformatsResponseParsingService, MappedCollectionsReponseParsingService, DebugResponseParsingService, SearchResponseParsingService, @@ -264,6 +260,8 @@ const PROVIDERS = [ LicenseDataService, ItemTypeDataService, WorkflowActionDataService, + MetadataSchemaDataService, + MetadataFieldDataService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts new file mode 100644 index 0000000000..59af99e558 --- /dev/null +++ b/src/app/core/data/metadata-field-data.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { FindListOptions } from './request.models'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { SearchParam } from '../cache/models/search-param.model'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint + */ +@Injectable() +@dataService(METADATA_FIELD) +export class MetadataFieldDataService extends DataService { + protected linkPath = 'metadatafields'; + protected searchBySchemaLinkPath = 'bySchema'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected comparator: DefaultChangeAnalyzer, + protected http: HttpClient, + protected notificationsService: NotificationsService) { + super(); + } + + findBySchema(schema: MetadataSchema, options: FindListOptions = {}, ...linksToFollow: Array>) { + const optionsWithSchema = Object.assign(new FindListOptions(), options, { + searchParams: [new SearchParam('schema', schema.prefix)] + }); + return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow); + } + +} diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 915f588379..bdb4b9315f 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -9,37 +9,17 @@ import { CoreState } from '../core.reducers'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ChangeAnalyzer } from './change-analyzer'; - import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RequestService } from './request.service'; -/* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { - protected linkPath = 'metadataschemas'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: ChangeAnalyzer) { - super(); - } - -} - /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ @Injectable() @dataService(METADATA_SCHEMA) -export class MetadataSchemaDataService { - private dataService: DataServiceImpl; +export class MetadataSchemaDataService extends DataService { + protected linkPath = 'metadataschemas'; constructor( protected requestService: RequestService, @@ -50,6 +30,6 @@ export class MetadataSchemaDataService { protected comparator: DefaultChangeAnalyzer, protected http: HttpClient, protected notificationsService: NotificationsService) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + super(); } } diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts deleted file mode 100644 index 6cc031f3c9..0000000000 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { PageInfo } from '../shared/page-info.model'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { - RegistryBitstreamformatsSuccessResponse -} from '../cache/response.models'; -import { RegistryBitstreamformatsResponseParsingService } from './registry-bitstreamformats-response-parsing.service'; - -describe('RegistryBitstreamformatsResponseParsingService', () => { - let service: RegistryBitstreamformatsResponseParsingService; - - const mockDSOParser = Object.assign({ - processPageInfo: () => new PageInfo() - }) as DSOResponseParsingService; - - const data = Object.assign({ - payload: { - _embedded: { - bitstreamformats: [ - { - uuid: 'uuid-1', - description: 'a description' - }, - { - uuid: 'uuid-2', - description: 'another description' - }, - ] - } - } - }) as DSpaceRESTV2Response; - - beforeEach(() => { - service = new RegistryBitstreamformatsResponseParsingService(mockDSOParser); - }); - - it('should parse the data correctly', () => { - const response = service.parse(null, data); - expect(response.constructor).toBe(RegistryBitstreamformatsSuccessResponse); - }); -}); diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts deleted file mode 100644 index 1cbcf358e3..0000000000 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Injectable } from '@angular/core'; -import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; -import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; - -@Injectable() -export class RegistryBitstreamformatsResponseParsingService implements ResponseParsingService { - constructor(private dsoParser: DSOResponseParsingService) { - } - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload; - - const bitstreamformats = payload._embedded.bitstreamformats; - payload.bitstreamformats = bitstreamformats; - - const deserialized = new DSpaceSerializer(RegistryBitstreamformatsResponse).deserialize(payload); - return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page)); - } - -} diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts deleted file mode 100644 index 5ede21954a..0000000000 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { PageInfo } from '../shared/page-info.model'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { - RegistryMetadatafieldsSuccessResponse -} from '../cache/response.models'; -import { RegistryMetadatafieldsResponseParsingService } from './registry-metadatafields-response-parsing.service'; - -describe('RegistryMetadatafieldsResponseParsingService', () => { - let service: RegistryMetadatafieldsResponseParsingService; - - const mockDSOParser = Object.assign({ - processPageInfo: () => new PageInfo() - }) as DSOResponseParsingService; - - const data = Object.assign({ - payload: { - _embedded: { - metadatafields: [ - { - id: 1, - element: 'element', - qualifier: 'qualifier', - scopeNote: 'a scope note', - _embedded: { - schema: { - id: 1, - prefix: 'test', - namespace: 'test namespace' - } - } - }, - { - id: 2, - element: 'secondelement', - qualifier: 'secondqualifier', - scopeNote: 'a second scope note', - _embedded: { - schema: { - id: 1, - prefix: 'test', - namespace: 'test namespace' - } - } - }, - ] - } - } - }) as DSpaceRESTV2Response; - - const emptyData = Object.assign({ - payload: {} - }) as DSpaceRESTV2Response; - - beforeEach(() => { - service = new RegistryMetadatafieldsResponseParsingService(mockDSOParser); - }); - - it('should parse the data correctly', () => { - const response = service.parse(null, data); - expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse); - }); - - it('should not produce an error and parse the data correctly when the data is empty', () => { - const response = service.parse(null, emptyData); - expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse); - }); -}); diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts deleted file mode 100644 index cf9484c4c4..0000000000 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@angular/core'; -import { hasValue } from '../../shared/empty.util'; -import { RegistryMetadatafieldsSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; -import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; - -@Injectable() -export class RegistryMetadatafieldsResponseParsingService implements ResponseParsingService { - constructor(private dsoParser: DSOResponseParsingService) { - } - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload; - - let metadatafields = []; - - if (hasValue(payload._embedded)) { - metadatafields = payload._embedded.metadatafields; - metadatafields.forEach((field) => { - field.schema = field._embedded.schema; - }); - } - - payload.metadatafields = metadatafields; - - const deserialized = new DSpaceSerializer(RegistryMetadatafieldsResponse).deserialize(payload); - return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); - } - -} diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts deleted file mode 100644 index e49305d06a..0000000000 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service'; -import { PageInfo } from '../shared/page-info.model'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RegistryMetadataschemasSuccessResponse } from '../cache/response.models'; - -describe('RegistryMetadataschemasResponseParsingService', () => { - let service: RegistryMetadataschemasResponseParsingService; - - const mockDSOParser = Object.assign({ - processPageInfo: () => new PageInfo() - }) as DSOResponseParsingService; - - const data = Object.assign({ - payload: { - _embedded: { - metadataschemas: [ - { - id: 1, - prefix: 'test', - namespace: 'test namespace' - }, - { - id: 2, - prefix: 'second', - namespace: 'second test namespace' - } - ] - } - } - }) as DSpaceRESTV2Response; - - const emptyData = Object.assign({ - payload: {} - }) as DSpaceRESTV2Response; - - beforeEach(() => { - service = new RegistryMetadataschemasResponseParsingService(mockDSOParser); - }); - - it('should parse the data correctly', () => { - const response = service.parse(null, data); - expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse); - }); - - it('should not produce an error and parse the data correctly when the data is empty', () => { - const response = service.parse(null, emptyData); - expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse); - }); -}); diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts deleted file mode 100644 index 416ed19dc2..0000000000 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@angular/core'; -import { hasValue } from '../../shared/empty.util'; -import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; -import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; - -@Injectable() -export class RegistryMetadataschemasResponseParsingService implements ResponseParsingService { - constructor(private dsoParser: DSOResponseParsingService) { - } - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload; - - let metadataschemas = []; - if (hasValue(payload._embedded)) { - metadataschemas = payload._embedded.metadataschemas; - } - payload.metadataschemas = metadataschemas; - - const deserialized = new DSpaceSerializer(RegistryMetadataschemasResponse).deserialize(payload); - return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); - } - -} diff --git a/src/app/core/registry/registry-bitstreamformats-response.model.ts b/src/app/core/registry/registry-bitstreamformats-response.model.ts deleted file mode 100644 index 4da30b4ffc..0000000000 --- a/src/app/core/registry/registry-bitstreamformats-response.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { autoserialize, deserialize } from 'cerialize'; -import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; -import { HALLink } from '../shared/hal-link.model'; -import { PageInfo } from '../shared/page-info.model'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { link } from '../cache/builders/build-decorators'; - -export class RegistryBitstreamformatsResponse { - @autoserialize - page: PageInfo; - - /** - * The {@link HALLink}s for this RegistryBitstreamformatsResponse - */ - @deserialize - _links: { - self: HALLink; - bitstreamformats: HALLink; - }; - - @link(BITSTREAM_FORMAT) - bitstreamformats?: BitstreamFormat[]; - -} diff --git a/src/app/core/registry/registry-metadatafields-response.model.ts b/src/app/core/registry/registry-metadatafields-response.model.ts deleted file mode 100644 index 5dc492ab0f..0000000000 --- a/src/app/core/registry/registry-metadatafields-response.model.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { autoserialize, deserialize } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; -import { MetadataField } from '../metadata/metadata-field.model'; -import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; -import { HALLink } from '../shared/hal-link.model'; -import { PageInfo } from '../shared/page-info.model'; -import { ResourceType } from '../shared/resource-type'; -import { excludeFromEquals } from '../utilities/equals.decorators'; - -/** - * Class that represents a response with a registry's metadata fields - */ -@typedObject -export class RegistryMetadatafieldsResponse { - static type = METADATA_FIELD; - - /** - * The object type - */ - @excludeFromEquals - @autoserialize - type: ResourceType; - - /** - * List of metadata fields in the response - */ - @deserialize - metadatafields: MetadataField[]; - - /** - * Page info of this response - */ - @autoserialize - page: PageInfo; - - /** - * The REST link to this response - */ - @autoserialize - self: string; - - @deserialize - _links: { - self: HALLink, - } -} diff --git a/src/app/core/registry/registry-metadataschemas-response.model.ts b/src/app/core/registry/registry-metadataschemas-response.model.ts deleted file mode 100644 index 7a485d8849..0000000000 --- a/src/app/core/registry/registry-metadataschemas-response.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PageInfo } from '../shared/page-info.model'; -import { autoserialize, deserialize } from 'cerialize'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; - -export class RegistryMetadataschemasResponse { - @deserialize - metadataschemas: MetadataSchema[]; - - @autoserialize - page: PageInfo; - - @autoserialize - self: string; -} diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index fbc42b26f4..ab846fa6d1 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -7,32 +7,20 @@ import { PageInfo } from '../shared/page-info.model'; import { CreateMetadataFieldRequest, CreateMetadataSchemaRequest, - DeleteRequest, - GetRequest, - RestRequest, + DeleteRequest, FindListOptions, UpdateMetadataFieldRequest, UpdateMetadataSchemaRequest } from '../data/request.models'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { ResponseParsingService } from '../data/parsing.service'; -import { RegistryMetadataschemasResponseParsingService } from '../data/registry-metadataschemas-response-parsing.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { MetadatafieldSuccessResponse, MetadataschemaSuccessResponse, - RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service'; -import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { configureRequest, getFirstSucceededRemoteDataPayload, getResponseFromEntry } from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; @@ -57,6 +45,9 @@ import { TranslateService } from '@ngx-translate/core'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; import { getClassForType } from '../cache/builders/build-decorators'; +import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'; +import { MetadataFieldDataService } from '../data/metadata-field-data.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); @@ -80,211 +71,60 @@ export class RegistryService { private halService: HALEndpointService, private store: Store, private notificationsService: NotificationsService, - private translateService: TranslateService) { + private translateService: TranslateService, + private metadataSchemaService: MetadataSchemaDataService, + private metadataFieldService: MetadataFieldDataService) { } /** * Retrieves all metadata schemas - * @param pagination The pagination info used to retrieve the schemas + * @param options The options used to retrieve the schemas + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - public getMetadataSchemas(pagination: PaginationComponentOptions): Observable>> { - const requestObs = this.getMetadataSchemasRequestObs(pagination); - - const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const rmrObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) - ); - - const metadataschemasObs: Observable = rmrObs.pipe( - map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas) - ); - - const pageInfoObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: RegistryMetadataschemasSuccessResponse) => response.pageInfo) - ); - - const payloadObs = observableCombineLatest(metadataschemasObs, pageInfoObs).pipe( - map(([metadataschemas, pageInfo]) => { - return new PaginatedList(pageInfo, metadataschemas); - }) - ); - - return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + public getMetadataSchemas(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.metadataSchemaService.findAll(options, ...linksToFollow); } /** * Retrieves a metadata schema by its name * @param schemaName The name of the schema to find + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - public getMetadataSchemaByName(schemaName: string): Observable> { - // Temporary pagination to get ALL metadataschemas until there's a rest api endpoint for fetching a specific schema - const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'all-metadatafields-pagination', - pageSize: 10000 + public getMetadataSchemaByName(schemaName: string, ...linksToFollow: Array>): Observable> { + // Temporary options to get ALL metadataschemas until there's a rest api endpoint for fetching a specific schema + const options: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 10000 }); - const requestObs = this.getMetadataSchemasRequestObs(pagination); - - const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) + return this.getMetadataSchemas(options).pipe( + getFirstSucceededRemoteDataPayload(), + map((schemas: PaginatedList) => schemas.page.filter((schema) => schema.prefix === schemaName)[0]), + flatMap((schema: MetadataSchema) => this.metadataSchemaService.findById(`${schema.id}`, ...linksToFollow)) ); - - const rmrObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) - ); - - const metadataschemaObs: Observable = rmrObs.pipe( - map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas), - map((metadataSchemas: MetadataSchema[]) => metadataSchemas.filter((value) => value.prefix === schemaName)[0]) - ); - - return this.rdb.toRemoteDataObservable(requestEntryObs, metadataschemaObs); } /** * retrieves all metadata fields that belong to a certain metadata schema * @param schema The schema to filter by - * @param pagination The pagination info used to retrieve the fields + * @param options The options info used to retrieve the fields + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable>> { - const requestObs = this.getMetadataFieldsBySchemaRequestObs(pagination, schema); - - 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) - ); - - 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 getMetadataFieldsBySchema(schema: MetadataSchema, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.metadataFieldService.findBySchema(schema, options, ...linksToFollow); } /** * Retrieve all existing metadata fields as a paginated list - * @param pagination Pagination options to determine which page of metadata fields should be requested - * When no pagination is provided, all metadata fields are requested in one large page + * @param options Options to determine which page of metadata fields should be requested + * When no options are provided, all metadata fields are requested in one large page + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @returns an observable that emits a remote data object with a page of metadata fields */ - public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable>> { - if (hasNoValue(pagination)) { - pagination = {currentPage: 1, pageSize: 10000} as any; + public getAllMetadataFields(options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + if (hasNoValue(options)) { + options = {currentPage: 1, elementsPerPage: 10000} as any; } - const requestObs = this.getMetadataFieldsRequestObs(pagination); - - const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const rmrObs: Observable = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) - ); - - const metadatafieldsObs: Observable = rmrObs.pipe( - map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), - /* Make sure to explicitly cast this into a MetadataField object, on first page loads this object comes from the object cache created by the server and its prototype is unknown */ - map((metadataFields: MetadataField[]) => metadataFields.map((metadataField: MetadataField) => Object.assign(new MetadataField(), metadataField))) - ); - - 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 getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable { - return this.halService.getEndpoint(this.metadataSchemasPath).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 RegistryMetadataschemasResponseParsingService; - } - }); - }), - tap((request: RestRequest) => this.requestService.configure(request)), - ); - } - - private getMetadataFieldsBySchemaRequestObs(pagination: PaginationComponentOptions, schema: MetadataSchema): Observable { - return this.halService.getEndpoint(this.metadataFieldsPath + '/search/bySchema').pipe( - // return this.halService.getEndpoint(this.metadataFieldsPath).pipe( - map((url: string) => { - const args: string[] = []; - args.push(`schema=${schema.prefix}`); - 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 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)), - ); + return this.metadataFieldService.findAll(options, ...linksToFollow); } public editMetadataSchema(schema: MetadataSchema) { diff --git a/src/app/shared/pagination/pagination.utils.ts b/src/app/shared/pagination/pagination.utils.ts new file mode 100644 index 0000000000..5701c96b54 --- /dev/null +++ b/src/app/shared/pagination/pagination.utils.ts @@ -0,0 +1,14 @@ +import { PaginationComponentOptions } from './pagination-component-options.model'; +import { FindListOptions } from '../../core/data/request.models'; + +/** + * Transform a PaginationComponentOptions object into a FindListOptions object + * @param pagination The PaginationComponentOptions to transform + * @param original An original FindListOptions object to start from + */ +export function toFindListOptions(pagination: PaginationComponentOptions, original?: FindListOptions): FindListOptions { + return Object.assign(new FindListOptions(), original, { + currentPage: pagination.currentPage, + elementsPerPage: pagination.pageSize + }); +} From 7677a673aae9aaffe5cfd58d67fcfcee85fd38b4 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 May 2020 16:36:03 +0200 Subject: [PATCH 02/59] 70834: Intermediate commit --- .../core/data/metadata-field-data.service.ts | 62 +++++++- .../core/data/metadata-schema-data.service.ts | 67 +++++++- src/app/core/registry/registry.service.ts | 149 ++---------------- 3 files changed, 138 insertions(+), 140 deletions(-) diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 59af99e558..9d1298dced 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -8,14 +8,21 @@ import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { FindListOptions } from './request.models'; +import { CreateMetadataFieldRequest, FindListOptions, UpdateMetadataFieldRequest } from './request.models'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { SearchParam } from '../cache/models/search-param.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, map, take, tap } from 'rxjs/operators'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { MetadatafieldSuccessResponse, RestResponse } from '../cache/response.models'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint @@ -45,4 +52,55 @@ export class MetadataFieldDataService extends DataService { return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow); } + createOrUpdateMetadataField(field: MetadataField): Observable { + const isUpdate = hasValue(field.id); + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.getBrowseEndpoint().pipe( + isNotEmptyOperator(), + map((endpoint: string) => (isUpdate ? `${endpoint}/${field.id}` : `${endpoint}?schemaId=${field.schema.id}`)), + distinctUntilChanged() + ); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => { + if (isUpdate) { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field), options); + } else { + return new CreateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field)); + } + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return response + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); + } + } else { + return response; + } + }), + isNotEmptyOperator() + ); + } + + clearRequests(): Observable { + return this.getBrowseEndpoint().pipe( + tap((href: string) => this.requestService.removeByHrefSubstring(href)) + ); + } + } diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index bdb4b9315f..f75f1e453f 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,8 +1,8 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; +import { dataService, getClassForType } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; @@ -12,6 +12,15 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RequestService } from './request.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, map, take, tap } from 'rxjs/operators'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { CreateMetadataSchemaRequest, UpdateMetadataSchemaRequest } from './request.models'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint @@ -32,4 +41,58 @@ export class MetadataSchemaDataService extends DataService { protected notificationsService: NotificationsService) { super(); } + + createOrUpdateMetadataSchema(schema: MetadataSchema): Observable { + const isUpdate = hasValue(schema.id); + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.getBrowseEndpoint().pipe( + isNotEmptyOperator(), + map((endpoint: string) => (isUpdate ? `${endpoint}/${schema.id}` : endpoint)), + distinctUntilChanged() + ); + + const serializedSchema = new DSpaceSerializer(getClassForType(MetadataSchema.type)).serialize(schema); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => { + if (isUpdate) { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema), options); + } else { + return new CreateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema)); + } + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return created/updated schema + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); + } + } else { + return response; + } + }), + isNotEmptyOperator() + ); + } + + clearRequests(): Observable { + return this.getBrowseEndpoint().pipe( + tap((href: string) => this.requestService.removeByHrefSubstring(href)) + ); + } + } diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index ab846fa6d1..fe1ae63144 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -2,15 +2,8 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Injectable } from '@angular/core'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { PageInfo } from '../shared/page-info.model'; -import { - CreateMetadataFieldRequest, - CreateMetadataSchemaRequest, - DeleteRequest, FindListOptions, - UpdateMetadataFieldRequest, - UpdateMetadataSchemaRequest -} from '../data/request.models'; +import { FindListOptions } from '../data/request.models'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; import { @@ -19,8 +12,8 @@ import { RestResponse } from '../cache/response.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { configureRequest, getFirstSucceededRemoteDataPayload, getResponseFromEntry } from '../shared/operators'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers'; @@ -36,15 +29,11 @@ import { MetadataRegistrySelectFieldAction, MetadataRegistrySelectSchemaAction } from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; -import { distinctUntilChanged, flatMap, map, take, tap } from 'rxjs/operators'; +import { flatMap, map, tap } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { HttpHeaders } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; -import { getClassForType } from '../cache/builders/build-decorators'; import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'; import { MetadataFieldDataService } from '../data/metadata-field-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -61,11 +50,6 @@ const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelec @Injectable() export class RegistryService { - private metadataSchemasPath = 'metadataschemas'; - private metadataFieldsPath = 'metadatafields'; - - // private bitstreamFormatsPath = 'bitstreamformats'; - constructor(protected requestService: RequestService, private rdb: RemoteDataBuildService, private halService: HALEndpointService, @@ -232,51 +216,10 @@ export class RegistryService { */ public createOrUpdateMetadataSchema(schema: MetadataSchema): Observable { const isUpdate = hasValue(schema.id); - const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.halService.getEndpoint(this.metadataSchemasPath).pipe( - isNotEmptyOperator(), - map((endpoint: string) => (isUpdate ? `${endpoint}/${schema.id}` : endpoint)), - distinctUntilChanged() - ); - - const serializedSchema = new DSpaceSerializer(getClassForType(MetadataSchema.type)).serialize(schema); - - const request$ = endpoint$.pipe( - take(1), - map((endpoint: string) => { - if (isUpdate) { - const options: HttpOptions = Object.create({}); - let headers = new HttpHeaders(); - headers = headers.append('Content-Type', 'application/json'); - options.headers = headers; - return new UpdateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema), options); - } else { - return new CreateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema)); - } - }) - ); - - // Execute the post/put request - request$.pipe( - configureRequest(this.requestService) - ).subscribe(); - - // Return created/updated schema - return this.requestService.getByUUID(requestId).pipe( - getResponseFromEntry(), - map((response: RestResponse) => { - if (!response.isSuccessful) { - if (hasValue((response as any).errorMessage)) { - this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); - } - } else { - this.showNotifications(true, isUpdate, false, {prefix: schema.prefix}); - return response; - } - }), - isNotEmptyOperator(), + return this.metadataSchemaService.createOrUpdateMetadataSchema(schema).pipe( map((response: MetadataschemaSuccessResponse) => { if (isNotEmpty(response.metadataschema)) { + this.showNotifications(true, isUpdate, false, {prefix: schema.prefix}); return response.metadataschema; } }) @@ -288,16 +231,14 @@ export class RegistryService { * @param id The id of the metadata schema to delete */ public deleteMetadataSchema(id: number): Observable { - return this.delete(this.metadataSchemasPath, id); + return this.metadataSchemaService.deleteAndReturnResponse(`${id}`); } /** * Method that clears a cached metadata schema request and returns its REST url */ public clearMetadataSchemaRequests(): Observable { - return this.halService.getEndpoint(this.metadataSchemasPath).pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) - ); + return this.metadataSchemaService.clearRequests(); } /** @@ -310,50 +251,11 @@ export class RegistryService { */ public createOrUpdateMetadataField(field: MetadataField): Observable { const isUpdate = hasValue(field.id); - const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.halService.getEndpoint(this.metadataFieldsPath).pipe( - isNotEmptyOperator(), - map((endpoint: string) => (isUpdate ? `${endpoint}/${field.id}` : `${endpoint}?schemaId=${field.schema.id}`)), - distinctUntilChanged() - ); - - const request$ = endpoint$.pipe( - take(1), - map((endpoint: string) => { - if (isUpdate) { - const options: HttpOptions = Object.create({}); - let headers = new HttpHeaders(); - headers = headers.append('Content-Type', 'application/json'); - options.headers = headers; - return new UpdateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field), options); - } else { - return new CreateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field)); - } - }) - ); - - // Execute the post/put request - request$.pipe( - configureRequest(this.requestService) - ).subscribe(); - - // Return created/updated field - return this.requestService.getByUUID(requestId).pipe( - getResponseFromEntry(), - map((response: RestResponse) => { - if (!response.isSuccessful) { - if (hasValue((response as any).errorMessage)) { - this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1)); - } - } else { - const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`; - this.showNotifications(true, isUpdate, true, {field: fieldString}); - return response; - } - }), - isNotEmptyOperator(), + return this.metadataFieldService.createOrUpdateMetadataField(field).pipe( map((response: MetadatafieldSuccessResponse) => { if (isNotEmpty(response.metadatafield)) { + const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`; + this.showNotifications(true, isUpdate, true, {field: fieldString}); return response.metadatafield; } }) @@ -365,38 +267,13 @@ export class RegistryService { * @param id The id of the metadata field to delete */ public deleteMetadataField(id: number): Observable { - return this.delete(this.metadataFieldsPath, id); + return this.metadataFieldService.deleteAndReturnResponse(`${id}`); } /** * Method that clears a cached metadata field request and returns its REST url */ public clearMetadataFieldRequests(): Observable { - return this.halService.getEndpoint(this.metadataFieldsPath).pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) - ); - } - - private delete(path: string, id: number): Observable { - const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.halService.getEndpoint(path).pipe( - isNotEmptyOperator(), - map((endpoint: string) => `${endpoint}/${id}`), - distinctUntilChanged() - ); - - const request$ = endpoint$.pipe( - take(1), - map((endpoint: string) => new DeleteRequest(requestId, endpoint)) - ); - - // Execute the delete request - request$.pipe( - configureRequest(this.requestService) - ).subscribe(); - - return this.requestService.getByUUID(requestId).pipe( - getResponseFromEntry() - ); + return this.metadataFieldService.clearRequests(); } private showNotifications(success: boolean, edited: boolean, isField: boolean, options: any) { From cd46f339097cd64d148e6d532767cf3eb3b72f91 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 13 May 2020 13:56:42 +0200 Subject: [PATCH 03/59] 70834: Metadata schema component refactoring and caching issue fix --- .../metadata-field-form.component.ts | 1 + .../metadata-schema.component.html | 62 ++++++++++--------- .../metadata-schema.component.ts | 41 ++++++------ .../core/data/metadata-field-data.service.ts | 22 ++++++- 4 files changed, 76 insertions(+), 50 deletions(-) diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts index 0811530343..52fee16473 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts @@ -177,6 +177,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { }); } this.clearFields(); + this.registryService.cancelEditMetadataField(); } ); } diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html index 4a7a4cf34d..49ef748349 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html @@ -1,36 +1,37 @@
diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index 0167d61686..a9a42bf3dd 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -1,7 +1,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Store, StoreModule } from '@ngrx/store'; +import { StoreModule } from '@ngrx/store'; import { LogInComponent } from './log-in.component'; import { authReducer } from '../../core/auth/auth.reducer'; @@ -13,11 +13,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { SharedModule } from '../shared.module'; import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; import { ActivatedRouteStub } from '../testing/active-router.stub'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RouterStub } from '../testing/router.stub'; +import { ActivatedRoute } from '@angular/router'; import { NativeWindowService } from '../../core/services/window.service'; import { provideMockStore } from '@ngrx/store/testing'; import { createTestComponent } from '../testing/utils.test'; +import { RouterTestingModule } from '@angular/router/testing'; describe('LogInComponent', () => { @@ -46,6 +46,7 @@ describe('LogInComponent', () => { strictActionImmutability: false } }), + RouterTestingModule, SharedModule, TranslateModule.forRoot() ], @@ -55,7 +56,7 @@ describe('LogInComponent', () => { providers: [ { provide: AuthService, useClass: AuthServiceStub }, { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, - { provide: Router, useValue: new RouterStub() }, + // { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, provideMockStore({ initialState }), LogInComponent diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index 92350de442..6634389c26 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -8,6 +8,7 @@ import { AuthMethod } from '../../core/auth/models/auth.method'; import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; import { AuthService } from '../../core/auth/auth.service'; +import { getRegisterPath } from '../../app-routing.module'; /** * /users/sign-in @@ -82,4 +83,7 @@ export class LogInComponent implements OnInit, OnDestroy { this.alive = false; } + getRegisterPath() { + return getRegisterPath(); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 67290ed2e3..a07f4fbbae 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2068,13 +2068,13 @@ "register-page.create-profile.submit": "Complete Registration", - "register-page.create-profile.submit.error.content": "Registration failed", + "register-page.create-profile.submit.error.content": "Something went wrong while registering a new user.", - "register-page.create-profile.submit.error.head": "Something went wrong while registering a new user.", + "register-page.create-profile.submit.error.head": "Registration failed", - "register-page.create-profile.submit.success.content": "Registration completed", + "register-page.create-profile.submit.success.content": "The registration was successful. You have been logged in as the created user.", - "register-page.create-profile.submit.success.head": "The registration was successful. You have been logged in as the created user.", + "register-page.create-profile.submit.success.head": "Registration completed", "register-page.registration.header": "New user registration", From 8fc2309d488c21935f9b3595e68dfb957e8eb594 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 28 May 2020 10:34:03 +0200 Subject: [PATCH 13/59] Fix rebase issues --- src/app/core/data/eperson-registration.service.spec.ts | 2 +- .../create-profile/create-profile.component.spec.ts | 6 ++---- .../create-profile/create-profile.component.ts | 5 ++--- .../register-email/register-email.component.spec.ts | 4 ++-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index a653f05e33..d8623e7046 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -1,11 +1,11 @@ import { RequestService } from './request.service'; import { EpersonRegistrationService } from './eperson-registration.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RegistrationSuccessResponse, RestResponse } from '../cache/response.models'; import { RequestEntry } from './request.reducer'; import { cold } from 'jasmine-marbles'; import { PostRequest } from './request.models'; import { Registration } from '../shared/registration.model'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; describe('EpersonRegistrationService', () => { let service: EpersonRegistrationService; diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index 04e1d0ffdc..5fed324a22 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -9,16 +9,15 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { Store } from '@ngrx/store'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { RouterStub } from '../../shared/testing/router-stub'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { of as observableOf } from 'rxjs'; import { RestResponse } from '../../core/cache/response.models'; import { By } from '@angular/platform-browser'; -import { GLOBAL_CONFIG } from '../../../config'; import { CoreState } from '../../core/core.reducers'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { AuthenticateAction } from '../../core/auth/auth.actions'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; describe('CreateProfileComponent', () => { let comp: CreateProfileComponent; @@ -90,7 +89,6 @@ describe('CreateProfileComponent', () => { imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], declarations: [CreateProfileComponent], providers: [ - {provide: GLOBAL_CONFIG, useValue: mockConfig}, {provide: Router, useValue: router}, {provide: ActivatedRoute, useValue: route}, {provide: Store, useValue: store}, diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts index 947a8ac1ef..9b892bdcfc 100644 --- a/src/app/register-page/create-profile/create-profile.component.ts +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -9,11 +9,11 @@ import { TranslateService } from '@ngx-translate/core'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { LangConfig } from '../../../config/lang-config.interface'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { Store } from '@ngrx/store'; import { CoreState } from '../../core/core.reducers'; import { AuthenticateAction } from '../../core/auth/auth.actions'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { environment } from '../../../environments/environment'; /** * Component that renders the create profile page to be used by a user registering through a token @@ -33,7 +33,6 @@ export class CreateProfileComponent implements OnInit { activeLangs: LangConfig[]; constructor( - @Inject(GLOBAL_CONFIG) public config: GlobalConfig, private translateService: TranslateService, private ePersonDataService: EPersonDataService, private store: Store, @@ -53,7 +52,7 @@ export class CreateProfileComponent implements OnInit { this.email = registration.email; this.token = registration.token; }); - this.activeLangs = this.config.languages.filter((MyLangConfig) => MyLangConfig.active === true); + this.activeLangs = environment.languages.filter((MyLangConfig) => MyLangConfig.active === true); this.userInfoForm = this.formBuilder.group({ firstName: new FormControl('', { diff --git a/src/app/register-page/register-email/register-email.component.spec.ts b/src/app/register-page/register-email/register-email.component.spec.ts index eb95fd220e..67986853ea 100644 --- a/src/app/register-page/register-email/register-email.component.spec.ts +++ b/src/app/register-page/register-email/register-email.component.spec.ts @@ -1,7 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { of as observableOf } from 'rxjs'; -import { RouterStub } from '../../shared/testing/router-stub'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { RestResponse } from '../../core/cache/response.models'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; @@ -13,6 +11,8 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { RegisterEmailComponent } from './register-email.component'; import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; import { By } from '@angular/platform-browser'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; describe('RegisterEmailComponent', () => { From a51a683215c620e03037b6d0880ecb3d6ca75fc4 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 3 Jun 2020 11:47:40 +0200 Subject: [PATCH 14/59] 71174: Breadcrumb test fix --- src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts index d34d6d8a9b..a06abdc816 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts @@ -14,7 +14,7 @@ describe('I18nBreadcrumbResolver', () => { }); it('should resolve the breadcrumb config', () => { - const resolvedConfig = resolver.resolve({ data: { breadcrumbKey: i18nKey }, url: [path] } as any, {} as any); + const resolvedConfig = resolver.resolve({ data: { breadcrumbKey: i18nKey }, url: [path], pathFromRoot: [{ url: [path] }] } as any, {} as any); const expectedConfig = { provider: i18nBreadcrumbService, key: i18nKey, url: path }; expect(resolvedConfig).toEqual(expectedConfig); }); From ca7a76b80f458b6338604a088a621478c15b0913 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Wed, 10 Jun 2020 10:15:06 +0200 Subject: [PATCH 15/59] Implement community feedback Remove other registration references Enabled enter submit in registration form Fixed create-profile password validation to be on debounce Fixed register page title --- src/app/core/auth/auth.actions.ts | 49 ------------------- src/app/core/auth/auth.effects.ts | 15 ------ src/app/core/auth/auth.reducer.ts | 13 ----- src/app/core/auth/auth.service.ts | 12 ----- .../create-profile.component.html | 12 ++--- .../create-profile.component.ts | 21 ++++++-- .../register-email.component.html | 2 +- .../register-email.component.ts | 22 +++++---- src/assets/i18n/en.json5 | 1 + 9 files changed, 37 insertions(+), 110 deletions(-) diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 9237c30db9..be4bdf2a26 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -3,7 +3,6 @@ import { Action } from '@ngrx/store'; // import type function import { type } from '../../shared/ngrx/type'; // import models -import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; @@ -31,9 +30,6 @@ export const AuthActionTypes = { LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), LOG_OUT_SUCCESS: type('dspace/auth/LOG_OUT_SUCCESS'), - REGISTRATION: type('dspace/auth/REGISTRATION'), - REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'), - REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'), SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'), RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), @@ -263,48 +259,6 @@ export class RetrieveTokenAction implements Action { public type: string = AuthActionTypes.RETRIEVE_TOKEN; } -/** - * Sign up. - * @class RegistrationAction - * @implements {Action} - */ -export class RegistrationAction implements Action { - public type: string = AuthActionTypes.REGISTRATION; - payload: EPerson; - - constructor(user: EPerson) { - this.payload = user; - } -} - -/** - * Sign up error. - * @class RegistrationErrorAction - * @implements {Action} - */ -export class RegistrationErrorAction implements Action { - public type: string = AuthActionTypes.REGISTRATION_ERROR; - payload: Error; - - constructor(payload: Error) { - this.payload = payload; - } -} - -/** - * Sign up success. - * @class RegistrationSuccessAction - * @implements {Action} - */ -export class RegistrationSuccessAction implements Action { - public type: string = AuthActionTypes.REGISTRATION_SUCCESS; - payload: EPerson; - - constructor(user: EPerson) { - this.payload = user; - } -} - /** * Add uthentication message. * @class AddAuthenticationMessageAction @@ -439,9 +393,6 @@ export type AuthActions | CheckAuthenticationTokenCookieAction | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction - | RegistrationAction - | RegistrationErrorAction - | RegistrationSuccessAction | AddAuthenticationMessageAction | RefreshTokenAction | RefreshTokenErrorAction diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 717aaff01e..c0b0de2e18 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -32,9 +32,6 @@ import { RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, - RegistrationAction, - RegistrationErrorAction, - RegistrationSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, @@ -138,18 +135,6 @@ export class AuthEffects { }) ); - @Effect() - public createUser$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REGISTRATION), - debounceTime(500), // to remove when functionality is implemented - switchMap((action: RegistrationAction) => { - return this.authService.create(action.payload).pipe( - map((user: EPerson) => new RegistrationSuccessAction(user)), - catchError((error) => observableOf(new RegistrationErrorAction(error))) - ); - }) - ); - @Effect() public retrieveToken$: Observable = this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_TOKEN), diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 16990b35a8..34c8fe2b41 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -115,7 +115,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_ERROR: - case AuthActionTypes.REGISTRATION_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, @@ -157,18 +156,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut userId: undefined }); - case AuthActionTypes.REGISTRATION: - return Object.assign({}, state, { - authenticated: false, - authToken: undefined, - error: undefined, - loading: true, - info: undefined - }); - - case AuthActionTypes.REGISTRATION_SUCCESS: - return state; - case AuthActionTypes.REFRESH_TOKEN: return Object.assign({}, state, { refreshing: true, diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 588d9e2675..f9c1fc2cb9 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -270,18 +270,6 @@ export class AuthService { return observableOf(authMethods); } - /** - * Create a new user - * @returns {User} - */ - public create(user: EPerson): Observable { - // Normally you would do an HTTP request to POST the user - // details and then return the new user object - // but, let's just return the new user for this example. - // this._authenticated = true; - return observableOf(user); - } - /** * End session * @returns {Observable} diff --git a/src/app/register-page/create-profile/create-profile.component.html b/src/app/register-page/create-profile/create-profile.component.html index a1fe5a3832..dc6a3ddeef 100644 --- a/src/app/register-page/create-profile/create-profile.component.html +++ b/src/app/register-page/create-profile/create-profile.component.html @@ -7,7 +7,7 @@ for="email">{{'register-page.create-profile.identification.email' | translate}} {{(registration$ |async).email}}
-
+
@@ -64,13 +64,13 @@

{{'register-page.create-profile.security.header' | translate}}

{{'register-page.create-profile.security.info' | translate}}

- +
@@ -78,9 +78,9 @@
-
{{ 'register-page.create-profile.security.password.error' | translate }}
@@ -92,7 +92,7 @@
diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts index 9b892bdcfc..aee9cd598d 100644 --- a/src/app/register-page/create-profile/create-profile.component.ts +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -1,6 +1,6 @@ -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { map } from 'rxjs/operators'; +import { debounceTime, map } from 'rxjs/operators'; import { Registration } from '../../core/shared/registration.model'; import { Observable } from 'rxjs'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; @@ -32,6 +32,8 @@ export class CreateProfileComponent implements OnInit { passwordForm: FormGroup; activeLangs: LangConfig[]; + isValidPassWord$: Observable; + constructor( private translateService: TranslateService, private ePersonDataService: EPersonDataService, @@ -68,15 +70,26 @@ export class CreateProfileComponent implements OnInit { this.passwordForm = this.formBuilder.group({ password: new FormControl('', { validators: [Validators.required, Validators.minLength(6)], - updateOn: 'blur' + updateOn: 'change' }), confirmPassword: new FormControl('', { validators: [Validators.required], - updateOn: 'blur' + updateOn: 'change' }) }, { validator: ConfirmedValidator('password', 'confirmPassword') }); + + this.isValidPassWord$ = this.passwordForm.statusChanges.pipe( + debounceTime(300), + map((status: string) => { + if (status === 'VALID') { + return true; + } else { + return false; + } + }) + ); } get firstName() { diff --git a/src/app/register-page/register-email/register-email.component.html b/src/app/register-page/register-email/register-email.component.html index b25a47d262..f506ab8f5d 100644 --- a/src/app/register-page/register-email/register-email.component.html +++ b/src/app/register-page/register-email/register-email.component.html @@ -2,7 +2,7 @@

{{'register-page.registration.header'|translate}}

{{'register-page.registration.info' | translate}}

- +
diff --git a/src/app/register-page/register-email/register-email.component.ts b/src/app/register-page/register-email/register-email.component.ts index b901f0e6e4..c23a7797a2 100644 --- a/src/app/register-page/register-email/register-email.component.ts +++ b/src/app/register-page/register-email/register-email.component.ts @@ -42,17 +42,19 @@ export class RegisterEmailComponent implements OnInit { * Register an email address */ register() { - this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RestResponse) => { - if (response.isSuccessful) { - this.notificationService.success(this.translateService.get('register-page.registration.success.head'), - this.translateService.get('register-page.registration.success.content', {email: this.email.value})); - this.router.navigate(['/home']); - } else { - this.notificationService.error(this.translateService.get('register-page.registration.error.head'), - this.translateService.get('register-page.registration.error.content', {email: this.email.value})); + if (!this.form.invalid) { + this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationService.success(this.translateService.get('register-page.registration.success.head'), + this.translateService.get('register-page.registration.success.content', {email: this.email.value})); + this.router.navigate(['/home']); + } else { + this.notificationService.error(this.translateService.get('register-page.registration.error.head'), + this.translateService.get('register-page.registration.error.content', {email: this.email.value})); + } } - } - ); + ); + } } get email() { diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index a07f4fbbae..018fe09b0b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2037,6 +2037,7 @@ "publication.search.title": "DSpace Angular :: Publication Search", + "register-email.title": "New user registration", "register-page.create-profile.header": "Create Profile", From d27034d6340e9b2888179cc08b3906af03fec060 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 20 Apr 2020 12:51:46 +0200 Subject: [PATCH 16/59] fix issue where the current collection or community would no longer be selected in the new and edit menus --- ...e-community-parent-selector.component.html | 4 +-- .../dso-selector-modal-wrapper.component.html | 2 +- ...o-selector-modal-wrapper.component.spec.ts | 2 +- .../dso-selector-modal-wrapper.component.ts | 29 ++++++++++++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html index e6a0db3b62..d0ba9c7108 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -14,6 +14,6 @@
{{'dso-selector.create.community.sub-level' | translate}}
- +
-
\ No newline at end of file +
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html index 1181e097eb..4800bb8733 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html @@ -5,6 +5,6 @@
diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index 7b5c020f1b..fb49fe084a 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -63,7 +63,7 @@ describe('DSOSelectorModalWrapperComponent', () => { }); it('should initially set the DSO to the activated route\'s item/collection/community', () => { - component.dsoRD$ + component.dsoRD .pipe(first()) .subscribe((a) => { expect(a).toEqual(itemRD); diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 881476cac6..c13bd2b69d 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -1,11 +1,12 @@ import { Injectable, Input, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { RemoteData } from '../../../core/data/remote-data'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { map } from 'rxjs/operators'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { hasValue, isNotEmpty } from '../../empty.util'; export enum SelectorActionType { CREATE = 'create', @@ -21,7 +22,7 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { /** * The current page's DSO */ - @Input() dsoRD$: Observable>; + @Input() dsoRD: RemoteData; /** * The type of the DSO that's being edited or created @@ -45,10 +46,30 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { * Get de current page's DSO based on the selectorType */ ngOnInit(): void { - const typeString = this.selectorType.toString().toLowerCase(); - this.dsoRD$ = this.route.root.firstChild.firstChild.data.pipe(map((data) => data[typeString])); + const matchingRoute = this.findRouteData( + (route: ActivatedRouteSnapshot) => hasValue(route.data.dso), + this.route.root.snapshot + ); + if (hasValue(matchingRoute)) { + this.dsoRD = matchingRoute.data.dso; + } } + findRouteData(predicate: (value: ActivatedRouteSnapshot, index?: number, obj?: ActivatedRouteSnapshot[]) => unknown, ...routes: ActivatedRouteSnapshot[]) { + const result = routes.find(predicate); + if (hasValue(result)) { + return result; + } else { + const nextLevelRoutes = routes + .map((route: ActivatedRouteSnapshot) => route.children) + .reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]); + if (isNotEmpty(nextLevelRoutes)) { + return this.findRouteData(predicate, ...nextLevelRoutes) + } else { + return undefined; + } + } + } /** * Method called when an object has been selected * @param dso The selected DSpaceObject From 405815ac5a60ccf047f02db5a44ed1dd193e7ad2 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 20 Apr 2020 13:36:01 +0200 Subject: [PATCH 17/59] fix test --- .../dso-selector-modal-wrapper.component.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index fb49fe084a..c51edc5d9c 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -63,11 +63,7 @@ describe('DSOSelectorModalWrapperComponent', () => { }); it('should initially set the DSO to the activated route\'s item/collection/community', () => { - component.dsoRD - .pipe(first()) - .subscribe((a) => { - expect(a).toEqual(itemRD); - }) + expect(component.dsoRD).toEqual(itemRD); }); describe('selectObject', () => { From 6c1e636f5c257fe83b84e8018a8e442641aa2c82 Mon Sep 17 00:00:00 2001 From: Samuel Date: Wed, 27 May 2020 14:15:46 +0200 Subject: [PATCH 18/59] fix tests --- ...eate-collection-parent-selector.component.spec.ts | 10 +++++++++- ...reate-community-parent-selector.component.spec.ts | 10 +++++++++- .../create-item-parent-selector.component.spec.ts | 10 +++++++++- .../dso-selector-modal-wrapper.component.spec.ts | 12 ++++++++++-- .../edit-collection-selector.component.spec.ts | 10 +++++++++- .../edit-community-selector.component.spec.ts | 10 +++++++++- .../edit-item-selector.component.spec.ts | 10 +++++++++- 7 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts index 480f6ff709..df62534593 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts @@ -39,7 +39,15 @@ describe('CreateCollectionParentSelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } } + useValue: { + root: { + snapshot: { + data: { + dso: communityRD, + }, + }, + } + }, }, { provide: Router, useValue: router diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts index b723d3fe98..9c6185199c 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts @@ -33,7 +33,15 @@ describe('CreateCommunityParentSelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } } + useValue: { + root: { + snapshot: { + data: { + dso: communityRD, + }, + }, + } + }, }, { provide: Router, useValue: router diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts index 854349a47c..e8cd35fb50 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts @@ -32,7 +32,15 @@ describe('CreateItemParentSelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ collection: collectionRD }) } } } } + useValue: { + root: { + snapshot: { + data: { + dso: collectionRD, + }, + }, + } + }, }, { provide: Router, useValue: router diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index c51edc5d9c..f52ce3fc8f 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -41,8 +41,16 @@ describe('DSOSelectorModalWrapperComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ item: itemRD }) } } } } - } + useValue: { + root: { + snapshot: { + data: { + dso: itemRD, + }, + }, + } + } + }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts index a17d9e4c21..21ff5e846d 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts @@ -33,7 +33,15 @@ describe('EditCollectionSelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ collection: collectionRD }) } } } } + useValue: { + root: { + snapshot: { + data: { + dso: collectionRD, + }, + }, + } + }, }, { provide: Router, useValue: router diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts index c48d29baa9..b37fa23024 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts @@ -33,7 +33,15 @@ describe('EditCommunitySelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } } + useValue: { + root: { + snapshot: { + data: { + dso: communityRD, + }, + }, + } + }, }, { provide: Router, useValue: router diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts index 582320acae..e310d6ac02 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts @@ -33,7 +33,15 @@ describe('EditItemSelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: ActivatedRoute, - useValue: { root: { firstChild: { firstChild: { data: observableOf({ item: itemRD }) } } } } + useValue: { + root: { + snapshot: { + data: { + dso: itemRD, + }, + }, + } + }, }, { provide: Router, useValue: router From ee9f5ec7f1d9b5af20f36460f8723cfe6961de50 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 Jun 2020 15:48:26 +0200 Subject: [PATCH 19/59] 70834: MetadataField search responseMsToLive fix --- .../metadata-registry.component.html | 1 - .../core/data/metadata-field-data.service.ts | 34 +++++++++++++++++-- src/app/core/registry/registry.service.ts | 6 ++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html index a254f20428..42b7558397 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -11,7 +11,6 @@ { ); } + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param linksToFollow The array of [[FollowLinkConfig]] + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); + + return hrefObs.pipe( + find((href: string) => hasValue(href)), + tap((href: string) => { + this.requestService.removeByHrefSubstring(href); + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); + + this.requestService.configure(request); + } + ), + switchMap((href) => this.requestService.getByHref(href)), + skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), + switchMap((href) => + this.rdbService.buildList(hrefObs, ...linksToFollow) as Observable>> + ) + ); + } + } diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 83e5fc9c64..79b982da8a 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -12,7 +12,7 @@ import { RestResponse } from '../cache/response.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators'; import { createSelector, select, Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; @@ -79,7 +79,9 @@ export class RegistryService { }); return this.getMetadataSchemas(options).pipe( getFirstSucceededRemoteDataPayload(), - map((schemas: PaginatedList) => schemas.page.filter((schema) => schema.prefix === schemaName)[0]), + map((schemas: PaginatedList) => schemas.page), + isNotEmptyOperator(), + map((schemas: MetadataSchema[]) => schemas.filter((schema) => schema.prefix === schemaName)[0]), flatMap((schema: MetadataSchema) => this.metadataSchemaService.findById(`${schema.id}`, ...linksToFollow)) ); } From 7421eef223c21a0f5f8f1bb984ca81cfd580986a Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 12 Jun 2020 09:54:13 +0200 Subject: [PATCH 20/59] Misc edit community and collection bugs --- .../comcol-form/comcol-form.component.spec.ts | 66 +++++++++++-------- .../comcol-form/comcol-form.component.ts | 32 +++++++-- .../comcol-metadata.component.spec.ts | 31 ++++++--- .../comcol-metadata.component.ts | 42 +++++++----- src/assets/i18n/en.json5 | 4 ++ 5 files changed, 115 insertions(+), 60 deletions(-) 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 a1bac46f87..3fcdc280d0 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 @@ -22,6 +22,7 @@ import { NotificationsService } from '../../notifications/notifications.service' import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; import { VarDirective } from '../../utils/var.directive'; import { ComColFormComponent } from './comcol-form.component'; +import { Operation } from 'fast-json-patch'; describe('ComColFormComponent', () => { let comp: ComColFormComponent; @@ -40,11 +41,8 @@ describe('ComColFormComponent', () => { } }; const dcTitle = 'dc.title'; - const dcRandom = 'dc.random'; const dcAbstract = 'dc.description.abstract'; - const titleMD = { [dcTitle]: [{ value: 'Community Title', language: null }] }; - const randomMD = { [dcRandom]: [{ value: 'Random metadata excluded from form', language: null }] }; const abstractMD = { [dcAbstract]: [{ value: 'Community description', language: null }] }; const newTitleMD = { [dcTitle]: [{ value: 'New Community Title', language: null }] }; const formModel = [ @@ -112,33 +110,47 @@ describe('ComColFormComponent', () => { }); it('should emit the new version of the community', () => { - comp.dso = Object.assign( - new Community(), - { - metadata: { - ...titleMD, - ...randomMD - } - } - ); + comp.dso = new Community(); comp.onSubmit(); + const operations: Operation[] = [ + { + op: 'replace', + path: '/metadata/dc.title', + value: { + value: 'New Community Title', + language: null, + }, + }, + { + op: 'replace', + path: '/metadata/dc.description.abstract', + value: { + value: 'Community description', + language: null, + }, + }, + ]; + expect(comp.submitForm.emit).toHaveBeenCalledWith( { - dso: Object.assign( - {}, - new Community(), - { + dso: Object.assign({}, comp.dso, { metadata: { - ...newTitleMD, - ...randomMD, - ...abstractMD + 'dc.title': [{ + value: 'New Community Title', + language: null, + }], + 'dc.description.abstract': [{ + value: 'Community description', + language: null, + }], }, - type: Community.type - }, + type: Community.type, + } ), uploader: undefined, - deleteLogo: false + deleteLogo: false, + operations: operations, } ); }) @@ -164,11 +176,6 @@ describe('ComColFormComponent', () => { it('should emit finish', () => { expect(comp.finish.emit).toHaveBeenCalled(); }); - - it('should remove the object\'s cache', () => { - expect(requestServiceStub.removeByHrefSubstring).toHaveBeenCalled(); - expect(objectCacheStub.remove).toHaveBeenCalled(); - }); }); describe('onUploadError', () => { @@ -239,6 +246,11 @@ describe('ComColFormComponent', () => { it('should display a success notification', () => { expect(notificationsService.success).toHaveBeenCalled(); }); + + it('should remove the object\'s cache', () => { + expect(requestServiceStub.removeByHrefSubstring).toHaveBeenCalled(); + expect(objectCacheStub.remove).toHaveBeenCalled(); + }); }); describe('when dsoService.deleteLogo returns an error response', () => { 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 f8199d2aad..91e896ce6c 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 @@ -25,6 +25,7 @@ import { hasValue, isNotEmpty } from '../../empty.util'; import { NotificationsService } from '../../notifications/notifications.service'; import { UploaderOptions } from '../../uploader/uploader-options.model'; import { UploaderComponent } from '../../uploader/uploader.component'; +import { Operation } from 'fast-json-patch'; /** * A form for creating and editing Communities or Collections @@ -85,7 +86,8 @@ export class ComColFormComponent implements OnInit, OnDe @Output() submitForm: EventEmitter<{ dso: T, uploader: FileUploader, - deleteLogo: boolean + deleteLogo: boolean, + operations: Operation[], }> = new EventEmitter(); /** @@ -189,9 +191,9 @@ export class ComColFormComponent implements OnInit, OnDe const formMetadata = {} as MetadataMap; this.formModel.forEach((fieldModel: DynamicInputModel) => { const value: MetadataValue = { - value: fieldModel.value as string, - language: null - } as any; + value: fieldModel.value as string, + language: null + } as any; if (formMetadata.hasOwnProperty(fieldModel.name)) { formMetadata[fieldModel.name].push(value); } else { @@ -206,10 +208,26 @@ export class ComColFormComponent implements OnInit, OnDe }, type: Community.type }); + + const operations: Operation[] = []; + this.formModel.forEach((fieldModel: DynamicInputModel) => { + if (fieldModel.value !== this.dso.firstMetadataValue(fieldModel.name)) { + operations.push({ + op: 'replace', + path: `/metadata/${fieldModel.name}`, + value: { + value: fieldModel.value, + language: null, + }, + }); + } + }); + this.submitForm.emit({ dso: updatedDSO, uploader: hasValue(this.uploaderComponent) ? this.uploaderComponent.uploader : undefined, - deleteLogo: this.markLogoForDeletion + deleteLogo: this.markLogoForDeletion, + operations: operations, }); } @@ -257,7 +275,9 @@ export class ComColFormComponent implements OnInit, OnDe * The request was successful, display a success notification */ public onCompleteItem() { - this.refreshCache(); + if (hasValue(this.dso.id)) { + this.refreshCache(); + } this.notificationsService.success(null, this.translate.get(this.type.value + '.edit.logo.notifications.add.success')); this.finish.emit(); } diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts index 414d64cbff..c606f50a71 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts @@ -27,6 +27,7 @@ describe('ComColMetadataComponent', () => { let communityDataServiceStub; let routerStub; let routeStub; + let isSuccessful = true; const logoEndpoint = 'rest/api/logo/endpoint'; @@ -49,6 +50,11 @@ describe('ComColMetadataComponent', () => { communityDataServiceStub = { update: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity), + patch: () => { + return observableOf({ + isSuccessful, + }) + }, getLogoEndpoint: () => observableOf(logoEndpoint) }; @@ -95,21 +101,28 @@ describe('ComColMetadataComponent', () => { describe('with an empty queue in the uploader', () => { beforeEach(() => { data = { - dso: Object.assign(new Community(), { - metadata: [{ - key: 'dc.title', - value: 'test' - }] - }), + operations: [ + { + op: 'replace', + path: '/metadata/dc.title', + value: { + value: 'test', + language: null, + }, + }, + ], + dso: new Community(), uploader: { options: { url: '' }, queue: [], /* tslint:disable:no-empty */ - uploadAll: () => {} + uploadAll: () => { + } /* tslint:enable:no-empty */ - } + }, + deleteLogo: false, } }); @@ -121,8 +134,8 @@ describe('ComColMetadataComponent', () => { }); it('should not navigate on failure', () => { + isSuccessful = false; spyOn(router, 'navigate'); - spyOn(dsoDataService, 'update').and.returnValue(createFailedRemoteDataObject$(newCommunity)); comp.onSubmit(data); fixture.detectChanges(); expect(router.navigate).not.toHaveBeenCalled(); diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts index 1031fead10..02c28d989c 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts @@ -5,8 +5,7 @@ import { RemoteData } from '../../../../core/data/remote-data'; import { ActivatedRoute, Router } from '@angular/router'; import { first, map, take } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; -import { hasValue, isNotUndefined } from '../../../empty.util'; -import { DataService } from '../../../../core/data/data.service'; +import { hasValue, isEmpty } from '../../../empty.util'; import { ResourceType } from '../../../../core/shared/resource-type'; import { ComColDataService } from '../../../../core/data/comcol-data.service'; import { NotificationsService } from '../../../notifications/notifications.service'; @@ -49,26 +48,33 @@ export class ComcolMetadataComponent implements On * @param event The event returned by the community/collection form. Contains the new dso and logo uploader */ onSubmit(event) { - const dso = event.dso; + const uploader = event.uploader; const deleteLogo = event.deleteLogo; - this.dsoDataService.update(dso) - .pipe(getSucceededRemoteData()) - .subscribe((dsoRD: RemoteData) => { - if (isNotUndefined(dsoRD)) { - const newUUID = dsoRD.payload.uuid; - if (hasValue(uploader) && uploader.queue.length > 0) { - this.dsoDataService.getLogoEndpoint(newUUID).pipe(take(1)).subscribe((href: string) => { - uploader.options.url = href; - uploader.uploadAll(); - }); - } else if (!deleteLogo) { - this.router.navigate([this.frontendURL + newUUID]); - } - this.notificationsService.success(null, this.translate.get(this.type.value + '.edit.notifications.success')); - } + const newLogo = hasValue(uploader) && uploader.queue.length > 0; + if (newLogo) { + this.dsoDataService.getLogoEndpoint(event.dso.uuid).pipe(take(1)).subscribe((href: string) => { + uploader.options.url = href; + uploader.uploadAll(); }); + } + + if (!isEmpty(event.operations)) { + this.dsoDataService.patch(event.dso, event.operations) + .subscribe(async (response) => { + if (response.isSuccessful) { + if (!newLogo && !deleteLogo) { + await this.router.navigate([this.frontendURL + event.dso.uuid]); + } + this.notificationsService.success(null, this.translate.get(`${this.type.value}.edit.notifications.success`)); + } else if (response.statusCode === 403) { + this.notificationsService.error(null, this.translate.get(`${this.type.value}.edit.notifications.unauthorized`)); + } else { + this.notificationsService.error(null, this.translate.get(`${this.type.value}.edit.notifications.error`)); + } + }); + } } /** diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4173fa1cf2..aad6cbf7b1 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -767,6 +767,10 @@ "community.edit.notifications.success": "Successfully edited the Community", + "community.edit.notifications.unauthorized": "You do not have privileges to make this change", + + "community.edit.notifications.error": "An error occured while editing the Community", + "community.edit.return": "Return", From b8ab83ce998ed95c3e36a618d5e5bd4a9d1b9edc Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 12 Jun 2020 11:11:31 +0200 Subject: [PATCH 21/59] Misc edit community and collection bugs - repair test --- .../comcol-metadata.component.spec.ts | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts index c606f50a71..b50a1d3ac4 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts @@ -13,13 +13,13 @@ import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NotificationsService } from '../../../notifications/notifications.service'; import { SharedModule } from '../../../shared.module'; import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; +import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; import { ComcolMetadataComponent } from './comcol-metadata.component'; describe('ComColMetadataComponent', () => { let comp: ComcolMetadataComponent; let fixture: ComponentFixture>; - let dsoDataService: CommunityDataService; + let dsoDataService; let router: Router; let community; @@ -27,7 +27,6 @@ describe('ComColMetadataComponent', () => { let communityDataServiceStub; let routerStub; let routeStub; - let isSuccessful = true; const logoEndpoint = 'rest/api/logo/endpoint'; @@ -50,11 +49,7 @@ describe('ComColMetadataComponent', () => { communityDataServiceStub = { update: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity), - patch: () => { - return observableOf({ - isSuccessful, - }) - }, + patch: () => null, getLogoEndpoint: () => observableOf(logoEndpoint) }; @@ -123,22 +118,38 @@ describe('ComColMetadataComponent', () => { /* tslint:enable:no-empty */ }, deleteLogo: false, - } + }; + spyOn(router, 'navigate'); }); - it('should navigate when successful', () => { - spyOn(router, 'navigate'); - comp.onSubmit(data); - fixture.detectChanges(); - expect(router.navigate).toHaveBeenCalled(); + describe('when successful', () => { + + beforeEach(() => { + spyOn(dsoDataService, 'patch').and.returnValue(observableOf({ + isSuccessful: true, + })); + }); + + it('should navigate', () => { + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).toHaveBeenCalled(); + }); }); - it('should not navigate on failure', () => { - isSuccessful = false; - spyOn(router, 'navigate'); - comp.onSubmit(data); - fixture.detectChanges(); - expect(router.navigate).not.toHaveBeenCalled(); + describe('on failure', () => { + + beforeEach(() => { + spyOn(dsoDataService, 'patch').and.returnValue(observableOf({ + isSuccessful: false, + })); + }); + + it('should not navigate', () => { + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); }); From 6f9f4ec9683291f3aa34392a88c2fa15986cfc56 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Mon, 15 Jun 2020 11:59:25 +0200 Subject: [PATCH 22/59] 71304: Implement Forgot password components --- src/app/app-routing.module.ts | 8 + .../data/eperson-registration.service.spec.ts | 11 +- .../core/data/eperson-registration.service.ts | 2 +- .../core/eperson/eperson-data.service.spec.ts | 10 ++ src/app/core/eperson/eperson-data.service.ts | 30 ++++ .../forgot-email.component.html | 3 + .../forgot-email.component.spec.ts | 29 ++++ .../forgot-email.component.ts | 12 ++ .../forgot-password-form.component.html | 36 +++++ .../forgot-password-form.component.spec.ts | 117 +++++++++++++++ .../forgot-password-form.component.ts | 87 +++++++++++ .../forgot-password-routing.module.ts | 32 ++++ .../forgot-password/forgot-password.module.ts | 31 ++++ .../profile-page-security-form.component.html | 9 +- ...ofile-page-security-form.component.spec.ts | 81 +++------- .../profile-page-security-form.component.ts | 116 +++++++-------- .../profile-page/profile-page.component.html | 6 +- .../profile-page.component.spec.ts | 77 ++++++++-- .../profile-page/profile-page.component.ts | 95 ++++++++++-- src/app/profile-page/profile-page.module.ts | 3 + .../register-email-form.component.html | 36 +++++ .../register-email-form.component.spec.ts | 92 ++++++++++++ .../register-email-form.component.ts | 73 +++++++++ .../register-email-form.module.ts | 26 ++++ .../registration.resolver.spec.ts | 3 +- .../registration.resolver.ts | 0 .../create-profile.component.html | 140 ++++++++---------- .../create-profile.component.spec.ts | 43 +++++- .../create-profile.component.ts | 68 ++++----- .../register-email.component.html | 39 +---- .../register-email.component.spec.ts | 70 +-------- .../register-email.component.ts | 58 +------- .../register-page-routing.module.ts | 4 +- src/app/register-page/register-page.module.ts | 7 + src/app/shared/log-in/log-in.component.html | 2 +- src/app/shared/log-in/log-in.component.ts | 6 +- src/assets/i18n/en.json5 | 68 ++++++++- 37 files changed, 1087 insertions(+), 443 deletions(-) create mode 100644 src/app/forgot-password/forgot-password-email/forgot-email.component.html create mode 100644 src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts create mode 100644 src/app/forgot-password/forgot-password-email/forgot-email.component.ts create mode 100644 src/app/forgot-password/forgot-password-form/forgot-password-form.component.html create mode 100644 src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts create mode 100644 src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts create mode 100644 src/app/forgot-password/forgot-password-routing.module.ts create mode 100644 src/app/forgot-password/forgot-password.module.ts create mode 100644 src/app/register-email-form/register-email-form.component.html create mode 100644 src/app/register-email-form/register-email-form.component.spec.ts create mode 100644 src/app/register-email-form/register-email-form.component.ts create mode 100644 src/app/register-email-form/register-email-form.module.ts rename src/app/{register-page => register-email-form}/registration.resolver.spec.ts (91%) rename src/app/{register-page => register-email-form}/registration.resolver.ts (100%) diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index db0a09662b..aace169f6c 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -52,6 +52,13 @@ export function getRegisterPath() { } +const FORGOT_PASSWORD_PATH = 'forgot'; + +export function getForgotPasswordPath() { + return `/${FORGOT_PASSWORD_PATH}`; + +} + const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems'; export function getWorkflowItemModulePath() { @@ -79,6 +86,7 @@ export function getDSOPath(dso: DSpaceObject): string { { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, + { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index d8623e7046..4c91ffd4f1 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -16,6 +16,10 @@ describe('EpersonRegistrationService', () => { const registration = new Registration(); registration.email = 'test@mail.org'; + const registrationWithUser = new Registration(); + registrationWithUser.email = 'test@mail.org'; + registrationWithUser.user = 'test-uuid'; + beforeEach(() => { halService = new HALEndpointServiceStub('rest-url'); @@ -65,7 +69,7 @@ describe('EpersonRegistrationService', () => { beforeEach(() => { (requestService.getByUUID as jasmine.Spy).and.returnValue( cold('a', - {a: Object.assign(new RequestEntry(), {response: new RegistrationSuccessResponse(registration, 200, 'Success')})}) + {a: Object.assign(new RequestEntry(), {response: new RegistrationSuccessResponse(registrationWithUser, 200, 'Success')})}) ); }); it('should return a registration corresponding to the provided token', () => { @@ -73,8 +77,9 @@ describe('EpersonRegistrationService', () => { expect(expected).toBeObservable(cold('(a|)', { a: Object.assign(new Registration(), { - email: registration.email, - token: 'test-token' + email: registrationWithUser.email, + token: 'test-token', + user: registrationWithUser.user }) })); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index a1ca1dbef4..df1e8580e8 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -98,7 +98,7 @@ export class EpersonRegistrationService { return this.requestService.getByUUID(requestId).pipe( filterSuccessfulResponses(), map((restResponse: RegistrationSuccessResponse) => { - return Object.assign(new Registration(), {email: restResponse.registration.email, token: token}); + return Object.assign(new Registration(), {email: restResponse.registration.email, token: token, user: restResponse.registration.user}); }), take(1), ); diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index 1dd4e8dbd3..a1a6951545 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -299,6 +299,16 @@ describe('EPersonDataService', () => { expect(requestService.configure).toHaveBeenCalledWith(expected); }); }); + describe('patchPasswordWithToken', () => { + it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => { + service.patchPasswordWithToken('test-uuid', 'test-token','test-password'); + + const operation = Object.assign({ op: 'replace', path: '/password', value: 'test-password' }); + const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); }); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 672793bc20..8723e2dc87 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -28,6 +28,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { EPERSON } from './models/eperson.resource-type'; +import { RequestEntry } from '../data/request.reducer'; const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); @@ -270,4 +271,33 @@ export class EPersonDataService extends DataService { } + /** + * Sends a patch request to update an epersons password based on a forgot password token + * @param uuid Uuid of the eperson + * @param token The forgot password token + * @param password The new password value + */ + patchPasswordWithToken(uuid: string, token: string, password: string): Observable { + const requestId = this.requestService.generateRequestId(); + + const operation = Object.assign({ op: 'replace', path: '/password', value: password }); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, uuid)), + map((href: string) => `${href}?token=${token}`)); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PatchRequest(requestId, href, [operation]); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } + } diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.html b/src/app/forgot-password/forgot-password-email/forgot-email.component.html new file mode 100644 index 0000000000..263f142c2e --- /dev/null +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.html @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts b/src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts new file mode 100644 index 0000000000..d466cbf11b --- /dev/null +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts @@ -0,0 +1,29 @@ +import { ForgotEmailComponent } from './forgot-email.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { ReactiveFormsModule } from '@angular/forms'; + +describe('ForgotEmailComponent', () => { + let comp: ForgotEmailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [ForgotEmailComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ForgotEmailComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should be defined', () => { + expect(comp).toBeDefined(); + }); +}); diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.ts b/src/app/forgot-password/forgot-password-email/forgot-email.component.ts new file mode 100644 index 0000000000..5e18aff113 --- /dev/null +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-forgot-email', + templateUrl: './forgot-email.component.html' +}) +/** + * Component responsible the forgot password email step + */ +export class ForgotEmailComponent { + +} diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html new file mode 100644 index 0000000000..06a1909f00 --- /dev/null +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html @@ -0,0 +1,36 @@ +
+

{{'forgot-password.form.head' | translate}}

+
+
{{'forgot-password.form.identification.header' | translate}}
+
+
+
+ + {{(registration$ |async).email}}
+
+
+
+ +
+
{{'forgot-password.form.card.security' | translate}}
+
+ + +
+
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts new file mode 100644 index 0000000000..7c23a2af94 --- /dev/null +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts @@ -0,0 +1,117 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RestResponse } from '../../core/cache/response.models'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { CoreState } from '../../core/core.reducers'; +import { Registration } from '../../core/shared/registration.model'; +import { ForgotPasswordFormComponent } from './forgot-password-form.component'; +import { By } from '@angular/platform-browser'; +import { AuthenticateAction } from '../../core/auth/auth.actions'; + +describe('ForgotPasswordFormComponent', () => { + let comp: ForgotPasswordFormComponent; + let fixture: ComponentFixture; + + let router; + let route; + let ePersonDataService: EPersonDataService; + let notificationsService; + let store: Store; + + const registration = Object.assign(new Registration(), { + email: 'test@email.org', + user: 'test-uuid', + token: 'test-token' + }); + + beforeEach(async(() => { + + route = {data: observableOf({registration: registration})}; + router = new RouterStub(); + notificationsService = new NotificationsServiceStub(); + + ePersonDataService = jasmine.createSpyObj('ePersonDataService', { + patchPasswordWithToken: observableOf(new RestResponse(true, 200, 'Success')) + }); + + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [ForgotPasswordFormComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: ActivatedRoute, useValue: route}, + {provide: Store, useValue: store}, + {provide: EPersonDataService, useValue: ePersonDataService}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: notificationsService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ForgotPasswordFormComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + + describe('init', () => { + it('should initialise mail address', () => { + const elem = fixture.debugElement.queryAll(By.css('span#email'))[0].nativeElement; + expect(elem.innerHTML).toContain('test@email.org'); + }); + }); + + describe('submit', () => { + + it('should submit a patch request for the user uuid and log in on success', () => { + comp.password = 'password'; + comp.isInValid = false; + + comp.submit(); + + expect(ePersonDataService.patchPasswordWithToken).toHaveBeenCalledWith('test-uuid', 'test-token', 'password'); + expect(store.dispatch).toHaveBeenCalledWith(new AuthenticateAction('test@email.org', 'password')); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should submit a patch request for the user uuid and stay on page on error', () => { + + (ePersonDataService.patchPasswordWithToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.password = 'password'; + comp.isInValid = false; + + comp.submit(); + + expect(ePersonDataService.patchPasswordWithToken).toHaveBeenCalledWith('test-uuid', 'test-token', 'password'); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + }); + it('should submit a patch request for the user uuid when the form is invalid', () => { + + comp.password = 'password'; + comp.isInValid = true; + + comp.submit(); + + expect(ePersonDataService.patchPasswordWithToken).not.toHaveBeenCalled(); + }); + }) +}); diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts new file mode 100644 index 0000000000..1ad66a0b04 --- /dev/null +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts @@ -0,0 +1,87 @@ +import { Component } from '@angular/core'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { Observable } from 'rxjs'; +import { Registration } from '../../core/shared/registration.model'; +import { map } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthenticateAction } from '../../core/auth/auth.actions'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core/core.reducers'; + +@Component({ + selector: 'ds-forgot-password-form', + templateUrl: './forgot-password-form.component.html' +}) +/** + * Component for a user to enter a new password for a forgot token. + */ +export class ForgotPasswordFormComponent { + + registration$: Observable; + + token: string; + email: string; + user: string; + + isInValid = true; + password: string; + + /** + * Prefix for the notification messages of this component + */ + NOTIFICATIONS_PREFIX = 'forgot-password.form.notification'; + + constructor(private ePersonDataService: EPersonDataService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private store: Store, + private router: Router, + private route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.registration$ = this.route.data.pipe( + map((data) => data.registration as Registration), + ); + this.registration$.subscribe((registration: Registration) => { + this.email = registration.email; + this.token = registration.token; + this.user = registration.user; + }); + } + + setInValid($event: boolean) { + this.isInValid = $event; + } + + setPasswordValue($event: string) { + this.password = $event; + } + + /** + * Submits the password to the eperson service to be updated. + * The submission will not be made when the form is not valid. + */ + submit() { + if (!this.isInValid) { + this.ePersonDataService.patchPasswordWithToken(this.user, this.token, this.password).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.title'), + this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.content') + ); + this.store.dispatch(new AuthenticateAction(this.email, this.password)); + this.router.navigate(['/home']); + } else { + this.notificationsService.error( + this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + } + } +} diff --git a/src/app/forgot-password/forgot-password-routing.module.ts b/src/app/forgot-password/forgot-password-routing.module.ts new file mode 100644 index 0000000000..702de03a9d --- /dev/null +++ b/src/app/forgot-password/forgot-password-routing.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ItemPageResolver } from '../+item-page/item-page.resolver'; +import { RegistrationResolver } from '../register-email-form/registration.resolver'; +import { ForgotPasswordFormComponent } from './forgot-password-form/forgot-password-form.component'; +import { ForgotEmailComponent } from './forgot-password-email/forgot-email.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: ForgotEmailComponent, + data: {title: 'forgot-password.title'}, + }, + { + path: ':token', + component: ForgotPasswordFormComponent, + resolve: {registration: RegistrationResolver} + } + ]) + ], + providers: [ + RegistrationResolver, + ItemPageResolver + ] +}) +/** + * This module defines the routing to the components related to the forgot password components. + */ +export class ForgotPasswordRoutingModule { +} diff --git a/src/app/forgot-password/forgot-password.module.ts b/src/app/forgot-password/forgot-password.module.ts new file mode 100644 index 0000000000..5f01f0fcd2 --- /dev/null +++ b/src/app/forgot-password/forgot-password.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { ForgotEmailComponent } from './forgot-password-email/forgot-email.component'; +import { ForgotPasswordRoutingModule } from './forgot-password-routing.module'; +import { RegisterEmailFormModule } from '../register-email-form/register-email-form.module'; +import { ForgotPasswordFormComponent } from './forgot-password-form/forgot-password-form.component'; +import { ProfilePageModule } from '../profile-page/profile-page.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ForgotPasswordRoutingModule, + RegisterEmailFormModule, + ProfilePageModule, + ], + declarations: [ + ForgotEmailComponent, + ForgotPasswordFormComponent + ], + providers: [], + entryComponents: [] +}) + +/** + * Module related to the Forgot Password components + */ +export class ForgotPasswordModule { + +} diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index 50a081c6f2..ad9f768297 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -1,9 +1,10 @@ -
{{'profile.security.form.info' | translate}}
+
{{FORM_PREFIX + 'info' | translate}}
-
{{'profile.security.form.error.password-length' | translate}}
-
{{'profile.security.form.error.matching-passwords' | translate}}
+
{{FORM_PREFIX + 'error.password-length' | translate}}
+
{{FORM_PREFIX + 'error.matching-passwords' | translate}}
+
{{FORM_PREFIX + 'error.empty-password' | translate}}
diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index 225bd8507e..ba487f7158 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -1,5 +1,4 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { EPerson } from '../../core/eperson/models/eperson.model'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; @@ -15,18 +14,10 @@ describe('ProfilePageSecurityFormComponent', () => { let component: ProfilePageSecurityFormComponent; let fixture: ComponentFixture; - let user; - let epersonService; let notificationsService; function init() { - user = Object.assign(new EPerson(), { - _links: { - self: { href: 'user-selflink' } - } - }); - epersonService = jasmine.createSpyObj('epersonService', { patch: observableOf(new RestResponse(true, 200, 'OK')) }); @@ -43,8 +34,8 @@ describe('ProfilePageSecurityFormComponent', () => { declarations: [ProfilePageSecurityFormComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: EPersonDataService, useValue: epersonService }, - { provide: NotificationsService, useValue: notificationsService }, + {provide: EPersonDataService, useValue: epersonService}, + {provide: NotificationsService, useValue: notificationsService}, FormBuilderService ], schemas: [NO_ERRORS_SCHEMA] @@ -54,65 +45,35 @@ describe('ProfilePageSecurityFormComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ProfilePageSecurityFormComponent); component = fixture.componentInstance; - component.user = user; fixture.detectChanges(); }); - describe('updateSecurity', () => { - describe('when no values changed', () => { - let result; - + describe('On value change', () => { + describe('when the password has changed', () => { beforeEach(() => { - result = component.updateSecurity(); + component.formGroup.patchValue({password: 'password'}); + component.formGroup.patchValue({passwordrepeat: 'password'}); }); - it('should return false', () => { - expect(result).toEqual(false); - }); + it('should emit the value and validity on password change with invalid validity', fakeAsync(() => { + spyOn(component.passwordValue, 'emit'); + spyOn(component.isInvalid, 'emit'); + component.formGroup.patchValue({password: 'new-password'}); - it('should not call epersonService.patch', () => { - expect(epersonService.patch).not.toHaveBeenCalled(); - }); - }); + tick(300); - describe('when password is filled in, but the confirm field is empty', () => { - let result; + expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); + expect(component.isInvalid.emit).toHaveBeenCalledWith(true); + })); - beforeEach(() => { - setModelValue('password', 'test'); - result = component.updateSecurity(); - }); + it('should emit the value on password change', fakeAsync(() => { + spyOn(component.passwordValue, 'emit'); + component.formGroup.patchValue({password: 'new-password'}); - it('should return true', () => { - expect(result).toEqual(true); - }); - }); + tick(300); - describe('when both password fields are filled in, long enough and equal', () => { - let result; - let operations; - - beforeEach(() => { - setModelValue('password', 'testest'); - setModelValue('passwordrepeat', 'testest'); - operations = [{ op: 'replace', path: '/password', value: 'testest' }]; - result = component.updateSecurity(); - }); - - it('should return true', () => { - expect(result).toEqual(true); - }); - - it('should return call epersonService.patch', () => { - expect(epersonService.patch).toHaveBeenCalledWith(user, operations); - }); + expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); + })); }); }); - - function setModelValue(id: string, value: string) { - component.formGroup.patchValue({ - [id]: value - }); - component.formGroup.markAllAsTouched(); - } }); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index b8ac07e6d8..1013cad44b 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -1,16 +1,12 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { - DynamicFormControlModel, - DynamicFormService, - DynamicInputModel -} from '@ng-dynamic-forms/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { DynamicFormControlModel, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { FormGroup } from '@angular/forms'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { debounceTime, map } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; @Component({ selector: 'ds-profile-page-security-form', @@ -21,10 +17,15 @@ import { NotificationsService } from '../../shared/notifications/notifications.s * Displays a form containing a password field and a confirmation of the password */ export class ProfilePageSecurityFormComponent implements OnInit { + /** - * The user to display the form for + * Emits the validity of the password */ - @Input() user: EPerson; + @Output() isInvalid = new EventEmitter(); + /** + * Emits the value of the password + */ + @Output() passwordValue = new EventEmitter(); /** * The form's input models @@ -48,14 +49,17 @@ export class ProfilePageSecurityFormComponent implements OnInit { formGroup: FormGroup; /** - * Prefix for the notification messages of this component + * Indicates whether the "checkPasswordEmpty" needs to be added or not */ - NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + @Input() + passwordCanBeEmpty = true; /** * Prefix for the form's label messages of this component */ - LABEL_PREFIX = 'profile.security.form.label.'; + @Input() + FORM_PREFIX: string; + private subs: Subscription[] = []; constructor(protected formService: DynamicFormService, protected translate: TranslateService, @@ -64,12 +68,35 @@ export class ProfilePageSecurityFormComponent implements OnInit { } ngOnInit(): void { - this.formGroup = this.formService.createFormGroup(this.formModel, { validators: [this.checkPasswordsEqual, this.checkPasswordLength] }); + if (this.passwordCanBeEmpty) { + this.formGroup = this.formService.createFormGroup(this.formModel, + {validators: [this.checkPasswordsEqual, this.checkPasswordLength]}); + } else { + this.formGroup = this.formService.createFormGroup(this.formModel, + {validators: [this.checkPasswordsEqual, this.checkPasswordLength, this.checkPasswordEmpty]}); + } this.updateFieldTranslations(); this.translate.onLangChange .subscribe(() => { this.updateFieldTranslations(); }); + + this.subs.push(this.formGroup.statusChanges.pipe( + debounceTime(300), + map((status: string) => { + if (status !== 'VALID') { + return true; + } else { + return false; + } + })).subscribe((status) => this.isInvalid.emit(status)) + ); + + this.subs.push(this.formGroup.valueChanges.pipe( + debounceTime(300), + ).subscribe((valueChange) => { + this.passwordValue.emit(valueChange.password); + })); } /** @@ -78,7 +105,7 @@ export class ProfilePageSecurityFormComponent implements OnInit { updateFieldTranslations() { this.formModel.forEach( (fieldModel: DynamicInputModel) => { - fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id); + fieldModel.label = this.translate.instant(this.FORM_PREFIX + 'label.' + fieldModel.id); } ); } @@ -91,7 +118,7 @@ export class ProfilePageSecurityFormComponent implements OnInit { const pass = group.get('password').value; const repeatPass = group.get('passwordrepeat').value; - return pass === repeatPass ? null : { notSame: true }; + return pass === repeatPass ? null : {notSame: true}; } /** @@ -101,51 +128,24 @@ export class ProfilePageSecurityFormComponent implements OnInit { checkPasswordLength(group: FormGroup) { const pass = group.get('password').value; - return isEmpty(pass) || pass.length >= 6 ? null : { notLongEnough: true }; + return isEmpty(pass) || pass.length >= 6 ? null : {notLongEnough: true}; } /** - * Update the user's security details - * - * Sends a patch request for changing the user's password when a new password is present and the password confirmation - * matches the new password. - * Nothing happens when no passwords are filled in. - * An error notification is displayed when the password confirmation does not match the new password. - * - * Returns false when nothing happened + * Checks if the password is empty + * @param group The FormGroup to validate */ - updateSecurity() { - const pass = this.formGroup.get('password').value; - const passEntered = isNotEmpty(pass); - if (!this.formGroup.valid) { - if (passEntered) { - if (this.checkPasswordsEqual(this.formGroup) != null) { - this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same')); - } - if (this.checkPasswordLength(this.formGroup) != null) { - this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-long-enough')); - } - return true; - } - return false; - } - if (passEntered) { - const operation = Object.assign({ op: 'replace', path: '/password', value: pass }); - this.epersonService.patch(this.user, [operation]).subscribe((response: RestResponse) => { - if (response.isSuccessful) { - this.notificationsService.success( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') - ); - } else { - this.notificationsService.error( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage - ); - } - }); + checkPasswordEmpty(group: FormGroup) { + const pass = group.get('password').value; + return isEmpty(pass) ? {emptyPassword: true} : null; + } - } - - return passEntered; + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); } } diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index b6e62665b4..ab8f1ce026 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -10,7 +10,11 @@
{{'profile.card.security' | translate}}
- +
diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index d63aba46f5..8d78539bab 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -13,8 +13,9 @@ import { NotificationsService } from '../shared/notifications/notifications.serv import { authReducer } from '../core/auth/auth.reducer'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { createPaginatedList } from '../shared/testing/utils.test'; -import { of } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { AuthService } from '../core/auth/auth.service'; +import { RestResponse } from '../core/cache/response.models'; describe('ProfilePageComponent', () => { let component: ProfilePageComponent; @@ -40,10 +41,11 @@ describe('ProfilePageComponent', () => { }; authService = jasmine.createSpyObj('authService', { - getAuthenticatedUserFromStore: of(user) + getAuthenticatedUserFromStore: observableOf(user) }); epersonService = jasmine.createSpyObj('epersonService', { - findById: createSuccessfulRemoteDataObject$(user) + findById: createSuccessfulRemoteDataObject$(user), + patch: observableOf(Object.assign(new RestResponse(true, 200, 'Success'))) }); notificationsService = jasmine.createSpyObj('notificationsService', { success: {}, @@ -84,9 +86,7 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: false }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: true - }); + spyOn(component, 'updateSecurity').and.returnValue(true); component.updateProfile(); }); @@ -100,9 +100,6 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: true }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: false - }); component.updateProfile(); }); @@ -116,9 +113,6 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: true }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: true - }); component.updateProfile(); }); @@ -132,9 +126,6 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: false }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: false - }); component.updateProfile(); }); @@ -143,4 +134,60 @@ describe('ProfilePageComponent', () => { }); }); }); + + describe('updateSecurity', () => { + describe('when no password value present', () => { + let result; + + beforeEach(() => { + component.setPasswordValue(''); + + result = component.updateSecurity(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.patch', () => { + expect(epersonService.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, but the password is invalid', () => { + let result; + + beforeEach(() => { + component.setPasswordValue('test'); + component.setInvalid(true); + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + expect(epersonService.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, and is valid', () => { + let result; + let operations; + + beforeEach(() => { + component.setPasswordValue('testest'); + component.setInvalid(false); + + operations = [{op: 'replace', path: '/password', value: 'testest'}]; + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should return call epersonService.patch', () => { + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + }); + }); + }); }); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 8f4cd492a2..bc06c49f81 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -2,7 +2,6 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { Observable } from 'rxjs/internal/Observable'; import { EPerson } from '../core/eperson/models/eperson.model'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; -import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { Group } from '../core/eperson/models/group.model'; @@ -11,9 +10,10 @@ import { PaginatedList } from '../core/data/paginated-list'; import { filter, switchMap, tap } from 'rxjs/operators'; import { EPersonDataService } from '../core/eperson/eperson-data.service'; import { getAllSucceededRemoteData, getRemoteDataPayload } from '../core/shared/operators'; -import { hasValue } from '../shared/empty.util'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; import { followLink } from '../shared/utils/follow-link-config.model'; import { AuthService } from '../core/auth/auth.service'; +import { ErrorResponse, RestResponse } from '../core/cache/response.models'; @Component({ selector: 'ds-profile-page', @@ -26,15 +26,10 @@ export class ProfilePageComponent implements OnInit { /** * A reference to the metadata form component */ - @ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent; + @ViewChild(ProfilePageMetadataFormComponent, {static: false}) metadataForm: ProfilePageMetadataFormComponent; /** - * A reference to the security form component - */ - @ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent; - - /** - * The authenticated user + * The authenticated user as observable */ user$: Observable; @@ -48,6 +43,26 @@ export class ProfilePageComponent implements OnInit { */ NOTIFICATIONS_PREFIX = 'profile.notifications.'; + /** + * Prefix for the notification messages of this security form + */ + PASSWORD_NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + + /** + * The validity of the password filled in, in the security form + */ + private invalidSecurity: boolean; + + /** + * The password filled in, in the security form + */ + private password: string; + + /** + * The authenticated user + */ + private currentUser: EPerson; + constructor(private authService: AuthService, private notificationsService: NotificationsService, private translate: TranslateService, @@ -59,7 +74,8 @@ export class ProfilePageComponent implements OnInit { filter((user: EPerson) => hasValue(user.id)), switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))), getAllSucceededRemoteData(), - getRemoteDataPayload() + getRemoteDataPayload(), + tap((user: EPerson) => this.currentUser = user) ); this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); } @@ -70,7 +86,7 @@ export class ProfilePageComponent implements OnInit { */ updateProfile() { const metadataChanged = this.metadataForm.updateProfile(); - const securityChanged = this.securityForm.updateSecurity(); + const securityChanged = this.updateSecurity(); if (!metadataChanged && !securityChanged) { this.notificationsService.warning( this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.title'), @@ -78,4 +94,61 @@ export class ProfilePageComponent implements OnInit { ); } } + + /** + * Sets the validity of the password based on an emitted of the form + * @param $event + */ + setInvalid($event: boolean) { + this.invalidSecurity = $event; + } + + /** + * Update the user's security details + * + * Sends a patch request for changing the user's password when a new password is present and the password confirmation + * matches the new password. + * Nothing happens when no passwords are filled in. + * An error notification is displayed when the password confirmation does not match the new password. + * + * Returns false when the password was empty + */ + updateSecurity() { + const passEntered = isNotEmpty(this.password); + + if (this.invalidSecurity) { + this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general')); + } + if (!this.invalidSecurity && passEntered) { + const operation = Object.assign({op: 'replace', path: '/password', value: this.password}); + this.epersonService.patch(this.currentUser, [operation]).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'), + this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.content') + ); + } else { + this.notificationsService.error( + this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + } + return passEntered; + } + + /** + * Set the password value based on the value emitted from the security form + * @param $event + */ + setPasswordValue($event: string) { + this.password = $event; + } + + /** + * Submit of the security form that triggers the updateProfile method + */ + submit() { + this.updateProfile(); + } } diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index f40c125ff8..54b59c97ce 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -12,6 +12,9 @@ import { ProfilePageSecurityFormComponent } from './profile-page-security-form/p CommonModule, SharedModule ], + exports: [ + ProfilePageSecurityFormComponent + ], declarations: [ ProfilePageComponent, ProfilePageMetadataFormComponent, diff --git a/src/app/register-email-form/register-email-form.component.html b/src/app/register-email-form/register-email-form.component.html new file mode 100644 index 0000000000..e47eedb6ae --- /dev/null +++ b/src/app/register-email-form/register-email-form.component.html @@ -0,0 +1,36 @@ +
+

{{MESSAGE_PREFIX + '.header'|translate}}

+

{{MESSAGE_PREFIX + '.info' | translate}}

+ + + +
+
+
+ + +
+ + {{ MESSAGE_PREFIX + '.email.error.required' | translate }} + + + {{ MESSAGE_PREFIX + '.email.error.pattern' | translate }} + +
+
+
+ {{MESSAGE_PREFIX + '.email.hint' |translate}} +
+ +
+ +
+ + + +
diff --git a/src/app/register-email-form/register-email-form.component.spec.ts b/src/app/register-email-form/register-email-form.component.spec.ts new file mode 100644 index 0000000000..af3c70dc33 --- /dev/null +++ b/src/app/register-email-form/register-email-form.component.spec.ts @@ -0,0 +1,92 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../core/cache/response.models'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; +import { By } from '@angular/platform-browser'; +import { RouterStub } from '../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; +import { RegisterEmailFormComponent } from './register-email-form.component'; + +describe('RegisterEmailComponent', () => { + + let comp: RegisterEmailFormComponent; + let fixture: ComponentFixture; + + let router; + let epersonRegistrationService: EpersonRegistrationService; + let notificationsService; + + beforeEach(async(() => { + + router = new RouterStub(); + notificationsService = new NotificationsServiceStub(); + + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + registerEmail: observableOf(new RestResponse(true, 200, 'Success')) + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [RegisterEmailFormComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: EpersonRegistrationService, useValue: epersonRegistrationService}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: notificationsService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(RegisterEmailFormComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + describe('init', () => { + it('should initialise the form', () => { + const elem = fixture.debugElement.queryAll(By.css('input#email'))[0].nativeElement; + expect(elem).toBeDefined(); + }); + }); + describe('email validation', () => { + it('should be invalid when no email is present', () => { + expect(comp.form.invalid).toBeTrue(); + }); + it('should be invalid when no valid email is present', () => { + comp.form.patchValue({email: 'invalid'}); + expect(comp.form.invalid).toBeTrue(); + }); + it('should be invalid when no valid email is present', () => { + comp.form.patchValue({email: 'valid@email.org'}); + expect(comp.form.invalid).toBeFalse(); + }); + }); + describe('register', () => { + it('should send a registration to the service and on success display a message and return to home', () => { + comp.form.patchValue({email: 'valid@email.org'}); + + comp.register(); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + }); + it('should send a registration to the service and on error display a message', () => { + (epersonRegistrationService.registerEmail as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 400, 'Bad Request'))); + + comp.form.patchValue({email: 'valid@email.org'}); + + comp.register(); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/register-email-form/register-email-form.component.ts b/src/app/register-email-form/register-email-form.component.ts new file mode 100644 index 0000000000..59fe3791e0 --- /dev/null +++ b/src/app/register-email-form/register-email-form.component.ts @@ -0,0 +1,73 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; +import { RestResponse } from '../core/cache/response.models'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'ds-register-email-form', + templateUrl: './register-email-form.component.html' +}) +/** + * Component responsible to render an email registration form. + */ +export class RegisterEmailFormComponent implements OnInit { + + /** + * The form containing the mail address + */ + form: FormGroup; + + /** + * The message prefix + */ + @Input() + MESSAGE_PREFIX: string; + + constructor( + private epersonRegistrationService: EpersonRegistrationService, + private notificationService: NotificationsService, + private translateService: TranslateService, + private router: Router, + private formBuilder: FormBuilder + ) { + + } + + ngOnInit(): void { + this.form = this.formBuilder.group({ + email: new FormControl('', { + validators: [Validators.required, + Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$') + ], + }) + }); + + } + + /** + * Register an email address + */ + register() { + if (!this.form.invalid) { + this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`), + this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value})); + this.router.navigate(['/home']); + } else { + this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), + this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value})); + } + } + ); + } + } + + get email() { + return this.form.get('email'); + } + +} diff --git a/src/app/register-email-form/register-email-form.module.ts b/src/app/register-email-form/register-email-form.module.ts new file mode 100644 index 0000000000..a5fe0899a9 --- /dev/null +++ b/src/app/register-email-form/register-email-form.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { RegisterEmailFormComponent } from './register-email-form.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + RegisterEmailFormComponent, + ], + providers: [], + exports: [ + RegisterEmailFormComponent, + ], + entryComponents: [] +}) + +/** + * The module that contains the components related to the email registration + */ +export class RegisterEmailFormModule { + +} diff --git a/src/app/register-page/registration.resolver.spec.ts b/src/app/register-email-form/registration.resolver.spec.ts similarity index 91% rename from src/app/register-page/registration.resolver.spec.ts rename to src/app/register-email-form/registration.resolver.spec.ts index 94f7b37a4f..8feef919a7 100644 --- a/src/app/register-page/registration.resolver.spec.ts +++ b/src/app/register-email-form/registration.resolver.spec.ts @@ -9,7 +9,7 @@ describe('RegistrationResolver', () => { let epersonRegistrationService: EpersonRegistrationService; const token = 'test-token'; - const registration = Object.assign(new Registration(), {email: 'test@email.org', token: token}); + const registration = Object.assign(new Registration(), {email: 'test@email.org', token: token, user:'user-uuid'}); beforeEach(() => { epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { @@ -25,6 +25,7 @@ describe('RegistrationResolver', () => { (resolved) => { expect(resolved.token).toEqual(token); expect(resolved.email).toEqual('test@email.org'); + expect(resolved.user).toEqual('user-uuid'); } ); }); diff --git a/src/app/register-page/registration.resolver.ts b/src/app/register-email-form/registration.resolver.ts similarity index 100% rename from src/app/register-page/registration.resolver.ts rename to src/app/register-email-form/registration.resolver.ts diff --git a/src/app/register-page/create-profile/create-profile.component.html b/src/app/register-page/create-profile/create-profile.component.html index dc6a3ddeef..cfd6e7ab16 100644 --- a/src/app/register-page/create-profile/create-profile.component.html +++ b/src/app/register-page/create-profile/create-profile.component.html @@ -1,99 +1,89 @@
-

{{'register-page.create-profile.header' | translate}}

-

{{'register-page.create-profile.identification.header' | translate}}

-
-
- - {{(registration$ |async).email}}
-
-
- -
+

{{'register-page.create-profile.header' | translate}}

+
+
{{'register-page.create-profile.identification.header' | translate}}
+
- -
+ for="email">{{'register-page.create-profile.identification.email' | translate}} + {{(registration$ |async).email}}
+
+ + +
+
+
+ + +
{{ 'register-page.create-profile.identification.first-name.error' | translate }} -
-
+
+
-
-
-
- - -
+
+
+
+ + +
{{ 'register-page.create-profile.identification.last-name.error' | translate }} +
+
+
+
+
+ + +
+
+
+
+ + + +
-
-
-
- - -
-
-
-
- - - -
-
+
- +
-

{{'register-page.create-profile.security.header' | translate}}

-

{{'register-page.create-profile.security.info' | translate}}

+
+
{{'register-page.create-profile.security.header' | translate}}
+
-
-
-
-
- - -
-
-
-
- - -
- {{ 'register-page.create-profile.security.password.error' | translate }} -
-
-
+
-
+
+
+ [disabled]="isInValidPassword || userInfoForm.invalid" + class="btn btn-default btn-primary" + (click)="submitEperson()">{{'register-page.create-profile.submit' | translate}}
diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index 5fed324a22..f3017ba918 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -116,15 +116,11 @@ describe('CreateProfileComponent', () => { const lastName = fixture.debugElement.queryAll(By.css('input#lastName'))[0].nativeElement; const contactPhone = fixture.debugElement.queryAll(By.css('input#contactPhone'))[0].nativeElement; const language = fixture.debugElement.queryAll(By.css('select#language'))[0].nativeElement; - const password = fixture.debugElement.queryAll(By.css('input#password'))[0].nativeElement; - const confirmPassword = fixture.debugElement.queryAll(By.css('input#confirmPassword'))[0].nativeElement; expect(firstName).toBeDefined(); expect(lastName).toBeDefined(); expect(contactPhone).toBeDefined(); expect(language).toBeDefined(); - expect(password).toBeDefined(); - expect(confirmPassword).toBeDefined(); }); }); @@ -135,8 +131,8 @@ describe('CreateProfileComponent', () => { comp.lastName.patchValue('Last'); comp.contactPhone.patchValue('Phone'); comp.language.patchValue('en'); - comp.password.patchValue('password'); - comp.confirmPassword.patchValue('password'); + comp.password = 'password'; + comp.isInValidPassword = false; comp.submitEperson(); @@ -154,8 +150,8 @@ describe('CreateProfileComponent', () => { comp.lastName.patchValue('Last'); comp.contactPhone.patchValue('Phone'); comp.language.patchValue('en'); - comp.password.patchValue('password'); - comp.confirmPassword.patchValue('password'); + comp.password = 'password'; + comp.isInValidPassword = false; comp.submitEperson(); @@ -164,5 +160,36 @@ describe('CreateProfileComponent', () => { expect(router.navigate).not.toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled(); }); + it('should submit not submit an eperson when the user info form is invalid', () => { + + (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.firstName.patchValue(''); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).not.toHaveBeenCalled(); + }); + it('should submit not submit an eperson when the password is invalid', () => { + + (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = true; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts index aee9cd598d..2755a17739 100644 --- a/src/app/register-page/create-profile/create-profile.component.ts +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -1,10 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { debounceTime, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { Registration } from '../../core/shared/registration.model'; import { Observable } from 'rxjs'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { ConfirmedValidator } from './confirmed.validator'; import { TranslateService } from '@ngx-translate/core'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; @@ -14,6 +13,7 @@ import { CoreState } from '../../core/core.reducers'; import { AuthenticateAction } from '../../core/auth/auth.actions'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { environment } from '../../../environments/environment'; +import { isEmpty } from '../../shared/empty.util'; /** * Component that renders the create profile page to be used by a user registering through a token @@ -28,11 +28,11 @@ export class CreateProfileComponent implements OnInit { email: string; token: string; - userInfoForm: FormGroup; - passwordForm: FormGroup; - activeLangs: LangConfig[]; + isInValidPassword = true; + password: string; - isValidPassWord$: Observable; + userInfoForm: FormGroup; + activeLangs: LangConfig[]; constructor( private translateService: TranslateService, @@ -67,29 +67,23 @@ export class CreateProfileComponent implements OnInit { language: new FormControl(''), }); - this.passwordForm = this.formBuilder.group({ - password: new FormControl('', { - validators: [Validators.required, Validators.minLength(6)], - updateOn: 'change' - }), - confirmPassword: new FormControl('', { - validators: [Validators.required], - updateOn: 'change' - }) - }, { - validator: ConfirmedValidator('password', 'confirmPassword') - }); + } - this.isValidPassWord$ = this.passwordForm.statusChanges.pipe( - debounceTime(300), - map((status: string) => { - if (status === 'VALID') { - return true; - } else { - return false; - } - }) - ); + /** + * Sets the validity of the password based on a value emitted from the form + * @param $event + */ + setInValid($event: boolean) { + this.isInValidPassword = $event || isEmpty(this.password); + } + + /** + * Sets the value of the password based on a value emitted from the form + * @param $event + */ + setPasswordValue($event: string) { + this.password = $event; + this.isInValidPassword = this.isInValidPassword || isEmpty(this.password); } get firstName() { @@ -108,20 +102,12 @@ export class CreateProfileComponent implements OnInit { return this.userInfoForm.get('language'); } - get password() { - return this.passwordForm.get('password'); - } - - get confirmPassword() { - return this.passwordForm.get('confirmPassword'); - } - /** * Submits the eperson to the service to be created. - * The submission will not be made when the form is not valid. + * The submission will not be made when the form or the password is not valid. */ submitEperson() { - if (!(this.userInfoForm.invalid || this.passwordForm.invalid)) { + if (!(this.userInfoForm.invalid || this.isInValidPassword)) { const values = { metadata: { 'eperson.firstname': [ @@ -146,7 +132,7 @@ export class CreateProfileComponent implements OnInit { ] }, email: this.email, - password: this.password.value, + password: this.password, canLogIn: true, requireCertificate: false }; @@ -156,14 +142,14 @@ export class CreateProfileComponent implements OnInit { if (response.isSuccessful) { this.notificationsService.success(this.translateService.get('register-page.create-profile.submit.success.head'), this.translateService.get('register-page.create-profile.submit.success.content')); - this.store.dispatch(new AuthenticateAction(this.email, this.password.value)); + this.store.dispatch(new AuthenticateAction(this.email, this.password)); this.router.navigate(['/home']); } else { this.notificationsService.error(this.translateService.get('register-page.create-profile.submit.error.head'), this.translateService.get('register-page.create-profile.submit.error.content')); } }); - } } + } diff --git a/src/app/register-page/register-email/register-email.component.html b/src/app/register-page/register-email/register-email.component.html index f506ab8f5d..a60dc4c31e 100644 --- a/src/app/register-page/register-email/register-email.component.html +++ b/src/app/register-page/register-email/register-email.component.html @@ -1,36 +1,3 @@ -
-

{{'register-page.registration.header'|translate}}

-

{{'register-page.registration.info' | translate}}

- -
- -
-
-
- - -
- - {{ 'register-page.registration.email.error.required' | translate }} - - - {{ 'register-page.registration.email.error.pattern' | translate }} - -
-
-
- {{'register-page.registration.email.hint' |translate}} -
- -
- -
-
- - -
+ + diff --git a/src/app/register-page/register-email/register-email.component.spec.ts b/src/app/register-page/register-email/register-email.component.spec.ts index 67986853ea..74bd247d74 100644 --- a/src/app/register-page/register-email/register-email.component.spec.ts +++ b/src/app/register-page/register-email/register-email.component.spec.ts @@ -1,46 +1,19 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../../core/cache/response.models'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ReactiveFormsModule } from '@angular/forms'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { RegisterEmailComponent } from './register-email.component'; -import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; -import { By } from '@angular/platform-browser'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; describe('RegisterEmailComponent', () => { let comp: RegisterEmailComponent; let fixture: ComponentFixture; - let router; - let epersonRegistrationService: EpersonRegistrationService; - let notificationsService; - beforeEach(async(() => { - - router = new RouterStub(); - notificationsService = new NotificationsServiceStub(); - - epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - registerEmail: observableOf(new RestResponse(true, 200, 'Success')) - }); - TestBed.configureTestingModule({ - imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], + imports: [CommonModule, TranslateModule.forRoot(), ReactiveFormsModule], declarations: [RegisterEmailComponent], - providers: [ - {provide: Router, useValue: router}, - {provide: EpersonRegistrationService, useValue: epersonRegistrationService}, - {provide: FormBuilder, useValue: new FormBuilder()}, - {provide: NotificationsService, useValue: notificationsService}, - ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); })); @@ -50,43 +23,8 @@ describe('RegisterEmailComponent', () => { fixture.detectChanges(); }); - describe('init', () => { - it('should initialise the form', () => { - const elem = fixture.debugElement.queryAll(By.css('input#email'))[0].nativeElement; - expect(elem).toBeDefined(); - }); - }); - describe('email validation', () => { - it('should be invalid when no email is present', () => { - expect(comp.form.invalid).toBeTrue(); - }); - it('should be invalid when no valid email is present', () => { - comp.form.patchValue({email: 'invalid'}); - expect(comp.form.invalid).toBeTrue(); - }); - it('should be invalid when no valid email is present', () => { - comp.form.patchValue({email: 'valid@email.org'}); - expect(comp.form.invalid).toBeFalse(); - }); - }); - describe('register', () => { - it('should send a registration to the service and on success display a message and return to home', () => { - comp.form.patchValue({email: 'valid@email.org'}); - comp.register(); - expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); - expect(notificationsService.success).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/home']); - }); - it('should send a registration to the service and on error display a message', () => { - (epersonRegistrationService.registerEmail as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 400, 'Bad Request'))); - - comp.form.patchValue({email: 'valid@email.org'}); - - comp.register(); - expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); - expect(notificationsService.error).toHaveBeenCalled(); - expect(router.navigate).not.toHaveBeenCalled(); - }); + it('should be defined', () => { + expect(comp).toBeDefined(); }); }); diff --git a/src/app/register-page/register-email/register-email.component.ts b/src/app/register-page/register-email/register-email.component.ts index c23a7797a2..ac221c109a 100644 --- a/src/app/register-page/register-email/register-email.component.ts +++ b/src/app/register-page/register-email/register-email.component.ts @@ -1,64 +1,12 @@ -import { Component, OnInit } from '@angular/core'; -import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; -import { RestResponse } from '../../core/cache/response.models'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { Router } from '@angular/router'; -import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { Component } from '@angular/core'; @Component({ selector: 'ds-register-email', templateUrl: './register-email.component.html' }) /** - * Component responsible the email registration step + * Component responsible the email registration step when registering as a new user */ -export class RegisterEmailComponent implements OnInit { - - form: FormGroup; - - constructor( - private epersonRegistrationService: EpersonRegistrationService, - private notificationService: NotificationsService, - private translateService: TranslateService, - private router: Router, - private formBuilder: FormBuilder - ) { - - } - - ngOnInit(): void { - this.form = this.formBuilder.group({ - email: new FormControl('', { - validators: [Validators.required, - Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$') - ], - }) - }); - - } - - /** - * Register an email address - */ - register() { - if (!this.form.invalid) { - this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RestResponse) => { - if (response.isSuccessful) { - this.notificationService.success(this.translateService.get('register-page.registration.success.head'), - this.translateService.get('register-page.registration.success.content', {email: this.email.value})); - this.router.navigate(['/home']); - } else { - this.notificationService.error(this.translateService.get('register-page.registration.error.head'), - this.translateService.get('register-page.registration.error.content', {email: this.email.value})); - } - } - ); - } - } - - get email() { - return this.form.get('email'); - } +export class RegisterEmailComponent { } diff --git a/src/app/register-page/register-page-routing.module.ts b/src/app/register-page/register-page-routing.module.ts index ac0edd7e70..c7cceeaaf4 100644 --- a/src/app/register-page/register-page-routing.module.ts +++ b/src/app/register-page/register-page-routing.module.ts @@ -2,8 +2,8 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { RegisterEmailComponent } from './register-email/register-email.component'; import { CreateProfileComponent } from './create-profile/create-profile.component'; -import { RegistrationResolver } from './registration.resolver'; import { ItemPageResolver } from '../+item-page/item-page.resolver'; +import { RegistrationResolver } from '../register-email-form/registration.resolver'; @NgModule({ imports: [ @@ -26,7 +26,7 @@ import { ItemPageResolver } from '../+item-page/item-page.resolver'; ] }) /** - * This module defines the default component to load when navigating to the mydspace page path. + * Module related to the navigation to components used to register a new user */ export class RegisterPageRoutingModule { } diff --git a/src/app/register-page/register-page.module.ts b/src/app/register-page/register-page.module.ts index c6c45c8a23..b29d2ecaaf 100644 --- a/src/app/register-page/register-page.module.ts +++ b/src/app/register-page/register-page.module.ts @@ -4,12 +4,16 @@ import { SharedModule } from '../shared/shared.module'; import { RegisterPageRoutingModule } from './register-page-routing.module'; import { RegisterEmailComponent } from './register-email/register-email.component'; import { CreateProfileComponent } from './create-profile/create-profile.component'; +import { RegisterEmailFormModule } from '../register-email-form/register-email-form.module'; +import { ProfilePageModule } from '../profile-page/profile-page.module'; @NgModule({ imports: [ CommonModule, SharedModule, RegisterPageRoutingModule, + RegisterEmailFormModule, + ProfilePageModule, ], declarations: [ RegisterEmailComponent, @@ -19,6 +23,9 @@ import { CreateProfileComponent } from './create-profile/create-profile.componen entryComponents: [] }) +/** + * Module related to components used to register a new user + */ export class RegisterPageModule { } diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 2d52bf79bb..5285bc65e4 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -9,5 +9,5 @@ {{"login.form.new-user" | translate}} - {{"login.form.forgot-password" | translate}} + {{"login.form.forgot-password" | translate}}
diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index 6634389c26..32e10fef45 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -8,7 +8,7 @@ import { AuthMethod } from '../../core/auth/models/auth.method'; import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; import { AuthService } from '../../core/auth/auth.service'; -import { getRegisterPath } from '../../app-routing.module'; +import { getForgotPasswordPath, getRegisterPath } from '../../app-routing.module'; /** * /users/sign-in @@ -86,4 +86,8 @@ export class LogInComponent implements OnInit, OnDestroy { getRegisterPath() { return getRegisterPath(); } + + getForgotPath() { + return getForgotPasswordPath(); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 12011ee18a..776ec09e1f 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -934,6 +934,62 @@ "footer.link.duraspace": "DuraSpace", + "forgot-email.form.header": "Forgot Password", + + "forgot-email.form.info": "Enter Register an account to subscribe to collections for email updates, and submit new items to DSpace.", + + "forgot-email.form.email": "Email Address *", + + "forgot-email.form.email.error.required": "Please fill in an email address", + + "forgot-email.form.email.error.pattern": "Please fill in a valid email address", + + "forgot-email.form.email.hint": "This address will be verified and used as your login name.", + + "forgot-email.form.submit": "Submit", + + "forgot-email.form.success.head": "Verification email sent", + + "forgot-email.form.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", + + "forgot-email.form.error.head": "Error when trying to register email", + + "forgot-email.form.error.content": "An error occured when registering the following email address: {{ email }}", + + + + "forgot-password.title": "Forgot Password", + + "forgot-password.form.head": "Forgot Password", + + "forgot-password.form.info": "Enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", + + "forgot-password.form.card.security": "Security", + + "forgot-password.form.identification.header": "Identify", + + "forgot-password.form.identification.email": "Email address: ", + + "forgot-password.form.label.password": "Password", + + "forgot-password.form.label.passwordrepeat": "Retype to confirm", + + "forgot-password.form.error.empty-password": "Please enter a password in the box below.", + + "forgot-password.form.error.matching-passwords": "The passwords do not match.", + + "forgot-password.form.error.password-length": "The password should be at least 6 characters long.", + + "forgot-password.form.notification.error.title": "Error when trying to submit new password", + + "forgot-password.form.notification.success.content": "The password reset was successful. You have been logged in as the created user.", + + "forgot-password.form.notification.success.title": "Password reset completed", + + "forgot-password.form.submit": "Submit password", + + + "form.add": "Add", "form.add-help": "Click here to add the current entry and to add another one", @@ -2066,11 +2122,15 @@ "register-page.create-profile.security.info": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", - "register-page.create-profile.security.password": "Password *", + "register-page.create-profile.security.label.password": "Password *", - "register-page.create-profile.security.confirm-password": "Retype to confirm *", + "register-page.create-profile.security.label.passwordrepeat": "Retype to confirm *", - "register-page.create-profile.security.password.error": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", + "register-page.create-profile.security.error.empty-password": "Please enter a password in the box below.", + + "register-page.create-profile.security.error.matching-passwords": "The passwords do not match.", + + "register-page.create-profile.security.error.password-length": "The password should be at least 6 characters long.", "register-page.create-profile.submit": "Complete Registration", @@ -2095,7 +2155,7 @@ "register-page.registration.email.hint": "This address will be verified and used as your login name.", - "register-page.registration.register": "Register", + "register-page.registration.submit": "Register", "register-page.registration.success.head": "Verification email sent", From 16d3d0e063cf3b0229ea90b1a7e41d33b61e6c3c Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 15 Jun 2020 17:48:03 +0200 Subject: [PATCH 23/59] 71380: Remove object updates for drag-and-drop and send out immediate patch requests for bitstream drag-and-drop --- .../item-bitstreams.component.html | 3 +- .../item-bitstreams.component.ts | 67 +++--- .../item-edit-bitstream-bundle.component.html | 2 +- .../item-edit-bitstream-bundle.component.ts | 9 +- .../object-updates/object-updates.actions.ts | 88 +------- .../object-updates/object-updates.reducer.ts | 193 +----------------- .../object-updates/object-updates.service.ts | 89 +------- ...-paginated-drag-and-drop-list.component.ts | 121 +++++------ 8 files changed, 98 insertions(+), 474 deletions(-) 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 index dc017a9f92..82ca1f58d9 100644 --- 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 @@ -36,7 +36,8 @@ + [columnSizes]="columnSizes" + (dropObject)="dropBitstream(bundle, $event)">
observableZip(...bundles.map((bundle: Bundle) => - this.objectUpdatesService.getMoveOperations(bundle.self).pipe( - take(1), - map((operations: MoveOperation[]) => [...operations.map((operation: MoveOperation) => Object.assign(operation, { - from: `/_links/bitstreams${operation.from}/href`, - path: `/_links/bitstreams${operation.path}/href` - }))]) - ) - ))) - ); - - // Send out an immediate patch request for each bundle - const patchResponses$ = observableCombineLatest(bundlesOnce$, moveOperations$).pipe( - switchMap(([bundles, moveOperationList]: [Bundle[], Operation[][]]) => - observableZip(...bundles.map((bundle: Bundle, index: number) => { - if (isNotEmpty(moveOperationList[index])) { - return this.bundleService.patch(bundle, moveOperationList[index]); - } else { - return observableOf(undefined); - } - })) - ) - ); - // Fetch all removed bitstreams from the object update service const removedBitstreams$ = bundlesOnce$.pipe( switchMap((bundles: Bundle[]) => observableZip( @@ -201,19 +172,35 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme ); // Perform the setup actions from above in order and display notifications - patchResponses$.pipe( - switchMap((responses: RestResponse[]) => { - this.displayNotifications('item.edit.bitstreams.notifications.move', responses); - return removedResponses$ - }), - take(1) - ).subscribe((responses: RestResponse[]) => { + removedResponses$.pipe(take(1)).subscribe((responses: RestResponse[]) => { this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); this.reset(); this.submitting = false; }); } + /** + * A bitstream was dropped in a new location. Send out a Move Patch request to the REST API, display notifications, + * refresh the bundle's cache (so the lists can properly reload) and call the event's callback function (which will + * navigate the user to the correct page) + * @param bundle The bundle to send patch requests to + * @param event The event containing the index the bitstream came from and was dropped to + */ + dropBitstream(bundle: Bundle, event: any) { + if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { + const moveOperation = Object.assign({ + op: 'move', + from: `/_links/bitstreams/${event.fromIndex}/href`, + path: `/_links/bitstreams/${event.toIndex}/href` + }); + this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RestResponse) => { + this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); + this.requestService.removeByHrefSubstring(bundle.self); + event.finish(); + }); + } + } + /** * Display notifications * - Error notification for each failed response with their message diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html index 58273bb931..c28ef9b525 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -17,5 +17,5 @@
- + diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts index 115e326241..72e2055bf7 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, ViewChild, ViewContainerRef } from '@angular/core'; import { Bundle } from '../../../../core/shared/bundle.model'; import { Item } from '../../../../core/shared/item.model'; import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; @@ -36,6 +36,13 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ @Input() columnSizes: ResponsiveTableSizes; + /** + * Send an event when the user drops an object on the pagination + * The event contains details about the index the object came from and is dropped to (across the entirety of the list, + * not just within a single page) + */ + @Output() dropObject: EventEmitter = new EventEmitter(); + /** * The bootstrap sizes used for the Bundle Name column * This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit 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 94918157ee..f26be768b1 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -8,7 +8,6 @@ import {INotification} from '../../../shared/notifications/models/notification.m */ export const ObjectUpdatesActionTypes = { INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), - ADD_PAGE_TO_CUSTOM_ORDER: type('dspace/core/cache/object-updates/ADD_PAGE_TO_CUSTOM_ORDER'), 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'), @@ -17,8 +16,7 @@ export const ObjectUpdatesActionTypes = { REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'), - REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), - MOVE: type('dspace/core/cache/object-updates/MOVE'), + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') }; /* tslint:disable:max-classes-per-file */ @@ -29,8 +27,7 @@ export const ObjectUpdatesActionTypes = { export enum FieldChangeType { UPDATE = 0, ADD = 1, - REMOVE = 2, - MOVE = 3 + REMOVE = 2 } /** @@ -41,10 +38,7 @@ export class InitializeFieldsAction implements Action { payload: { url: string, fields: Identifiable[], - lastModified: Date, - order: string[], - pageSize: number, - page: number + lastModified: Date }; /** @@ -61,42 +55,9 @@ export class InitializeFieldsAction implements Action { constructor( url: string, fields: Identifiable[], - lastModified: Date, - order: string[] = [], - pageSize: number = 9999, - page: number = 0 + lastModified: Date ) { - this.payload = { url, fields, lastModified, order, pageSize, page }; - } -} - -/** - * An ngrx action to initialize a new page's fields in the ObjectUpdates state - */ -export class AddPageToCustomOrderAction implements Action { - type = ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER; - payload: { - url: string, - fields: Identifiable[], - order: string[], - page: number - }; - - /** - * Create a new AddPageToCustomOrderAction - * - * @param url The unique url of the page for which the fields are being added - * @param fields The identifiable fields of which the updates are kept track of - * @param order A custom order to keep track of objects moving around - * @param page The page to populate in the custom order - */ - constructor( - url: string, - fields: Identifiable[], - order: string[] = [], - page: number = 0 - ) { - this.payload = { url, fields, order, page }; + this.payload = { url, fields, lastModified }; } } @@ -320,43 +281,6 @@ export class RemoveFieldUpdateAction implements Action { } } -/** - * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid - */ -export class MoveFieldUpdateAction implements Action { - type = ObjectUpdatesActionTypes.MOVE; - payload: { - url: string, - from: number, - to: number, - fromPage: number, - toPage: number, - field?: Identifiable - }; - - /** - * Create a new RemoveObjectUpdatesAction - * - * @param url - * the unique url of the page for which a field's change should be removed - * @param from The index of the object to move - * @param to The index to move the object to - * @param fromPage The page to move the object from - * @param toPage The page to move the object to - * @param field Optional field to add to the fieldUpdates list (useful when we want to track updates across multiple pages) - */ - constructor( - url: string, - from: number, - to: number, - fromPage: number, - toPage: number, - field?: Identifiable - ) { - this.payload = { url, from, to, fromPage, toPage, field }; - } -} - /* tslint:enable:max-classes-per-file */ /** @@ -369,8 +293,6 @@ export type ObjectUpdatesAction | ReinstateObjectUpdatesAction | RemoveObjectUpdatesAction | RemoveFieldUpdateAction - | MoveFieldUpdateAction - | AddPageToCustomOrderAction | RemoveAllObjectUpdatesAction | SelectVirtualMetadataAction | SetEditableFieldUpdateAction 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 759a9f5c87..b1626a5ff5 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,8 +1,8 @@ import { - AddFieldUpdateAction, AddPageToCustomOrderAction, + AddFieldUpdateAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, MoveFieldUpdateAction, + InitializeFieldsAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, @@ -12,9 +12,7 @@ import { SetValidFieldUpdateAction, SelectVirtualMetadataAction, } from './object-updates.actions'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; -import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; -import { from } from 'rxjs/internal/observable/from'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; import {Relationship} from '../../shared/item-relationships/relationship.model'; /** @@ -83,20 +81,6 @@ export interface DeleteRelationship extends Relationship { keepRightVirtualMetadata: boolean, } -/** - * A custom order given to the list of objects - */ -export interface CustomOrder { - initialOrderPages: OrderPage[], - newOrderPages: OrderPage[], - pageSize: number; - changed: boolean -} - -export interface OrderPage { - order: string[] -} - /** * The updated state of a single page */ @@ -105,7 +89,6 @@ export interface ObjectUpdatesEntry { fieldUpdates: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; - customOrder: CustomOrder } /** @@ -138,9 +121,6 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { return initializeFieldsUpdate(state, action as InitializeFieldsAction); } - case ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER: { - return addPageToCustomOrder(state, action as AddPageToCustomOrderAction); - } case ObjectUpdatesActionTypes.ADD_FIELD: { return addFieldUpdate(state, action as AddFieldUpdateAction); } @@ -168,9 +148,6 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.SET_VALID_FIELD: { return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); } - case ObjectUpdatesActionTypes.MOVE: { - return moveFieldUpdate(state, action as MoveFieldUpdateAction); - } default: { return state; } @@ -186,50 +163,18 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { const url: string = action.payload.url; const fields: Identifiable[] = action.payload.fields; const lastModifiedServer: Date = action.payload.lastModified; - const order = action.payload.order; - const pageSize = action.payload.pageSize; - const page = action.payload.page; const fieldStates = createInitialFieldStates(fields); - const initialOrderPages = addOrderToPages([], order, pageSize, page); const newPageState = Object.assign( {}, state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, { virtualMetadataSources: {} }, - { lastModified: lastModifiedServer }, - { customOrder: { - initialOrderPages: initialOrderPages, - newOrderPages: initialOrderPages, - pageSize: pageSize, - changed: false } - } + { lastModified: lastModifiedServer } ); return Object.assign({}, state, { [url]: newPageState }); } -/** - * Add a page of objects to the state of a specific url and update a specific page of the custom order - * @param state The current state - * @param action The action to perform on the current state - */ -function addPageToCustomOrder(state: any, action: AddPageToCustomOrderAction) { - const url: string = action.payload.url; - const fields: Identifiable[] = action.payload.fields; - const fieldStates = createInitialFieldStates(fields); - const order = action.payload.order; - const page = action.payload.page; - const pageState: ObjectUpdatesEntry = state[url] || {}; - const newPageState = Object.assign({}, pageState, { - fieldStates: Object.assign({}, pageState.fieldStates, fieldStates), - customOrder: Object.assign({}, pageState.customOrder, { - newOrderPages: addOrderToPages(pageState.customOrder.newOrderPages, order, pageState.customOrder.pageSize, page), - initialOrderPages: addOrderToPages(pageState.customOrder.initialOrderPages, order, pageState.customOrder.pageSize, page) - }) - }); - return Object.assign({}, state, { [url]: newPageState }); -} - /** * Add a new update for a specific field to the store * @param state The current state @@ -338,19 +283,9 @@ function discardObjectUpdatesFor(url: string, state: any) { } }); - const newCustomOrder = Object.assign({}, pageState.customOrder); - if (pageState.customOrder.changed) { - const initialOrder = pageState.customOrder.initialOrderPages; - if (isNotEmpty(initialOrder)) { - newCustomOrder.newOrderPages = initialOrder; - newCustomOrder.changed = false; - } - } - const discardedPageState = Object.assign({}, pageState, { fieldUpdates: {}, - fieldStates: newFieldStates, - customOrder: newCustomOrder + fieldStates: newFieldStates }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -504,121 +439,3 @@ function createInitialFieldStates(fields: Identifiable[]) { uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); return fieldStates; } - -/** - * Method to add a list of objects to an existing FieldStates object - * @param fieldStates FieldStates to add states to - * @param fields Identifiable objects The list of objects to add to the FieldStates - */ -function addFieldStates(fieldStates: FieldStates, fields: Identifiable[]) { - const uuids = fields.map((field: Identifiable) => field.uuid); - uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); - return fieldStates; -} - -/** - * Move an object within the custom order of a page state - * @param state The current state - * @param action The move action to perform - */ -function moveFieldUpdate(state: any, action: MoveFieldUpdateAction) { - const url = action.payload.url; - const fromIndex = action.payload.from; - const toIndex = action.payload.to; - const fromPage = action.payload.fromPage; - const toPage = action.payload.toPage; - const field = action.payload.field; - - const pageState: ObjectUpdatesEntry = state[url]; - const initialOrderPages = pageState.customOrder.initialOrderPages; - const customOrderPages = [...pageState.customOrder.newOrderPages]; - - // Create a copy of the custom orders for the from- and to-pages - const fromPageOrder = [...customOrderPages[fromPage].order]; - const toPageOrder = [...customOrderPages[toPage].order]; - if (fromPage === toPage) { - if (isNotEmpty(customOrderPages[fromPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex]) && isNotEmpty(customOrderPages[fromPage].order[toIndex])) { - // Move an item from one index to another within the same page - moveItemInArray(fromPageOrder, fromIndex, toIndex); - // Update the custom order for this page - customOrderPages[fromPage] = { order: fromPageOrder }; - } - } else { - if (isNotEmpty(customOrderPages[fromPage]) && hasValue(customOrderPages[toPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex])) { - // Move an item from one index of one page to an index in another page - transferArrayItem(fromPageOrder, toPageOrder, fromIndex, toIndex); - // Update the custom order for both pages - customOrderPages[fromPage] = { order: fromPageOrder }; - customOrderPages[toPage] = { order: toPageOrder }; - } - } - - // Create a field update if it doesn't exist for this field yet - let fieldUpdate = {}; - if (hasValue(field)) { - fieldUpdate = pageState.fieldUpdates[field.uuid]; - if (hasNoValue(fieldUpdate)) { - fieldUpdate = { field: field, changeType: undefined } - } - } - - // Update the store's state with new values and return - return Object.assign({}, state, { [url]: Object.assign({}, pageState, { - fieldUpdates: Object.assign({}, pageState.fieldUpdates, hasValue(field) ? { [field.uuid]: fieldUpdate } : {}), - customOrder: Object.assign({}, pageState.customOrder, { newOrderPages: customOrderPages, changed: checkForOrderChanges(initialOrderPages, customOrderPages) }) - })}) -} - -/** - * Compare two lists of OrderPage objects and return whether there's at least one change in the order of objects within - * @param initialOrderPages The initial list of OrderPages - * @param customOrderPages The changed list of OrderPages - */ -function checkForOrderChanges(initialOrderPages: OrderPage[], customOrderPages: OrderPage[]) { - let changed = false; - initialOrderPages.forEach((orderPage: OrderPage, page: number) => { - if (isNotEmpty(orderPage) && isNotEmpty(orderPage.order) && isNotEmpty(customOrderPages[page]) && isNotEmpty(customOrderPages[page].order)) { - orderPage.order.forEach((id: string, index: number) => { - if (id !== customOrderPages[page].order[index]) { - changed = true; - return; - } - }); - if (changed) { - return; - } - } - }); - return changed; -} - -/** - * Initialize a custom order page by providing the list of all pages, a list of UUIDs, pageSize and the page to populate - * @param initialPages The initial list of OrderPage objects - * @param order The list of UUIDs to create a page for - * @param pageSize The pageSize used to populate empty spacer pages - * @param page The index of the page to add - */ -function addOrderToPages(initialPages: OrderPage[], order: string[], pageSize: number, page: number): OrderPage[] { - const result = [...initialPages]; - const orderPage: OrderPage = { order: order }; - if (page < result.length) { - // The page we're trying to add already exists in the list. Overwrite it. - result[page] = orderPage; - } else if (page === result.length) { - // The page we're trying to add is the next page in the list, add it. - result.push(orderPage); - } else { - // The page we're trying to add is at least one page ahead of the list, fill the list with empty pages before adding the page. - const emptyOrder = []; - for (let i = 0; i < pageSize; i++) { - emptyOrder.push(undefined); - } - const emptyOrderPage: OrderPage = { order: emptyOrder }; - for (let i = result.length; i < page; i++) { - result.push(emptyOrderPage); - } - result.push(orderPage); - } - return result; -} 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 c9a7f47e81..779a22fb5b 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -8,16 +8,15 @@ import { Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState, OrderPage, + ObjectUpdatesState, VirtualMetadataSource } from './object-updates.reducer'; import { Observable } from 'rxjs'; import { - AddFieldUpdateAction, AddPageToCustomOrderAction, + AddFieldUpdateAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, - MoveFieldUpdateAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, @@ -27,9 +26,6 @@ import { import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; -import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; -import { MoveOperation } from 'fast-json-patch/lib/core'; -import { flatten } from '@angular/compiler'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -52,9 +48,7 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel */ @Injectable() export class ObjectUpdatesService { - constructor(private store: Store, - private comparator: ArrayMoveChangeAnalyzer) { - + constructor(private store: Store) { } /** @@ -67,28 +61,6 @@ export class ObjectUpdatesService { this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); } - /** - * Method to dispatch an InitializeFieldsAction to the store and keeping track of the order objects are stored - * @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 - * @param pageSize The page size to use for adding pages to the custom order - * @param page The first page to populate the custom order with - */ - initializeWithCustomOrder(url, fields: Identifiable[], lastModified: Date, pageSize = 9999, page = 0): void { - this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, fields.map((field) => field.uuid), pageSize, page)); - } - - /** - * Method to dispatch an AddPageToCustomOrderAction, adding a new page to an already existing custom order tracking - * @param url The URL for which the changes are being mapped - * @param fields The fields to add a new page for - * @param page The page number (starting from index 0) - */ - addPageToCustomOrder(url, fields: Identifiable[], page: number): void { - this.store.dispatch(new AddPageToCustomOrderAction(url, fields, fields.map((field) => field.uuid), page)); - } - /** * Method to dispatch an AddFieldUpdateAction to the store * @param url The page's URL for which the changes are saved @@ -166,31 +138,6 @@ export class ObjectUpdatesService { })) } - /** - * Method that combines the state's updates with the initial values (when there's no update), - * sorted by their custom order 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 - * @param page The page to retrieve - */ - getFieldUpdatesByCustomOrder(url: string, initialFields: Identifiable[], page = 0): Observable { - const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe(map((objectEntry) => { - const fieldUpdates: FieldUpdates = {}; - if (hasValue(objectEntry) && hasValue(objectEntry.customOrder) && isNotEmpty(objectEntry.customOrder.newOrderPages) && page < objectEntry.customOrder.newOrderPages.length) { - for (const uuid of objectEntry.customOrder.newOrderPages[page].order) { - let fieldUpdate = objectEntry.fieldUpdates[uuid]; - if (isEmpty(fieldUpdate)) { - const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); - fieldUpdate = {field: identifiable, changeType: undefined}; - } - fieldUpdates[uuid] = fieldUpdate; - } - } - return fieldUpdates; - })) - } - /** * Method to check if a specific field is currently editable in the store * @param url The URL of the page on which the field resides @@ -260,19 +207,6 @@ export class ObjectUpdatesService { this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); } - /** - * Dispatches a MoveFieldUpdateAction - * @param url The page's URL for which the changes are saved - * @param from The index of the object to move - * @param to The index to move the object to - * @param fromPage The page to move the object from - * @param toPage The page to move the object to - * @param field Optional field to add to the fieldUpdates list (useful if we want to track updates across multiple pages) - */ - saveMoveFieldUpdate(url: string, from: number, to: number, fromPage = 0, toPage = 0, field?: Identifiable) { - this.store.dispatch(new MoveFieldUpdateAction(url, from, to, fromPage, toPage, field)); - } - /** * Check whether the virtual metadata of a given item is selected to be saved as real metadata * @param url The URL of the page on which the field resides @@ -387,7 +321,7 @@ export class ObjectUpdatesService { * @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) || objectEntry.customOrder.changed))); + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); } /** @@ -405,19 +339,4 @@ export class ObjectUpdatesService { getLastModified(url: string): Observable { return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); } - - /** - * Get move operations based on the custom order - * @param url The page's url - */ - getMoveOperations(url: string): Observable { - return this.getObjectEntry(url).pipe( - map((objectEntry) => objectEntry.customOrder), - map((customOrder) => this.comparator.diff( - flatten(customOrder.initialOrderPages.map((orderPage: OrderPage) => orderPage.order)), - flatten(customOrder.newOrderPages.map((orderPage: OrderPage) => orderPage.order))) - ) - ); - } - } diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index a34b5d5bc0..a0f1d3386e 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -5,19 +5,20 @@ import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; -import { switchMap, take, tap } from 'rxjs/operators'; -import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; +import { switchMap, take } from 'rxjs/operators'; +import { hasValue } from '../empty.util'; import { paginatedListToArray } from '../../core/shared/operators'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; -import { ElementRef, ViewChild } from '@angular/core'; +import { ElementRef, EventEmitter, Output, ViewChild } from '@angular/core'; import { PaginationComponent } from '../pagination/pagination.component'; /** * An abstract component containing general methods and logic to be able to drag and drop objects within a paginated * list. This implementation supports being able to drag and drop objects between pages. - * Dragging an object on top of a page number will automatically detect the page it's being dropped on, send an update - * to the store and add the object on top of that page. + * Dragging an object on top of a page number will automatically detect the page it's being dropped on and send a + * dropObject event to the parent component containing detailed information about the indexes the object was dropped from + * and to. * * To extend this component, it is important to make sure to: * - Initialize objectsRD$ within the initializeObjectsRD() method @@ -34,6 +35,13 @@ export abstract class AbstractPaginatedDragAndDropListComponent = new EventEmitter(); + /** * The URL to use for accessing the object updates from this list */ @@ -52,7 +60,7 @@ export abstract class AbstractPaginatedDragAndDropListComponent(1); - /** - * A list of pages that have been initialized in the field-update store - */ - initializedPages: number[] = []; - - /** - * An object storing information about an update that should be fired whenever fireToUpdate is called - */ - toUpdate: { - fromIndex: number, - toIndex: number, - fromPage: number, - toPage: number, - field?: T - }; - protected constructor(protected objectUpdatesService: ObjectUpdatesService, protected elRef: ElementRef) { } @@ -110,28 +102,17 @@ export abstract class AbstractPaginatedDragAndDropListComponent { + this.objectUpdatesService.initialize(this.url, objects, new Date()); + }); this.updates$ = this.objectsRD$.pipe( paginatedListToArray(), - tap((objects: T[]) => { - // Pages in the field-update store are indexed starting at 0 (because they're stored in an array of pages) - const updatesPage = this.currentPage$.value - 1; - if (isEmpty(this.initializedPages)) { - // No updates have been initialized yet for this list, initialize the first page - this.objectUpdatesService.initializeWithCustomOrder(this.url, objects, new Date(), this.pageSize, updatesPage); - this.initializedPages.push(updatesPage); - } else if (this.initializedPages.indexOf(updatesPage) < 0) { - // Updates were initialized for this list, but not the page we're on. Add the current page to the field-update store for this list - this.objectUpdatesService.addPageToCustomOrder(this.url, objects, updatesPage); - this.initializedPages.push(updatesPage); - } - - // The new page is loaded into the store, check if there are any updates waiting and fire those as well - this.fireToUpdate(); - }), - switchMap((objects: T[]) => this.objectUpdatesService.getFieldUpdatesByCustomOrder(this.url, objects, this.currentPage$.value - 1)) + switchMap((objects: T[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, objects)) ); } @@ -144,52 +125,42 @@ export abstract class AbstractPaginatedDragAndDropListComponent) { + const dragIndex = event.previousIndex; + let dropIndex = event.currentIndex; + const dragPage = this.currentPage$.value - 1; + let dropPage = this.currentPage$.value - 1; + // Check if the user is hovering over any of the pagination's pages at the time of dropping the object const droppedOnElement = this.elRef.nativeElement.querySelector('.page-item:hover'); if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent)) { // The user is hovering over a page, fetch the page's number from the element - const page = Number(droppedOnElement.textContent); - if (hasValue(page) && !Number.isNaN(page)) { - const id = event.item.element.nativeElement.id; - this.updates$.pipe(take(1)).subscribe((updates: FieldUpdates) => { - const field = hasValue(updates[id]) ? updates[id].field : undefined; - this.toUpdate = Object.assign({ - fromIndex: event.previousIndex, - toIndex: 0, - fromPage: this.currentPage$.value - 1, - toPage: page - 1, - field - }); - // Switch to the dropped-on page and force a page update for the pagination component - this.currentPage$.next(page); - this.paginationComponent.doPageChange(page); - if (this.initializedPages.indexOf(page - 1) >= 0) { - // The page the object is being dropped to has already been loaded before, directly fire an update to the store. - // For pages that haven't been loaded before, the updates$ observable will call fireToUpdate after the new page - // has loaded - this.fireToUpdate(); - } - }); + const droppedPage = Number(droppedOnElement.textContent); + if (hasValue(droppedPage) && !Number.isNaN(droppedPage)) { + dropPage = droppedPage - 1; + dropIndex = 0; } - } else { - this.objectUpdatesService.saveMoveFieldUpdate(this.url, event.previousIndex, event.currentIndex, this.currentPage$.value - 1, this.currentPage$.value - 1); } - } - /** - * Method checking if there's an update ready to be fired. Send out a MoveFieldUpdate to the store if there's an - * update present and clear the update afterwards. - */ - fireToUpdate() { - if (hasValue(this.toUpdate)) { - this.objectUpdatesService.saveMoveFieldUpdate(this.url, this.toUpdate.fromIndex, this.toUpdate.toIndex, this.toUpdate.fromPage, this.toUpdate.toPage, this.toUpdate.field); - this.toUpdate = undefined; + const redirectPage = dropPage + 1; + const fromIndex = (dragPage * this.pageSize) + dragIndex; + const toIndex = (dropPage * this.pageSize) + dropIndex; + // Send out a drop event when the field exists and the "from" and "to" indexes are different from each other + if (fromIndex !== toIndex) { + this.dropObject.emit(Object.assign({ + fromIndex, + toIndex, + finish: () => { + this.currentPage$.next(redirectPage); + this.paginationComponent.doPageChange(redirectPage); + } + })); } } } From c6ee46fdea68fe7b89fe92daa20ef1f94a0ca44f Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 15 Jun 2020 17:57:25 +0200 Subject: [PATCH 24/59] 71380: Reset page size back to normal --- .../abstract-paginated-drag-and-drop-list.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index a0f1d3386e..7f94a5eaa5 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -60,7 +60,7 @@ export abstract class AbstractPaginatedDragAndDropListComponent Date: Tue, 16 Jun 2020 14:26:59 +0200 Subject: [PATCH 25/59] [CST-3090] done --- .../my-dspace-new-submission.component.html | 4 +- .../my-dspace-new-submission.component.ts | 15 +- src/app/core/data/collection-data.service.ts | 16 +- .../collection-dropdown.component.html | 43 +++ .../collection-dropdown.component.scss | 15 + .../collection-dropdown.component.spec.ts | 200 +++++++++++++ .../collection-dropdown.component.ts | 229 +++++++++++++++ ...create-item-parent-selector.component.html | 11 + .../create-item-parent-selector.component.ts | 3 +- src/app/shared/shared.module.ts | 7 +- .../submission-form-collection.component.html | 28 +- ...bmission-form-collection.component.spec.ts | 274 +----------------- .../submission-form-collection.component.ts | 104 +------ 13 files changed, 550 insertions(+), 399 deletions(-) create mode 100644 src/app/shared/collection-dropdown/collection-dropdown.component.html create mode 100644 src/app/shared/collection-dropdown/collection-dropdown.component.scss create mode 100644 src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts create mode 100644 src/app/shared/collection-dropdown/collection-dropdown.component.ts create mode 100644 src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html index 911ba26b31..4809f206ae 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -7,9 +7,9 @@ diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index 81d66bb5f7..8d20a5736a 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -15,6 +15,9 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { NotificationType } from '../../shared/notifications/models/notification-type'; import { hasValue } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; +import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CreateItemParentSelectorComponent } from 'src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; /** * This component represents the whole mydspace page header @@ -55,7 +58,9 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { private halService: HALEndpointService, private notificationsService: NotificationsService, private store: Store, - private translate: TranslateService) { + private translate: TranslateService, + private router: Router, + private modalService: NgbModal) { } /** @@ -105,6 +110,14 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); } + /** + * Method called on clicking the button "New Submition", It opens a dialog for + * select a collection. + */ + openDialog() { + this.modalService.open(CreateItemParentSelectorComponent); + } + /** * Unsubscribe from the subscription */ diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 0639a7d8ca..d28421356a 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -72,14 +72,18 @@ export class CollectionDataService extends ComColDataService { /** * Get all collections the user is authorized to submit to * + * @param query limit the returned collection to those with metadata values matching the query terms. * @param options The [[FindListOptions]] object * @return Observable>> * collection list */ - getAuthorizedCollection(options: FindListOptions = {}): Observable>> { + getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { const searchHref = 'findAuthorized'; + options = Object.assign({}, options, { + searchParams: [new RequestParam('query', query)] + }); - return this.searchBy(searchHref, options).pipe( + return this.searchBy(searchHref, options, ...linksToFollow).pipe( filter((collections: RemoteData>) => !collections.isResponsePending)); } @@ -87,14 +91,18 @@ export class CollectionDataService extends ComColDataService { * Get all collections the user is authorized to submit to, by community * * @param communityId The community id + * @param query limit the returned collection to those with metadata values matching the query terms. * @param options The [[FindListOptions]] object * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable>> { const searchHref = 'findAuthorizedByCommunity'; options = Object.assign({}, options, { - searchParams: [new RequestParam('uuid', communityId)] + searchParams: [ + new RequestParam('uuid', communityId), + new RequestParam('query', query) + ] }); return this.searchBy(searchHref, options).pipe( diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html new file mode 100644 index 0000000000..0674084a43 --- /dev/null +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.html @@ -0,0 +1,43 @@ +
+ +
+ +
+
+ + + +
+
\ No newline at end of file diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.scss b/src/app/shared/collection-dropdown/collection-dropdown.component.scss new file mode 100644 index 0000000000..deecc39510 --- /dev/null +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.scss @@ -0,0 +1,15 @@ +.scrollable-menu { + height: auto; + max-height: $dropdown-menu-max-height; + overflow-x: hidden; +} + +.collection-item { + border-bottom: $dropdown-border-width solid $dropdown-border-color; +} + +#collectionControlsDropdownMenu { + outline: 0; + left: 0 !important; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts new file mode 100644 index 0000000000..33c848f9c4 --- /dev/null +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts @@ -0,0 +1,200 @@ +import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { CollectionDropdownComponent } from './collection-dropdown.component'; +import { FollowLinkConfig } from '../utils/follow-link-config.model'; +import { Observable, of } from 'rxjs'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { PaginatedList } from 'src/app/core/data/paginated-list'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; +import { PageInfo } from 'src/app/core/shared/page-info.model'; +import { Collection } from '../../core/shared/collection.model'; +import { NO_ERRORS_SCHEMA, ChangeDetectorRef, ElementRef } from '@angular/core'; +import { CollectionDataService } from 'src/app/core/data/collection-data.service'; +import { FindListOptions } from 'src/app/core/data/request.models'; +import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; +import { TestScheduler } from 'rxjs/testing'; +import { By } from '@angular/platform-browser'; +import { Community } from 'src/app/core/shared/community.model'; + +const community: Community = Object.assign(new Community(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'Community 1' +}); + +const collections: Collection[] = [ + Object.assign(new Collection(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'Collection 1', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + name: 'Collection 2', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 2' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + name: 'Collection 3', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 3' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + name: 'Collection 4', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 4' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: 'a5159760-f362-4659-9e81-e3253ad91ede', + name: 'Collection 5', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 5' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }) +]; + +const listElementMock = { + communities: [ + { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'Community 1' + } + ], + collection: { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + uuid: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + name: 'Collection 3' + } + }; + +// tslint:disable-next-line: max-classes-per-file +class CollectionDataServiceMock { + getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return of( + createSuccessfulRemoteDataObject( + new PaginatedList(new PageInfo(), collections) + ) + ); + } +} + +describe('CollectionDropdownComponent', () => { + let component: CollectionDropdownComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + const searchedCollection = 'TEXT'; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ CollectionDropdownComponent ], + providers: [ + {provide: CollectionDataService, useClass: CollectionDataServiceMock}, + {provide: ChangeDetectorRef, useValue: {}}, + {provide: ElementRef, userValue: {}} + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(CollectionDropdownComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should populate collections list with five items', () => { + const elements = fixture.debugElement.queryAll(By.css('.collection-item')); + expect(elements.length).toEqual(5); + }); + + it('should trigger onSelect method when select a new collection from list', fakeAsync(() => { + spyOn(component, 'onSelect'); + const collectionItem = fixture.debugElement.query(By.css('.collection-item:nth-child(2)')); + collectionItem.triggerEventHandler('click', null); + fixture.detectChanges(); + tick(); + fixture.whenStable().then(() => { + expect(component.onSelect).toHaveBeenCalled(); + }); + })); + + it('should emit collectionChange event when selecting a new collection', () => { + spyOn(component.selectionChange, 'emit').and.callThrough(); + component.ngOnInit(); + component.onSelect(listElementMock as any); + fixture.detectChanges(); + + expect(component.selectionChange.emit).toHaveBeenCalledWith(listElementMock as any); + }); + + it('should reset collections list after reset of searchField', fakeAsync(() => { + spyOn(component, 'reset').and.callThrough(); + spyOn(component.searchField, 'setValue').and.callThrough(); + spyOn(component, 'resetPagination').and.callThrough(); + spyOn(component, 'populateCollectionList').and.callThrough(); + component.reset(); + const input = fixture.debugElement.query(By.css('input.form-control')); + const el = input.nativeElement; + el.value = searchedCollection; + el.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(250); + + fixture.whenStable().then(() => { + expect(component.reset).toHaveBeenCalled(); + expect(component.searchField.setValue).toHaveBeenCalledWith(''); + expect(component.resetPagination).toHaveBeenCalled(); + expect(component.currentQuery).toEqual(''); + expect(component.populateCollectionList).toHaveBeenCalledWith(component.currentQuery, component.currentPage); + }); + })); +}); diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts new file mode 100644 index 0000000000..e9e0445ca6 --- /dev/null +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -0,0 +1,229 @@ +import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit, AfterViewChecked } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Observable, of, Subscription } from 'rxjs'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { find, map, mergeMap, filter, reduce, startWith, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { FindListOptions } from 'src/app/core/data/request.models'; +import { PaginatedList } from 'src/app/core/data/paginated-list'; +import { Community } from 'src/app/core/shared/community.model'; +import { CollectionDataService } from 'src/app/core/data/collection-data.service'; +import { Collection } from '../../core/shared/collection.model'; +import { followLink } from '../utils/follow-link-config.model'; + +/** + * An interface to represent a collection entry + */ +interface CollectionListEntryItem { + id: string; + uuid: string; + name: string; +} + +/** + * An interface to represent an entry in the collection list + */ +interface CollectionListEntry { + communities: CollectionListEntryItem[], + collection: CollectionListEntryItem +} + +@Component({ + selector: 'ds-collection-dropdown', + templateUrl: './collection-dropdown.component.html', + styleUrls: ['./collection-dropdown.component.scss'] +}) +export class CollectionDropdownComponent implements OnInit, OnDestroy { + + /** + * The search form control + * @type {FormControl} + */ + public searchField: FormControl = new FormControl(); + + /** + * The collection list obtained from a search + * @type {Observable} + */ + public searchListCollection$: Observable; + + /** + * A boolean representing if dropdown list is scrollable to the bottom + * @type {boolean} + */ + private scrollableBottom = false; + + /** + * A boolean representing if dropdown list is scrollable to the top + * @type {boolean} + */ + private scrollableTop = false; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * The list of collection to render + */ + searchListCollection: CollectionListEntry[] = []; + + @Output() selectionChange = new EventEmitter(); + /** + * A boolean representing if the loader is visible or not + */ + isLoadingList: boolean; + + /** + * A numeric representig current page + */ + currentPage: number; + + /** + * A boolean representing if exist another page to render + */ + hasNextPage: boolean; + + /** + * Current seach query used to filter collection list + */ + currentQuery: string; + + constructor( + private changeDetectorRef: ChangeDetectorRef, + private collectionDataService: CollectionDataService, + private el: ElementRef + ) { } + + /** + * Method called on mousewheel event, it prevent the page scroll + * when arriving at the top/bottom of dropdown menu + * + * @param event + * mousewheel event + */ + @HostListener('mousewheel', ['$event']) onMousewheel(event) { + if (event.wheelDelta > 0 && this.scrollableTop) { + event.preventDefault(); + } + if (event.wheelDelta < 0 && this.scrollableBottom) { + event.preventDefault(); + } + } + + /** + * Initialize collection list + */ + ngOnInit() { + this.subs.push(this.searchField.valueChanges.pipe( + debounceTime(200), + distinctUntilChanged(), + startWith('') + ).subscribe( + (next) => { + if (hasValue(next)) { + this.resetPagination(); + this.currentQuery = next; + this.populateCollectionList(this.currentQuery, this.currentPage); + } + } + )); + // Workaround for prevent the scroll of main page when this component is placed in a dialog + setTimeout(() => this.el.nativeElement.querySelector('input').focus(), 0); + } + + /** + * Check if dropdown scrollbar is at the top or bottom of the dropdown list + * + * @param event + */ + onScroll(event) { + this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight); + this.scrollableTop = (event.target.scrollTop === 0); + } + + /** + * Method used from infitity scroll for retrive more data on scroll down + */ + onScrollDown() { + if ( this.hasNextPage ) { + this.populateCollectionList(this.currentQuery, ++this.currentPage); + } + } + + /** + * Emit a [selectionChange] event when a new collection is selected from list + * + * @param event + * the selected [CollectionListEntry] + */ + onSelect(event: CollectionListEntry) { + this.selectionChange.emit(event); + } + + /** + * Method called for populate the collection list + * @param query text for filter the collection list + * @param page page number + */ + populateCollectionList(query?: string, page?: number) { + this.isLoadingList = true; + // Set the pagination info + const findOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: page + }; + this.searchListCollection$ = this.collectionDataService + .getAuthorizedCollection(query, findOptions, followLink('parentCommunity')) + .pipe( + find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), + mergeMap((collections: RemoteData>) => { + if ( (this.searchListCollection.length + findOptions.elementsPerPage) >= collections.payload.totalElements ) { + this.hasNextPage = false; + } + return collections.payload.page; + }), + filter((collectionData: Collection) => isNotEmpty(collectionData)), + mergeMap((collection: Collection) => collection.parentCommunity.pipe( + find((communityResponse: RemoteData) => !communityResponse.isResponsePending && communityResponse.hasSucceeded), + mergeMap((communityResponse: RemoteData) => of(communityResponse.payload)), + map((community: Community) => ({ + communities: [{ id: community.id, name: community.name }], + collection: { id: collection.id, uuid: collection.id, name: collection.name } + }) + ))), + reduce((acc: any, value: any) => [...acc, ...value], []), + startWith([]) + ); + this.subs.push(this.searchListCollection$.subscribe( + (next) => { this.searchListCollection.push(...next); }, undefined, + () => { this.isLoadingList = false; this.changeDetectorRef.detectChanges(); } + )); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + + /** + * Reset search form control + */ + reset() { + this.searchField.setValue(''); + } + + /** + * Reset pagination values + */ + resetPagination() { + this.currentPage = 1; + this.currentQuery = ''; + this.hasNextPage = true; + this.searchListCollection = []; + } +} diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html new file mode 100644 index 0000000000..ef8865ad87 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index 02a0bd79cd..45d15ae306 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -13,7 +13,8 @@ import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-sel @Component({ selector: 'ds-create-item-parent-selector', // styleUrls: ['./create-item-parent-selector.component.scss'], - templateUrl: '../dso-selector-modal-wrapper.component.html', + // templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './create-item-parent-selector.component.html' }) export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { objectType = DSpaceObjectType.ITEM; diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 67d7db5c5d..8ef3f91257 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -202,6 +202,7 @@ import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/reso import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver'; import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component'; import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; +import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -386,7 +387,8 @@ const COMPONENTS = [ ResourcePolicyFormComponent, EpersonGroupListComponent, EpersonSearchBoxComponent, - GroupSearchBoxComponent + GroupSearchBoxComponent, + CollectionDropdownComponent ]; const ENTRY_COMPONENTS = [ @@ -504,8 +506,7 @@ const DIRECTIVES = [ ...COMPONENTS, ...DIRECTIVES, ...ENTRY_COMPONENTS, - ...SHARED_ITEM_PAGE_COMPONENTS, - + ...SHARED_ITEM_PAGE_COMPONENTS ], providers: [ ...PROVIDERS diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html index 6f4a8a864c..ad53be200c 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.html +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -20,31 +20,9 @@ class="dropdown-menu" id="collectionControlsDropdownMenu" aria-labelledby="collectionControlsMenuButton"> -
- -
- -
- - -
+ + diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index 105d94b966..5baa1013ab 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -1,17 +1,14 @@ -import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement, SimpleChange } from '@angular/core'; +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { of as observableOf } from 'rxjs'; -import { filter } from 'rxjs/operators'; import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; -import { cold } from 'jasmine-marbles'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { mockSubmissionId, mockSubmissionRestResponse } from '../../../shared/mocks/submission.mock'; +import { mockSubmissionId } from '../../../shared/mocks/submission.mock'; import { SubmissionService } from '../../submission.service'; import { SubmissionFormCollectionComponent } from './submission-form-collection.component'; import { CommunityDataService } from '../../../core/data/community-data.service'; @@ -19,173 +16,9 @@ import { SubmissionJsonPatchOperationsService } from '../../../core/submission/s import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service.stub'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Community } from '../../../core/shared/community.model'; -import { PaginatedList } from '../../../core/data/paginated-list'; -import { PageInfo } from '../../../core/shared/page-info.model'; -import { Collection } from '../../../core/shared/collection.model'; import { createTestComponent } from '../../../shared/testing/utils.test'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -const subcommunities = [Object.assign(new Community(), { - name: 'SubCommunity 1', - id: '123456789-1', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'SubCommunity 1' - }] -}), - Object.assign(new Community(), { - name: 'SubCommunity 1', - id: '123456789s-1', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'SubCommunity 1' - }] - }) -]; - -const mockCommunity1Collection1 = Object.assign(new Collection(), { - name: 'Community 1-Collection 1', - id: '1234567890-1', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 1-Collection 1' - }] -}); - -const mockCommunity1Collection2 = Object.assign(new Collection(), { - name: 'Community 1-Collection 2', - id: '1234567890-2', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 1-Collection 2' - }] -}); - -const mockCommunity2Collection1 = Object.assign(new Collection(), { - name: 'Community 2-Collection 1', - id: '1234567890-3', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 2-Collection 1' - }] -}); - -const mockCommunity2Collection2 = Object.assign(new Collection(), { - name: 'Community 2-Collection 2', - id: '1234567890-4', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 2-Collection 2' - }] -}); - -const mockCommunity = Object.assign(new Community(), { - name: 'Community 1', - id: '123456789-1', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 1' - }], - collections: observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), [mockCommunity1Collection1, mockCommunity1Collection2]))), - subcommunities: observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), subcommunities))), -}); - -const mockCommunity2 = Object.assign(new Community(), { - name: 'Community 2', - id: '123456789-2', - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 2' - }], - collections: observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), [mockCommunity2Collection1, mockCommunity2Collection2]))), - subcommunities: observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), []))), -}); - -const mockCommunity1Collection1Rd = observableOf(new RemoteData(true, true, true, - undefined, mockCommunity1Collection1)); - -const mockCommunityList = observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), [mockCommunity, mockCommunity2]))); - -const mockCommunityCollectionList = observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), [mockCommunity1Collection1, mockCommunity1Collection2]))); - -const mockCommunity2CollectionList = observableOf(new RemoteData(true, true, true, - undefined, new PaginatedList(new PageInfo(), [mockCommunity2Collection1, mockCommunity2Collection2]))); - -const mockCollectionList = [ - { - communities: [ - { - id: '123456789-1', - name: 'Community 1' - } - ], - collection: { - id: '1234567890-1', - name: 'Community 1-Collection 1' - } - }, - { - communities: [ - { - id: '123456789-1', - name: 'Community 1' - } - ], - collection: { - id: '1234567890-2', - name: 'Community 1-Collection 2' - } - }, - { - communities: [ - { - id: '123456789-2', - name: 'Community 2' - } - ], - collection: { - id: '1234567890-3', - name: 'Community 2-Collection 1' - } - }, - { - communities: [ - { - id: '123456789-2', - name: 'Community 2' - } - ], - collection: { - id: '1234567890-4', - name: 'Community 2-Collection 2' - } - } -]; - describe('SubmissionFormCollectionComponent Component', () => { let comp: SubmissionFormCollectionComponent; @@ -197,8 +30,6 @@ describe('SubmissionFormCollectionComponent Component', () => { const submissionId = mockSubmissionId; const collectionId = '1234567890-1'; const definition = 'traditional'; - const submissionRestResponse = mockSubmissionRestResponse; - const searchedCollection = 'Community 2-Collection 2'; const communityDataService: any = jasmine.createSpyObj('communityDataService', { findAll: jasmine.createSpy('findAll') @@ -299,72 +130,11 @@ describe('SubmissionFormCollectionComponent Component', () => { expect(compAsAny.pathCombiner).toEqual(expected); }); - it('should init collection list properly', () => { - communityDataService.findAll.and.returnValue(mockCommunityList); - collectionDataService.findById.and.returnValue(mockCommunity1Collection1Rd); - collectionDataService.getAuthorizedCollectionByCommunity.and.returnValues(mockCommunityCollectionList, mockCommunity2CollectionList); - - comp.ngOnChanges({ - currentCollectionId: new SimpleChange(null, collectionId, true) - }); - - expect(comp.searchListCollection$).toBeObservable(cold('(ab)', { - a: [], - b: mockCollectionList - })); - - expect(comp.selectedCollectionName$).toBeObservable(cold('(a|)', { - a: 'Community 1-Collection 1' - })); - }); - - it('should show only the searched collection', () => { - comp.searchListCollection$ = observableOf(mockCollectionList); - fixture.detectChanges(); - - comp.searchField.setValue(searchedCollection); - fixture.detectChanges(); - - comp.searchListCollection$.pipe( - filter(() => !comp.disabled$.getValue()) - ).subscribe((list) => { - expect(list).toEqual([mockCollectionList[3]]); - }); - - }); - - it('should emit collectionChange event when selecting a new collection', () => { - spyOn(comp.searchField, 'reset').and.callThrough(); - spyOn(comp.collectionChange, 'emit').and.callThrough(); - jsonPatchOpServiceStub.jsonPatchByResourceID.and.returnValue(observableOf(submissionRestResponse)); - comp.ngOnInit(); - comp.onSelect(mockCollectionList[1]); - fixture.detectChanges(); - - expect(comp.searchField.reset).toHaveBeenCalled(); - expect(comp.collectionChange.emit).toHaveBeenCalledWith(submissionRestResponse[0] as any); - expect(submissionServiceStub.changeSubmissionCollection).toHaveBeenCalled(); - expect(comp.selectedCollectionId).toBe(mockCollectionList[1].collection.id); - expect(comp.selectedCollectionName$).toBeObservable(cold('(a|)', { - a: mockCollectionList[1].collection.name - })); - - }); - - it('should reset searchField when dropdown menu has been closed', () => { - spyOn(comp.searchField, 'reset').and.callThrough(); - comp.toggled(false); - - expect(comp.searchField.reset).toHaveBeenCalled(); - }); - describe('', () => { let dropdowBtn: DebugElement; let dropdownMenu: DebugElement; beforeEach(() => { - - comp.searchListCollection$ = observableOf(mockCollectionList); fixture.detectChanges(); dropdowBtn = fixture.debugElement.query(By.css('#collectionControlsMenuButton')); dropdownMenu = fixture.debugElement.query(By.css('#collectionControlsDropdownMenu')); @@ -387,46 +157,6 @@ describe('SubmissionFormCollectionComponent Component', () => { fixture.whenStable().then(() => { expect(comp.onClose).toHaveBeenCalled(); expect(dropdownMenu.nativeElement.classList).toContain('show'); - expect(dropdownMenu.queryAll(By.css('.collection-item')).length).toBe(4); - }); - })); - - it('should trigger onSelect method when select a new collection from dropdown menu', fakeAsync(() => { - - spyOn(comp, 'onSelect'); - dropdowBtn.triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); - - const secondLink: DebugElement = dropdownMenu.query(By.css('.collection-item:nth-child(2)')); - secondLink.triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - - expect(comp.onSelect).toHaveBeenCalled(); - }); - })); - - it('should update searchField on input type', fakeAsync(() => { - - dropdowBtn.triggerEventHandler('click', null); - tick(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - const input = fixture.debugElement.query(By.css('input.form-control')); - const el = input.nativeElement; - - expect(el.value).toBe(''); - - el.value = searchedCollection; - el.dispatchEvent(new Event('input')); - - fixture.detectChanges(); - - expect(fixture.componentInstance.searchField.value).toEqual(searchedCollection); }); })); diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index f84764d6a4..691d93aed1 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -7,52 +7,27 @@ import { OnChanges, OnInit, Output, - SimpleChanges + SimpleChanges, + ViewChild } from '@angular/core'; -import { FormControl } from '@angular/forms'; -import { BehaviorSubject, combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { - debounceTime, - distinctUntilChanged, - filter, find, - flatMap, - map, - mergeMap, - reduce, - startWith + map } from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; -import { Community } from '../../../core/shared/community.model'; -import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RemoteData } from '../../../core/data/remote-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { PaginatedList } from '../../../core/data/paginated-list'; import { SubmissionService } from '../../submission.service'; import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { FindListOptions } from '../../../core/data/request.models'; - -/** - * An interface to represent a collection entry - */ -interface CollectionListEntryItem { - id: string; - name: string; -} - -/** - * An interface to represent an entry in the collection list - */ -interface CollectionListEntry { - communities: CollectionListEntryItem[], - collection: CollectionListEntryItem -} +import { CollectionDropdownComponent } from 'src/app/shared/collection-dropdown/collection-dropdown.component'; /** * This component allows to show the current collection the submission belonging to and to change it. @@ -100,18 +75,6 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ public processingChange$ = new BehaviorSubject(false); - /** - * The search form control - * @type {FormControl} - */ - public searchField: FormControl = new FormControl(); - - /** - * The collection list obtained from a search - * @type {Observable} - */ - public searchListCollection$: Observable; - /** * The selected collection id * @type {string} @@ -148,6 +111,11 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ private subs: Subscription[] = []; + /** + * The html child that contains the collections list + */ + @ViewChild(CollectionDropdownComponent, {static: false}) collectionDropdown: CollectionDropdownComponent; + /** * Initialize instance variables * @@ -204,51 +172,6 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { find((collectionRD: RemoteData) => isNotEmpty(collectionRD.payload)), map((collectionRD: RemoteData) => collectionRD.payload.name) ); - - const findOptions: FindListOptions = { - elementsPerPage: 1000 - }; - - // Retrieve collection list only when is the first change - if (changes.currentCollectionId.isFirstChange()) { - // @TODO replace with search/top browse endpoint - // @TODO implement community/subcommunity hierarchy - const communities$ = this.communityDataService.findAll(findOptions).pipe( - find((communities: RemoteData>) => isNotEmpty(communities.payload)), - mergeMap((communities: RemoteData>) => communities.payload.page)); - - const listCollection$ = communities$.pipe( - flatMap((communityData: Community) => { - return this.collectionDataService.getAuthorizedCollectionByCommunity(communityData.uuid, findOptions).pipe( - find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), - mergeMap((collections: RemoteData>) => collections.payload.page), - filter((collectionData: Collection) => isNotEmpty(collectionData)), - map((collectionData: Collection) => ({ - communities: [{ id: communityData.id, name: communityData.name }], - collection: { id: collectionData.id, name: collectionData.name } - })) - ); - }), - reduce((acc: any, value: any) => [...acc, ...value], []), - startWith([]) - ); - - const searchTerm$ = this.searchField.valueChanges.pipe( - debounceTime(200), - distinctUntilChanged(), - startWith('') - ); - - this.searchListCollection$ = combineLatest(searchTerm$, listCollection$).pipe( - map(([searchTerm, listCollection]) => { - this.disabled$.next(isEmpty(listCollection)); - if (isEmpty(searchTerm)) { - return listCollection; - } else { - return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5); - } - })); - } } } @@ -273,7 +196,6 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { * the selected [CollectionListEntryItem] */ onSelect(event) { - this.searchField.reset(); this.processingChange$.next(true); this.operationsBuilder.replace(this.pathCombiner.getPath(), event.collection.id, true); this.subs.push(this.operationsService.jsonPatchByResourceID( @@ -296,7 +218,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { * Reset search form control on dropdown menu close */ onClose() { - this.searchField.reset(); + this.collectionDropdown.reset(); } /** @@ -307,7 +229,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ toggled(isOpen: boolean) { if (!isOpen) { - this.searchField.reset(); + this.collectionDropdown.reset(); } } } From 5f0f665501825a7f873fb2af40b575fcb3101f1d Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Tue, 16 Jun 2020 15:51:56 +0200 Subject: [PATCH 26/59] [CTS-3090] - fix --- .../collection-dropdown/collection-dropdown.component.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index e9e0445ca6..cf69fc31a6 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -10,7 +10,7 @@ import { Community } from 'src/app/core/shared/community.model'; import { CollectionDataService } from 'src/app/core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { followLink } from '../utils/follow-link-config.model'; - +import { getFirstSucceededRemoteDataPayload, getAllSucceededRemoteData, getSucceededRemoteWithNotEmptyData } from '../../core/shared/operators'; /** * An interface to represent a collection entry */ @@ -178,17 +178,15 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { this.searchListCollection$ = this.collectionDataService .getAuthorizedCollection(query, findOptions, followLink('parentCommunity')) .pipe( - find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), + getSucceededRemoteWithNotEmptyData(), mergeMap((collections: RemoteData>) => { if ( (this.searchListCollection.length + findOptions.elementsPerPage) >= collections.payload.totalElements ) { this.hasNextPage = false; } return collections.payload.page; }), - filter((collectionData: Collection) => isNotEmpty(collectionData)), mergeMap((collection: Collection) => collection.parentCommunity.pipe( - find((communityResponse: RemoteData) => !communityResponse.isResponsePending && communityResponse.hasSucceeded), - mergeMap((communityResponse: RemoteData) => of(communityResponse.payload)), + getFirstSucceededRemoteDataPayload(), map((community: Community) => ({ communities: [{ id: community.id, name: community.name }], collection: { id: collection.id, uuid: collection.id, name: collection.name } From e277a72ebf6df6aa76eb382f9eb110bab785c3bb Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Tue, 16 Jun 2020 15:58:19 +0200 Subject: [PATCH 27/59] 71304: Fix spec descriptions --- .../register-email-form/register-email-form.component.spec.ts | 2 +- .../create-profile/create-profile.component.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/register-email-form/register-email-form.component.spec.ts b/src/app/register-email-form/register-email-form.component.spec.ts index af3c70dc33..8faaa4021d 100644 --- a/src/app/register-email-form/register-email-form.component.spec.ts +++ b/src/app/register-email-form/register-email-form.component.spec.ts @@ -64,7 +64,7 @@ describe('RegisterEmailComponent', () => { comp.form.patchValue({email: 'invalid'}); expect(comp.form.invalid).toBeTrue(); }); - it('should be invalid when no valid email is present', () => { + it('should be valid when a valid email is present', () => { comp.form.patchValue({email: 'valid@email.org'}); expect(comp.form.invalid).toBeFalse(); }); diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index f3017ba918..62a88d460a 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -160,7 +160,7 @@ describe('CreateProfileComponent', () => { expect(router.navigate).not.toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled(); }); - it('should submit not submit an eperson when the user info form is invalid', () => { + it('should submit not create an eperson when the user info form is invalid', () => { (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); @@ -175,7 +175,7 @@ describe('CreateProfileComponent', () => { expect(ePersonDataService.createEPersonForToken).not.toHaveBeenCalled(); }); - it('should submit not submit an eperson when the password is invalid', () => { + it('should submit not create an eperson when the password is invalid', () => { (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); From efc476ab312eb45924f84b5cc11f0b9ddf40a92d Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 16 Jun 2020 17:46:24 +0200 Subject: [PATCH 28/59] remove unused disabled$ variable --- .../collection/submission-form-collection.component.html | 2 +- .../form/collection/submission-form-collection.component.ts | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html index ad53be200c..98ec9e0576 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.html +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -10,7 +10,7 @@ class="btn btn-outline-primary" (blur)="onClose()" (click)="onClose()" - [disabled]="(disabled$ | async) || (processingChange$ | async)" + [disabled]="(processingChange$ | async)" ngbDropdownToggle> {{ selectedCollectionName$ | async }} diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 691d93aed1..613bf70ede 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -63,12 +63,6 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ @Output() collectionChange: EventEmitter = new EventEmitter(); - /** - * A boolean representing if this dropdown button is disabled - * @type {BehaviorSubject} - */ - public disabled$ = new BehaviorSubject(true); - /** * A boolean representing if a collection change operation is processing * @type {BehaviorSubject} From 294f5e5f3140128845a67eeb16faf012e9955b6b Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Wed, 17 Jun 2020 10:47:46 +0200 Subject: [PATCH 29/59] [CST-3090] fix services name and dropdown --- src/app/core/data/collection-data.service.ts | 6 +-- .../collection-dropdown.component.ts | 2 +- .../submission-form-collection.component.ts | 39 ------------------- 3 files changed, 4 insertions(+), 43 deletions(-) diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index d28421356a..41f70dd31c 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -78,7 +78,7 @@ export class CollectionDataService extends ComColDataService { * collection list */ getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - const searchHref = 'findAuthorized'; + const searchHref = 'findSubmitAuthorized'; options = Object.assign({}, options, { searchParams: [new RequestParam('query', query)] }); @@ -97,7 +97,7 @@ export class CollectionDataService extends ComColDataService { * collection list */ getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable>> { - const searchHref = 'findAuthorizedByCommunity'; + const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [ new RequestParam('uuid', communityId), @@ -116,7 +116,7 @@ export class CollectionDataService extends ComColDataService { * true if the user has at least one collection to submit to */ hasAuthorizedCollection(): Observable { - const searchHref = 'findAuthorized'; + const searchHref = 'findSubmitAuthorized'; const options = new FindListOptions(); options.elementsPerPage = 1; diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index cf69fc31a6..0bb3ebdad9 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -123,7 +123,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { startWith('') ).subscribe( (next) => { - if (hasValue(next)) { + if (hasValue(next) && next !== this.currentQuery) { this.resetPagination(); this.currentQuery = next; this.populateCollectionList(this.currentQuery, this.currentPage); diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 613bf70ede..d2f45e002c 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -87,18 +87,6 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ protected pathCombiner: JsonPatchOperationPathCombiner; - /** - * A boolean representing if dropdown list is scrollable to the bottom - * @type {boolean} - */ - private scrollableBottom = false; - - /** - * A boolean representing if dropdown list is scrollable to the top - * @type {boolean} - */ - private scrollableTop = false; - /** * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} @@ -121,39 +109,12 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { * @param {SubmissionService} submissionService */ constructor(protected cdr: ChangeDetectorRef, - private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, private operationsBuilder: JsonPatchOperationsBuilder, private operationsService: SubmissionJsonPatchOperationsService, private submissionService: SubmissionService) { } - /** - * Method called on mousewheel event, it prevent the page scroll - * when arriving at the top/bottom of dropdown menu - * - * @param event - * mousewheel event - */ - @HostListener('mousewheel', ['$event']) onMousewheel(event) { - if (event.wheelDelta > 0 && this.scrollableTop) { - event.preventDefault(); - } - if (event.wheelDelta < 0 && this.scrollableBottom) { - event.preventDefault(); - } - } - - /** - * Check if dropdown scrollbar is at the top or bottom of the dropdown list - * - * @param event - */ - onScroll(event) { - this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight); - this.scrollableTop = (event.target.scrollTop === 0); - } - /** * Initialize collection list */ From 752cf9778789d587b550b17f7cbd1641bbc3d6b9 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 17 Jun 2020 13:12:49 +0200 Subject: [PATCH 30/59] 71380: Fix tests --- .../item-bitstreams.component.spec.ts | 15 ++- .../object-updates.reducer.spec.ts | 74 +----------- .../object-updates.service.spec.ts | 113 +----------------- ...nated-drag-and-drop-list.component.spec.ts | 79 +++--------- 4 files changed, 38 insertions(+), 243 deletions(-) diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index cc1ec39bad..5aa085a42c 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -188,8 +188,21 @@ describe('ItemBitstreamsComponent', () => { it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => { expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id); }); + }); - it('should send out a patch for the move operations', () => { + describe('when dropBitstream is called', () => { + const event = { + fromIndex: 0, + toIndex: 50, + // tslint:disable-next-line:no-empty + finish: () => {} + }; + + beforeEach(() => { + comp.dropBitstream(bundle, event); + }); + + it('should send out a patch for the move operation', () => { expect(bundleService.patch).toHaveBeenCalled(); }); }); 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 bdf202049e..cb7f44039c 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 @@ -1,9 +1,9 @@ import * as deepFreeze from 'deep-freeze'; import { - AddFieldUpdateAction, AddPageToCustomOrderAction, + AddFieldUpdateAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, MoveFieldUpdateAction, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction @@ -85,16 +85,6 @@ describe('objectUpdatesReducer', () => { virtualMetadataSources: { [relationship.uuid]: {[identifiable1.uuid]: true} }, - customOrder: { - initialOrderPages: [ - { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } - ], - newOrderPages: [ - { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } - ], - pageSize: 10, - changed: false - } } }; @@ -121,16 +111,6 @@ describe('objectUpdatesReducer', () => { virtualMetadataSources: { [relationship.uuid]: {[identifiable1.uuid]: true} }, - customOrder: { - initialOrderPages: [ - { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } - ], - newOrderPages: [ - { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } - ], - pageSize: 10, - changed: false - } }, [url + OBJECT_UPDATES_TRASH_PATH]: { fieldStates: { @@ -165,16 +145,6 @@ describe('objectUpdatesReducer', () => { virtualMetadataSources: { [relationship.uuid]: {[identifiable1.uuid]: true} }, - customOrder: { - initialOrderPages: [ - { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } - ], - newOrderPages: [ - { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } - ], - pageSize: 10, - changed: false - } } }; @@ -243,7 +213,7 @@ describe('objectUpdatesReducer', () => { }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { - const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0); + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); const expectedState = { [url]: { @@ -261,17 +231,7 @@ describe('objectUpdatesReducer', () => { }, fieldUpdates: {}, virtualMetadataSources: {}, - lastModified: modDate, - customOrder: { - initialOrderPages: [ - { order: [identifiable1.uuid, identifiable3.uuid] } - ], - newOrderPages: [ - { order: [identifiable1.uuid, identifiable3.uuid] } - ], - pageSize: 10, - changed: false - } + lastModified: modDate } }; const newState = objectUpdatesReducer(testState, action); @@ -337,30 +297,4 @@ describe('objectUpdatesReducer', () => { const newState = objectUpdatesReducer(testState, action); expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); }); - - it('should move the custom order from the state when the MOVE action is dispatched', () => { - const action = new MoveFieldUpdateAction(url, 0, 1, 0, 0); - - const newState = objectUpdatesReducer(testState, action); - expect(newState[url].customOrder.newOrderPages[0].order[0]).toEqual(testState[url].customOrder.newOrderPages[0].order[1]); - expect(newState[url].customOrder.newOrderPages[0].order[1]).toEqual(testState[url].customOrder.newOrderPages[0].order[0]); - expect(newState[url].customOrder.changed).toEqual(true); - }); - - it('should add a new page to the custom order and add empty pages in between when the ADD_PAGE_TO_CUSTOM_ORDER action is dispatched', () => { - const identifiable4 = { - uuid: 'a23eae5a-7857-4ef9-8e52-989436ad2955', - key: 'dc.description.abstract', - language: null, - value: 'Extra value' - }; - const action = new AddPageToCustomOrderAction(url, [identifiable4], [identifiable4.uuid], 2); - - const newState = objectUpdatesReducer(testState, action); - // Confirm the page in between the two pages (index 1) has been filled with 10 (page size) undefined values - expect(newState[url].customOrder.newOrderPages[1].order.length).toEqual(10); - expect(newState[url].customOrder.newOrderPages[1].order[0]).toBeUndefined(); - // Verify the new page is correct - expect(newState[url].customOrder.newOrderPages[2].order[0]).toEqual(identifiable4.uuid); - }); }); 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 780a402a84..04018b8de2 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 @@ -2,7 +2,6 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; import { ObjectUpdatesService } from './object-updates.service'; import { - AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, @@ -13,8 +12,6 @@ import { Notification } from '../../../shared/notifications/models/notification. import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import {Relationship} from '../../shared/item-relationships/relationship.model'; -import { MoveOperation } from 'fast-json-patch/lib/core'; -import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -47,7 +44,7 @@ describe('ObjectUpdatesService', () => { }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer()); + service = new ObjectUpdatesService(store); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -63,25 +60,6 @@ describe('ObjectUpdatesService', () => { }); }); - describe('initializeWithCustomOrder', () => { - const pageSize = 20; - const page = 0; - - it('should dispatch an INITIALIZE action with the correct URL, initial identifiables, last modified , custom order, page size and page', () => { - service.initializeWithCustomOrder(url, identifiables, modDate, pageSize, page); - expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate, identifiables.map((identifiable) => identifiable.uuid), pageSize, page)); - }); - }); - - describe('addPageToCustomOrder', () => { - const page = 2; - - it('should dispatch an ADD_PAGE_TO_CUSTOM_ORDER action with the correct URL, identifiables, custom order and page number to add', () => { - service.addPageToCustomOrder(url, identifiables, page); - expect(store.dispatch).toHaveBeenCalledWith(new AddPageToCustomOrderAction(url, identifiables, identifiables.map((identifiable) => identifiable.uuid), page)); - }); - }); - describe('getFieldUpdates', () => { it('should return the list of all fields, including their update if there is one', () => { const result$ = service.getFieldUpdates(url, identifiables); @@ -116,49 +94,6 @@ describe('ObjectUpdatesService', () => { }); }); - describe('getFieldUpdatesByCustomOrder', () => { - beforeEach(() => { - const fieldStates = { - [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, - [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, - [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, - }; - - const customOrder = { - initialOrderPages: [{ - order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] - }], - newOrderPages: [{ - order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid] - }], - pageSize: 20, - changed: true - }; - - const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder - }; - - (service as any).getObjectEntry.and.returnValue(observableOf(objectEntry)) - }); - - it('should return the list of all fields, including their update if there is one, ordered by their custom order', (done) => { - const result$ = service.getFieldUpdatesByCustomOrder(url, identifiables); - expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); - - const expectedResult = { - [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, - [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE } - }; - - result$.subscribe((result) => { - expect(result).toEqual(expectedResult); - done(); - }); - }); - }); - describe('isEditable', () => { it('should return false if this identifiable is currently not editable in the store', () => { const result$ = service.isEditable(url, identifiable1.uuid); @@ -274,11 +209,7 @@ describe('ObjectUpdatesService', () => { }); describe('when updates are emtpy', () => { beforeEach(() => { - (service as any).getObjectEntry.and.returnValue(observableOf({ - customOrder: { - changed: false - } - })) + (service as any).getObjectEntry.and.returnValue(observableOf({})) }); it('should return false when there are no updates', () => { @@ -346,44 +277,4 @@ describe('ObjectUpdatesService', () => { }); }); - describe('getMoveOperations', () => { - beforeEach(() => { - const fieldStates = { - [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, - [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, - [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, - }; - - const customOrder = { - initialOrderPages: [{ - order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] - }], - newOrderPages: [{ - order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid] - }], - pageSize: 20, - changed: true - }; - - const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder - }; - - (service as any).getObjectEntry.and.returnValue(observableOf(objectEntry)) - }); - - it('should return the expected move operations', (done) => { - const result$ = service.getMoveOperations(url); - - const expectedResult = [ - { op: 'move', from: '/0', path: '/2' } - ] as MoveOperation[]; - - result$.subscribe((result) => { - expect(result).toEqual(expectedResult); - done(); - }); - }); - }); - }); diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts index 9fcfd21586..0c55afb2e0 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts @@ -52,10 +52,8 @@ describe('AbstractPaginatedDragAndDropListComponent', () => { beforeEach(() => { objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { - initializeWithCustomOrder: {}, - addPageToCustomOrder: {}, - getFieldUpdatesByCustomOrder: observableOf(updates), - saveMoveFieldUpdate: {} + initialize: {}, + getFieldUpdatesExclusive: observableOf(updates) }); elRef = { nativeElement: jasmine.createSpyObj('nativeElement', { @@ -71,13 +69,8 @@ describe('AbstractPaginatedDragAndDropListComponent', () => { component.ngOnInit(); }); - it('should call initializeWithCustomOrder to initialize the first page and add it to initializedPages', (done) => { - expect(component.initializedPages.indexOf(0)).toBeLessThan(0); - component.updates$.pipe(take(1)).subscribe(() => { - expect(objectUpdatesService.initializeWithCustomOrder).toHaveBeenCalled(); - expect(component.initializedPages.indexOf(0)).toBeGreaterThanOrEqual(0); - done(); - }); + it('should call initialize to initialize the objects in the store', () => { + expect(objectUpdatesService.initialize).toHaveBeenCalled(); }); it('should initialize the updates correctly', (done) => { @@ -87,43 +80,6 @@ describe('AbstractPaginatedDragAndDropListComponent', () => { }); }); - describe('when a new page is loaded', () => { - const page = 5; - - beforeEach((done) => { - component.updates$.pipe(take(1)).subscribe(() => { - component.currentPage$.next(page); - objectsRD$.next(objectsRD); - done(); - }); - }); - - it('should call addPageToCustomOrder to initialize the new page and add it to initializedPages', (done) => { - component.updates$.pipe(take(1)).subscribe(() => { - expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalled(); - expect(component.initializedPages.indexOf(page - 1)).toBeGreaterThanOrEqual(0); - done(); - }); - }); - - describe('twice', () => { - beforeEach((done) => { - component.updates$.pipe(take(1)).subscribe(() => { - component.currentPage$.next(page); - objectsRD$.next(objectsRD); - done(); - }); - }); - - it('shouldn\'t call addPageToCustomOrder again, as the page has already been initialized', (done) => { - component.updates$.pipe(take(1)).subscribe(() => { - expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalledTimes(1); - done(); - }); - }); - }); - }); - describe('switchPage', () => { const page = 3; @@ -149,30 +105,31 @@ describe('AbstractPaginatedDragAndDropListComponent', () => { beforeEach(() => { elRef.nativeElement.querySelector.and.returnValue(hoverElement); - component.initializedPages.push(hoverPage - 1); + spyOn(component.dropObject, 'emit'); component.drop(event); }); - it('should detect the page and set currentPage$ to its value', () => { - expect(component.currentPage$.value).toEqual(hoverPage); - }); - - it('should detect the page and update the pagination component with its value', () => { - expect(paginationComponent.doPageChange).toHaveBeenCalledWith(hoverPage); - }); - - it('should send out a saveMoveFieldUpdate with the correct values', () => { - expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, 0, 0, hoverPage - 1, object1); + it('should send out a dropObject event with the expected processed paginated indexes', () => { + expect(component.dropObject.emit).toHaveBeenCalledWith(Object.assign({ + fromIndex: ((component.currentPage$.value - 1) * component.pageSize) + event.previousIndex, + toIndex: ((hoverPage - 1) * component.pageSize), + finish: jasmine.anything() + })); }); }); describe('when the user is not hovering over a new page', () => { beforeEach(() => { + spyOn(component.dropObject, 'emit'); component.drop(event); }); - it('should send out a saveMoveFieldUpdate with the correct values', () => { - expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, event.currentIndex, 0, 0); + it('should send out a dropObject event with the expected properties', () => { + expect(component.dropObject.emit).toHaveBeenCalledWith(Object.assign({ + fromIndex: event.previousIndex, + toIndex: event.currentIndex, + finish: jasmine.anything() + })); }); }); }); From 3b581b93c5e7ae90e173ebb587033cfb0f27488c Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Wed, 17 Jun 2020 16:31:11 +0200 Subject: [PATCH 31/59] [CST-3090] fix --- .../collection-dropdown.component.html | 4 +-- .../collection-dropdown.component.ts | 33 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html index 0674084a43..36269294c1 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.html +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.html @@ -20,7 +20,7 @@ [infiniteScrollContainer]="'.scrollable-menu'" [fromRoot]="true" (scrolled)="onScrollDown()"> - - diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 0bb3ebdad9..0c36d40ff6 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -1,8 +1,8 @@ import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit, AfterViewChecked } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { Observable, of, Subscription } from 'rxjs'; +import { Observable, of, Subscription, BehaviorSubject } from 'rxjs'; import { hasValue, isNotEmpty } from '../empty.util'; -import { find, map, mergeMap, filter, reduce, startWith, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { find, map, mergeMap, filter, reduce, startWith, debounceTime, distinctUntilChanged, switchMap, merge, scan } from 'rxjs/operators'; import { RemoteData } from 'src/app/core/data/remote-data'; import { FindListOptions } from 'src/app/core/data/request.models'; import { PaginatedList } from 'src/app/core/data/paginated-list'; @@ -11,6 +11,8 @@ import { CollectionDataService } from 'src/app/core/data/collection-data.service import { Collection } from '../../core/shared/collection.model'; import { followLink } from '../utils/follow-link-config.model'; import { getFirstSucceededRemoteDataPayload, getAllSucceededRemoteData, getSucceededRemoteWithNotEmptyData } from '../../core/shared/operators'; +import { constructor } from 'lodash'; +import { query } from '@angular/animations'; /** * An interface to represent a collection entry */ @@ -74,7 +76,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { /** * A boolean representing if the loader is visible or not */ - isLoadingList: boolean; + isLoadingList: BehaviorSubject = new BehaviorSubject(false); /** * A numeric representig current page @@ -91,6 +93,8 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { */ currentQuery: string; + hideLoaderWhenUnsubscribed$ = new Observable(() => () => this.hideShowLoader(false) ); + constructor( private changeDetectorRef: ChangeDetectorRef, private collectionDataService: CollectionDataService, @@ -118,7 +122,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { */ ngOnInit() { this.subs.push(this.searchField.valueChanges.pipe( - debounceTime(200), + debounceTime(300), distinctUntilChanged(), startWith('') ).subscribe( @@ -168,8 +172,8 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { * @param query text for filter the collection list * @param page page number */ - populateCollectionList(query?: string, page?: number) { - this.isLoadingList = true; + populateCollectionList(query: string, page: number) { + this.isLoadingList.next(true); // Set the pagination info const findOptions: FindListOptions = { elementsPerPage: 10, @@ -179,7 +183,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { .getAuthorizedCollection(query, findOptions, followLink('parentCommunity')) .pipe( getSucceededRemoteWithNotEmptyData(), - mergeMap((collections: RemoteData>) => { + switchMap((collections: RemoteData>) => { if ( (this.searchListCollection.length + findOptions.elementsPerPage) >= collections.payload.totalElements ) { this.hasNextPage = false; } @@ -192,12 +196,13 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { collection: { id: collection.id, uuid: collection.id, name: collection.name } }) ))), - reduce((acc: any, value: any) => [...acc, ...value], []), - startWith([]) + scan((acc: any, value: any) => [...acc, ...value], []), + startWith([]), + merge(this.hideLoaderWhenUnsubscribed$) ); this.subs.push(this.searchListCollection$.subscribe( (next) => { this.searchListCollection.push(...next); }, undefined, - () => { this.isLoadingList = false; this.changeDetectorRef.detectChanges(); } + () => { this.hideShowLoader(false); this.changeDetectorRef.detectChanges(); } )); } @@ -224,4 +229,12 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { this.hasNextPage = true; this.searchListCollection = []; } + + /** + * Hide/Show the collection list loader + * @param hideShow true for show, false otherwise + */ + hideShowLoader(hideShow: boolean) { + this.isLoadingList.next(hideShow); + } } From be2d49633533323551302aca1daea34269b5ce19 Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Wed, 17 Jun 2020 17:02:27 +0200 Subject: [PATCH 32/59] [CTS-3090] update imports --- .../collection-dropdown.component.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 0c36d40ff6..fb044ce073 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, EventEmitter, ViewChild, ElementRef, AfterViewInit, AfterViewChecked } from '@angular/core'; +import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, EventEmitter, ElementRef } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { Observable, of, Subscription, BehaviorSubject } from 'rxjs'; -import { hasValue, isNotEmpty } from '../empty.util'; -import { find, map, mergeMap, filter, reduce, startWith, debounceTime, distinctUntilChanged, switchMap, merge, scan } from 'rxjs/operators'; +import { Observable, Subscription, BehaviorSubject } from 'rxjs'; +import { hasValue } from '../empty.util'; +import { map, mergeMap, startWith, debounceTime, distinctUntilChanged, switchMap, merge, scan } from 'rxjs/operators'; import { RemoteData } from 'src/app/core/data/remote-data'; import { FindListOptions } from 'src/app/core/data/request.models'; import { PaginatedList } from 'src/app/core/data/paginated-list'; @@ -10,9 +10,8 @@ import { Community } from 'src/app/core/shared/community.model'; import { CollectionDataService } from 'src/app/core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { followLink } from '../utils/follow-link-config.model'; -import { getFirstSucceededRemoteDataPayload, getAllSucceededRemoteData, getSucceededRemoteWithNotEmptyData } from '../../core/shared/operators'; -import { constructor } from 'lodash'; -import { query } from '@angular/animations'; +import { getFirstSucceededRemoteDataPayload, getSucceededRemoteWithNotEmptyData } from '../../core/shared/operators'; + /** * An interface to represent a collection entry */ From f33f391eb39ef7e918899e23a77b656ad54cda7b Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Thu, 18 Jun 2020 10:23:56 +0200 Subject: [PATCH 33/59] [CTS-3090] - fix pagination --- .../collection-dropdown.component.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index fb044ce073..0986badf45 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, import { FormControl } from '@angular/forms'; import { Observable, Subscription, BehaviorSubject } from 'rxjs'; import { hasValue } from '../empty.util'; -import { map, mergeMap, startWith, debounceTime, distinctUntilChanged, switchMap, merge, scan } from 'rxjs/operators'; +import { map, mergeMap, startWith, debounceTime, distinctUntilChanged, switchMap, merge, scan, reduce } from 'rxjs/operators'; import { RemoteData } from 'src/app/core/data/remote-data'; import { FindListOptions } from 'src/app/core/data/request.models'; import { PaginatedList } from 'src/app/core/data/paginated-list'; @@ -92,8 +92,6 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { */ currentQuery: string; - hideLoaderWhenUnsubscribed$ = new Observable(() => () => this.hideShowLoader(false) ); - constructor( private changeDetectorRef: ChangeDetectorRef, private collectionDataService: CollectionDataService, @@ -121,7 +119,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { */ ngOnInit() { this.subs.push(this.searchField.valueChanges.pipe( - debounceTime(300), + debounceTime(500), distinctUntilChanged(), startWith('') ).subscribe( @@ -195,9 +193,8 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { collection: { id: collection.id, uuid: collection.id, name: collection.name } }) ))), - scan((acc: any, value: any) => [...acc, ...value], []), - startWith([]), - merge(this.hideLoaderWhenUnsubscribed$) + reduce((acc: any, value: any) => [...acc, ...value], []), + startWith([]) ); this.subs.push(this.searchListCollection$.subscribe( (next) => { this.searchListCollection.push(...next); }, undefined, From 136e36afab428d104dcb8e95d5aba0cbbb7b9958 Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Thu, 18 Jun 2020 11:30:45 +0200 Subject: [PATCH 34/59] [CST-3105] done --- .../submission-form-collection.component.html | 17 ++++++++++++++++- .../submission-form-collection.component.ts | 11 ++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/submission/form/collection/submission-form-collection.component.html b/src/app/submission/form/collection/submission-form-collection.component.html index 98ec9e0576..d897cc31fd 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.html +++ b/src/app/submission/form/collection/submission-form-collection.component.html @@ -1,5 +1,20 @@
-
+
+
+ {{ 'submission.sections.general.collection' | translate }} +
+
+ {{ selectedCollectionName$ | async }} +
+
+
{{ 'submission.sections.general.collection' | translate }} diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index d2f45e002c..6517be7101 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -28,6 +28,7 @@ import { SubmissionObject } from '../../../core/submission/models/submission-obj import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; import { CollectionDataService } from '../../../core/data/collection-data.service'; import { CollectionDropdownComponent } from 'src/app/shared/collection-dropdown/collection-dropdown.component'; +import { SectionsService } from '../../sections/sections.service'; /** * This component allows to show the current collection the submission belonging to and to change it. @@ -98,6 +99,12 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ @ViewChild(CollectionDropdownComponent, {static: false}) collectionDropdown: CollectionDropdownComponent; + /** + * A boolean representing if the collection section is available + * @type {BehaviorSubject} + */ + available$: Observable; + /** * Initialize instance variables * @@ -112,7 +119,8 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { private collectionDataService: CollectionDataService, private operationsBuilder: JsonPatchOperationsBuilder, private operationsService: SubmissionJsonPatchOperationsService, - private submissionService: SubmissionService) { + private submissionService: SubmissionService, + private sectionsService: SectionsService) { } /** @@ -135,6 +143,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { */ ngOnInit() { this.pathCombiner = new JsonPatchOperationPathCombiner('sections', 'collection'); + this.available$ = this.sectionsService.isSectionAvailable(this.submissionId, 'collection'); } /** From 709726e04122d62db1afda4df0aa43850b31808f Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Thu, 18 Jun 2020 12:00:30 +0200 Subject: [PATCH 35/59] [CST-3105] tests --- ...bmission-form-collection.component.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index 5baa1013ab..cfdc2e2406 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -18,8 +18,12 @@ import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/jso import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { createTestComponent } from '../../../shared/testing/utils.test'; import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { hot } from 'jasmine-marbles'; +import { of } from 'rxjs'; +import { SectionsService } from '../../sections/sections.service'; +import { componentFactoryName } from '@angular/compiler'; -describe('SubmissionFormCollectionComponent Component', () => { +fdescribe('SubmissionFormCollectionComponent Component', () => { let comp: SubmissionFormCollectionComponent; let compAsAny: any; @@ -48,6 +52,10 @@ describe('SubmissionFormCollectionComponent Component', () => { replace: jasmine.createSpy('replace') }); + const sectionsService: any = jasmine.createSpyObj('sectionsService', { + isSectionAvailable: of(true) + }); + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ @@ -67,6 +75,7 @@ describe('SubmissionFormCollectionComponent Component', () => { { provide: CommunityDataService, useValue: communityDataService }, { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, { provide: Store, useValue: store }, + { provide: SectionsService, useValue: sectionsService }, ChangeDetectorRef, SubmissionFormCollectionComponent ], @@ -160,6 +169,17 @@ describe('SubmissionFormCollectionComponent Component', () => { }); })); + it('the dropdown menu should be enable', () => { + const dropDown = fixture.debugElement.query(By.css('#collectionControlsDropdownMenu')); + expect(dropDown).toBeTruthy(); + }); + + it('the dropdown menu should be disabled', () => { + comp.available$ = of(false); + fixture.detectChanges(); + const dropDown = fixture.debugElement.query(By.css('#collectionControlsDropdownMenu')); + expect(dropDown).toBeFalsy(); + }); }); }); From 82a3014af4f6f5ee81ea2d787d02b023ecb0c84e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 18 Jun 2020 12:11:20 +0200 Subject: [PATCH 36/59] 71380: drag-and-drop-list customOrder to avoid elements hopping back after drop + freeze fix --- .../edit-item-page/edit-item-page.module.ts | 4 +- .../item-bitstreams.component.ts | 31 ++++---- ...rag-and-drop-bitstream-list.component.html | 39 ++++++---- ...-and-drop-bitstream-list.component.spec.ts | 8 +- ...-drag-and-drop-bitstream-list.component.ts | 4 +- .../object-updates/object-updates.service.ts | 4 +- ...nated-drag-and-drop-list.component.spec.ts | 8 +- ...-paginated-drag-and-drop-list.component.ts | 77 ++++++++++++++++--- 8 files changed, 126 insertions(+), 49 deletions(-) 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 acb23fe592..44cb4099f0 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 @@ -32,6 +32,7 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ObjectValuesPipe } from '../../shared/utils/object-values-pipe'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -75,7 +76,8 @@ import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/cr ResourcePolicyCreateComponent, ], providers: [ - BundleDataService + BundleDataService, + ObjectValuesPipe ] }) export class EditItemPageModule { 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 index 40602e0969..35da302961 100644 --- 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 @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { Observable } from 'rxjs/internal/Observable'; @@ -88,7 +88,8 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme public objectCache: ObjectCacheService, public requestService: RequestService, public cdRef: ChangeDetectorRef, - public bundleService: BundleDataService + public bundleService: BundleDataService, + public zone: NgZone ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, route); } @@ -187,18 +188,20 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme * @param event The event containing the index the bitstream came from and was dropped to */ dropBitstream(bundle: Bundle, event: any) { - if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { - const moveOperation = Object.assign({ - op: 'move', - from: `/_links/bitstreams/${event.fromIndex}/href`, - path: `/_links/bitstreams/${event.toIndex}/href` - }); - this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RestResponse) => { - this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); - this.requestService.removeByHrefSubstring(bundle.self); - event.finish(); - }); - } + this.zone.runOutsideAngular(() => { + if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { + const moveOperation = Object.assign({ + op: 'move', + from: `/_links/bitstreams/${event.fromIndex}/href`, + path: `/_links/bitstreams/${event.toIndex}/href` + }); + this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RestResponse) => { + this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); + this.requestService.removeByHrefSubstring(bundle.self); + this.zone.run(() => event.finish()); + }); + } + }); } /** diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html index 25941f472e..9197b89796 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html @@ -7,24 +7,29 @@ [collectionSize]="(objectsRD$ | async)?.payload?.totalElements" [disableRouteParameterUpdate]="true" (pageChange)="switchPage($event)"> -
-
+
+ +
- -
- + +
+ +
+
-
+
-
+ + diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts index 03d1d00520..54171ed8af 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts @@ -22,6 +22,7 @@ describe('PaginatedDragAndDropBitstreamListComponent', () => { let fixture: ComponentFixture; let objectUpdatesService: ObjectUpdatesService; let bundleService: BundleDataService; + let objectValuesPipe: ObjectValuesPipe; const columnSizes = new ResponsiveTableSizes([ new ResponsiveColumnSizes(2, 2, 3, 4, 4), @@ -100,12 +101,15 @@ describe('PaginatedDragAndDropBitstreamListComponent', () => { getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])) }); + objectValuesPipe = new ObjectValuesPipe(); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective, ObjectValuesPipe], + declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: BundleDataService, useValue: bundleService } + { provide: BundleDataService, useValue: bundleService }, + { provide: ObjectValuesPipe, useValue: objectValuesPipe } ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts index 5548da4029..19cf3b27e4 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts @@ -8,6 +8,7 @@ import { switchMap } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-search-options.model'; import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; @Component({ selector: 'ds-paginated-drag-and-drop-bitstream-list', @@ -33,8 +34,9 @@ export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginate constructor(protected objectUpdatesService: ObjectUpdatesService, protected elRef: ElementRef, + protected objectValuesPipe: ObjectValuesPipe, protected bundleService: BundleDataService) { - super(objectUpdatesService, elRef); + super(objectUpdatesService, elRef, objectValuesPipe); } ngOnInit() { 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 779a22fb5b..84f0f06035 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -24,7 +24,7 @@ import { SetValidFieldUpdateAction } from './object-updates.actions'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; function objectUpdatesStateSelector(): MemoizedSelector { @@ -125,7 +125,7 @@ export class ObjectUpdatesService { */ getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable { const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe(map((objectEntry) => { + return objectUpdates.pipe(isNotEmptyOperator(), map((objectEntry) => { const fieldUpdates: FieldUpdates = {}; for (const object of initialFields) { let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts index 0c55afb2e0..5fd7f3ec56 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts @@ -12,14 +12,16 @@ import { take } from 'rxjs/operators'; import { PaginationComponent } from '../pagination/pagination.component'; import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; import { createPaginatedList } from '../testing/utils.test'; +import { ObjectValuesPipe } from '../utils/object-values-pipe'; class MockAbstractPaginatedDragAndDropListComponent extends AbstractPaginatedDragAndDropListComponent { constructor(protected objectUpdatesService: ObjectUpdatesService, protected elRef: ElementRef, + protected objectValuesPipe: ObjectValuesPipe, protected mockUrl: string, protected mockObjectsRD$: Observable>>) { - super(objectUpdatesService, elRef); + super(objectUpdatesService, elRef, objectValuesPipe); } initializeObjectsRD(): void { @@ -35,6 +37,7 @@ describe('AbstractPaginatedDragAndDropListComponent', () => { let component: MockAbstractPaginatedDragAndDropListComponent; let objectUpdatesService: ObjectUpdatesService; let elRef: ElementRef; + let objectValuesPipe: ObjectValuesPipe; const url = 'mock-abstract-paginated-drag-and-drop-list-component'; @@ -60,11 +63,12 @@ describe('AbstractPaginatedDragAndDropListComponent', () => { querySelector: {} }) }; + objectValuesPipe = new ObjectValuesPipe(); paginationComponent = jasmine.createSpyObj('paginationComponent', { doPageChange: {} }); objectsRD$ = new BehaviorSubject(objectsRD); - component = new MockAbstractPaginatedDragAndDropListComponent(objectUpdatesService, elRef, url, objectsRD$); + component = new MockAbstractPaginatedDragAndDropListComponent(objectUpdatesService, elRef, objectValuesPipe, url, objectsRD$); component.paginationComponent = paginationComponent; component.ngOnInit(); }); diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index 7f94a5eaa5..37279bcfed 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -1,17 +1,26 @@ -import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { FieldUpdate, FieldUpdates, Identifiable } from '../../core/data/object-updates/object-updates.reducer'; import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; -import { switchMap, take } from 'rxjs/operators'; +import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../empty.util'; import { paginatedListToArray } from '../../core/shared/operators'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { CdkDragDrop } from '@angular/cdk/drag-drop'; -import { ElementRef, EventEmitter, Output, ViewChild } from '@angular/core'; +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { ElementRef, EventEmitter, OnDestroy, Output, ViewChild } from '@angular/core'; import { PaginationComponent } from '../pagination/pagination.component'; +import { ObjectValuesPipe } from '../utils/object-values-pipe'; +import { compareArraysUsing } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { Subscription } from 'rxjs/internal/Subscription'; + +/** + * Operator used for comparing {@link FieldUpdate}s by their field's UUID + */ +export const compareArraysUsingFieldUuids = () => + compareArraysUsing((fieldUpdate: FieldUpdate) => (hasValue(fieldUpdate) && hasValue(fieldUpdate.field)) ? fieldUpdate.field.uuid : undefined); /** * An abstract component containing general methods and logic to be able to drag and drop objects within a paginated @@ -29,7 +38,7 @@ import { PaginationComponent } from '../pagination/pagination.component'; * * An example component extending from this abstract component: PaginatedDragAndDropBitstreamListComponent */ -export abstract class AbstractPaginatedDragAndDropListComponent { +export abstract class AbstractPaginatedDragAndDropListComponent implements OnDestroy { /** * A view on the child pagination component */ @@ -57,10 +66,16 @@ export abstract class AbstractPaginatedDragAndDropListComponent; + /** + * A list of object UUIDs + * This is the order the objects will be displayed in + */ + customOrder: string[]; + /** * The amount of objects to display per page */ - pageSize = 10; + pageSize = 3; /** * The page options to use for fetching the objects @@ -77,8 +92,21 @@ export abstract class AbstractPaginatedDragAndDropListComponent(1); + /** + * Whether or not we should display a loading animation + * This is used to display a loading page when the user drops a bitstream onto a new page. The loading animation + * should stop once the bitstream has moved to the new page and the new page's response has loaded + */ + loading$: BehaviorSubject = new BehaviorSubject(false); + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + protected constructor(protected objectUpdatesService: ObjectUpdatesService, - protected elRef: ElementRef) { + protected elRef: ElementRef, + protected objectValuesPipe: ObjectValuesPipe) { } /** @@ -114,6 +142,14 @@ export abstract class AbstractPaginatedDragAndDropListComponent this.objectUpdatesService.getFieldUpdatesExclusive(this.url, objects)) ); + this.subs.push( + this.updates$.pipe( + map((fieldUpdates) => this.objectValuesPipe.transform(fieldUpdates)), + distinctUntilChanged(compareArraysUsingFieldUuids()) + ).subscribe((updateValues) => { + this.customOrder = updateValues.map((fieldUpdate) => fieldUpdate.field.uuid); + }) + ); } /** @@ -148,19 +184,40 @@ export abstract class AbstractPaginatedDragAndDropListComponent { - this.currentPage$.next(redirectPage); - this.paginationComponent.doPageChange(redirectPage); + if (isNewPage) { + this.currentPage$.next(redirectPage); + this.loading$.next(false); + } } })); } } + + /** + * unsub all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } } From 28891211e49b87ccbec20a48be86270a462d7eb8 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 18 Jun 2020 12:13:32 +0200 Subject: [PATCH 37/59] 71380: Reset page size --- .../abstract-paginated-drag-and-drop-list.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index 37279bcfed..3d249b7393 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -75,7 +75,7 @@ export abstract class AbstractPaginatedDragAndDropListComponent Date: Thu, 18 Jun 2020 12:25:50 +0200 Subject: [PATCH 38/59] Misc edit community and collection bugs - repair create top level community --- .../create-comcol-page/create-comcol-page.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index a8d6499cbd..4a7cd9afb1 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -77,7 +77,8 @@ export class CreateComColPageComponent implements const uploader = event.uploader; this.parentUUID$.pipe(take(1)).subscribe((uuid: string) => { - this.dsoDataService.create(dso, new RequestParam('parent', uuid)) + const params = uuid ? [new RequestParam('parent', uuid)] : []; + this.dsoDataService.create(dso, ...params) .pipe(getSucceededRemoteData()) .subscribe((dsoRD: RemoteData) => { if (isNotUndefined(dsoRD)) { From f841e45019057b1652e94cca44b3f8c45c99c535 Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Thu, 18 Jun 2020 15:38:03 +0200 Subject: [PATCH 39/59] [CST-3090] fix tests --- ...my-dspace-new-submission.component.spec.ts | 28 +++++- .../core/data/collection-data.service.spec.ts | 87 ++++++++++++++++++- .../collection-dropdown.component.spec.ts | 43 ++++++++- .../collection-dropdown.component.ts | 2 +- ...bmission-form-collection.component.spec.ts | 85 +++++++++++++++++- 5 files changed, 237 insertions(+), 8 deletions(-) diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index ac9eea6c0c..16b50d18f0 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, inject, TestBed, tick, fakeAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { Store } from '@ngrx/store'; @@ -21,6 +21,8 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { SharedModule } from '../../shared/shared.module'; import { getMockScrollToService } from '../../shared/mocks/scroll-to-service.mock'; import { UploaderService } from '../../shared/uploader/uploader.service'; +import { By } from '@angular/platform-browser'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -54,6 +56,11 @@ describe('MyDSpaceNewSubmissionComponent test', () => { { provide: ScrollToService, useValue: getMockScrollToService() }, { provide: Store, useValue: store }, { provide: TranslateService, useValue: translateService }, + { + provide: NgbModal, useValue: { + open: () => {/*comment*/} + } + }, ChangeDetectorRef, MyDSpaceNewSubmissionComponent, UploaderService @@ -86,6 +93,25 @@ describe('MyDSpaceNewSubmissionComponent test', () => { })); }); + describe('', () => { + let fixture: ComponentFixture; + let comp: MyDSpaceNewSubmissionComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpaceNewSubmissionComponent); + comp = fixture.componentInstance; + }); + + it('should call app.openDialog', () => { + spyOn(comp, 'openDialog'); + const submissionButton = fixture.debugElement.query(By.css('button.btn-primary')); + submissionButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + expect(comp.openDialog).toHaveBeenCalled(); + }); + }); }); // declare a test component diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index eb3dabf195..7087655a26 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -13,13 +13,19 @@ import { RequestEntry } from './request.reducer'; import { ErrorResponse, RestResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Collection } from '../shared/collection.model'; +import { PageInfo } from '../shared/page-info.model'; +import { PaginatedList } from './paginated-list'; +import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; +import { hot, getTestScheduler, cold } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; describe('CollectionDataService', () => { let service: CollectionDataService; - + let scheduler: TestScheduler; let requestService: RequestService; let translate: TranslateService; let notificationsService: any; @@ -27,6 +33,44 @@ describe('CollectionDataService', () => { let objectCache: ObjectCacheService; let halService: any; + const mockCollection1: Collection = Object.assign(new Collection(), { + id: 'test-collection-1-1', + name: 'test-collection-1', + _links: { + self: { + href: 'https://rest.api/collections/test-collection-1-1' + } + } + }); + + const mockCollection2: Collection = Object.assign(new Collection(), { + id: 'test-collection-2-2', + name: 'test-collection-2', + _links: { + self: { + href: 'https://rest.api/collections/test-collection-2-2' + } + } + }); + + const mockCollection3: Collection = Object.assign(new Collection(), { + id: 'test-collection-3-3', + name: 'test-collection-3', + _links: { + self: { + href: 'https://rest.api/collections/test-collection-3-3' + } + } + }); + + const queryString = 'test-string'; + const communityId = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const pageInfo = new PageInfo(); + const array = [mockCollection1, mockCollection2, mockCollection3]; + const paginatedList = new PaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + describe('when the requests are successful', () => { beforeEach(() => { createService(); @@ -74,6 +118,43 @@ describe('CollectionDataService', () => { }); }); + describe('when calling getAuthorizedCollection', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn(service, 'getAuthorizedCollection').and.callThrough(); + spyOn(service, 'getAuthorizedCollectionByCommunity').and.callThrough(); + }); + + it('should proxy the call to getAuthorizedCollection', () => { + scheduler.schedule(() => service.getAuthorizedCollection(queryString)); + scheduler.flush(); + + expect(service.getAuthorizedCollection).toHaveBeenCalledWith(queryString); + }); + + it('should return a RemoteData> for the getAuthorizedCollection', () => { + const result = service.getAuthorizedCollection(queryString) + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + + it('should proxy the call to getAuthorizedCollectionByCommunity', () => { + scheduler.schedule(() => service.getAuthorizedCollectionByCommunity(communityId, queryString)); + scheduler.flush(); + + expect(service.getAuthorizedCollectionByCommunity).toHaveBeenCalledWith(communityId, queryString); + }); + + it('should return a RemoteData> for the getAuthorizedCollectionByCommunity', () => { + const result = service.getAuthorizedCollectionByCommunity(communityId, queryString) + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); }); describe('when the requests are unsuccessful', () => { @@ -117,7 +198,9 @@ describe('CollectionDataService', () => { function createService(requestEntry$?) { requestService = getMockRequestService(requestEntry$); rdbService = jasmine.createSpyObj('rdbService', { - buildList: jasmine.createSpy('buildList') + buildList: hot('a|', { + a: paginatedListRD + }) }); objectCache = jasmine.createSpyObj('objectCache', { remove: jasmine.createSpy('remove') diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts index 33c848f9c4..8530be665e 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts @@ -167,6 +167,21 @@ describe('CollectionDropdownComponent', () => { }); })); + it('should init component with collection list', fakeAsync(() => { + spyOn(component.subs, 'push').and.callThrough(); + spyOn(component, 'resetPagination').and.callThrough(); + spyOn(component, 'populateCollectionList').and.callThrough(); + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(component.subs.push).toHaveBeenCalled(); + expect(component.resetPagination).toHaveBeenCalled(); + expect(component.populateCollectionList).toHaveBeenCalled(); + }); + })); + it('should emit collectionChange event when selecting a new collection', () => { spyOn(component.selectionChange, 'emit').and.callThrough(); component.ngOnInit(); @@ -177,6 +192,7 @@ describe('CollectionDropdownComponent', () => { }); it('should reset collections list after reset of searchField', fakeAsync(() => { + spyOn(component.subs, 'push').and.callThrough(); spyOn(component, 'reset').and.callThrough(); spyOn(component.searchField, 'setValue').and.callThrough(); spyOn(component, 'resetPagination').and.callThrough(); @@ -187,7 +203,7 @@ describe('CollectionDropdownComponent', () => { el.value = searchedCollection; el.dispatchEvent(new Event('input')); fixture.detectChanges(); - tick(250); + tick(500); fixture.whenStable().then(() => { expect(component.reset).toHaveBeenCalled(); @@ -195,6 +211,31 @@ describe('CollectionDropdownComponent', () => { expect(component.resetPagination).toHaveBeenCalled(); expect(component.currentQuery).toEqual(''); expect(component.populateCollectionList).toHaveBeenCalledWith(component.currentQuery, component.currentPage); + expect(component.searchListCollection).toEqual(collections as any); + expect(component.subs.push).toHaveBeenCalled(); }); })); + + it('should reset searchField when dropdown menu has been closed', () => { + spyOn(component.searchField, 'setValue').and.callThrough(); + component.reset(); + + expect(component.searchField.setValue).toHaveBeenCalled(); + }); + + it('should change loader status', () => { + spyOn(component.isLoadingList, 'next').and.callThrough(); + component.hideShowLoader(true); + + expect(component.isLoadingList.next).toHaveBeenCalledWith(true); + }); + + it('reset pagination fields', () => { + component.resetPagination(); + + expect(component.currentPage).toEqual(1); + expect(component.currentQuery).toEqual(''); + expect(component.hasNextPage).toEqual(true); + expect(component.searchListCollection).toEqual([]); + }); }); diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 0986badf45..0e9a4ab629 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -64,7 +64,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} */ - private subs: Subscription[] = []; + public subs: Subscription[] = []; /** * The list of collection to render diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index cfdc2e2406..0132289266 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -8,7 +8,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; -import { mockSubmissionId } from '../../../shared/mocks/submission.mock'; +import { mockSubmissionId, mockSubmissionRestResponse } from '../../../shared/mocks/submission.mock'; import { SubmissionService } from '../../submission.service'; import { SubmissionFormCollectionComponent } from './submission-form-collection.component'; import { CommunityDataService } from '../../../core/data/community-data.service'; @@ -18,12 +18,13 @@ import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/jso import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { createTestComponent } from '../../../shared/testing/utils.test'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { hot } from 'jasmine-marbles'; +import { hot, cold } from 'jasmine-marbles'; import { of } from 'rxjs'; import { SectionsService } from '../../sections/sections.service'; import { componentFactoryName } from '@angular/compiler'; +import { Collection } from 'src/app/core/shared/collection.model'; -fdescribe('SubmissionFormCollectionComponent Component', () => { +describe('SubmissionFormCollectionComponent Component', () => { let comp: SubmissionFormCollectionComponent; let compAsAny: any; @@ -34,6 +35,58 @@ fdescribe('SubmissionFormCollectionComponent Component', () => { const submissionId = mockSubmissionId; const collectionId = '1234567890-1'; const definition = 'traditional'; + const submissionRestResponse = mockSubmissionRestResponse; + + const mockCollectionList = [ + { + communities: [ + { + id: '123456789-1', + name: 'Community 1' + } + ], + collection: { + id: '1234567890-1', + name: 'Community 1-Collection 1' + } + }, + { + communities: [ + { + id: '123456789-1', + name: 'Community 1' + } + ], + collection: { + id: '1234567890-2', + name: 'Community 1-Collection 2' + } + }, + { + communities: [ + { + id: '123456789-2', + name: 'Community 2' + } + ], + collection: { + id: '1234567890-3', + name: 'Community 2-Collection 1' + } + }, + { + communities: [ + { + id: '123456789-2', + name: 'Community 2' + } + ], + collection: { + id: '1234567890-4', + name: 'Community 2-Collection 2' + } + } + ]; const communityDataService: any = jasmine.createSpyObj('communityDataService', { findAll: jasmine.createSpy('findAll') @@ -180,6 +233,32 @@ fdescribe('SubmissionFormCollectionComponent Component', () => { const dropDown = fixture.debugElement.query(By.css('#collectionControlsDropdownMenu')); expect(dropDown).toBeFalsy(); }); + + it('should be simulated when the drop-down menu is closed', () => { + spyOn(comp, 'onClose'); + comp.onClose(); + expect(comp.onClose).toHaveBeenCalled(); + }); + + it('should be simulated when the drop-down menu is toggled', () => { + spyOn(comp, 'toggled'); + comp.toggled(false); + expect(comp.toggled).toHaveBeenCalled(); + }); + + it('should ', () => { + spyOn(comp.collectionChange, 'emit').and.callThrough(); + jsonPatchOpServiceStub.jsonPatchByResourceID.and.returnValue(of(submissionRestResponse)); + comp.ngOnInit(); + comp.onSelect(mockCollectionList[1]); + fixture.detectChanges(); + + expect(submissionServiceStub.changeSubmissionCollection).toHaveBeenCalled(); + expect(comp.selectedCollectionId).toBe(mockCollectionList[1].collection.id); + expect(comp.selectedCollectionName$).toBeObservable(cold('(a|)', { + a: mockCollectionList[1].collection.name + })); + }); }); }); From 4c1b5891587a09c7f643438a06653aaf40c78cc6 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 19 Jun 2020 11:09:02 +0200 Subject: [PATCH 40/59] 70834: Use data-service's responseMsToLive for searchBy --- src/app/core/data/data.service.ts | 4 ++- .../core/data/metadata-field-data.service.ts | 29 ------------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index e31095ca65..7f77d72d3a 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -344,7 +344,9 @@ export abstract class DataService { tap((href: string) => { this.requestService.removeByHrefSubstring(href); const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - request.responseMsToLive = 10 * 1000; + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } this.requestService.configure(request); } diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 34b438cc10..f50be20f13 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -86,33 +86,4 @@ export class MetadataFieldDataService extends DataService { ); } - /** - * Make a new FindListRequest with given search method - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @param linksToFollow The array of [[FollowLinkConfig]] - * @return {Observable>} - * Return an observable that emits response from the server - */ - searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); - - return hrefObs.pipe( - find((href: string) => hasValue(href)), - tap((href: string) => { - this.requestService.removeByHrefSubstring(href); - const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - - this.requestService.configure(request); - } - ), - switchMap((href) => this.requestService.getByHref(href)), - skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), - switchMap((href) => - this.rdbService.buildList(hrefObs, ...linksToFollow) as Observable>> - ) - ); - } - } From bbaaaed4b5f00dfba958b1257f2362a21b381e10 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 19 Jun 2020 17:26:11 +0200 Subject: [PATCH 41/59] 71504: short-lived token for downloading files through FileService --- src/app/core/auth/auth-request.service.ts | 29 +++++++++++++++++-- src/app/core/auth/auth.service.ts | 10 +++++++ .../auth/token-response-parsing.service.ts | 23 +++++++++++++++ src/app/core/cache/response.models.ts | 14 +++++++++ src/app/core/core.module.ts | 2 ++ src/app/core/data/request.models.ts | 10 +++++++ src/app/core/shared/file.service.ts | 26 ++++++++--------- 7 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 src/app/core/auth/token-response-parsing.service.ts diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 465fb69dd2..50a285bdf9 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,12 +1,19 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; -import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models'; -import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; +import { + AuthGetRequest, + AuthPostRequest, + GetRequest, + PostRequest, + RestRequest, + TokenPostRequest +} from '../data/request.models'; +import { AuthStatusResponse, ErrorResponse, RestResponse, TokenResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { getResponseFromEntry } from '../shared/operators'; import { HttpClient } from '@angular/common/http'; @@ -15,6 +22,7 @@ import { HttpClient } from '@angular/common/http'; export class AuthRequestService { protected linkName = 'authn'; protected browseEndpoint = ''; + protected shortlivedtokensEndpoint = 'shortlivedtokens'; constructor(protected halService: HALEndpointService, protected requestService: RequestService, @@ -67,4 +75,19 @@ export class AuthRequestService { mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } + + /** + * Send a POST request to retrieve a short-lived token which provides download access of restricted files + */ + public getShortlivedToken(): Observable { + return this.halService.getEndpoint(`${this.linkName}/${this.shortlivedtokensEndpoint}`).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new TokenPostRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: PostRequest) => this.requestService.configure(request)), + switchMap((request: PostRequest) => this.requestService.getByUUID(request.uuid)), + getResponseFromEntry(), + map((response: TokenResponse) => response.token) + ); + } } diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 588d9e2675..dc1ad4ddd7 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -546,4 +546,14 @@ export class AuthService { return this.getImpersonateID() === epersonId; } + /** + * Get a short-lived token for appending to download urls of restricted files + * Returns null if the user isn't authenticated + */ + getShortlivedToken(): Observable { + return this.isAuthenticated().pipe( + switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)) + ); + } + } diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts new file mode 100644 index 0000000000..a1b1e23aa4 --- /dev/null +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -0,0 +1,23 @@ +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RestResponse, TokenResponse } from '../cache/response.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { Injectable } from '@angular/core'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a token string + * wrapped in a TokenResponse + */ +export class TokenResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload.token) && (data.statusCode === 200)) { + return new TokenResponse(data.payload.token, true, data.statusCode, data.statusText); + } else { + return new TokenResponse(null, false, data.statusCode, data.statusText) + } + } + +} diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3f46ecf647..7439b05355 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -211,6 +211,20 @@ export class AuthStatusResponse extends RestResponse { } } +/** + * A REST Response containing a token + */ +export class TokenResponse extends RestResponse { + constructor( + public token: string, + public isSuccessful: boolean, + public statusCode: number, + public statusText: string + ) { + super(isSuccessful, statusCode, statusText); + } +} + export class IntegrationSuccessResponse extends RestResponse { constructor( public dataDefinition: PaginatedList, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 715f7a5cc0..7426cffda3 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -145,6 +145,7 @@ import { Version } from './shared/version.model'; import { VersionHistory } from './shared/version-history.model'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { TokenResponseParsingService } from './auth/token-response-parsing.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -264,6 +265,7 @@ const PROVIDERS = [ LicenseDataService, ItemTypeDataService, WorkflowActionDataService, + TokenResponseParsingService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 5866cce797..8f05114b32 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -20,6 +20,7 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; +import { TokenResponseParsingService } from '../auth/token-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -241,6 +242,15 @@ export class AuthGetRequest extends GetRequest { } } +/** + * A POST request for retrieving a token + */ +export class TokenPostRequest extends PostRequest { + getResponseParser(): GenericConstructor { + return TokenResponseParsingService; + } +} + export class IntegrationRequest extends GetRequest { constructor(uuid: string, href: string) { super(uuid, href); diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts index 7e89a4e5dd..841cb60869 100644 --- a/src/app/core/shared/file.service.ts +++ b/src/app/core/shared/file.service.ts @@ -1,10 +1,10 @@ -import { Injectable } from '@angular/core'; -import { HttpHeaders } from '@angular/common/http'; - -import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { saveAs } from 'file-saver'; +import { Inject, Injectable } from '@angular/core'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { AuthService } from '../auth/auth.service'; +import { take } from 'rxjs/operators'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { hasValue } from '../../shared/empty.util'; /** * Provides utility methods to save files on the client-side. @@ -12,22 +12,20 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. @Injectable() export class FileService { constructor( - private restService: DSpaceRESTv2Service + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private authService: AuthService ) { } /** - * Makes a HTTP Get request to download a file + * Combines an URL with a short-lived token and sets the current URL to the newly created one * * @param url * file url */ downloadFile(url: string) { - const headers = new HttpHeaders(); - const options: HttpOptions = Object.create({headers, responseType: 'blob'}); - return this.restService.request(RestRequestMethod.GET, url, null, options) - .subscribe((data) => { - saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data)); - }); + this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => { + this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?token=${token}`).toString() : url; + }); } /** From b578aa408bd5737d33342f8f393d8d2330ae203b Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 22 Jun 2020 11:40:39 +0200 Subject: [PATCH 42/59] 71504: FileDownloadLinkComponent --- .../full-file-section.component.html | 4 +- .../file-section/file-section.component.html | 4 +- .../file-download-link.component.html | 6 +++ .../file-download-link.component.scss | 0 .../file-download-link.component.spec.ts | 25 ++++++++++ .../file-download-link.component.ts | 48 +++++++++++++++++++ src/app/shared/shared.module.ts | 7 ++- 7 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/app/shared/file-download-link/file-download-link.component.html create mode 100644 src/app/shared/file-download-link/file-download-link.component.scss create mode 100644 src/app/shared/file-download-link/file-download-link.component.spec.ts create mode 100644 src/app/shared/file-download-link/file-download-link.component.ts diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index b8ab9bdb41..7c1719eb82 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -21,9 +21,9 @@
diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html index 6533322e03..17e4a795e7 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html @@ -1,11 +1,11 @@ diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html new file mode 100644 index 0000000000..06624c8b40 --- /dev/null +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/shared/file-download-link/file-download-link.component.scss b/src/app/shared/file-download-link/file-download-link.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts new file mode 100644 index 0000000000..7e1999816b --- /dev/null +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileDownloadLinkComponent } from './file-download-link.component'; + +describe('FileDownloadLinkComponent', () => { + let component: FileDownloadLinkComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FileDownloadLinkComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts new file mode 100644 index 0000000000..9df7c191ff --- /dev/null +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FileService } from '../../core/shared/file.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { AuthService } from '../../core/auth/auth.service'; + +@Component({ + selector: 'ds-file-download-link', + templateUrl: './file-download-link.component.html', + styleUrls: ['./file-download-link.component.scss'] +}) +/** + * Component displaying a download link + * When the user is authenticated, a short-lived token retrieved from the REST API is added to the download link, + * ensuring the user is authorized to download the file. + */ +export class FileDownloadLinkComponent implements OnInit { + /** + * Href to link to + */ + @Input() href: string; + + /** + * Optional file name for the download + */ + @Input() download: string; + + /** + * Whether or not the current user is authenticated + */ + isAuthenticated$: Observable; + + constructor(private fileService: FileService, + private authService: AuthService) { } + + ngOnInit() { + this.isAuthenticated$ = this.authService.isAuthenticated(); + } + + /** + * Start a download of the file + * Return false to ensure the original href is displayed when the user hovers over the link + */ + downloadFile(): boolean { + this.fileService.downloadFile(this.href); + return false; + } + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 67d7db5c5d..09d090406a 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -202,6 +202,7 @@ import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/reso import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver'; import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component'; import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; +import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -386,7 +387,8 @@ const COMPONENTS = [ ResourcePolicyFormComponent, EpersonGroupListComponent, EpersonSearchBoxComponent, - GroupSearchBoxComponent + GroupSearchBoxComponent, + FileDownloadLinkComponent, ]; const ENTRY_COMPONENTS = [ @@ -459,7 +461,8 @@ const ENTRY_COMPONENTS = [ ClaimedTaskActionsApproveComponent, ClaimedTaskActionsRejectComponent, ClaimedTaskActionsReturnToPoolComponent, - ClaimedTaskActionsEditMetadataComponent + ClaimedTaskActionsEditMetadataComponent, + FileDownloadLinkComponent, ]; const SHARED_ITEM_PAGE_COMPONENTS = [ From 24858fceab35fda1429ebaa44732a40797159352 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Mon, 22 Jun 2020 13:44:16 +0200 Subject: [PATCH 43/59] 71504: Shortlived tokens + file-download-link test cases --- src/app/core/auth/auth.service.spec.ts | 57 ++++++++++++++++++- .../token-response-parsing.service.spec.ts | 45 +++++++++++++++ .../file-download-link.component.spec.ts | 40 +++++++++++-- .../testing/auth-request-service.stub.ts | 5 ++ 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 src/app/core/auth/token-response-parsing.service.spec.ts diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 3b6fae4dd1..a15d604cc4 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -1,17 +1,14 @@ import { async, inject, TestBed } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; - import { Store, StoreModule } from '@ngrx/store'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; - import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; import { RouterStub } from '../../shared/testing/router.stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; - import { CookieService } from '../services/cookie.service'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; import { AuthRequestService } from './auth-request.service'; @@ -49,6 +46,7 @@ describe('AuthService test', () => { let storage: CookieService; let token: AuthTokenInfo; let authenticatedState; + let unAuthenticatedState; let linkService; function init() { @@ -67,6 +65,13 @@ describe('AuthService test', () => { authToken: token, user: EPersonMock }; + unAuthenticatedState = { + authenticated: false, + loaded: true, + loading: false, + authToken: undefined, + user: undefined + }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); linkService = { @@ -214,6 +219,12 @@ describe('AuthService test', () => { }); }); + it('should return the shortlived token when user is logged in', () => { + authService.getShortlivedToken().subscribe((shortlivedToken: string) => { + expect(shortlivedToken).toEqual(authRequest.mockShortLivedToken); + }); + }); + it('should return token object when it is valid', () => { authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => { expect(tokenState).toBe(token); @@ -448,4 +459,44 @@ describe('AuthService test', () => { }); }); }); + + describe('when user is not logged in', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ authReducer }, { + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false + } + }) + ], + providers: [ + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: RemoteDataBuildService, useValue: linkService }, + CookieService, + AuthService + ] + }).compileComponents(); + })); + + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = unAuthenticatedState; + }); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + })); + + it('should return null for the shortlived token', () => { + authService.getShortlivedToken().subscribe((shortlivedToken: string) => { + expect(shortlivedToken).toBeNull(); + }); + }); + }); }); diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts new file mode 100644 index 0000000000..35927708f6 --- /dev/null +++ b/src/app/core/auth/token-response-parsing.service.spec.ts @@ -0,0 +1,45 @@ +import { TokenResponseParsingService } from './token-response-parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { TokenResponse } from '../cache/response.models'; + +describe('TokenResponseParsingService', () => { + let service: TokenResponseParsingService; + + beforeEach(() => { + service = new TokenResponseParsingService(); + }); + + describe('parse', () => { + it('should return a TokenResponse containing the token', () => { + const data = { + payload: { + token: 'valid-token' + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(data.payload.token, true, 200, 'OK'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + + it('should return an empty TokenResponse when payload doesn\'t contain a token', () => { + const data = { + payload: {}, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(null, false, 200, 'OK'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + + it('should return an error TokenResponse when the response failed', () => { + const data = { + payload: {}, + statusCode: 400, + statusText: 'BAD REQUEST' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(null, false, 400, 'BAD REQUEST'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + }); +}); diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 7e1999816b..ac1751d43d 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -1,14 +1,33 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - import { FileDownloadLinkComponent } from './file-download-link.component'; +import { AuthService } from '../../core/auth/auth.service'; +import { FileService } from '../../core/shared/file.service'; +import { of as observableOf } from 'rxjs'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; let fixture: ComponentFixture; + let authService: AuthService; + let fileService: FileService; + let href: string; + + function init() { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true) + }); + fileService = jasmine.createSpyObj('fileService', ['downloadFile']); + href = 'test-download-file-link'; + } + beforeEach(async(() => { + init(); TestBed.configureTestingModule({ - declarations: [ FileDownloadLinkComponent ] + declarations: [ FileDownloadLinkComponent ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: FileService, useValue: fileService } + ] }) .compileComponents(); })); @@ -16,10 +35,23 @@ describe('FileDownloadLinkComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(FileDownloadLinkComponent); component = fixture.componentInstance; + component.href = href; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('downloadFile', () => { + let result; + + beforeEach(() => { + result = component.downloadFile(); + }); + + it('should call fileService.downloadFile with the provided href', () => { + expect(fileService.downloadFile).toHaveBeenCalledWith(href); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); }); }); diff --git a/src/app/shared/testing/auth-request-service.stub.ts b/src/app/shared/testing/auth-request-service.stub.ts index 1dc04380df..671c9237bf 100644 --- a/src/app/shared/testing/auth-request-service.stub.ts +++ b/src/app/shared/testing/auth-request-service.stub.ts @@ -9,6 +9,7 @@ import { EPersonMock } from './eperson.mock'; export class AuthRequestServiceStub { protected mockUser: EPerson = EPersonMock; protected mockTokenInfo = new AuthTokenInfo('test_token'); + protected mockShortLivedToken = 'test-shortlived-token'; public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { const authStatusStub: AuthStatus = new AuthStatus(); @@ -82,4 +83,8 @@ export class AuthRequestServiceStub { } return obj; } + + public getShortlivedToken() { + return observableOf(this.mockShortLivedToken); + } } From 73c25998e37139618c6ab3f5e52fb7db140cb6a5 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 25 Jun 2020 11:09:37 +0200 Subject: [PATCH 44/59] 71380: Fix loading/cache issue with dropping objects on page --- .../item-bitstreams.component.ts | 8 +++--- ...-and-drop-bitstream-list.component.spec.ts | 12 +++++++-- ...-drag-and-drop-bitstream-list.component.ts | 20 +++++++++----- src/app/core/data/bundle-data.service.ts | 11 ++++---- ...-paginated-drag-and-drop-list.component.ts | 27 +++++++++++++++---- 5 files changed, 57 insertions(+), 21 deletions(-) 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 index 35da302961..115e8489d4 100644 --- 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 @@ -196,9 +196,11 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme path: `/_links/bitstreams/${event.toIndex}/href` }); this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RestResponse) => { - this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); - this.requestService.removeByHrefSubstring(bundle.self); - this.zone.run(() => event.finish()); + this.zone.run(() => { + this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); + this.requestService.removeByHrefSubstring(bundle.self); + event.finish(); + }); }); } }); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts index 54171ed8af..118f2b1619 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts @@ -16,6 +16,7 @@ import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-siz import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../../../shared/testing/utils.test'; +import { RequestService } from '../../../../../core/data/request.service'; describe('PaginatedDragAndDropBitstreamListComponent', () => { let comp: PaginatedDragAndDropBitstreamListComponent; @@ -23,6 +24,7 @@ describe('PaginatedDragAndDropBitstreamListComponent', () => { let objectUpdatesService: ObjectUpdatesService; let bundleService: BundleDataService; let objectValuesPipe: ObjectValuesPipe; + let requestService: RequestService; const columnSizes = new ResponsiveTableSizes([ new ResponsiveColumnSizes(2, 2, 3, 4, 4), @@ -98,18 +100,24 @@ describe('PaginatedDragAndDropBitstreamListComponent', () => { ); bundleService = jasmine.createSpyObj('bundleService', { - getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])) + getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])), + getBitstreamsEndpoint: observableOf('') }); objectValuesPipe = new ObjectValuesPipe(); + requestService = jasmine.createSpyObj('requestService', { + hasByHrefObservable: observableOf(true) + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, { provide: BundleDataService, useValue: bundleService }, - { provide: ObjectValuesPipe, useValue: objectValuesPipe } + { provide: ObjectValuesPipe, useValue: objectValuesPipe }, + { provide: RequestService, useValue: requestService } ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts index 19cf3b27e4..a288e9993a 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts @@ -9,6 +9,7 @@ import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-s import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; import { followLink } from '../../../../../shared/utils/follow-link-config.model'; import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; +import { RequestService } from '../../../../../core/data/request.service'; @Component({ selector: 'ds-paginated-drag-and-drop-bitstream-list', @@ -35,7 +36,8 @@ export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginate constructor(protected objectUpdatesService: ObjectUpdatesService, protected elRef: ElementRef, protected objectValuesPipe: ObjectValuesPipe, - protected bundleService: BundleDataService) { + protected bundleService: BundleDataService, + protected requestService: RequestService) { super(objectUpdatesService, elRef, objectValuesPipe); } @@ -48,11 +50,17 @@ export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginate */ initializeObjectsRD(): void { this.objectsRD$ = this.currentPage$.pipe( - switchMap((page: number) => this.bundleService.getBitstreams( - this.bundle.id, - new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}), - followLink('format') - )) + switchMap((page: number) => { + const paginatedOptions = new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}); + return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe( + switchMap((href) => this.requestService.hasByHrefObservable(href)), + switchMap(() => this.bundleService.getBitstreams( + this.bundle.id, + paginatedOptions, + followLink('format') + )) + ); + }) ); } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 160ea0ff0d..de0e8a4337 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -88,10 +88,12 @@ export class BundleDataService extends DataService { /** * Get the bitstreams endpoint for a bundle * @param bundleId + * @param searchOptions */ - getBitstreamsEndpoint(bundleId: string): Observable { + getBitstreamsEndpoint(bundleId: string, searchOptions?: PaginatedSearchOptions): Observable { return this.getBrowseEndpoint().pipe( - switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)) + switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)), + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) ); } @@ -102,9 +104,8 @@ export class BundleDataService extends DataService { * @param linksToFollow The {@link FollowLinkConfig}s for the request */ getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> { - const hrefObs = this.getBitstreamsEndpoint(bundleId).pipe( - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) - ); + const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions); + hrefObs.pipe( take(1) ).subscribe((href) => { diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index 3d249b7393..9c46a70b5e 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -1,4 +1,4 @@ -import { FieldUpdate, FieldUpdates, Identifiable } from '../../core/data/object-updates/object-updates.reducer'; +import { FieldUpdate, FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; @@ -6,7 +6,7 @@ import { PaginationComponentOptions } from '../pagination/pagination-component-o import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; -import { hasValue } from '../empty.util'; +import { hasValue, isNotEmpty } from '../empty.util'; import { paginatedListToArray } from '../../core/shared/operators'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; @@ -95,10 +95,19 @@ export abstract class AbstractPaginatedDragAndDropListComponent = new BehaviorSubject(false); + /** + * ID of the object the page's first element needs to match in order to stop the loading animation. + * This is to ensure the new page is fully loaded containing the latest data from the REST API whenever an object is + * dropped on a new page. This allows the component to expect the dropped object to be present on top of the new page, + * while displaying a loading animation until this is the case. + */ + stopLoadingWhenFirstIs: string; + /** * List of subscriptions */ @@ -148,7 +157,15 @@ export abstract class AbstractPaginatedDragAndDropListComponent { this.customOrder = updateValues.map((fieldUpdate) => fieldUpdate.field.uuid); - }) + // Check if stopLoadingWhenFirstIs contains a value. If it does and it equals the first value in customOrder, stop the loading animation. + // This is to ensure the page is updated to contain the new values first, before displaying it. + if (hasValue(this.stopLoadingWhenFirstIs) && isNotEmpty(this.customOrder) && this.customOrder[0] === this.stopLoadingWhenFirstIs) { + this.stopLoadingWhenFirstIs = undefined; + this.loading$.next(false); + } + }), + // Disable the pagination when objects are loading + this.loading$.subscribe((loading) => this.options.disabled = loading) ); } @@ -197,6 +214,7 @@ export abstract class AbstractPaginatedDragAndDropListComponent { if (isNewPage) { this.currentPage$.next(redirectPage); - this.loading$.next(false); } } })); From bfdd943d45cdfac688145c2047a975896c6f494a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 25 Jun 2020 17:51:11 +0200 Subject: [PATCH 45/59] 71380: Remove redundant code and add subscribable removeByHrefSubstring --- .../item-bitstreams.component.ts | 7 ++++-- src/app/core/data/request.service.ts | 8 ++++++- src/app/shared/mocks/request.service.mock.ts | 2 +- ...-paginated-drag-and-drop-list.component.ts | 23 ++++--------------- 4 files changed, 17 insertions(+), 23 deletions(-) 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 index 115e8489d4..45b8e23108 100644 --- 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 @@ -198,8 +198,11 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RestResponse) => { this.zone.run(() => { this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); - this.requestService.removeByHrefSubstring(bundle.self); - event.finish(); + // Remove all cached requests from this bundle and call the event's callback when the requests are cleared + this.requestService.removeByHrefSubstring(bundle.self).pipe( + filter((isCached) => isCached), + take(1) + ).subscribe(() => event.finish()); }); }); } diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 105d84cf4a..9a2c565301 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -201,8 +201,9 @@ export class RequestService { * Remove all request cache providing (part of) the href * This also includes href-to-uuid index cache * @param href A substring of the request(s) href + * @return Returns an observable emitting whether or not the cache is removed */ - removeByHrefSubstring(href: string) { + removeByHrefSubstring(href: string): Observable { this.store.pipe( select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), take(1) @@ -213,6 +214,11 @@ export class RequestService { }); this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0); this.indexStore.dispatch(new RemoveFromIndexBySubstringAction(IndexName.REQUEST, href)); + + return this.store.pipe( + select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), + map((uuids) => isEmpty(uuids)) + ); } /** diff --git a/src/app/shared/mocks/request.service.mock.ts b/src/app/shared/mocks/request.service.mock.ts index da297f56ac..6a3f182868 100644 --- a/src/app/shared/mocks/request.service.mock.ts +++ b/src/app/shared/mocks/request.service.mock.ts @@ -11,7 +11,7 @@ export function getMockRequestService(requestEntry$: Observable = getByUUID: requestEntry$, uriEncodeBody: jasmine.createSpy('uriEncodeBody'), isCachedOrPending: false, - removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring'), + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring').and.returnValue(observableOf(true)), hasByHrefObservable: observableOf(false) }); } diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index 9c46a70b5e..433d6877fb 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -75,7 +75,7 @@ export abstract class AbstractPaginatedDragAndDropListComponent = new BehaviorSubject(false); - /** - * ID of the object the page's first element needs to match in order to stop the loading animation. - * This is to ensure the new page is fully loaded containing the latest data from the REST API whenever an object is - * dropped on a new page. This allows the component to expect the dropped object to be present on top of the new page, - * while displaying a loading animation until this is the case. - */ - stopLoadingWhenFirstIs: string; - /** * List of subscriptions */ @@ -157,12 +149,8 @@ export abstract class AbstractPaginatedDragAndDropListComponent { this.customOrder = updateValues.map((fieldUpdate) => fieldUpdate.field.uuid); - // Check if stopLoadingWhenFirstIs contains a value. If it does and it equals the first value in customOrder, stop the loading animation. - // This is to ensure the page is updated to contain the new values first, before displaying it. - if (hasValue(this.stopLoadingWhenFirstIs) && isNotEmpty(this.customOrder) && this.customOrder[0] === this.stopLoadingWhenFirstIs) { - this.stopLoadingWhenFirstIs = undefined; - this.loading$.next(false); - } + // We received new values, stop displaying a loading indicator if it's present + this.loading$.next(false); }), // Disable the pagination when objects are loading this.loading$.subscribe((loading) => this.options.disabled = loading) @@ -214,9 +202,6 @@ export abstract class AbstractPaginatedDragAndDropListComponent { if (isNewPage) { - this.currentPage$.next(redirectPage); + this.paginationComponent.doPageChange(redirectPage); } } })); From f24bd9fe36d5e1dd7cde7cb9b5428f1b4723d736 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 25 Jun 2020 17:52:02 +0200 Subject: [PATCH 46/59] 71380: Reset page size --- .../abstract-paginated-drag-and-drop-list.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts index 433d6877fb..f8a4cdee61 100644 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -75,7 +75,7 @@ export abstract class AbstractPaginatedDragAndDropListComponent Date: Fri, 26 Jun 2020 09:37:43 +0200 Subject: [PATCH 47/59] [CST-3090] fix position of word 'here' --- src/app/shared/notifications/notifications.service.ts | 2 +- src/assets/i18n/en.json5 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts index fa65d69530..ab21ab61f7 100644 --- a/src/app/shared/notifications/notifications.service.ts +++ b/src/app/shared/notifications/notifications.service.ts @@ -75,7 +75,7 @@ export class NotificationsService { this.translate.get(hrefTranslateLabel) .pipe(first()) .subscribe((hrefMsg) => { - const anchor = ` + const anchor = ` ${hrefMsg} `; const interpolateParams = Object.create({}); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4173fa1cf2..388874e107 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1737,7 +1737,7 @@ "mydspace.description": "", - "mydspace.general.text-here": "HERE", + "mydspace.general.text-here": "here", "mydspace.messages.controller-help": "Select this option to send a message to item's submitter.", From 5642a2798aff510511dcd80470e089d9c28a0d56 Mon Sep 17 00:00:00 2001 From: Danilo Di Nuzzo Date: Fri, 26 Jun 2020 09:55:01 +0200 Subject: [PATCH 48/59] [CST-3090] fix unused import alerts --- .../shared/collection-dropdown/collection-dropdown.component.ts | 2 +- .../form/collection/submission-form-collection.component.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 0e9a4ab629..05105d74a7 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, import { FormControl } from '@angular/forms'; import { Observable, Subscription, BehaviorSubject } from 'rxjs'; import { hasValue } from '../empty.util'; -import { map, mergeMap, startWith, debounceTime, distinctUntilChanged, switchMap, merge, scan, reduce } from 'rxjs/operators'; +import { map, mergeMap, startWith, debounceTime, distinctUntilChanged, switchMap, reduce } from 'rxjs/operators'; import { RemoteData } from 'src/app/core/data/remote-data'; import { FindListOptions } from 'src/app/core/data/request.models'; import { PaginatedList } from 'src/app/core/data/paginated-list'; diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 6517be7101..aa1bf9cb0a 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component, EventEmitter, - HostListener, Input, OnChanges, OnInit, @@ -18,7 +17,6 @@ import { } from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; -import { CommunityDataService } from '../../../core/data/community-data.service'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RemoteData } from '../../../core/data/remote-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; From e1199673258678c3acc3f3a20723f75dd31ecc39 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 29 Jun 2020 17:24:00 +0200 Subject: [PATCH 49/59] fix issue where beforeEach wouldn't wait for dropBitstream to end --- .../item-bitstreams.component.spec.ts | 18 +++++++++--------- src/app/shared/mocks/request.service.mock.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index 5aa085a42c..125fc1fb0c 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -191,15 +191,15 @@ describe('ItemBitstreamsComponent', () => { }); describe('when dropBitstream is called', () => { - const event = { - fromIndex: 0, - toIndex: 50, - // tslint:disable-next-line:no-empty - finish: () => {} - }; - - beforeEach(() => { - comp.dropBitstream(bundle, event); + beforeEach((done) => { + comp.dropBitstream(bundle, { + fromIndex: 0, + toIndex: 50, + // tslint:disable-next-line:no-empty + finish: () => { + done(); + } + }) }); it('should send out a patch for the move operation', () => { diff --git a/src/app/shared/mocks/request.service.mock.ts b/src/app/shared/mocks/request.service.mock.ts index 6a3f182868..385195bc77 100644 --- a/src/app/shared/mocks/request.service.mock.ts +++ b/src/app/shared/mocks/request.service.mock.ts @@ -11,7 +11,7 @@ export function getMockRequestService(requestEntry$: Observable = getByUUID: requestEntry$, uriEncodeBody: jasmine.createSpy('uriEncodeBody'), isCachedOrPending: false, - removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring').and.returnValue(observableOf(true)), + removeByHrefSubstring: observableOf(true), hasByHrefObservable: observableOf(false) }); } From 066e6cd142b69a1be5fe9b6509ec4f06ce3ebb81 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 29 Jun 2020 17:42:16 +0200 Subject: [PATCH 50/59] fixed a number of instances in community-list-service.spec.ts where async code was presumed synchronous --- .../community-list-service.spec.ts | 131 +++++++++++------- 1 file changed, 84 insertions(+), 47 deletions(-) diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index 6b7ab2bd77..f4955b2a36 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -210,13 +210,16 @@ describe('CommunityListService', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities page 1 and 2', () => { let findTopSpy; - beforeEach(() => { + beforeEach((done) => { findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough(); service.getNextPageTopCommunities(); - const sub = service.loadCommunities(null) - .subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities(null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('flatnode list should contain just flatnodes of top community list page 1 and 2', () => { expect(findTopSpy).toHaveBeenCalled(); @@ -236,10 +239,13 @@ describe('CommunityListService', () => { describe('should transform all communities in a list of flatnodes with possible subcoms and collections as subflatnodes if they\'re expanded', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities', () => { - beforeEach(() => { - const sub = service.loadCommunities(null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.loadCommunities(null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length); @@ -256,7 +262,7 @@ describe('CommunityListService', () => { }); }); describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const expandedNodes = []; mockListOfTopCommunitiesPage1.map((community: Community) => { const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); @@ -264,9 +270,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities(expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of its possible page-limited children', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); @@ -281,14 +290,17 @@ describe('CommunityListService', () => { }); }); describe('Just first top comm expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[0], observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities(expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of page-limited children of first top community', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length); @@ -300,14 +312,17 @@ describe('CommunityListService', () => { }); }); describe('Just second top comm expanded, collections at page 2: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[1], observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities(expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of page-limited children of second top community', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockCollectionsPage1.length + mockCollectionsPage2.length); @@ -333,10 +348,13 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('None expanded: should return list containing only flatnodes of the communities in the test list', () => { - beforeEach(() => { - const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as community test list', () => { expect(flatNodeList.length).toEqual(listOfCommunities.length); @@ -353,7 +371,7 @@ describe('CommunityListService', () => { }); }); describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const expandedNodes = []; listOfCommunities.map((community: Community) => { const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); @@ -361,9 +379,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as community test list and size of its possible children', () => { expect(flatNodeList.length).toEqual(listOfCommunities.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); @@ -397,10 +418,13 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { - beforeEach(() => { - const sub = service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be 1', () => { expect(flatNodeList.length).toEqual(1); @@ -426,10 +450,14 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { - beforeAll(() => { - const sub = service.transformCommunity(communityWithSubcoms, 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeAll((done) => { + service.transformCommunity(communityWithSubcoms, 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); + }); it('length of flatnode list should be 1', () => { expect(flatNodeList.length).toEqual(1); @@ -455,14 +483,17 @@ describe('CommunityListService', () => { } }); let flatNodeList; - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(communityWithSubcoms, observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('list of flatnodes is length is 1 + nrOfSubcoms & first flatnode is of expanded test community', () => { expect(flatNodeList.length).toEqual(1 + mockSubcommunities1Page1.length); @@ -485,7 +516,7 @@ describe('CommunityListService', () => { describe('should return list containing flatnodes of that community, its collections of the first two pages', () => { let communityWithCollections; let flatNodeList; - beforeEach(() => { + beforeEach((done) => { communityWithCollections = Object.assign(new Community(), { id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', @@ -500,9 +531,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.transformCommunity(communityWithCollections, 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformCommunity(communityWithCollections, 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('list of flatnodes is length is 1 + nrOfCollections & first flatnode is of expanded test community', () => { expect(flatNodeList.length).toEqual(1 + mockCollectionsPage1.length + mockCollectionsPage2.length); @@ -533,7 +567,7 @@ describe('CommunityListService', () => { describe('getIsExpandable', () => { describe('should return true', () => { - it('if community has subcommunities', () => { + it('if community has subcommunities', (done) => { const communityWithSubcoms = Object.assign(new Community(), { id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', @@ -546,9 +580,10 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithSubcoms).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); + done(); }); }); - it('if community has collections', () => { + it('if community has collections', (done) => { const communityWithCollections = Object.assign(new Community(), { id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', @@ -561,11 +596,12 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithCollections).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); + done(); }); }); }); describe('should return false', () => { - it('if community has neither subcommunities nor collections', () => { + it('if community has neither subcommunities nor collections', (done) => { const communityWithNoSubcomsOrColls = Object.assign(new Community(), { id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', @@ -578,6 +614,7 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithNoSubcomsOrColls).pipe(take(1)).subscribe((result) => { expect(result).toEqual(false); + done(); }); }); }); From b1e44a7fa583da100dc3609dd40be5b1d9e2b5da Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Sun, 3 May 2020 01:53:46 +0200 Subject: [PATCH 51/59] 70599: Improvements comcol tree --- .../community-list-datasource.ts | 5 +- .../community-list-service.ts | 119 +++++++----------- .../community-list.component.ts | 18 ++- .../builders/remote-data-build.service.ts | 7 +- 4 files changed, 60 insertions(+), 89 deletions(-) diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 3a9d9f2077..4974b2c4fe 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,3 +1,4 @@ +import { FindListOptions } from '../core/data/request.models'; import { CommunityListService, FlatNode } from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; import { BehaviorSubject, Observable, } from 'rxjs'; @@ -21,10 +22,10 @@ export class CommunityListDatasource implements DataSource { return this.communityList$.asObservable(); } - loadCommunities(expandedNodes: FlatNode[]) { + loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) { this.loading$.next(true); - this.communityListService.loadCommunities(expandedNodes).pipe( + this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( take(1), finalize(() => this.loading$.next(false)), ).subscribe((flatNodes: FlatNode[]) => { diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index be04887e71..70db048d3e 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -4,11 +4,12 @@ import { combineLatest as observableCombineLatest } from 'rxjs/internal/observab import { Observable, of as observableOf } from 'rxjs'; import { AppState } from '../app.reducer'; import { CommunityDataService } from '../core/data/community-data.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; +import { FindListOptions } from '../core/data/request.models'; +import { map, flatMap } from 'rxjs/operators'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { PageInfo } from '../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { PaginatedList } from '../core/data/paginated-list'; @@ -46,8 +47,7 @@ export class ShowMoreFlatNode { // Helper method to combine an flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Array>): Observable => observableCombineLatest(...obsList).pipe( - map((matrix: FlatNode[][]) => - matrix.reduce((combinedList, currentList: FlatNode[]) => [...combinedList, ...currentList])) + map((matrix: any[][]) => [].concat(...matrix)) ); /** @@ -99,6 +99,8 @@ const communityListStateSelector = (state: AppState) => state.communityList; const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); +export const MAX_COMCOLS_PER_PAGE = 2; + /** * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource * and connection to the store to retrieve and save the state of the community list @@ -107,26 +109,8 @@ const loadingNodeSelector = createSelector(communityListStateSelector, (communit @Injectable() export class CommunityListService { - // page-limited list of top-level communities - payloads$: Array>>; - - topCommunitiesConfig: PaginationComponentOptions; - topCommunitiesSortConfig: SortOptions; - - maxSubCommunitiesPerPage: number; - maxCollectionsPerPage: number; - constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, private store: Store) { - this.topCommunitiesConfig = new PaginationComponentOptions(); - this.topCommunitiesConfig.id = 'top-level-pagination'; - this.topCommunitiesConfig.pageSize = 10; - this.topCommunitiesConfig.currentPage = 1; - this.topCommunitiesSortConfig = new SortOptions('dc.title', SortDirection.ASC); - this.initTopCommunityList(); - - this.maxSubCommunitiesPerPage = 3; - this.maxCollectionsPerPage = 3; } saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void { @@ -141,57 +125,46 @@ export class CommunityListService { return this.store.select(loadingNodeSelector); } - /** - * Increases the payload so it contains the next page of top level communities - */ - getNextPageTopCommunities(): void { - this.topCommunitiesConfig.currentPage = this.topCommunitiesConfig.currentPage + 1; - this.payloads$ = [...this.payloads$, this.communityDataService.findTop({ - currentPage: this.topCommunitiesConfig.currentPage, - elementsPerPage: this.topCommunitiesConfig.pageSize, - sort: { - field: this.topCommunitiesSortConfig.field, - direction: this.topCommunitiesSortConfig.direction - } - }).pipe( - take(1), - map((results) => results.payload), - )]; - } - /** * Gets all top communities, limited by page, and transforms this in a list of flatNodes. * @param expandedNodes List of expanded nodes; if a node is not expanded its subCommunities and collections need * not be added to the list */ - loadCommunities(expandedNodes: FlatNode[]): Observable { - const res = this.payloads$.map((payload) => { - return payload.pipe( - take(1), - switchMap((result: PaginatedList) => { - return this.transformListOfCommunities(result, 0, null, expandedNodes); - }), - catchError(() => observableOf([])), - ); - }); - return combineAndFlatten(res); + loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]): Observable { + const currentPage = findOptions.currentPage; + const topCommunities = []; + for (let i = 1; i <= currentPage; i++) { + const pagination: FindListOptions = Object.assign({}, findOptions, { currentPage: i }); + topCommunities.push(this.getTopCommunities(pagination)); + } + const topComs$ = observableCombineLatest(...topCommunities).pipe( + map((coms: Array>) => { + const newPages: Community[][] = coms.map((unit: PaginatedList) => unit.page); + const newPage: Community[] = [].concat(...newPages); + let newPageInfo = new PageInfo(); + if (coms && coms.length > 0) { + newPageInfo = Object.assign({}, coms[0].pageInfo, { currentPage }) + } + return new PaginatedList(newPageInfo, newPage); + }) + ); + return topComs$.pipe(flatMap((topComs: PaginatedList) => this.transformListOfCommunities(topComs, 0, null, expandedNodes))); }; /** * Puts the initial top level communities in a list to be called upon */ - private initTopCommunityList(): void { - this.payloads$ = [this.communityDataService.findTop({ - currentPage: this.topCommunitiesConfig.currentPage, - elementsPerPage: this.topCommunitiesConfig.pageSize, + private getTopCommunities(options: FindListOptions): Observable> { + return this.communityDataService.findTop({ + currentPage: options.currentPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, sort: { - field: this.topCommunitiesSortConfig.field, - direction: this.topCommunitiesSortConfig.direction + field: options.sort.field, + direction: options.sort.direction } }).pipe( - take(1), map((results) => results.payload), - )]; + ); } /** @@ -206,16 +179,15 @@ export class CommunityListService { parent: FlatNode, expandedNodes: FlatNode[]): Observable { if (isNotEmpty(listOfPaginatedCommunities.page)) { - let currentPage = this.topCommunitiesConfig.currentPage; + let currentPage = listOfPaginatedCommunities.currentPage; if (isNotEmpty(parent)) { currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage; } - const isNotAllCommunities = (listOfPaginatedCommunities.totalElements > (listOfPaginatedCommunities.elementsPerPage * currentPage)); let obsList = listOfPaginatedCommunities.page .map((community: Community) => { return this.transformCommunity(community, level, parent, expandedNodes) }); - if (isNotAllCommunities && listOfPaginatedCommunities.currentPage > currentPage) { + if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; } @@ -252,13 +224,12 @@ export class CommunityListService { let subcoms = []; for (let i = 1; i <= currentCommunityPage; i++) { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { - elementsPerPage: this.maxSubCommunitiesPerPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), - switchMap((rd: RemoteData>) => + getSucceededRemoteData(), + flatMap((rd: RemoteData>) => this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes)) ); @@ -271,16 +242,15 @@ export class CommunityListService { let collections = []; for (let i = 1; i <= currentCollectionPage; i++) { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { - elementsPerPage: this.maxCollectionsPerPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((rd: RemoteData>) => { let nodes = rd.payload.page .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); - if ((rd.payload.elementsPerPage * currentCollectionPage) < rd.payload.totalElements && rd.payload.currentPage > currentCollectionPage) { + if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; } return nodes; @@ -305,21 +275,18 @@ export class CommunityListService { let hasColls$: Observable; hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((results) => results.payload.totalElements > 0), ); hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((results) => results.payload.totalElements > 0), ); let hasChildren$: Observable; hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe( - take(1), map(([hasSubcoms, hasColls]: [boolean, boolean]) => { if (hasSubcoms || hasColls) { return true; diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index ddcd49cd1c..f672eae151 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,5 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/request.models'; import { CommunityListService, FlatNode } from '../community-list-service'; import { CommunityListDatasource } from '../community-list-datasource'; import { FlatTreeControl } from '@angular/cdk/tree'; @@ -27,7 +29,13 @@ export class CommunityListComponent implements OnInit, OnDestroy { dataSource: CommunityListDatasource; + paginationConfig: FindListOptions; + constructor(private communityListService: CommunityListService) { + this.paginationConfig = new FindListOptions(); + this.paginationConfig.elementsPerPage = 2; + this.paginationConfig.currentPage = 1; + this.paginationConfig.sort = new SortOptions('dc.title', SortDirection.ASC); } ngOnInit() { @@ -37,7 +45,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { }); this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => { this.expandedNodes = [...result]; - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); }); } @@ -74,7 +82,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { node.currentCommunityPage = 1; } } - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } /** @@ -94,10 +102,10 @@ export class CommunityListComponent implements OnInit, OnDestroy { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCommunityPage++; } - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } else { - this.communityListService.getNextPageTopCommunities(); - this.dataSource.loadCommunities(this.expandedNodes); + this.paginationConfig.currentPage++; + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } } 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 88d1890de2..6c9f40888f 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -150,12 +150,7 @@ export class RemoteDataBuildService { filterSuccessfulResponses(), map((response: DSOSuccessResponse) => { if (hasValue((response as DSOSuccessResponse).pageInfo)) { - const resPageInfo = (response as DSOSuccessResponse).pageInfo; - if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { - return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); - } else { - return resPageInfo; - } + return (response as DSOSuccessResponse).pageInfo; } }) ); From 1dfe7ec170137ca806000425fe82ec2332df7e78 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Mon, 4 May 2020 10:47:19 +0200 Subject: [PATCH 52/59] 70599: Test fixes comcol tree after changes --- .../community-list-service.spec.ts | 42 ++++++++++++------- .../community-list.component.spec.ts | 20 ++++----- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index f4955b2a36..accd0f23a5 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -1,21 +1,19 @@ -import { of as observableOf } from 'rxjs'; -import { TestBed, inject, async } from '@angular/core/testing'; +import { inject, TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; import { AppState } from '../app.reducer'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { PaginatedList } from '../core/data/paginated-list'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { StoreMock } from '../shared/testing/store.mock'; import { CommunityListService, FlatNode, toFlatNode } from './community-list-service'; import { CollectionDataService } from '../core/data/collection-data.service'; -import { PaginatedList } from '../core/data/paginated-list'; -import { PageInfo } from '../core/shared/page-info.model'; import { CommunityDataService } from '../core/data/community-data.service'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject$ -} from '../shared/remote-data.utils'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; -import { take } from 'rxjs/operators'; import { FindListOptions } from '../core/data/request.models'; +import { PageInfo } from '../core/shared/page-info.model'; describe('CommunityListService', () => { let store: StoreMock; @@ -212,9 +210,11 @@ describe('CommunityListService', () => { let findTopSpy; beforeEach((done) => { findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough(); - service.getNextPageTopCommunities(); - service.loadCommunities(null) + service.loadCommunities({ + currentPage: 2, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, null) .pipe(take(1)) .subscribe((value) => { flatNodeList = value; @@ -240,7 +240,10 @@ describe('CommunityListService', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities', () => { beforeEach((done) => { - service.loadCommunities(null) + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, null) .pipe(take(1)) .subscribe((value) => { flatNodeList = value; @@ -270,7 +273,10 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - service.loadCommunities(expandedNodes) + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) .pipe(take(1)) .subscribe((value) => { flatNodeList = value; @@ -295,7 +301,10 @@ describe('CommunityListService', () => { communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - service.loadCommunities(expandedNodes) + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) .pipe(take(1)) .subscribe((value) => { flatNodeList = value; @@ -317,7 +326,10 @@ describe('CommunityListService', () => { communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - service.loadCommunities(expandedNodes) + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) .pipe(take(1)) .subscribe((value) => { flatNodeList = value; diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index a91c5fa057..f4c5f959d1 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -114,15 +114,9 @@ describe('CommunityListComponent', () => { beforeEach(async(() => { communityListServiceStub = { - topPageSize: 2, - topCurrentPage: 1, - collectionPageSize: 2, - subcommunityPageSize: 2, + pageSize: 2, expandedNodes: [], loadingNode: null, - getNextPageTopCommunities() { - this.topCurrentPage++; - }, getLoadingNodeFromStore() { return observableOf(this.loadingNode); }, @@ -133,12 +127,12 @@ describe('CommunityListComponent', () => { this.expandedNodes = expandedNodes; this.loadingNode = loadingNode; }, - loadCommunities(expandedNodes) { + loadCommunities(options, expandedNodes) { let flatnodes; let showMoreTopComNode = false; flatnodes = [...mockTopFlatnodesUnexpanded]; - const currentPage = this.topCurrentPage; - const elementsPerPage = this.topPageSize; + const currentPage = options.currentPage; + const elementsPerPage = this.pageSize; let endPageIndex = (currentPage * elementsPerPage); if (endPageIndex >= flatnodes.length) { endPageIndex = flatnodes.length; @@ -171,14 +165,14 @@ describe('CommunityListComponent', () => { collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)]; }); if (isNotEmpty(subComFlatnodes)) { - const endSubComIndex = this.subcommunityPageSize * expandedParent.currentCommunityPage; + const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; if (subComFlatnodes.length > endSubComIndex) { flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; } } if (isNotEmpty(collFlatnodes)) { - const endColIndex = this.collectionPageSize * expandedParent.currentCollectionPage; + const endColIndex = this.pageSize * expandedParent.currentCollectionPage; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; if (collFlatnodes.length > endColIndex) { flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; @@ -225,6 +219,8 @@ describe('CommunityListComponent', () => { it('should render a cdk tree with the first elementsPerPage (2) nr of top level communities, unexpanded', () => { const expandableNodesFound = fixture.debugElement.queryAll(By.css('.expandable-node a')); const childlessNodesFound = fixture.debugElement.queryAll(By.css('.childless-node a')); + console.log('expandableNodesFound', expandableNodesFound) + console.log('childlessNodesFound', childlessNodesFound) const allNodes = [...expandableNodesFound, ...childlessNodesFound]; expect(allNodes.length).toEqual(2); mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { From 53779cf69e4df943b756f044cf2f74d4f6de1f7a Mon Sep 17 00:00:00 2001 From: Ben Bosman Date: Thu, 11 Jun 2020 11:52:58 +0200 Subject: [PATCH 53/59] remove console logs --- .../community-list/community-list.component.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index f4c5f959d1..ef9e89ff1b 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -219,8 +219,6 @@ describe('CommunityListComponent', () => { it('should render a cdk tree with the first elementsPerPage (2) nr of top level communities, unexpanded', () => { const expandableNodesFound = fixture.debugElement.queryAll(By.css('.expandable-node a')); const childlessNodesFound = fixture.debugElement.queryAll(By.css('.childless-node a')); - console.log('expandableNodesFound', expandableNodesFound) - console.log('childlessNodesFound', childlessNodesFound) const allNodes = [...expandableNodesFound, ...childlessNodesFound]; expect(allNodes.length).toEqual(2); mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { From 94e3f2d5e0f5618342afc2f562b7d3cfabe80a53 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 19 Jun 2020 13:17:46 +0200 Subject: [PATCH 54/59] Feedback processed: - pagesize comcol changed to 50 & - loadCommunities inside ngZone.runOutsideAngular - Chevron size small unexpanded and large expanded when logged in fixed --- ...pandable-admin-sidebar-section.component.scss | 4 ++-- .../community-list-datasource.ts | 16 ++++++++++------ .../community-list-service.ts | 2 +- .../community-list/community-list.component.ts | 7 ++++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss index 37fe15bd40..1f6e288608 100644 --- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss @@ -1,4 +1,4 @@ -::ng-deep { +:host ::ng-deep { .fa-chevron-right { padding-left: $spacer/2; font-size: 0.5rem; @@ -16,4 +16,4 @@ display: flex; flex-direction: column; } -} \ No newline at end of file +} diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 4974b2c4fe..8cd0c6b0af 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,3 +1,4 @@ +import { NgZone } from '@angular/core'; import { FindListOptions } from '../core/data/request.models'; import { CommunityListService, FlatNode } from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; @@ -15,7 +16,8 @@ export class CommunityListDatasource implements DataSource { private communityList$ = new BehaviorSubject([]); public loading$ = new BehaviorSubject(false); - constructor(private communityListService: CommunityListService) { + constructor(private communityListService: CommunityListService, + private zone: NgZone) { } connect(collectionViewer: CollectionViewer): Observable { @@ -25,11 +27,13 @@ export class CommunityListDatasource implements DataSource { loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) { this.loading$.next(true); - this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( - take(1), - finalize(() => this.loading$.next(false)), - ).subscribe((flatNodes: FlatNode[]) => { - this.communityList$.next(flatNodes); + this.zone.runOutsideAngular(() => { + this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( + take(1), + finalize(() => this.loading$.next(false)), + ).subscribe((flatNodes: FlatNode[]) => { + this.communityList$.next(flatNodes); + }); }); } diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index 70db048d3e..a5c3506e3d 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -99,7 +99,7 @@ const communityListStateSelector = (state: AppState) => state.communityList; const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); -export const MAX_COMCOLS_PER_PAGE = 2; +export const MAX_COMCOLS_PER_PAGE = 50; /** * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index f672eae151..be96ff1a0a 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { FindListOptions } from '../../core/data/request.models'; @@ -31,7 +31,8 @@ export class CommunityListComponent implements OnInit, OnDestroy { paginationConfig: FindListOptions; - constructor(private communityListService: CommunityListService) { + constructor(private communityListService: CommunityListService, + private zone: NgZone) { this.paginationConfig = new FindListOptions(); this.paginationConfig.elementsPerPage = 2; this.paginationConfig.currentPage = 1; @@ -39,7 +40,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { } ngOnInit() { - this.dataSource = new CommunityListDatasource(this.communityListService); + this.dataSource = new CommunityListDatasource(this.communityListService, this.zone); this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => { this.loadingNode = result; }); From 6433e211a8bdcd1c0ad3769fa192eb4ed446b6ac Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 19 Jun 2020 13:59:21 +0200 Subject: [PATCH 55/59] Feedback processed: - change so state change behaviourSubjects back in NgZone.run --- src/app/community-list-page/community-list-datasource.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 8cd0c6b0af..4ffb16759d 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -25,14 +25,13 @@ export class CommunityListDatasource implements DataSource { } loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) { - this.loading$.next(true); - this.zone.runOutsideAngular(() => { + this.loading$.next(true); this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( take(1), - finalize(() => this.loading$.next(false)), + finalize(() => this.zone.run(() => this.loading$.next(false))), ).subscribe((flatNodes: FlatNode[]) => { - this.communityList$.next(flatNodes); + this.zone.run(() => this.communityList$.next(flatNodes)); }); }); } From f992fe1afd57b5019fe39f82fb42379494ddb42b Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Tue, 30 Jun 2020 10:48:50 +0200 Subject: [PATCH 56/59] Feedback: - set initial loading moved outside of of NgZone --- src/app/community-list-page/community-list-datasource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 4ffb16759d..b77cbb5246 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -25,8 +25,8 @@ export class CommunityListDatasource implements DataSource { } loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) { + this.loading$.next(true); this.zone.runOutsideAngular(() => { - this.loading$.next(true); this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( take(1), finalize(() => this.zone.run(() => this.loading$.next(false))), From d35f2a419eb2020faf3dda3284611e67970f6e75 Mon Sep 17 00:00:00 2001 From: Philip Vissenaekens Date: Wed, 1 Jul 2020 10:15:49 +0200 Subject: [PATCH 57/59] 70504: removed unused import and variables --- src/app/core/auth/auth.effects.ts | 2 +- src/app/core/data/eperson-registration.service.ts | 2 +- .../create-profile/create-profile.component.spec.ts | 11 ----------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index b57d67cbd6..37ef3b79bc 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index a1ca1dbef4..bc5df87288 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -86,7 +86,7 @@ export class EpersonRegistrationService { find((href: string) => hasValue(href)), map((href: string) => { const request = new GetRequest(requestId, href); - const parser = Object.assign(request, { + Object.assign(request, { getResponseParser(): GenericConstructor { return RegistrationResponseParsingService; } diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index 5fed324a22..5d509866a9 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -62,17 +62,6 @@ describe('CreateProfileComponent', () => { const eperson = Object.assign(new EPerson(), values); beforeEach(async(() => { - const mockConfig = { - languages: [{ - code: 'en', - label: 'English', - active: true, - }, { - code: 'de', - label: 'Deutsch', - active: false - }] - }; route = {data: observableOf({registration: registration})}; router = new RouterStub(); notificationsService = new NotificationsServiceStub(); From 9a4f962a21b6f386162f459f2d33ba3457f3d140 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 1 Jul 2020 11:08:22 +0200 Subject: [PATCH 58/59] 71504: Remove unused imports --- src/app/core/auth/auth-request.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 50a285bdf9..93f55389f9 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,9 +1,8 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; import { AuthGetRequest, @@ -13,7 +12,7 @@ import { RestRequest, TokenPostRequest } from '../data/request.models'; -import { AuthStatusResponse, ErrorResponse, RestResponse, TokenResponse } from '../cache/response.models'; +import { AuthStatusResponse, ErrorResponse, TokenResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { getResponseFromEntry } from '../shared/operators'; import { HttpClient } from '@angular/common/http'; From 68ecc7ac315b568d6103fab73daf20bcd7941dd7 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 1 Jul 2020 18:42:24 +0200 Subject: [PATCH 59/59] rename token param to authentication-token --- src/app/core/shared/file.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts index 841cb60869..ca0a409b2d 100644 --- a/src/app/core/shared/file.service.ts +++ b/src/app/core/shared/file.service.ts @@ -24,7 +24,7 @@ export class FileService { */ downloadFile(url: string) { this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => { - this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?token=${token}`).toString() : url; + this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url; }); }