diff --git a/karma.conf.js b/karma.conf.js index 456c2ecd99..f40c8b2166 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,11 @@ module.exports = function (config) { }; var configuration = { - + client: { + jasmine: { + random: false + } + }, // base path that will be used to resolve all patterns (e.g. files, exclude) basePath: '', diff --git a/package.json b/package.json index 392d8f52f4..aaabc0271a 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "text-mask-core": "5.0.1", "ts-loader": "^5.2.1", "ts-md5": "^1.2.4", + "url-parse": "^1.4.7", "uuid": "^3.2.1", "webfontloader": "1.6.28", "webpack-cli": "^3.1.0", diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 3d2093e895..299c2afe63 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -459,6 +459,9 @@ "footer.link.duraspace": "DuraSpace", + "form.add": "Add", + + "form.add-help": "Click here to add the current entry and to add another one", "form.cancel": "Cancel", @@ -484,6 +487,10 @@ "form.loading": "Loading...", + "form.lookup": "Lookup", + + "form.lookup-help": "Click here to look up an existing relation", + "form.no-results": "No results found", "form.no-value": "No value entered", @@ -724,7 +731,7 @@ "item.edit.tabs.relationships.head": "Item Relationships", - "item.edit.tabs.relationships.title": "Item Edit - Relationships", + "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", @@ -1361,6 +1368,9 @@ "search.filters.applied.f.subject": "Subject", "search.filters.applied.f.submitter": "Submitter", + "search.filters.applied.f.jobTitle": "Job Title", + "search.filters.applied.f.birthDate.max": "End birth date", + "search.filters.applied.f.birthDate.min": "Start birth date", @@ -1529,15 +1539,69 @@ "submission.general.save-later": "Save for later", + "submission.sections.describe.relationship-lookup.close": "Close", + + "submission.sections.describe.relationship-lookup.search-tab.deselect-all": "Deselect all", + + "submission.sections.describe.relationship-lookup.search-tab.deselect-page": "Deselect page", + + "submission.sections.describe.relationship-lookup.search-tab.loading": "Loading...", + + "submission.sections.describe.relationship-lookup.search-tab.placeholder": "Search query", + + "submission.sections.describe.relationship-lookup.search-tab.search": "Go", + + "submission.sections.describe.relationship-lookup.search-tab.select-all": "Select all", + + "submission.sections.describe.relationship-lookup.search-tab.select-page": "Select page", + + "submission.sections.describe.relationship-lookup.selected": "Selected {{ size }} items", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Author": "Search for Authors", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal": "Search for Journals", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Issue": "Search for Journal Issues", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Volume": "Search for Journal Volumes", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding Agency": "Search for Funding Agencies", "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding": "Search for Funding", - - "submission.sections.describe.relationship-lookup.title.Funding Agency": "Funding Agency", - - "submission.sections.describe.relationship-lookup.title.Funding": "Funding", - + "submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Current Selection ({{ count }})", + + "submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues", + + "submission.sections.describe.relationship-lookup.title.Journal Volume": "Journal Volumes", + + "submission.sections.describe.relationship-lookup.title.Journal": "Journals", + + "submission.sections.describe.relationship-lookup.title.Author": "Authors", + + "submission.sections.describe.relationship-lookup.title.Funding Agency": "Funding Agency", + + "submission.sections.describe.relationship-lookup.title.Funding": "Funding", + + "submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Toggle dropdown", + + "submission.sections.describe.relationship-lookup.selection-tab.settings": "Settings", + + "submission.sections.describe.relationship-lookup.selection-tab.no-selection": "Your selection is currently empty.", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Author": "Selected Authors", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Journal": "Selected Journals", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Journal Volume": "Selected Journal Volume", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Journal Issue": "Selected Issue", + + "submission.sections.describe.relationship-lookup.name-variant.notification.content": "Would you like to save \"{{ value }}\" as a name variant for this person so you and others can reuse it for future submissions? If you don\'t you can still use it for this submission.", + + "submission.sections.describe.relationship-lookup.name-variant.notification.confirm": "Save a new name variant", + + "submission.sections.describe.relationship-lookup.name-variant.notification.decline": "Use only for this submission", "submission.sections.general.add-more": "Add more", diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index 3ad1bd4272..185d083764 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -13,7 +13,6 @@ import { combineLatest as combineLatestObservable } from 'rxjs'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; -import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index 0bbfb30821..62a8d8dabb 100644 --- a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -1,29 +1,23 @@ import { CollectionItemMapperComponent } from './collection-item-mapper.component'; -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { CommonModule } from '@angular/common'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { SearchFormComponent } from '../../shared/search-form/search-form.component'; -import { SearchPageModule } from '../../+search-page/search-page.module'; -import { ObjectCollectionComponent } from '../../shared/object-collection/object-collection.component'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { RouterStub } from '../../shared/testing/router-stub'; -import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service'; -import { SearchService } from '../../+search-page/search-service/search.service'; import { SearchServiceStub } from '../../shared/testing/search-service-stub'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { ItemDataService } from '../../core/data/item-data.service'; import { FormsModule } from '@angular/forms'; -import { SharedModule } from '../../shared/shared.module'; import { Collection } from '../../core/shared/collection.model'; import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { EventEmitter, NgModule } from '@angular/core'; +import { EventEmitter } from '@angular/core'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub'; import { By } from '@angular/platform-browser'; @@ -36,13 +30,14 @@ import { ItemSelectComponent } from '../../shared/object-select/item-select/item import { ObjectSelectService } from '../../shared/object-select/object-select.service'; import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service-stub'; import { VarDirective } from '../../shared/utils/var.directive'; -import { Observable } from 'rxjs/internal/Observable'; import { of as observableOf, of } from 'rxjs/internal/observable/of'; import { RestResponse } from '../../core/cache/response.models'; -import { SearchFixedFilterService } from '../../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { RouteService } from '../../core/services/route.service'; import { ErrorComponent } from '../../shared/error/error.component'; import { LoadingComponent } from '../../shared/loading/loading.component'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SearchService } from '../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; describe('CollectionItemMapperComponent', () => { let comp: CollectionItemMapperComponent; @@ -135,7 +130,6 @@ describe('CollectionItemMapperComponent', () => { { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: RouteService, useValue: routeServiceStub }, - { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub } ] }).compileComponents(); })); diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts index 750578cc35..5c67a78401 100644 --- a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -5,12 +5,9 @@ import { fadeIn, fadeInOut } from '../../shared/animations/fade'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; -import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service'; -import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; import { PaginatedList } from '../../core/data/paginated-list'; -import { map, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { map, startWith, switchMap, take } from 'rxjs/operators'; import { getRemoteDataPayload, getSucceededRemoteData, toDSpaceObjectListRD } from '../../core/shared/operators'; -import { SearchService } from '../../+search-page/search-service/search.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; @@ -22,6 +19,9 @@ import { isNotEmpty } from '../../shared/empty.util'; import { RestResponse } from '../../core/cache/response.models'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { SearchService } from '../../core/shared/search/search.service'; @Component({ selector: 'ds-collection-item-mapper', diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 41afbf2115..4866cf3b60 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, of as observableOf, Observable, Subject } from 'rxjs'; import { filter, flatMap, map, startWith, switchMap, take, tap } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; -import { SearchService } from '../+search-page/search-service/search.service'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; +import { SearchService } from '../core/shared/search/search.service'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { PaginatedList } from '../core/data/paginated-list'; diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts index e0a59e6916..d9e1d9465e 100644 --- a/src/app/+collection-page/collection-page.module.ts +++ b/src/app/+collection-page/collection-page.module.ts @@ -9,9 +9,8 @@ import { CreateCollectionPageComponent } from './create-collection-page/create-c import { CollectionFormComponent } from './collection-form/collection-form.component'; import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; -import { SearchService } from '../+search-page/search-service/search.service'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; -import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { SearchService } from '../core/shared/search/search.service'; import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ @@ -31,7 +30,6 @@ import { StatisticsModule } from '../statistics/statistics.module'; ], providers: [ SearchService, - SearchFixedFilterService ] }) export class CollectionPageModule { diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts index ed9351d5d2..c8740c35b2 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts @@ -1,15 +1,12 @@ -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { CommonModule } from '@angular/common'; import { ItemCollectionMapperComponent } from './item-collection-mapper.component'; import { ActivatedRoute, Router } from '@angular/router'; -import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service'; -import { SearchService } from '../../../+search-page/search-service/search.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { ItemDataService } from '../../../core/data/item-data.service'; import { RemoteData } from '../../../core/data/remote-data'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { RouterStub } from '../../../shared/testing/router-stub'; @@ -19,7 +16,6 @@ import { SearchServiceStub } from '../../../shared/testing/search-service-stub'; import { PaginatedList } from '../../../core/data/paginated-list'; import { PageInfo } from '../../../core/shared/page-info.model'; import { FormsModule } from '@angular/forms'; -import { SharedModule } from '../../../shared/shared.module'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; import { HostWindowService } from '../../../shared/host-window.service'; @@ -28,7 +24,6 @@ import { By } from '@angular/platform-browser'; import { Item } from '../../../core/shared/item.model'; import { ObjectSelectService } from '../../../shared/object-select/object-select.service'; import { ObjectSelectServiceStub } from '../../../shared/testing/object-select-service-stub'; -import { Observable } from 'rxjs/internal/Observable'; import { of } from 'rxjs/internal/observable/of'; import { RestResponse } from '../../../core/cache/response.models'; import { CollectionSelectComponent } from '../../../shared/object-select/collection-select/collection-select.component'; @@ -39,6 +34,9 @@ import { SearchFormComponent } from '../../../shared/search-form/search-form.com import { Collection } from '../../../core/shared/collection.model'; import { ErrorComponent } from '../../../shared/error/error.component'; import { LoadingComponent } from '../../../shared/loading/loading.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; describe('ItemCollectionMapperComponent', () => { let comp: ItemCollectionMapperComponent; diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts index 97b8164a6e..5494d5ab5f 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts @@ -2,15 +2,12 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core'; import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { Collection } from '../../../core/shared/collection.model'; import { Item } from '../../../core/shared/item.model'; import { getRemoteDataPayload, getSucceededRemoteData, toDSpaceObjectListRD } from '../../../core/shared/operators'; import { ActivatedRoute, Router } from '@angular/router'; -import { SearchService } from '../../../+search-page/search-service/search.service'; -import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service'; import { map, startWith, switchMap, take } from 'rxjs/operators'; import { ItemDataService } from '../../../core/data/item-data.service'; import { TranslateService } from '@ngx-translate/core'; @@ -19,6 +16,9 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { isNotEmpty } from '../../../shared/empty.util'; import { RestResponse } from '../../../core/cache/response.models'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SearchService } from '../../../core/shared/search/search.service'; @Component({ selector: 'ds-item-collection-mapper', diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts index e73b4b6f9a..aa84b160a0 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts @@ -9,7 +9,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ItemMoveComponent } from './item-move.component'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { SearchService } from '../../../+search-page/search-service/search.service'; import { of as observableOf } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -18,6 +17,7 @@ import { PaginatedList } from '../../../core/data/paginated-list'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { RestResponse } from '../../../core/cache/response.models'; import { Collection } from '../../../core/shared/collection.model'; +import { SearchService } from '../../../core/shared/search/search.service'; describe('ItemMoveComponent', () => { let comp: ItemMoveComponent; diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts index 113ee97b3f..4db7cf94da 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts @@ -1,12 +1,9 @@ import { Component, OnInit } from '@angular/core'; -import { SearchService } from '../../../+search-page/search-service/search.service'; import { first, map } from 'rxjs/operators'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; -import { SearchOptions } from '../../../+search-page/search-options.model'; import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { PaginatedList } from '../../../core/data/paginated-list'; -import { SearchResult } from '../../../+search-page/search-result.model'; import { Item } from '../../../core/shared/item.model'; import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -17,9 +14,10 @@ import { getItemEditPath } from '../../item-page-routing.module'; import { Observable, of as observableOf } from 'rxjs'; import { RestResponse } from '../../../core/cache/response.models'; import { Collection } from '../../../core/shared/collection.model'; -import { tap } from 'rxjs/internal/operators/tap'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { SearchResult } from '../../../shared/search/search-result.model'; @Component({ selector: 'ds-item-move', diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts index 48bc28a1b9..37745ec96a 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -156,7 +156,9 @@ describe('ItemRelationshipsComponent', () => { getRelatedItemsByLabel: observableOf([author1, author2]), getItemRelationshipsArray: observableOf(relationships), deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')), - getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)) + getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)), + getRelationshipsByRelatedItemIds: observableOf(relationships), + getRelationshipTypeLabelsByItem: observableOf([relationshipType.leftwardType]) } ); diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index e8f34bc70e..42ebc5563e 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -2,8 +2,8 @@ import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import { Observable } from 'rxjs/internal/Observable'; -import { filter, map, switchMap, take, tap } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; +import { filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { zip as observableZip } from 'rxjs'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; @@ -21,7 +21,6 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; import { Subscription } from 'rxjs/internal/Subscription'; -import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils'; @Component({ selector: 'ds-item-relationships', @@ -65,7 +64,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl */ ngOnInit(): void { super.ngOnInit(); - this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(this.item); + this.relationLabels$ = this.relationshipService.getRelationshipTypeLabelsByItem(this.item); this.initializeItemUpdate(); } @@ -113,8 +112,9 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl ); // Get all the relationships that should be removed const removedRelationships$ = removedItemIds$.pipe( - getRelationsByRelatedItemIds(this.item, this.relationshipService) + flatMap((uuids) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids)) ); + // const removedRelationships$ = removedItemIds$.pipe(flatMap((uuids: string[]) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids))); // Request a delete for every relationship found in the observable created above removedRelationships$.pipe( take(1), diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts index 0ff6612440..9f2eb32d99 100644 --- a/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts +++ b/src/app/+item-page/simple/item-types/publication/publication.component.spec.ts @@ -4,7 +4,6 @@ import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loa import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { ItemDataService } from '../../../../core/data/item-data.service'; -import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; @@ -15,6 +14,7 @@ import { createRelationshipsObservable } from '../shared/item.component.spec'; import { PublicationComponent } from './publication.component'; import { MetadataMap } from '../../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { RelationshipService } from '../../../../core/data/relationship.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), @@ -26,12 +26,6 @@ describe('PublicationComponent', () => { let comp: PublicationComponent; let fixture: ComponentFixture; - const searchFixedFilterServiceStub = { - /* tslint:disable:no-empty */ - getQueryByRelations: () => {} - /* tslint:enable:no-empty */ - }; - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ @@ -43,8 +37,8 @@ describe('PublicationComponent', () => { declarations: [PublicationComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ {provide: ItemDataService, useValue: {}}, - {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub}, - {provide: TruncatableService, useValue: {}} + {provide: TruncatableService, useValue: {}}, + {provide: RelationshipService, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.ts b/src/app/+item-page/simple/item-types/publication/publication.component.ts index a8c7539e64..d926e1efdb 100644 --- a/src/app/+item-page/simple/item-types/publication/publication.component.ts +++ b/src/app/+item-page/simple/item-types/publication/publication.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ItemComponent } from '../shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { Item } from '../../../../core/shared/item.model'; +import { ItemComponent } from '../shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; /** * Component that represents a publication Item page diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts index b40b1a17b6..7baf260c61 100644 --- a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts +++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts @@ -1,14 +1,12 @@ -import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; -import { hasNoValue, hasValue } from '../../../../shared/empty.util'; +import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; -import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { distinctUntilChanged, flatMap, map, switchMap } from 'rxjs/operators'; -import { zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; import { Item } from '../../../../core/shared/item.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { RelationshipService } from '../../../../core/data/relationship.service'; import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; /** * Operator for comparing arrays using a mapping function @@ -37,36 +35,6 @@ export const compareArraysUsing = (mapFn: (t: T) => any) => export const compareArraysUsingIds = () => compareArraysUsing((t: T) => hasValue(t) ? t.id : undefined); -/** - * Fetch the relationships which match the type label given - * @param {string} label Type label - * @param thisId The item's id of which the relations belong to - * @returns {(source: Observable<[Relationship[] , RelationshipType[]]>) => Observable} - */ -export const filterRelationsByTypeLabel = (label: string, thisId?: string) => - (source: Observable<[Relationship[], RelationshipType[]]>): Observable => - source.pipe( - switchMap(([relsCurrentPage, relTypesCurrentPage]) => { - const relatedItems$ = observableZip(...relsCurrentPage.map((rel: Relationship) => - observableCombineLatest( - rel.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), - rel.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload())) - ) - ); - return relatedItems$.pipe( - map((arr) => relsCurrentPage.filter((rel: Relationship, idx: number) => - hasValue(relTypesCurrentPage[idx]) && ( - (hasNoValue(thisId) && (relTypesCurrentPage[idx].leftwardType === label || - relTypesCurrentPage[idx].rightwardType === label)) || - (thisId === arr[idx][0].id && relTypesCurrentPage[idx].leftwardType === label) || - (thisId === arr[idx][1].id && relTypesCurrentPage[idx].rightwardType === label) - ) - )) - ); - }), - distinctUntilChanged(compareArraysUsingIds()) - ); - /** * Operator for turning a list of relationships into a list of the relevant items * @param {string} thisId The item's id of which the relations belong to @@ -128,17 +96,3 @@ export const paginatedRelationsToItems = (thisId: string) => ) }) ); - -/** - * Operator for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup) - * Only relationships where leftItem or rightItem's ID is present in the list provided will be returned - * @param item - * @param relationshipService - */ -export const getRelationsByRelatedItemIds = (item: Item, relationshipService: RelationshipService) => - (source: Observable): Observable => - source.pipe( - flatMap((relatedItemIds: string[]) => relationshipService.getItemResolvedRelatedItemsAndRelationships(item).pipe( - map(([leftItems, rightItems, rels]) => rels.filter((rel: Relationship, index: number) => relatedItemIds.indexOf(leftItems[index].uuid) > -1 || relatedItemIds.indexOf(rightItems[index].uuid) > -1)) - )) - ); diff --git a/src/app/+item-page/simple/item-types/shared/item.component.spec.ts b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts index add2fb1e17..5a9f1c509d 100644 --- a/src/app/+item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/+item-page/simple/item-types/shared/item.component.spec.ts @@ -9,7 +9,6 @@ import { MockTranslateLoader } from '../../../../shared/mocks/mock-translate-loa import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { isNotEmpty } from '../../../../shared/empty.util'; -import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { PaginatedList } from '../../../../core/data/paginated-list'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; @@ -24,6 +23,7 @@ import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-rep import { MetadataMap, MetadataValue } from '../../../../core/shared/metadata.models'; import { compareArraysUsing, compareArraysUsingIds } from './item-relationships-utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { RelationshipService } from '../../../../core/data/relationship.service'; /** * Create a generic test for an item-page-fields component using a mockItem and the type of component @@ -37,12 +37,6 @@ export function getItemPageFieldsTest(mockItem: Item, component) { let comp: any; let fixture: ComponentFixture; - const searchFixedFilterServiceStub = { - /* tslint:disable:no-empty */ - getQueryByRelations: () => {} - /* tslint:enable:no-empty */ - }; - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ @@ -54,8 +48,8 @@ export function getItemPageFieldsTest(mockItem: Item, component) { declarations: [component, GenericItemPageFieldComponent, TruncatePipe], providers: [ {provide: ItemDataService, useValue: {}}, - {provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub}, - {provide: TruncatableService, useValue: {}} + {provide: TruncatableService, useValue: {}}, + {provide: RelationshipService, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts index d4b0c8de89..64a96fdd52 100644 --- a/src/app/+item-page/simple/item-types/shared/item.component.ts +++ b/src/app/+item-page/simple/item-types/shared/item.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; @Component({ diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index 805b9747a9..23484f22e0 100644 --- a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -1,17 +1,15 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; -import { Observable } from 'rxjs/internal/Observable'; -import { RemoteData } from '../../../core/data/remote-data'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; import { RelationshipService } from '../../../core/data/relationship.service'; -import { Item } from '../../../core/shared/item.model'; -import { combineLatest as observableCombineLatest, of as observableOf, zip as observableZip } from 'rxjs'; import { MetadataValue } from '../../../core/shared/metadata.models'; -import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; -import { filter, map, switchMap } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { RemoteData } from '../../../core/data/remote-data'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { Item } from '../../../core/shared/item.model'; +import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import { Subscription } from 'rxjs/internal/Subscription'; import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; @Component({ diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts index 7eeddf8e70..d9e5dd9dce 100644 --- a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts @@ -4,13 +4,11 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { Item } from '../../../../core/shared/item.model'; describe('RelatedEntitiesSearchComponent', () => { let comp: RelatedEntitiesSearchComponent; let fixture: ComponentFixture; - let fixedFilterService: SearchFixedFilterService; const mockItem = Object.assign(new Item(), { id: 'id1' @@ -18,17 +16,11 @@ describe('RelatedEntitiesSearchComponent', () => { const mockRelationType = 'publicationsOfAuthor'; const mockConfiguration = 'publication'; const mockFilter= `f.${mockRelationType}=${mockItem.id}`; - const fixedFilterServiceStub = { - getFilterByRelation: () => mockFilter - }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], declarations: [RelatedEntitiesSearchComponent], - providers: [ - { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub } - ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); @@ -36,7 +28,6 @@ describe('RelatedEntitiesSearchComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(RelatedEntitiesSearchComponent); comp = fixture.componentInstance; - fixedFilterService = (comp as any).fixedFilterService; comp.relationType = mockRelationType; comp.item = mockItem; comp.configuration = mockConfiguration; diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts index 8f65cb9858..595734ed9f 100644 --- a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts @@ -1,9 +1,9 @@ import { Component, Input, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { Item } from '../../../../core/shared/item.model'; -import { SearchFixedFilterService } from '../../../../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { isNotEmpty } from '../../../../shared/empty.util'; import { of } from 'rxjs/internal/observable/of'; +import { getFilterByRelation } from '../../../../shared/utils/relation-query.utils'; @Component({ selector: 'ds-related-entities-search', @@ -47,12 +47,9 @@ export class RelatedEntitiesSearchComponent implements OnInit { fixedFilter: string; configuration$: Observable; - constructor(private fixedFilterService: SearchFixedFilterService) { - } - ngOnInit(): void { if (isNotEmpty(this.relationType) && isNotEmpty(this.item)) { - this.fixedFilter = this.fixedFilterService.getFilterByRelation(this.relationType, this.item.id); + this.fixedFilter = getFilterByRelation(this.relationType, this.item.id); } if (isNotEmpty(this.configuration)) { this.configuration$ = of(this.configuration); diff --git a/src/app/+item-page/simple/related-items/related-items-component.ts b/src/app/+item-page/simple/related-items/related-items-component.ts index c9ef8ab2a0..0446e53be5 100644 --- a/src/app/+item-page/simple/related-items/related-items-component.ts +++ b/src/app/+item-page/simple/related-items/related-items-component.ts @@ -3,9 +3,9 @@ import { Item } from '../../../core/shared/item.model'; import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; -import { RelationshipService } from '../../../core/data/relationship.service'; import { FindListOptions } from '../../../core/data/request.models'; import { ViewMode } from '../../../core/shared/view-mode.model'; +import { RelationshipService } from '../../../core/data/relationship.service'; import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; @Component({ diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts index 38d6769437..7037a971f0 100644 --- a/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.spec.ts @@ -1,10 +1,10 @@ import { of as observableOf } from 'rxjs'; import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; -import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { SearchFilter } from '../+search-page/search-filter.model'; +import { SearchFilter } from '../shared/search/search-filter.model'; import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; import { MockRoleService } from '../shared/mocks/mock-role-service'; import { cold, hot } from 'jasmine-marbles'; @@ -38,12 +38,8 @@ describe('MyDSpaceConfigurationService', () => { const roleService: any = new MockRoleService(); - const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { - getQueryByFilterName: observableOf(''), - }); - beforeEach(() => { - service = new MyDSpaceConfigurationService(roleService, fixedFilterService, spy, activatedRoute); + service = new MyDSpaceConfigurationService(roleService, spy, activatedRoute); }); describe('when the scope is called', () => { diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts index 39c7574407..10ca580d3c 100644 --- a/src/app/+my-dspace-page/my-dspace-configuration.service.ts +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts @@ -6,12 +6,11 @@ import { first, map } from 'rxjs/operators'; import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-type'; import { RoleService } from '../core/roles/role.service'; -import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; -import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; -import { RouteService } from '../core/services/route.service'; +import { SearchConfigurationOption } from '../shared/search/search-switch-configuration/search-configuration-option.model'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; +import { RouteService } from '../core/services/route.service'; /** * Service that performs all actions that have to do with the current mydspace configuration @@ -55,16 +54,14 @@ export class MyDSpaceConfigurationService extends SearchConfigurationService { * Initialize class * * @param {roleService} roleService - * @param {SearchFixedFilterService} fixedFilterService * @param {RouteService} routeService * @param {ActivatedRoute} route */ constructor(protected roleService: RoleService, - protected fixedFilterService: SearchFixedFilterService, protected routeService: RouteService, protected route: ActivatedRoute) { - super(routeService, fixedFilterService, route); + super(routeService, route); // override parent class initialization this._defaults = null; 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 76853db924..1d5564a295 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 @@ -14,7 +14,7 @@ import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; 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 '../../+search-page/search-result.model'; +import { SearchResult } from '../../shared/search/search-result.model'; /** * This component represents the whole mydspace page header diff --git a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts index b9f7fc881a..9970b4b8b2 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts @@ -19,15 +19,14 @@ import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.c import { RouteService } from '../core/services/route.service'; import { routeServiceStub } from '../shared/testing/route-service-stub'; import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; -import { SearchService } from '../+search-page/search-service/search.service'; -import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; -import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; +import { SearchService } from '../core/shared/search/search.service'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SearchFilterService } from '../+search-page/search-filters/search-filter/search-filter.service'; +import { SearchFilterService } from '../core/shared/search/search-filter.service'; import { RoleDirective } from '../shared/roles/role.directive'; import { RoleService } from '../core/roles/role.service'; import { MockRoleService } from '../shared/mocks/mock-role-service'; -import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; describe('MyDSpacePageComponent', () => { @@ -82,8 +81,6 @@ describe('MyDSpacePageComponent', () => { collapse: () => this.isCollapsed = observableOf(true), expand: () => this.isCollapsed = observableOf(false) }; - const mockFixedFilterService: SearchFixedFilterService = { - } as SearchFixedFilterService; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -123,10 +120,6 @@ describe('MyDSpacePageComponent', () => { provide: RoleService, useValue: new MockRoleService() }, - { - provide: SearchFixedFilterService, - useValue: mockFixedFilterService - } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(MyDSpacePageComponent, { diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts index 057155418c..6556eaaf1f 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.ts +++ b/src/app/+my-dspace-page/my-dspace-page.component.ts @@ -15,19 +15,19 @@ import { RemoteData } from '../core/data/remote-data'; import { DSpaceObject } from '../core/shared/dspace-object.model'; import { pushInOut } from '../shared/animations/push'; import { HostWindowService } from '../shared/host-window.service'; -import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; -import { SearchService } from '../+search-page/search-service/search.service'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; +import { SearchService } from '../core/shared/search/search.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { hasValue } from '../shared/empty.util'; import { getSucceededRemoteData } from '../core/shared/operators'; import { MyDSpaceResponseParsingService } from '../core/data/mydspace-response-parsing.service'; -import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; +import { SearchConfigurationOption } from '../shared/search/search-switch-configuration/search-configuration-option.model'; import { RoleType } from '../core/roles/role-types'; -import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; import { ViewMode } from '../core/shared/view-mode.model'; import { MyDSpaceRequest } from '../core/data/request.models'; -import { SearchResult } from '../+search-page/search-result.model'; +import { SearchResult } from '../shared/search/search-result.model'; import { Context } from '../core/shared/context.model'; export const MYDSPACE_ROUTE = '/mydspace'; diff --git a/src/app/+my-dspace-page/my-dspace-page.module.ts b/src/app/+my-dspace-page/my-dspace-page.module.ts index be14aebdea..1cf30c4ec9 100644 --- a/src/app/+my-dspace-page/my-dspace-page.module.ts +++ b/src/app/+my-dspace-page/my-dspace-page.module.ts @@ -5,7 +5,6 @@ import { SharedModule } from '../shared/shared.module'; import { MyDspacePageRoutingModule } from './my-dspace-page-routing.module'; import { MyDSpacePageComponent } from './my-dspace-page.component'; -import { SearchPageModule } from '../+search-page/search-page.module'; import { MyDSpaceResultsComponent } from './my-dspace-results/my-dspace-results.component'; import { WorkspaceItemSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component'; import { ClaimedSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component'; @@ -27,7 +26,6 @@ import { PoolSearchResultDetailElementComponent } from '../shared/object-detail/ CommonModule, SharedModule, MyDspacePageRoutingModule, - SearchPageModule ], declarations: [ MyDSpacePageComponent, diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts index 91c206fc79..2b0ef84658 100644 --- a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts @@ -2,12 +2,12 @@ import { Component, Input } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { fadeIn, fadeInOut } from '../../shared/animations/fade'; -import { SearchOptions } from '../../+search-page/search-options.model'; +import { SearchOptions } from '../../shared/search/search-options.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { ViewMode } from '../../core/shared/view-mode.model'; import { isEmpty } from '../../shared/empty.util'; -import { SearchResult } from '../../+search-page/search-result.model'; import { Context } from '../../core/shared/context.model'; +import { SearchResult } from '../../shared/search/search-result.model'; /** * Component that represents all results for mydspace page diff --git a/src/app/+search-page/configuration-search-page.component.spec.ts b/src/app/+search-page/configuration-search-page.component.spec.ts index 1d0ed9c09c..1d02f578d1 100644 --- a/src/app/+search-page/configuration-search-page.component.spec.ts +++ b/src/app/+search-page/configuration-search-page.component.spec.ts @@ -1,7 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { configureSearchComponentTestingModule } from './search.component.spec'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; describe('ConfigurationSearchPageComponent', () => { let comp: ConfigurationSearchPageComponent; diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts index 34446e8371..33d99a9cd2 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -1,15 +1,14 @@ import { HostWindowService } from '../shared/host-window.service'; -import { SearchService } from './search-service/search.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { SearchComponent } from './search.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; -import { Observable } from 'rxjs'; -import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; -import { map } from 'rxjs/operators'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; +import { Router } from '@angular/router'; +import { hasValue } from '../shared/empty.util'; import { RouteService } from '../core/services/route.service'; +import { SearchService } from '../core/shared/search/search.service'; /** * This component renders a search page using a configuration as input. @@ -45,8 +44,9 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements protected sidebarService: SidebarService, protected windowService: HostWindowService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, - protected routeService: RouteService) { - super(service, sidebarService, windowService, searchConfigService, routeService); + protected routeService: RouteService, + protected router: Router) { + super(service, sidebarService, windowService, searchConfigService, routeService, router); } /** @@ -58,24 +58,8 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements */ ngOnInit(): void { super.ngOnInit(); - } - - /** - * Get the current paginated search options after updating the configuration using the configuration input - * This is to make sure the configuration is included in the paginated search options, as it is not part of any - * query or route parameters - * @returns {Observable} - */ - protected getSearchOptions(): Observable { - return this.searchConfigService.paginatedSearchOptions.pipe( - map((options: PaginatedSearchOptions) => { - const config = this.configuration || options.configuration; - const filter = this.fixedFilterQuery || options.fixedFilter; - return Object.assign(options, { - configuration: config, - fixedFilter: filter - }); - }) - ); + if (hasValue(this.configuration)) { + this.routeService.setParameter('configuration', this.configuration); + } } } diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts deleted file mode 100644 index 591e26c8cc..0000000000 --- a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { SearchFixedFilterService } from './search-fixed-filter.service'; -import { RequestService } from '../../../core/data/request.service'; -import { of as observableOf } from 'rxjs'; -import { RequestEntry } from '../../../core/data/request.reducer'; -import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models'; - -describe('SearchFixedFilterService', () => { - let service: SearchFixedFilterService; - - const filterQuery = 'filter:query'; - - const requestServiceStub = Object.assign({ - /* tslint:disable:no-empty */ - configure: () => { - }, - /* tslint:enable:no-empty */ - generateRequestId: () => 'fake-id', - getByHref: () => observableOf(Object.assign(new RequestEntry(), { - response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') - })) - }) as RequestService; - - beforeEach(() => { - service = new SearchFixedFilterService(); - }); - - describe('when getQueryByRelations is called', () => { - const relationType = 'isRelationOf'; - const itemUUID = 'c5b277e6-2477-48bb-8993-356710c285f3'; - - it('should contain the relationType and itemUUID', () => { - const query = service.getQueryByRelations(relationType, itemUUID); - expect(query.length).toBeGreaterThan(relationType.length + itemUUID.length); - expect(query).toContain(relationType); - expect(query).toContain(itemUUID); - }); - }); -}); diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts deleted file mode 100644 index e2ac7e1547..0000000000 --- a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@angular/core'; - -/** - * Service for performing actions on the filtered-discovery-pages REST endpoint - */ -@Injectable() -export class SearchFixedFilterService { - - /** - * Get the query for looking up items by relation type - * @param {string} relationType Relation type - * @param {string} itemUUID Item UUID - * @returns {string} Query - */ - getQueryByRelations(relationType: string, itemUUID: string): string { - return `query=relation.${relationType}:${itemUUID}`; - } - - /** - * Get the filter for a relation with the item's UUID - * @param relationType The type of relation e.g. 'isAuthorOfPublication' - * @param itemUUID The item's UUID - */ - getFilterByRelation(relationType: string, itemUUID: string): string { - return `f.${relationType}=${itemUUID}`; - } -} diff --git a/src/app/+search-page/search-labels/search-labels.component.html b/src/app/+search-page/search-labels/search-labels.component.html deleted file mode 100644 index 6a668826da..0000000000 --- a/src/app/+search-page/search-labels/search-labels.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
- - - -
-
diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index 083a1b4410..315e15a593 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { SearchComponent } from './search.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { SearchPageComponent } from './search-page.component'; diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index 9eab7d1533..e5ce670013 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -3,68 +3,18 @@ import { CommonModule } from '@angular/common'; import { CoreModule } from '../core/core.module'; import { SharedModule } from '../shared/shared.module'; import { SearchPageRoutingModule } from './search-page-routing.module'; -import { SearchComponent } from './search.component'; -import { SearchResultsComponent } from './search-results/search-results.component'; -import { CommunitySearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component' -import { CollectionSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; -import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component'; -import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SidebarEffects } from '../shared/sidebar/sidebar-effects.service'; -import { SearchSettingsComponent } from './search-settings/search-settings.component'; -import { EffectsModule } from '@ngrx/effects'; -import { SearchFiltersComponent } from './search-filters/search-filters.component'; -import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component'; -import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component'; -import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; -import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; -import { SearchLabelsComponent } from './search-labels/search-labels.component'; -import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component'; -import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component'; -import { SearchFacetFilterWrapperComponent } from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component'; -import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component'; -import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; -import { SearchFacetOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component'; -import { SearchFacetSelectedOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component'; -import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; -import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component'; -import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component'; -import { SearchLabelComponent } from './search-labels/search-label/search-label.component'; +import { SearchPageComponent } from './search-page.component'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; -import { SearchPageComponent } from './search-page.component'; -import { SidebarFilterService } from '../shared/sidebar/filter/sidebar-filter.service'; -import { StatisticsModule } from '../statistics/statistics.module'; import { SearchTrackerComponent } from './search-tracker.component'; - -const effects = [ - SidebarEffects -]; +import { StatisticsModule } from '../statistics/statistics.module'; +import { SearchComponent } from './search.component'; const components = [ SearchPageComponent, SearchComponent, - SearchResultsComponent, - SearchSidebarComponent, - SearchSettingsComponent, - SearchFiltersComponent, - SearchFilterComponent, - SearchFacetFilterComponent, - SearchLabelsComponent, - SearchLabelComponent, - SearchFacetFilterComponent, - SearchFacetFilterWrapperComponent, - SearchRangeFilterComponent, - SearchTextFilterComponent, - SearchHierarchyFilterComponent, - SearchBooleanFilterComponent, - SearchFacetOptionComponent, - SearchFacetSelectedOptionComponent, - SearchFacetRangeOptionComponent, - SearchSwitchConfigurationComponent, - SearchAuthorityFilterComponent, ConfigurationSearchPageComponent, - SearchTrackerComponent, + SearchTrackerComponent ]; @NgModule({ @@ -72,30 +22,11 @@ const components = [ SearchPageRoutingModule, CommonModule, SharedModule, - EffectsModule.forFeature(effects), CoreModule.forRoot(), StatisticsModule.forRoot(), ], + providers: [ConfigurationSearchPageGuard], declarations: components, - providers: [ - SidebarService, - SidebarFilterService, - SearchFilterService, - SearchFixedFilterService, - ConfigurationSearchPageGuard, - SearchConfigurationService - ], - entryComponents: [ - SearchFacetFilterComponent, - SearchRangeFilterComponent, - SearchTextFilterComponent, - SearchHierarchyFilterComponent, - SearchBooleanFilterComponent, - SearchFacetOptionComponent, - SearchFacetSelectedOptionComponent, - SearchFacetRangeOptionComponent, - SearchAuthorityFilterComponent - ], exports: components }) diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts deleted file mode 100644 index 95359209d9..0000000000 --- a/src/app/+search-page/search-result.model.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DSpaceObject } from '../core/shared/dspace-object.model'; -import { MetadataMap } from '../core/shared/metadata.models'; -import { ListableObject } from '../shared/object-collection/shared/listable-object.model'; -import { GenericConstructor } from '../core/shared/generic-constructor'; - -/** - * Represents a search result object of a certain () DSpaceObject - */ -export class SearchResult implements ListableObject { - /** - * The DSpaceObject that was found - */ - indexableObject: T; - - /** - * The metadata that was used to find this item, hithighlighted - */ - hitHighlights: MetadataMap; - - /** - * Method that returns as which type of object this object should be rendered - */ - getRenderTypes(): Array> { - return [this.constructor as GenericConstructor]; - } -} diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html deleted file mode 100644 index c08a4f023f..0000000000 --- a/src/app/+search-page/search-results/search-results.component.html +++ /dev/null @@ -1,19 +0,0 @@ -

{{ (configuration ? configuration + '.search.results.head' : 'search.results.head') | translate }}

-
- -
- - -
- {{ 'search.results.no-results' | translate }} - - {{"search.results.no-results-link" | translate}} - -
diff --git a/src/app/+search-page/search-settings/search-settings.component.html b/src/app/+search-page/search-settings/search-settings.component.html deleted file mode 100644 index d8878982b4..0000000000 --- a/src/app/+search-page/search-settings/search-settings.component.html +++ /dev/null @@ -1,32 +0,0 @@ - -

{{ 'search.sidebar.settings.title' | translate}}

- -
- - - -
- -
- - - -
-
diff --git a/src/app/+search-page/search-tracker.component.ts b/src/app/+search-page/search-tracker.component.ts index e1df9b3905..58867e3f03 100644 --- a/src/app/+search-page/search-tracker.component.ts +++ b/src/app/+search-page/search-tracker.component.ts @@ -2,16 +2,17 @@ import { Component, Inject, OnInit } from '@angular/core'; import { Angulartics2 } from 'angulartics2'; import { filter, map, switchMap } from 'rxjs/operators'; import { SearchComponent } from './search.component'; -import { SearchService } from './search-service/search.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; import { HostWindowService } from '../shared/host-window.service'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; import { RouteService } from '../core/services/route.service'; import { hasValue } from '../shared/empty.util'; -import { SearchQueryResponse } from './search-service/search-query-response.model'; import { SearchSuccessResponse } from '../core/cache/response.models'; -import { PaginatedSearchOptions } from './paginated-search-options.model'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; +import { Router } from '@angular/router'; +import { SearchService } from '../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; +import { SearchQueryResponse } from '../shared/search/search-query-response.model'; /** * This component triggers a page view statistic @@ -30,14 +31,15 @@ import { PaginatedSearchOptions } from './paginated-search-options.model'; export class SearchTrackerComponent extends SearchComponent implements OnInit { constructor( - protected service:SearchService, - protected sidebarService:SidebarService, - protected windowService:HostWindowService, - @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService:SearchConfigurationService, - protected routeService:RouteService, - public angulartics2:Angulartics2 + protected service: SearchService, + protected sidebarService: SidebarService, + protected windowService: HostWindowService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + protected routeService: RouteService, + public angulartics2: Angulartics2, + protected router: Router ) { - super(service, sidebarService, windowService, searchConfigService, routeService); + super(service, sidebarService, windowService, searchConfigService, routeService, router); } ngOnInit():void { @@ -58,9 +60,9 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit { ) ) .subscribe((entry) => { - const config:PaginatedSearchOptions = entry.searchOptions; - const searchQueryResponse:SearchQueryResponse = entry.response; - const filters:Array<{ filter:string, operator:string, value:string, label:string; }> = []; + const config: PaginatedSearchOptions = entry.searchOptions; + const searchQueryResponse: SearchQueryResponse = entry.response; + const filters:Array<{ filter: string, operator: string, value: string, label: string; }> = []; const appliedFilters = searchQueryResponse.appliedFilters || []; for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { const appliedFilter = appliedFilters[i]; diff --git a/src/app/+search-page/search.component.html b/src/app/+search-page/search.component.html index 9423062bde..f3731607db 100644 --- a/src/app/+search-page/search.component.html +++ b/src/app/+search-page/search.component.html @@ -46,5 +46,9 @@ [scopes]="(scopeListRD$ | async)" [inPlaceSearch]="inPlaceSearch"> - +
+
+ +
+
diff --git a/src/app/+search-page/search.component.spec.ts b/src/app/+search-page/search.component.spec.ts index dbf79de977..2fe4128ba0 100644 --- a/src/app/+search-page/search.component.spec.ts +++ b/src/app/+search-page/search.component.spec.ts @@ -11,21 +11,19 @@ import { CommunityDataService } from '../core/data/community-data.service'; import { HostWindowService } from '../shared/host-window.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { SearchComponent } from './search.component'; -import { SearchService } from './search-service/search.service'; +import { SearchService } from '../core/shared/search/search.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; import { By } from '@angular/platform-browser'; import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; -import { RemoteData } from '../core/data/remote-data'; +import { SearchFilterService } from '../core/shared/search/search-filter.service'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; import { RouteService } from '../core/services/route.service'; import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; -import { PaginatedSearchOptions } from './paginated-search-options.model'; -import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; import { createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; let comp: SearchComponent; let fixture: ComponentFixture; @@ -89,7 +87,6 @@ const routeServiceStub = { return observableOf('') } }; -const mockFixedFilterService: SearchFixedFilterService = {} as SearchFixedFilterService; export function configureSearchComponentTestingModule(compType) { TestBed.configureTestingModule({ @@ -122,10 +119,6 @@ export function configureSearchComponentTestingModule(compType) { provide: SearchFilterService, useValue: {} }, - { - provide: SearchFixedFilterService, - useValue: mockFixedFilterService - }, { provide: SearchConfigurationService, useValue: { @@ -158,6 +151,7 @@ describe('SearchComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SearchComponent); comp = fixture.componentInstance; // SearchComponent test instance + comp.inPlaceSearch = false; fixture.detectChanges(); searchServiceObject = (comp as any).service; searchConfigurationServiceObject = (comp as any).searchConfigService; diff --git a/src/app/+search-page/search.component.ts b/src/app/+search-page/search.component.ts index cc763e253f..bfb99755d8 100644 --- a/src/app/+search-page/search.component.ts +++ b/src/app/+search-page/search.component.ts @@ -1,20 +1,22 @@ import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { startWith, switchMap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list'; import { RemoteData } from '../core/data/remote-data'; import { DSpaceObject } from '../core/shared/dspace-object.model'; import { pushInOut } from '../shared/animations/push'; import { HostWindowService } from '../shared/host-window.service'; -import { PaginatedSearchOptions } from './paginated-search-options.model'; -import { SearchResult } from './search-result.model'; -import { SearchService } from './search-service/search.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { hasValue, isNotEmpty } from '../shared/empty.util'; -import { SearchConfigurationService } from './search-service/search-configuration.service'; import { getSucceededRemoteData } from '../core/shared/operators'; import { RouteService } from '../core/services/route.service'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; +import { SearchResult } from '../shared/search/search-result.model'; +import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; +import { SearchService } from '../core/shared/search/search.service'; +import { currentPath } from '../shared/utils/route.utils'; +import { Router } from '@angular/router'; @Component({ selector: 'ds-search', @@ -96,7 +98,8 @@ export class SearchComponent implements OnInit { protected sidebarService: SidebarService, protected windowService: HostWindowService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, - protected routeService: RouteService) { + protected routeService: RouteService, + protected router: Router) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -159,7 +162,7 @@ export class SearchComponent implements OnInit { */ private getSearchLink(): string { if (this.inPlaceSearch) { - return './'; + return currentPath(this.router); } return this.service.getSearchLink(); } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index ad11daa121..a578c0d8c1 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -20,7 +20,7 @@ import { Store, StoreModule } from '@ngrx/store'; // Load the implementations that should be tested import { AppComponent } from './app.component'; -import { HostWindowState } from './shared/host-window.reducer'; +import { HostWindowState } from './shared/search/host-window.reducer'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { MetadataService } from './core/metadata/metadata.service'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 64e530fc84..1d0d765130 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,13 +1,5 @@ import { filter, map, take } from 'rxjs/operators'; -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - HostListener, - Inject, - OnInit, - ViewEncapsulation -} from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; import { select, Store } from '@ngrx/store'; @@ -18,12 +10,11 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; -import { HostWindowState } from './shared/host-window.reducer'; +import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; -import { RouteService } from './core/services/route.service'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; diff --git a/src/app/app.effects.ts b/src/app/app.effects.ts index b4ddb24c8e..64573609c7 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -1,9 +1,13 @@ import { StoreEffects } from './store.effects'; import { NotificationsEffects } from './shared/notifications/notifications.effects'; import { NavbarEffects } from './navbar/navbar.effects'; +import { SidebarEffects } from './shared/sidebar/sidebar-effects.service'; +import { RelationshipEffects } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects'; export const appEffects = [ StoreEffects, NavbarEffects, NotificationsEffects, + SidebarEffects, + RelationshipEffects ]; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3d8bf0ed43..926575d711 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -37,9 +37,9 @@ import { AdminSidebarComponent } from './+admin/admin-sidebar/admin-sidebar.comp import { AdminSidebarSectionComponent } from './+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; import { NavbarModule } from './navbar/navbar.module'; +import { ClientCookieService } from './core/services/client-cookie.service'; import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module'; -import { ClientCookieService } from './core/services/client-cookie.service'; export function getConfig() { return ENV_CONFIG; @@ -76,7 +76,7 @@ const ENTITY_IMPORTS = [ IMPORTS.push( StoreDevtoolsModule.instrument({ - maxAge: 100, + maxAge: 1000, logOnly: ENV_CONFIG.production, }) ); diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index d8de0cc45c..ad9247799b 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,38 +1,22 @@ -import { ActionReducerMap, createSelector, MemoizedSelector, State } from '@ngrx/store'; +import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; +import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; -import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; import { formReducer, FormState } from './shared/form/form.reducer'; -import { - SidebarState, - sidebarReducer -} from './shared/sidebar/sidebar.reducer'; -import { - SidebarFilterState, - sidebarFilterReducer, SidebarFiltersState -} from './shared/sidebar/filter/sidebar-filter.reducer'; -import { - filterReducer, - SearchFiltersState -} from './+search-page/search-filters/search-filter/search-filter.reducer'; -import { - notificationsReducer, - NotificationsState -} from './shared/notifications/notifications.reducers'; +import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; +import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; +import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; +import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; -import { - metadataRegistryReducer, - MetadataRegistryState -} from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; +import { metadataRegistryReducer, MetadataRegistryState } from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; import { hasValue } from './shared/empty.util'; import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; import { historyReducer, HistoryState } from './shared/history/history.reducer'; -import { - bitstreamFormatReducer, - BitstreamFormatRegistryState -} from './+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { selectableListReducer, SelectableListsState } from './shared/object-list/selectable-list/selectable-list.reducer'; +import { bitstreamFormatReducer, BitstreamFormatRegistryState } from './+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer'; +import { NameVariantListsState, nameVariantReducer } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; export interface AppState { router: fromRouter.RouterReducerState; @@ -49,6 +33,8 @@ export interface AppState { cssVariables: CSSVariablesState; menus: MenusState; objectSelection: ObjectSelectionListState; + selectableLists: SelectableListsState; + relationshipLists: NameVariantListsState; communityList: CommunityListState; } @@ -67,6 +53,8 @@ export const appReducers: ActionReducerMap = { cssVariables: cssVariablesReducer, menus: menusReducer, objectSelection: objectSelectionReducer, + selectableLists: selectableListReducer, + relationshipLists: nameVariantReducer, communityList: CommunityListReducer, }; diff --git a/src/app/core/cache/builders/normalized-object-build.service.ts b/src/app/core/cache/builders/normalized-object-build.service.ts index 936548cdd4..69d7454d2d 100644 --- a/src/app/core/cache/builders/normalized-object-build.service.ts +++ b/src/app/core/cache/builders/normalized-object-build.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { NormalizedObject } from '../models/normalized-object.model'; import { getMapsToType, getRelationships } from './build-decorators'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { TypedObject } from '../object-cache.reducer'; +import { CacheableObject, TypedObject } from '../object-cache.reducer'; /** * Return true if halObj has a value for `_links.self` @@ -34,14 +34,13 @@ export class NormalizedObjectBuildService { * * @param {TDomain} domainModel a domain model */ - normalize(domainModel: T): NormalizedObject { + normalize(domainModel: T): NormalizedObject { const normalizedConstructor = getMapsToType((domainModel as any).type); const relationships = getRelationships(normalizedConstructor) || []; - const normalizedModel = Object.assign({}, domainModel) as any; relationships.forEach((key: string) => { - if (hasValue(domainModel[key])) { - domainModel[key] = undefined; + if (hasValue(normalizedModel[key])) { + normalizedModel[key] = normalizedModel._links[key]; } }); return normalizedModel; 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 a9fd699af2..48c5090102 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -116,7 +116,7 @@ export class RemoteDataBuildService { const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); const tDomainList$ = requestEntry$.pipe( getResourceLinksFromResponse(), - flatMap((resourceUUIDs: string[]) => { + switchMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs).pipe( map((normList: Array>) => { return normList.map((normalized: NormalizedObject) => { @@ -273,12 +273,14 @@ export class RemoteDataBuildService { private toPaginatedList(input: Observable>>, pageInfo: PageInfo): Observable>> { return input.pipe( map((rd: RemoteData>) => { + const rdAny = rd as any; + const newRD = new RemoteData(rdAny.requestPending, rdAny.responsePending, rdAny.isSuccessful, rd.error, undefined); if (Array.isArray(rd.payload)) { - return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload) }) + return Object.assign(newRD, { payload: new PaginatedList(pageInfo, rd.payload) }) } else if (isNotUndefined(rd.payload)) { - return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload.page) }); + return Object.assign(newRD, { payload: new PaginatedList(pageInfo, rd.payload.page) }); } else { - return Object.assign(rd, { payload: new PaginatedList(pageInfo, []) }); + return Object.assign(newRD, { payload: new PaginatedList(pageInfo, []) }); } }) ); diff --git a/src/app/core/cache/models/items/normalized-relationship.model.ts b/src/app/core/cache/models/items/normalized-relationship.model.ts index 0930134b18..1c1dcf8d5b 100644 --- a/src/app/core/cache/models/items/normalized-relationship.model.ts +++ b/src/app/core/cache/models/items/normalized-relationship.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, deserializeAs, inheritSerialization } from 'cerialize'; import { Relationship } from '../../../shared/item-relationships/relationship.model'; import { mapsTo, relationship } from '../../builders/build-decorators'; import { NormalizedObject } from '../normalized-object.model'; @@ -16,20 +16,20 @@ export class NormalizedRelationship extends NormalizedObject { /** * The identifier of this Relationship */ - @autoserialize + @deserialize id: string; /** * The item to the left of this relationship */ - @autoserialize + @deserialize @relationship(Item, false) leftItem: string; /** * The item to the right of this relationship */ - @autoserialize + @deserialize @relationship(Item, false) rightItem: string; @@ -46,15 +46,27 @@ export class NormalizedRelationship extends NormalizedObject { rightPlace: number; /** - * The type of Relationship + * The name variant of the Item to the left side of this Relationship */ @autoserialize + leftwardValue: string; + + /** + * The name variant of the Item to the right side of this Relationship + */ + @autoserialize + rightwardValue: string; + + /** + * The type of Relationship + */ + @deserialize @relationship(RelationshipType, false) relationshipType: string; /** * The universally unique identifier of this Relationship */ - @autoserializeAs(new IDToUUIDSerializer(Relationship.type.value), 'id') + @deserializeAs(new IDToUUIDSerializer(Relationship.type.value), 'id') uuid: string; } diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts index bb5f192a7a..8a3aed32c9 100644 --- a/src/app/core/cache/models/normalized-object.model.ts +++ b/src/app/core/cache/models/normalized-object.model.ts @@ -1,5 +1,5 @@ import { CacheableObject, TypedObject } from '../object-cache.reducer'; -import { autoserialize } from 'cerialize'; +import { autoserialize, deserialize } from 'cerialize'; import { ResourceType } from '../../shared/resource-type'; /** * An abstract model class for a NormalizedObject. @@ -8,10 +8,10 @@ export abstract class NormalizedObject implements Cacheab /** * The link to the rest endpoint where this object can be found */ - @autoserialize + @deserialize self: string; - @autoserialize + @deserialize _links: { [name: string]: string }; @@ -19,6 +19,6 @@ export abstract class NormalizedObject implements Cacheab /** * A string representing the kind of object */ - @autoserialize + @deserialize type: string; } diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index e12629fa27..8d4e910471 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -196,8 +196,9 @@ export class ObjectCacheService { * false otherwise */ hasByUUID(uuid: string): boolean { - let result: boolean; + let result = false; + /* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/ this.store.pipe( select(selfLinkFromUuidSelector(uuid)), take(1) diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 03233e616b..3915eca23f 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,9 +1,9 @@ -import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; +import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; -import { FacetValue } from '../../+search-page/search-service/facet-value.model'; -import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-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'; diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 0eabfc5dc8..9f4242cc9a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -6,6 +6,7 @@ import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.e import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; import { RouteEffects } from './services/route.effects'; +import { RouterEffects } from './router/router.effects'; export const coreEffects = [ RequestEffects, @@ -15,5 +16,6 @@ export const coreEffects = [ JsonPatchOperationsEffects, ServerSyncBufferEffects, ObjectUpdatesEffects, - RouteEffects + RouteEffects, + RouterEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index f8a8626f15..4fdef02357 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -53,7 +53,7 @@ import { UUIDService } from './shared/uuid.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthRequestService } from './auth/auth-request.service'; import { AuthResponseParsingService } from './auth/auth-response-parsing.service'; -import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { AuthInterceptor } from './auth/auth.interceptor'; import { HALEndpointService } from './shared/hal-endpoint.service'; import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; @@ -80,7 +80,8 @@ import { NormalizedObjectBuildService } from './cache/builders/normalized-object import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; -import { SearchService } from '../+search-page/search-service/search.service'; +import { SearchService } from './shared/search/search.service'; +import { RelationshipService } from './data/relationship.service'; import { NormalizedCollection } from './cache/models/normalized-collection.model'; import { NormalizedCommunity } from './cache/models/normalized-community.model'; import { NormalizedDSpaceObject } from './cache/models/normalized-dspace-object.model'; @@ -101,7 +102,6 @@ import { NormalizedSubmissionFormsModel } from './config/models/normalized-confi import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model'; import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; -import { RelationshipService } from './data/relationship.service'; import { RoleService } from './roles/role.service'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; @@ -124,6 +124,31 @@ import { ObjectSelectService } from '../shared/object-select/object-select.servi import { SiteDataService } from './data/site-data.service'; import { NormalizedSite } from './cache/models/normalized-site.model'; +import { + MOCK_RESPONSE_MAP, + MockResponseMap, + mockResponseMap +} from '../shared/mocks/dspace-rest-v2/mocks/mock-response-map'; +import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; +import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config'; +import { SearchFilterService } from './shared/search/search-filter.service'; +import { SearchConfigurationService } from './shared/search/search-configuration.service'; +import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; +import { RelationshipTypeService } from './data/relationship-type.service'; +import { SidebarService } from '../shared/sidebar/sidebar.service'; + +/** + * When not in production, endpoint responses can be mocked for testing purposes + * If there is no mock version available for the endpoint, the actual REST response will be used just like in production mode + */ +export const restServiceFactory = (cfg: GlobalConfig, mocks: MockResponseMap, http: HttpClient) => { + if (ENV_CONFIG.production) { + return new DSpaceRESTv2Service(http); + } else { + return new EndpointMockingRestService(cfg, mocks, http); + } +}; + const IMPORTS = [ CommonModule, StoreModule.forFeature('core', coreReducers, {}), @@ -143,7 +168,8 @@ const PROVIDERS = [ CollectionDataService, SiteDataService, DSOResponseParsingService, - DSpaceRESTv2Service, + { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, + { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]}, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, @@ -214,6 +240,13 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + SearchService, + SidebarService, + SearchFilterService, + SearchFilterService, + SearchConfigurationService, + SelectableListService, + RelationshipTypeService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 8902fdef5e..0c032e6766 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -25,8 +25,8 @@ import { ResponseParsingService } from './parsing.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; import { SearchParam } from '../cache/models/search-param.model'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; @Injectable() export class CollectionDataService extends ComColDataService { @@ -71,7 +71,9 @@ export class CollectionDataService extends ComColDataService { */ getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable>> { const searchHref = 'findAuthorizedByCommunity'; - options.searchParams = [new SearchParam('uuid', communityId)]; + options = Object.assign({}, options, { + searchParams: [new SearchParam('uuid', communityId)] + }); return this.searchBy(searchHref, options).pipe( filter((collections: RemoteData>) => !collections.isResponsePending)); diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index f2f10eff35..1ea2652813 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -16,6 +16,7 @@ import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { Item } from '../shared/item.model'; +import * as uuidv4 from 'uuid/v4'; const endpoint = 'https://rest.api/core'; @@ -51,10 +52,11 @@ class DummyChangeAnalyzer implements ChangeAnalyzer { } } + describe('DataService', () => { let service: TestService; let options: FindListOptions; - const requestService = {} as RequestService; + const requestService = {generateRequestId: () => uuidv4()} as RequestService; const halService = {} as HALEndpointService; const rdbService = {} as RemoteDataBuildService; const notificationsService = {} as NotificationsService; @@ -87,6 +89,7 @@ describe('DataService', () => { comparator, ); } + service = initTestService(); describe('getFindAllHref', () => { @@ -188,7 +191,7 @@ describe('DataService', () => { dso2.self = selfLink; dso2.metadata = [{ key: 'dc.title', value: name2 }]; - spyOn(service, 'findById').and.returnValues(observableOf(dso)); + spyOn(service, 'findByHref').and.returnValues(observableOf(dso)); spyOn(objectCache, 'getObjectBySelfLink').and.returnValues(observableOf(dso)); spyOn(objectCache, 'addPatch'); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 135547cdcc..997ccec49f 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, skipWhile, switchMap, take, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; @@ -157,7 +157,7 @@ export abstract class DataService { findById(id: string): Observable> { const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id)))); + map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id)))); hrefObs.pipe( find((href: string) => hasValue(href))) @@ -204,15 +204,22 @@ export abstract class DataService { const hrefObs = this.getSearchByHref(searchMethod, options); - hrefObs.pipe( - first((href: string) => hasValue(href))) - .subscribe((href: string) => { - const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - request.responseMsToLive = 10 * 1000; - this.requestService.configure(request); - }); + return hrefObs.pipe( + find((href: string) => hasValue(href)), + tap((href: string) => { + this.requestService.removeByHrefSubstring(href); + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); + request.responseMsToLive = 10 * 1000; - return this.rdbService.buildList(hrefObs) as Observable>>; + this.requestService.configure(request); + } + ), + switchMap((href) => this.requestService.getByHref(href)), + skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), + switchMap((href) => + this.rdbService.buildList(hrefObs) as Observable>> + ) + ); } /** @@ -236,7 +243,7 @@ export abstract class DataService { if (isNotEmpty(operations)) { this.objectCache.addPatch(object.self, operations); } - return this.findById(object.uuid); + return this.findByHref(object.self); } )); diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index cd30479f6d..862c0e5b85 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -4,6 +4,7 @@ import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; import { CacheableObject } from '../cache/object-cache.reducer'; import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; /** * A class to determine what differs between two @@ -11,6 +12,8 @@ import { NormalizedObject } from '../cache/models/normalized-object.model'; */ @Injectable() export class DefaultChangeAnalyzer implements ChangeAnalyzer { + constructor(private normalizeService: NormalizedObjectBuildService) { + } /** * Compare the metadata of two CacheableObject and return the differences as @@ -22,6 +25,6 @@ export class DefaultChangeAnalyzer implements ChangeA * The second object to compare */ diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[] { - return compare(object1, object2); + return compare(this.normalizeService.normalize(object1), this.normalizeService.normalize(object2)); } } diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 15f520b249..19b37f8b5d 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -7,7 +7,7 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model'; +import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; diff --git a/src/app/core/data/facet-value-map-response-parsing.service.ts b/src/app/core/data/facet-value-map-response-parsing.service.ts index b67cef97c0..64c8e87e7d 100644 --- a/src/app/core/data/facet-value-map-response-parsing.service.ts +++ b/src/app/core/data/facet-value-map-response-parsing.service.ts @@ -9,7 +9,7 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { FacetValue } from '../../+search-page/search-service/facet-value.model'; +import { FacetValue } from '../../shared/search/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 49d72e0a01..70585bc3d9 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -4,7 +4,7 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { FacetValue } from '../../+search-page/search-service/facet-value.model'; +import { FacetValue } from '../../shared/search/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GLOBAL_CONFIG } from '../../../config'; diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index a6945e27b4..bd5d5b1083 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -6,7 +6,7 @@ import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { hasValue } from '../../shared/empty.util'; -import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; +import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @Injectable() diff --git a/src/app/core/data/paginated-list.ts b/src/app/core/data/paginated-list.ts index e1c1b22569..b9de67a34d 100644 --- a/src/app/core/data/paginated-list.ts +++ b/src/app/core/data/paginated-list.ts @@ -3,7 +3,7 @@ import { hasValue } from '../../shared/empty.util'; export class PaginatedList { - constructor(private pageInfo: PageInfo, + constructor(public pageInfo: PageInfo, public page: T[]) { } diff --git a/src/app/core/data/relationship-type.service.spec.ts b/src/app/core/data/relationship-type.service.spec.ts new file mode 100644 index 0000000000..118baf8738 --- /dev/null +++ b/src/app/core/data/relationship-type.service.spec.ts @@ -0,0 +1,99 @@ +import { RequestService } from './request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { PaginatedList } from './paginated-list'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { RelationshipTypeService } from './relationship-type.service'; +import { of as observableOf } from 'rxjs'; +import { ItemType } from '../shared/item-relationships/item-type.model'; + +describe('RelationshipTypeService', () => { + let service: RelationshipTypeService; + let requestService: RequestService; + let restEndpointURL; + let halService: any; + let publicationTypeString; + let personTypeString; + let orgUnitTypeString; + let publicationType; + let personType; + let orgUnitType; + + let relationshipType1; + let relationshipType2; + + let buildList; + let rdbService; + + function init() { + restEndpointURL = 'https://rest.api/relationshiptypes'; + halService = new HALEndpointServiceStub(restEndpointURL); + publicationTypeString = 'Publication'; + personTypeString = 'Person'; + orgUnitTypeString = 'OrgUnit'; + publicationType = Object.assign(new ItemType(), {label: publicationTypeString}); + personType = Object.assign(new ItemType(), {label: personTypeString}); + orgUnitType = Object.assign(new ItemType(), {label: orgUnitTypeString}); + + relationshipType1 = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor', + leftType: createSuccessfulRemoteDataObject$(publicationType), + rightType: createSuccessfulRemoteDataObject$(personType) + }); + + relationshipType2 = Object.assign(new RelationshipType(), { + id: '2', + uuid: '2', + leftwardType: 'isOrgUnitOfPublication', + rightwardType: 'isPublicationOfOrgUnit', + leftType: createSuccessfulRemoteDataObject$(publicationType), + rightType: createSuccessfulRemoteDataObject$(orgUnitType) + }); + + buildList = createSuccessfulRemoteDataObject(new PaginatedList(new PageInfo(), [relationshipType1, relationshipType2])); + rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList)); + + } + function initTestService() { + return new RelationshipTypeService( + requestService, + halService, + rdbService + ); + } + + beforeEach(() => { + init(); + requestService = getMockRequestService(); + service = initTestService(); + }); + + describe('getAllRelationshipTypes', () => { + + it('should return all relationshipTypes', (done) => { + const expected = service.getAllRelationshipTypes({}); + expected.subscribe((e) => { + expect(e).toBe(buildList); + done(); + }) + }); + }); + + describe('getRelationshipTypeByLabelAndTypes', () => { + + it('should return the type filtered by label and type strings', (done) => { + const expected = service.getRelationshipTypeByLabelAndTypes(relationshipType1.leftwardType, publicationTypeString, personTypeString); + expected.subscribe((e) => { + expect(e).toBe(relationshipType1); + done(); + }) + }); + }); + +}); diff --git a/src/app/core/data/relationship-type.service.ts b/src/app/core/data/relationship-type.service.ts new file mode 100644 index 0000000000..627fc4863f --- /dev/null +++ b/src/app/core/data/relationship-type.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { filter, find, map, switchMap, tap } from 'rxjs/operators'; +import { configureRequest, getSucceededRemoteData } from '../shared/operators'; +import { Observable } from 'rxjs/internal/Observable'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { RemoteData } from './remote-data'; +import { PaginatedList } from './paginated-list'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemType } from '../shared/item-relationships/item-type.model'; +import { isNotUndefined } from '../../shared/empty.util'; +import { FindListOptions, FindListRequest } from './request.models'; + +/** + * The service handling all relationship requests + */ +@Injectable() +export class RelationshipTypeService { + protected linkPath = 'relationshiptypes'; + + constructor(protected requestService: RequestService, + protected halService: HALEndpointService, + protected rdbService: RemoteDataBuildService) { + } + + /** + * Get the endpoint for a relationship type by ID + * @param id + */ + getRelationshipTypeEndpoint(id: number) { + return this.halService.getEndpoint(this.linkPath).pipe( + map((href: string) => `${href}/${id}`) + ); + } + + getAllRelationshipTypes(options: FindListOptions): Observable>> { + const link$ = this.halService.getEndpoint(this.linkPath); + return link$ + .pipe( + map((endpointURL: string) => new FindListRequest(this.requestService.generateRequestId(), endpointURL, options)), + configureRequest(this.requestService), + switchMap(() => this.rdbService.buildList(link$)) + ); + } + + /** + * Get the RelationshipType for a relationship type by label + * @param label + */ + getRelationshipTypeByLabelAndTypes(label: string, firstType: string, secondType: string): Observable { + return this.getAllRelationshipTypes({ currentPage: 1, elementsPerPage: Number.MAX_VALUE }) + .pipe( + getSucceededRemoteData(), + /* Flatten the page so we can treat it like an observable */ + switchMap((typeListRD: RemoteData>) => typeListRD.payload.page), + switchMap((type: RelationshipType) => { + if (type.leftwardType === label) { + return this.checkType(type, firstType, secondType); + } else if (type.rightwardType === label) { + return this.checkType(type, secondType, firstType); + } else { + return []; + } + }), + ); + } + + // Check if relationship type matches the given types + // returns a void observable if there's not match + // returns an observable that emits the relationship type when there is a match + private checkType(type: RelationshipType, firstType: string, secondType: string): Observable { + const entityTypes = observableCombineLatest(type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData())); + return entityTypes.pipe( + find(([leftTypeRD, rightTypeRD]: [RemoteData, RemoteData]) => leftTypeRD.payload.label === firstType && rightTypeRD.payload.label === secondType), + filter((types) => isNotUndefined(types)), + map(() => type) + ); + } +} diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index 4091759386..b33db80fbe 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -71,12 +71,14 @@ describe('RelationshipService', () => { const rdbService = getMockRemoteDataBuildService(undefined, buildList$); const objectCache = Object.assign({ /* tslint:disable:no-empty */ - remove: () => {} + remove: () => {}, + hasBySelfLinkObservable: () => observableOf(false) /* tslint:enable:no-empty */ }) as ObjectCacheService; const itemService = jasmine.createSpyObj('itemService', { - findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.filter((relatedItem) => relatedItem.id === uuid)[0]) + findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.find((relatedItem) => relatedItem.id === uuid)), + findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]) }); function initTestService() { @@ -90,6 +92,7 @@ describe('RelationshipService', () => { objectCache, null, null, + null, null ); } @@ -133,14 +136,6 @@ describe('RelationshipService', () => { }); }); - describe('getItemRelationshipLabels', () => { - it('should return the correct labels', () => { - service.getItemRelationshipLabels(item).subscribe((result) => { - expect(result).toEqual([relationshipType.rightwardType]); - }); - }); - }); - describe('getRelatedItems', () => { it('should return the related items', () => { service.getRelatedItems(item).subscribe((result) => { diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index f102e2ca53..d624238bb8 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -2,38 +2,43 @@ import { Injectable } from '@angular/core'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util'; -import { distinctUntilChanged, filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; -import { - configureRequest, - filterSuccessfulResponses, - getRemoteDataPayload, getResponseFromEntry, - getSucceededRemoteData -} from '../shared/operators'; -import { DeleteRequest, FindListOptions, RestRequest } from './request.models'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, map, mergeMap, skipWhile, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; +import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; import { Observable } from 'rxjs/internal/Observable'; import { RestResponse } from '../cache/response.models'; import { Item } from '../shared/item.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RemoteData } from './remote-data'; -import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; -import { zip as observableZip } from 'rxjs'; +import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; import { PaginatedList } from './paginated-list'; import { ItemDataService } from './item-data.service'; -import { - compareArraysUsingIds, filterRelationsByTypeLabel, paginatedRelationsToItems, - relationsToItems -} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DataService } from './data.service'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { Store } from '@ngrx/store'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { SearchParam } from '../cache/models/search-param.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { AppState, keySelector } from '../../app.reducer'; +import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; + +const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; + +const relationshipListStateSelector = (listID: string): MemoizedSelector => { + return keySelector(listID, relationshipListsStateSelector); +}; + +const relationshipStateSelector = (listID: string, itemID: string): MemoizedSelector => { + return keySelector(itemID, relationshipListStateSelector(listID)); +}; /** * The service handling all relationship requests @@ -52,7 +57,8 @@ export class RelationshipService extends DataService { protected objectCache: ObjectCacheService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { + protected comparator: DefaultChangeAnalyzer, + protected appStore: Store) { super(); } @@ -65,76 +71,94 @@ export class RelationshipService extends DataService { * @param uuid */ getRelationshipEndpoint(uuid: string) { - return this.halService.getEndpoint(this.linkPath).pipe( + return this.getBrowseEndpoint().pipe( map((href: string) => `${href}/${uuid}`) ); } - /** - * Find a relationship by its UUID - * @param uuid - */ - findById(uuid: string): Observable> { - const href$ = this.getRelationshipEndpoint(uuid); - return this.rdbService.buildSingle(href$); - } - /** * Send a delete request for a relationship by ID - * @param uuid + * @param id */ - deleteRelationship(uuid: string): Observable { - return this.getRelationshipEndpoint(uuid).pipe( + deleteRelationship(id: string): Observable { + return this.getRelationshipEndpoint(id).pipe( isNotEmptyOperator(), - distinctUntilChanged(), + take(1), map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), - tap(() => this.clearRelatedCache(uuid)) + tap(() => this.removeRelationshipItemsFromCacheByRelationship(id)) ); } /** - * Get a combined observable containing an array of all relationships in an item, as well as an array of the relationships their types - * This is used for easier access of a relationship's type because they exist as observables - * @param item + * Method to create a new relationship + * @param typeId The identifier of the relationship type + * @param item1 The first item of the relationship + * @param item2 The second item of the relationship + * @param leftwardValue The leftward value of the relationship + * @param rightwardValue The rightward value of the relationship */ - getItemResolvedRelsAndTypes(item: Item): Observable<[Relationship[], RelationshipType[]]> { - return observableCombineLatest( - this.getItemRelationshipsArray(item), - this.getItemRelationshipTypesArray(item) + addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + take(1), + map((endpointUrl: string) => `${endpointUrl}?relationshipType=${typeId}`), + map((endpointUrl: string) => isNotEmpty(leftwardValue) ? `${endpointUrl}&leftwardValue=${leftwardValue}` : endpointUrl), + map((endpointUrl: string) => isNotEmpty(rightwardValue) ? `${endpointUrl}&rightwardValue=${rightwardValue}` : endpointUrl), + map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, `${item1.self} \n ${item2.self}`, options)), + configureRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), + getResponseFromEntry(), + tap(() => this.removeRelationshipItemsFromCache(item1)), + tap(() => this.removeRelationshipItemsFromCache(item2)) ); } /** - * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships their types - * This is used for easier access of a relationship's type and left and right items because they exist as observables - * @param item + * Method to remove two items of a relationship from the cache using the identifier of the relationship + * @param relationshipId The identifier of the relationship */ - getItemResolvedRelatedItemsAndTypes(item: Item): Observable<[Item[], Item[], RelationshipType[]]> { - return observableCombineLatest( - this.getItemLeftRelatedItemArray(item), - this.getItemRightRelatedItemArray(item), - this.getItemRelationshipTypesArray(item) - ); + private removeRelationshipItemsFromCacheByRelationship(relationshipId: string) { + this.findById(relationshipId).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((relationship: Relationship) => combineLatest( + relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), + relationship.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()) + ) + ), + take(1) + ).subscribe(([item1, item2]) => { + this.removeRelationshipItemsFromCache(item1); + this.removeRelationshipItemsFromCache(item2); + }) } /** - * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships themselves - * This is used for easier access of the relationship and their left and right items because they exist as observables - * @param item + * Method to remove an item that's part of a relationship from the cache + * @param item The item to remove from the cache */ - getItemResolvedRelatedItemsAndRelationships(item: Item): Observable<[Item[], Item[], Relationship[]]> { - return observableCombineLatest( - this.getItemLeftRelatedItemArray(item), - this.getItemRightRelatedItemArray(item), - this.getItemRelationshipsArray(item) - ); + private removeRelationshipItemsFromCache(item) { + this.objectCache.remove(item.self); + this.requestService.removeByHrefSubstring(item.self); + combineLatest( + this.objectCache.hasBySelfLinkObservable(item.self), + this.requestService.hasByHrefObservable(item.self) + ).pipe( + filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC), + take(1), + switchMap(() => this.itemService.findByHref(item.self).pipe(take(1))) + ).subscribe(); } /** - * Get an item their relationships in the form of an array + * Get an item its relationships in the form of an array * @param item */ getItemRelationshipsArray(item: Item): Observable { @@ -148,67 +172,33 @@ export class RelationshipService extends DataService { } /** - * Get an item their relationship types in the form of an array - * @param item - */ - getItemRelationshipTypesArray(item: Item): Observable { - return this.getItemRelationshipsArray(item).pipe( - flatMap((rels: Relationship[]) => - observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( - map(([...arr]: Array>) => arr.map((d: RemoteData) => d.payload).filter((type) => hasValue(type))), - filter((arr) => arr.length === rels.length) - ) - ), - distinctUntilChanged(compareArraysUsingIds()) - ); - } - - /** - * Get an item his relationship's left-side related items in the form of an array - * @param item - */ - getItemLeftRelatedItemArray(item: Item): Observable { - return this.getItemRelationshipsArray(item).pipe( - flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.leftItem)).pipe( - map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), - filter((arr) => arr.length === rels.length) - )), - distinctUntilChanged(compareArraysUsingIds()) - ); - } - - /** - * Get an item his relationship's right-side related items in the form of an array - * @param item - */ - getItemRightRelatedItemArray(item: Item): Observable { - return this.getItemRelationshipsArray(item).pipe( - flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.rightItem)).pipe( - map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), - filter((arr) => arr.length === rels.length) - )), - distinctUntilChanged(compareArraysUsingIds()) - ); - } - - /** - * Get an array of an item their unique relationship type's labels + * Get an array of the labels of an item’s unique relationship types * The array doesn't contain any duplicate labels * @param item */ - getItemRelationshipLabels(item: Item): Observable { - return this.getItemResolvedRelatedItemsAndTypes(item).pipe( - map(([leftItems, rightItems, relTypesCurrentPage]) => { - return relTypesCurrentPage.map((type, index) => { - if (leftItems[index].uuid === item.uuid) { - return type.leftwardType; - } else { - return type.rightwardType; - } - }); - }), + getRelationshipTypeLabelsByItem(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + switchMap((relationships: Relationship[]) => observableCombineLatest(relationships.map((relationship: Relationship) => this.getRelationshipTypeLabelByRelationshipAndItem(relationship, item)))), map((labels: string[]) => Array.from(new Set(labels))) - ) + ); + } + + private getRelationshipTypeLabelByRelationshipAndItem(relationship: Relationship, item: Item): Observable { + return relationship.leftItem.pipe( + getSucceededRemoteData(), + map((itemRD: RemoteData) => itemRD.payload), + switchMap((otherItem: Item) => relationship.relationshipType.pipe( + getSucceededRemoteData(), + map((relationshipTypeRD) => relationshipTypeRD.payload), + map((relationshipType: RelationshipType) => { + if (otherItem.uuid === item.uuid) { + return relationshipType.leftwardType; + } else { + return relationshipType.rightwardType; + } + }) + ) + )) } /** @@ -244,7 +234,7 @@ export class RelationshipService extends DataService { if (options) { findListOptions = Object.assign(new FindListOptions(), options); } - const searchParams = [ new SearchParam('label', label), new SearchParam('dso', item.id) ]; + const searchParams = [new SearchParam('label', label), new SearchParam('dso', item.id)]; if (findListOptions.searchParams) { findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; } else { @@ -254,20 +244,146 @@ export class RelationshipService extends DataService { } /** - * Clear object and request caches of the items related to a relationship (left and right items) - * @param uuid + * Method for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup) + * Only relationships where leftItem or rightItem's ID is present in the list provided will be returned + * @param item + * @param uuids */ - clearRelatedCache(uuid: string) { - this.findById(uuid).pipe( + getRelationshipsByRelatedItemIds(item: Item, uuids: string[]): Observable { + return this.getItemRelationshipsArray(item).pipe( + switchMap((relationships: Relationship[]) => { + return observableCombineLatest(...relationships.map((relationship: Relationship) => { + const isLeftItem$ = this.isItemInUUIDArray(relationship.leftItem, uuids); + const isRightItem$ = this.isItemInUUIDArray(relationship.rightItem, uuids); + return observableCombineLatest(isLeftItem$, isRightItem$).pipe( + filter(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), + map(() => relationship), + startWith(undefined) + ); + })) + }), + map((relationships: Relationship[]) => relationships.filter(((relationship) => hasValue(relationship)))), + ) + } + + private isItemInUUIDArray(itemRD$: Observable>, uuids: string[]) { + return itemRD$.pipe( getSucceededRemoteData(), - flatMap((rd: RemoteData) => observableCombineLatest(rd.payload.leftItem.pipe(getSucceededRemoteData()), rd.payload.rightItem.pipe(getSucceededRemoteData()))), - take(1) - ).subscribe(([leftItem, rightItem]) => { - this.objectCache.remove(leftItem.payload.self); - this.objectCache.remove(rightItem.payload.self); - this.requestService.removeByHrefSubstring(leftItem.payload.self); - this.requestService.removeByHrefSubstring(rightItem.payload.self); - }); + map((itemRD: RemoteData) => itemRD.payload), + map((item: Item) => uuids.includes(item.uuid)) + ); + } + + /** + * Method to retrieve a relationship based on two items and a relationship type label + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param label The rightward or leftward type of the relationship + */ + getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string): Observable { + return this.getItemRelationshipsByLabel(item1, label) + .pipe( + getSucceededRemoteData(), + isNotEmptyOperator(), + map((relationshipListRD: RemoteData>) => relationshipListRD.payload.page), + mergeMap((relationships: Relationship[]) => { + return observableCombineLatest(...relationships.map((relationship: Relationship) => { + return observableCombineLatest( + this.isItemMatchWithItemRD(relationship.leftItem, item2), + this.isItemMatchWithItemRD(relationship.rightItem, item2) + ).pipe( + map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem), + map((isMatch) => isMatch ? relationship : undefined) + ); + })) + }), + map((relationships: Relationship[]) => relationships.find(((relationship) => hasValue(relationship)))) + ) + } + + private isItemMatchWithItemRD(itemRD$: Observable>, itemCheck: Item): Observable { + return itemRD$.pipe( + getSucceededRemoteData(), + map((itemRD: RemoteData) => itemRD.payload), + map((item: Item) => item.uuid === itemCheck.uuid) + ); + } + + /** + * Method to set the name variant for specific list and item + * @param listID The list for which to save the name variant + * @param itemID The item ID for which to save the name variant + * @param nameVariant The name variant to save + */ + public setNameVariant(listID: string, itemID: string, nameVariant: string) { + this.appStore.dispatch(new SetNameVariantAction(listID, itemID, nameVariant)); + } + + /** + * Method to retrieve the name variant for a specific list and item + * @param listID The list for which to retrieve the name variant + * @param itemID The item ID for which to retrieve the name variant + */ + public getNameVariant(listID: string, itemID: string): Observable { + return this.appStore.pipe( + select(relationshipStateSelector(listID, itemID)) + ); + } + + /** + * Method to remove the name variant for specific list and item + * @param listID The list for which to remove the name variant + * @param itemID The item ID for which to remove the name variant + */ + public removeNameVariant(listID: string, itemID: string) { + this.appStore.dispatch(new RemoveNameVariantAction(listID, itemID)); + } + + /** + * Method to retrieve all name variants for a single list + * @param listID The id of the list + */ + public getNameVariantsByListID(listID: string) { + return this.appStore.pipe(select(relationshipListStateSelector(listID))); + } + + /** + * Method to update the name variant on the server + * @param item1 The first item of the relationship + * @param item2 The second item of the relationship + * @param relationshipLabel The leftward or rightward type of the relationship + * @param nameVariant The name variant to set for the matching relationship + */ + public updateNameVariant(item1: Item, item2: Item, relationshipLabel: string, nameVariant: string): Observable> { + return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel) + .pipe( + switchMap((relation: Relationship) => + relation.relationshipType.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((type) => { + return { relation, type } + }) + ) + ), + switchMap((relationshipAndType: { relation: Relationship, type: RelationshipType }) => { + const { relation, type } = relationshipAndType; + let updatedRelationship; + if (relationshipLabel === type.leftwardType) { + updatedRelationship = Object.assign(new Relationship(), relation, { rightwardValue: nameVariant }); + } else { + updatedRelationship = Object.assign(new Relationship(), relation, { leftwardValue: nameVariant }); + } + return this.update(updatedRelationship); + }), + // skipWhile((relationshipRD: RemoteData) => !relationshipRD.isSuccessful) + tap((relationshipRD: RemoteData) => { + if (relationshipRD.hasSucceeded) { + this.removeRelationshipItemsFromCache(item1); + this.removeRelationshipItemsFromCache(item2); + } + }), + ) } } diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index 2aa3227d12..3be9248907 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -13,11 +13,11 @@ export enum RemoteDataState { */ export class RemoteData { constructor( - private requestPending: boolean, - private responsePending: boolean, - private isSuccessful: boolean, - public error: RemoteDataError, - public payload: T + private requestPending?: boolean, + private responsePending?: boolean, + private isSuccessful?: boolean, + public error?: RemoteDataError, + public payload?: T ) { } diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index bd65163892..af9bf54cb8 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -3,7 +3,7 @@ import { HttpHeaders } from '@angular/common/http'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { Observable, race as observableRace } from 'rxjs'; -import { filter, map, mergeMap, take } from 'rxjs/operators'; +import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { cloneDeep, remove } from 'lodash'; import { AppState } from '../../app.reducer'; @@ -304,6 +304,7 @@ export class RequestService { */ hasByHref(href: string): boolean { let result = false; + /* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/ this.getByHref(href).pipe( take(1) ).subscribe((requestEntry: RequestEntry) => result = this.isValid(requestEntry)); diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 389541745d..c449fa872f 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -6,7 +6,7 @@ import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { hasValue } from '../../shared/empty.util'; -import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; +import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @Injectable() diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 290f4be8a2..cf9b1067c1 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -26,7 +26,7 @@ export interface HttpOptions { @Injectable() export class DSpaceRESTv2Service { - constructor(private http: HttpClient) { + constructor(protected http: HttpClient) { } diff --git a/src/app/core/eperson/models/normalized-eperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts index 9a3bf4c246..489bf259c6 100644 --- a/src/app/core/eperson/models/normalized-eperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -9,6 +9,7 @@ import { Group } from './group.model'; @mapsTo(EPerson) @inheritSerialization(NormalizedDSpaceObject) export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject { + /** * A string representing the unique handle of this EPerson */ diff --git a/src/app/core/metadata/metadata-field.model.ts b/src/app/core/metadata/metadata-field.model.ts index 78d106f143..45ac4b2051 100644 --- a/src/app/core/metadata/metadata-field.model.ts +++ b/src/app/core/metadata/metadata-field.model.ts @@ -7,7 +7,7 @@ import { GenericConstructor } from '../shared/generic-constructor'; /** * Class the represents a metadata field */ -export class MetadataField implements ListableObject { +export class MetadataField extends ListableObject { static type = new ResourceType('metadatafield'); /** diff --git a/src/app/core/metadata/metadata-schema.model.ts b/src/app/core/metadata/metadata-schema.model.ts index 78d5338f2e..2059b21094 100644 --- a/src/app/core/metadata/metadata-schema.model.ts +++ b/src/app/core/metadata/metadata-schema.model.ts @@ -5,7 +5,7 @@ import { GenericConstructor } from '../shared/generic-constructor'; /** * Class that represents a metadata schema */ -export class MetadataSchema implements ListableObject { +export class MetadataSchema extends ListableObject { static type = new ResourceType('metadataschema'); /** diff --git a/src/app/core/metadata/normalized-metadata-field.model.ts b/src/app/core/metadata/normalized-metadata-field.model.ts index 075dda79d2..3d8750778d 100644 --- a/src/app/core/metadata/normalized-metadata-field.model.ts +++ b/src/app/core/metadata/normalized-metadata-field.model.ts @@ -2,7 +2,6 @@ import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { mapsTo, relationship } from '../cache/builders/build-decorators'; import { MetadataField } from './metadata-field.model'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { MetadataSchema } from './metadata-schema.model'; /** diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts index 1aa5609090..4b534725f4 100644 --- a/src/app/core/metadata/normalized-metadata-schema.model.ts +++ b/src/app/core/metadata/normalized-metadata-schema.model.ts @@ -1,7 +1,6 @@ import { autoserialize, inheritSerialization } from 'cerialize'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { mapsTo } from '../cache/builders/build-decorators'; -import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { MetadataSchema } from './metadata-schema.model'; /** @@ -33,4 +32,5 @@ export class NormalizedMetadataSchema extends NormalizedObject { */ @autoserialize namespace: string; + } diff --git a/src/app/core/router/router.actions.ts b/src/app/core/router/router.actions.ts new file mode 100644 index 0000000000..e12356f372 --- /dev/null +++ b/src/app/core/router/router.actions.ts @@ -0,0 +1,22 @@ +import { Action } from '@ngrx/store'; +import { type } from '../../shared/ngrx/type'; + +/** + * The list of HrefIndexAction type definitions + */ +export const RouterActionTypes = { + ROUTE_UPDATE: type('dspace/core/router/ROUTE_UPDATE'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * An ngrx action to be fired when the route is updated + * Note that, contrary to the router-store.ROUTER_NAVIGATION action, + * this action will only be fired when the path changes, + * not when just the query parameters change + */ +export class RouteUpdateAction implements Action { + type = RouterActionTypes.ROUTE_UPDATE; +} + +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/router/router.effects.ts b/src/app/core/router/router.effects.ts new file mode 100644 index 0000000000..b73d11c1d6 --- /dev/null +++ b/src/app/core/router/router.effects.ts @@ -0,0 +1,31 @@ +import { filter, map, pairwise } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects' +import * as fromRouter from '@ngrx/router-store'; +import { RouterNavigationAction } from '@ngrx/router-store'; +import { Router } from '@angular/router'; +import { RouteUpdateAction } from './router.actions'; + +@Injectable() +export class RouterEffects { + /** + * Effect that fires a new RouteUpdateAction when then path of route is changed + * @type {Observable} + */ + @Effect() routeChange$ = this.actions$ + .pipe( + ofType(fromRouter.ROUTER_NAVIGATION), + pairwise(), + map((actions: RouterNavigationAction[]) => + actions.map((navigateAction) => { + const urlTree = this.router.parseUrl(navigateAction.payload.routerState.url); + return urlTree.root.children.primary.segments.map((it) => it.path).join('/'); + })), + filter((actions: string[]) => actions[0] !== actions[1]), + map(() => new RouteUpdateAction()) + ); + + constructor(private actions$: Actions, private router: Router) { + } + +} diff --git a/src/app/core/services/route.actions.ts b/src/app/core/services/route.actions.ts index 968319d260..1f6381d2c6 100644 --- a/src/app/core/services/route.actions.ts +++ b/src/app/core/services/route.actions.ts @@ -10,6 +10,8 @@ export const RouteActionTypes = { SET_PARAMETERS: type('dspace/core/route/SET_PARAMETERS'), ADD_QUERY_PARAMETER: type('dspace/core/route/ADD_QUERY_PARAMETER'), ADD_PARAMETER: type('dspace/core/route/ADD_PARAMETER'), + SET_QUERY_PARAMETER: type('dspace/core/route/SET_QUERY_PARAMETER'), + SET_PARAMETER: type('dspace/core/route/SET_PARAMETER'), RESET: type('dspace/core/route/RESET'), }; @@ -96,6 +98,52 @@ export class AddParameterAction implements Action { } } +/** + * An ngrx action to set a query parameter + */ +export class SetQueryParameterAction implements Action { + type = RouteActionTypes.SET_QUERY_PARAMETER; + payload: { + key: string; + value: string; + }; + + /** + * Create a new SetQueryParameterAction + * + * @param key + * the key to set + * @param value + * the value of this key + */ + constructor(key: string, value: string) { + this.payload = { key, value }; + } +} + +/** + * An ngrx action to set a parameter + */ +export class SetParameterAction implements Action { + type = RouteActionTypes.SET_PARAMETER; + payload: { + key: string; + value: string; + }; + + /** + * Create a new SetParameterAction + * + * @param key + * the key to set + * @param value + * the value of this key + */ + constructor(key: string, value: string) { + this.payload = { key, value }; + } +} + /** * An ngrx action to reset the route state */ @@ -113,4 +161,5 @@ export type RouteActions = | SetParametersAction | AddQueryParameterAction | AddParameterAction - | ResetRouteStateAction; + | ResetRouteStateAction + | SetParameterAction; diff --git a/src/app/core/services/route.effects.ts b/src/app/core/services/route.effects.ts index 687f8f9921..f0a9f7521c 100644 --- a/src/app/core/services/route.effects.ts +++ b/src/app/core/services/route.effects.ts @@ -1,8 +1,9 @@ -import { map } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects' -import * as fromRouter from '@ngrx/router-store'; -import { ResetRouteStateAction } from './route.actions'; +import { ResetRouteStateAction, RouteActionTypes } from './route.actions'; +import { RouterActionTypes } from '../../core/router/router.actions'; +import { RouteService } from './route.service'; @Injectable() export class RouteEffects { @@ -12,12 +13,16 @@ export class RouteEffects { */ @Effect() routeChange$ = this.actions$ .pipe( - ofType(fromRouter.ROUTER_NAVIGATION), - map(() => new ResetRouteStateAction()) + ofType(RouterActionTypes.ROUTE_UPDATE), + map(() => new ResetRouteStateAction()), ); - constructor(private actions$: Actions) { + @Effect({dispatch: false }) afterResetChange$ = this.actions$ + .pipe( + ofType(RouteActionTypes.RESET), + tap(() => this.service.setCurrentRouteInfo()), + ); + constructor(private actions$: Actions, private service: RouteService) { } - } diff --git a/src/app/core/services/route.reducer.ts b/src/app/core/services/route.reducer.ts index b078521c11..2d5356a5db 100644 --- a/src/app/core/services/route.reducer.ts +++ b/src/app/core/services/route.reducer.ts @@ -3,7 +3,11 @@ import { AddParameterAction, AddQueryParameterAction, RouteActions, - RouteActionTypes, SetParametersAction, SetQueryParametersAction + RouteActionTypes, + SetParameterAction, + SetParametersAction, + SetQueryParameterAction, + SetQueryParametersAction } from './route.actions'; /** @@ -44,6 +48,12 @@ export function routeReducer(state = initialState, action: RouteActions): RouteS case RouteActionTypes.ADD_QUERY_PARAMETER: { return addParameter(state, action as AddQueryParameterAction, 'queryParams'); } + case RouteActionTypes.SET_PARAMETER: { + return setParameter(state, action as SetParameterAction, 'params'); + } + case RouteActionTypes.SET_QUERY_PARAMETER: { + return setParameter(state, action as SetQueryParameterAction, 'queryParams'); + } default: { return state; } @@ -60,9 +70,10 @@ function addParameter(state: RouteState, action: AddParameterAction | AddQueryPa const subState = state[paramType]; const existingValues = subState[action.payload.key] || []; const newValues = [...existingValues, action.payload.value]; - const newSubstate = Object.assign(subState, { [action.payload.key]: newValues }); + const newSubstate = Object.assign({}, subState, { [action.payload.key]: newValues }); return Object.assign({}, state, { [paramType]: newSubstate }); } + /** * Set a route or query parameter in the store * @param state The current state @@ -70,5 +81,17 @@ function addParameter(state: RouteState, action: AddParameterAction | AddQueryPa * @param paramType The type of parameter to set: route or query parameter */ function setParameters(state: RouteState, action: SetParametersAction | SetQueryParametersAction, paramType: string): RouteState { - return Object.assign({}, state, { [paramType]: action.payload }); + return Object.assign({}, state, { [paramType]: { [action.payload.key]: action.payload.value } }); +} + +/** + * Set a route or query parameter in the store + * @param state The current state + * @param action The set action to perform on the current state + * @param paramType The type of parameter to set: route or query parameter + */ +function setParameter(state: RouteState, action: SetParameterAction | SetQueryParameterAction, paramType: string): RouteState { + const subState = state[paramType]; + const newSubstate = Object.assign({}, subState, { [action.payload.key]: action.payload.value }); + return Object.assign({}, state, { [paramType]: newSubstate }); } diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts index 65aa858945..b29c491cb0 100644 --- a/src/app/core/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -1,4 +1,4 @@ -import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, take, tap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { ActivatedRoute, @@ -12,12 +12,17 @@ import { combineLatest, Observable } from 'rxjs'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { isEqual } from 'lodash'; -import { AddUrlToHistoryAction } from '../../shared/history/history.actions'; -import { historySelector } from '../../shared/history/selectors'; -import { SetParametersAction, SetQueryParametersAction } from './route.actions'; -import { CoreState } from '../core.reducers'; +import { + AddParameterAction, + SetParameterAction, + SetParametersAction, + SetQueryParametersAction +} from './route.actions'; +import { CoreState } from '../../core/core.reducers'; +import { coreSelector } from '../../core/core.selectors'; import { hasValue } from '../../shared/empty.util'; -import { coreSelector } from '../core.selectors'; +import { historySelector } from '../../shared/history/selectors'; +import { AddUrlToHistoryAction } from '../../shared/history/history.actions'; /** * Selector to select all route parameters from the store @@ -121,7 +126,7 @@ export class RouteService { } getRouteDataValue(datafield: string): Observable { - return this.route.data.pipe(map((data) => data[datafield]), distinctUntilChanged(),); + return this.route.data.pipe(map((data) => data[datafield]), distinctUntilChanged()); } /** @@ -157,11 +162,9 @@ export class RouteService { } public saveRouting(): void { - combineLatest(this.router.events, this.getRouteParams(), this.route.queryParams) - .pipe(filter(([event, params, queryParams]) => event instanceof NavigationEnd)) - .subscribe(([event, params, queryParams]: [NavigationEnd, Params, Params]) => { - this.store.dispatch(new SetParametersAction(params)); - this.store.dispatch(new SetQueryParametersAction(queryParams)); + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { this.store.dispatch(new AddUrlToHistoryAction(event.urlAfterRedirects)); }); } @@ -183,4 +186,26 @@ export class RouteService { map((history: string[]) => history[history.length - 2] || '') ); } + + public addParameter(key, value) { + this.store.dispatch(new AddParameterAction(key, value)); + } + + public setParameter(key, value) { + this.store.dispatch(new SetParameterAction(key, value)); + } + + /** + * Sets the current route parameters and query parameters in the store + */ + public setCurrentRouteInfo() { + combineLatest(this.getRouteParams(), this.route.queryParams) + .pipe(take(1)) + .subscribe( + ([params, queryParams]: [Params, Params]) => { + this.store.dispatch(new SetParametersAction(params)); + this.store.dispatch(new SetQueryParametersAction(queryParams)); + } + ) + } } diff --git a/src/app/core/shared/browse-entry.model.ts b/src/app/core/shared/browse-entry.model.ts index 977afb40f6..d6074de3f5 100644 --- a/src/app/core/shared/browse-entry.model.ts +++ b/src/app/core/shared/browse-entry.model.ts @@ -2,12 +2,13 @@ import { ListableObject } from '../../shared/object-collection/shared/listable-o import { TypedObject } from '../cache/object-cache.reducer'; import { ResourceType } from './resource-type'; import { GenericConstructor } from './generic-constructor'; +import { excludeFromEquals } from '../utilities/equals.decorators'; /** * Class object representing a browse entry * This class is not normalized because browse entries do not have self links */ -export class BrowseEntry implements ListableObject { +export class BrowseEntry extends ListableObject implements TypedObject { static type = new ResourceType('browseEntry'); /** @@ -28,6 +29,7 @@ export class BrowseEntry implements ListableObject { /** * The count of this browse entry */ + @excludeFromEquals count: number; /** diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 2e50f19c4f..4fec28d246 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -11,25 +11,29 @@ import { hasNoValue, isUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { excludeFromEquals } from '../utilities/equals.decorators'; import { ResourceType } from './resource-type'; import { GenericConstructor } from './generic-constructor'; /** * An abstract model class for a DSpaceObject. */ -export class DSpaceObject implements CacheableObject, ListableObject { +export class DSpaceObject extends ListableObject implements CacheableObject { /** * A string representing the kind of DSpaceObject, e.g. community, item, … */ static type = new ResourceType('dspaceobject'); + @excludeFromEquals private _name: string; + @excludeFromEquals self: string; /** * The human-readable identifier of this DSpaceObject */ + @excludeFromEquals id: string; /** @@ -37,6 +41,12 @@ export class DSpaceObject implements CacheableObject, ListableObject { */ uuid: string; + /** + * A string representing the kind of DSpaceObject, e.g. community, item, … + */ + @excludeFromEquals + type: ResourceType; + /** * The name for this DSpaceObject */ @@ -54,6 +64,7 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * All metadata of this DSpaceObject */ + @excludeFromEquals metadata: MetadataMap; /** @@ -66,11 +77,13 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * An array of DSpaceObjects that are direct parents of this DSpaceObject */ + @excludeFromEquals parents: Observable>; /** * The DSpaceObject that owns this DSpaceObject */ + @excludeFromEquals owner: Observable>; /** diff --git a/src/app/core/shared/item-relationships/item-type.model.ts b/src/app/core/shared/item-relationships/item-type.model.ts index 2635f154a8..0fc52b00a5 100644 --- a/src/app/core/shared/item-relationships/item-type.model.ts +++ b/src/app/core/shared/item-relationships/item-type.model.ts @@ -12,6 +12,8 @@ export class ItemType implements CacheableObject { */ id: string; + label: string; + /** * The link to the rest endpoint where this object can be found */ diff --git a/src/app/core/shared/item-relationships/relationship.model.ts b/src/app/core/shared/item-relationships/relationship.model.ts index 9ab9751489..2adcf42c04 100644 --- a/src/app/core/shared/item-relationships/relationship.model.ts +++ b/src/app/core/shared/item-relationships/relationship.model.ts @@ -46,6 +46,16 @@ export class Relationship implements CacheableObject { */ rightPlace: number; + /** + * The name variant of the Item to the left side of this Relationship + */ + leftwardValue: string; + + /** + * The name variant of the Item to the right side of this Relationship + */ + rightwardValue: string; + /** * The type of Relationship */ diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index 9c7e30dcb4..085cdb4504 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -81,6 +81,9 @@ export interface MetadataValueFilter { /** The value constraint. */ value?: string; + /** The authority constraint. */ + authority?: string; + /** Whether the value constraint should match without regard to case. */ ignoreCase?: boolean; diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index 1e1d7f86d5..f4b3517649 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -8,8 +8,8 @@ import { } from './metadata.models'; import { Metadata } from './metadata.utils'; -const mdValue = (value: string, language?: string): MetadataValue => { - return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: undefined, confidence: undefined }); +const mdValue = (value: string, language?: string, authority?: string): MetadataValue => { + return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: isUndefined(authority) ? null : authority, confidence: undefined }); }; const dcDescription = mdValue('Some description'); @@ -184,6 +184,8 @@ describe('Metadata', () => { testValueMatches(mdValue('a'), true, { language: null }); testValueMatches(mdValue('a'), false, { language: 'en_US' }); testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' }); + testValueMatches(mdValue('a', undefined, '4321'), true, { authority: '4321' }); + testValueMatches(mdValue('a', undefined, '4321'), false, { authority: '1234' }); }); describe('toViewModelList method', () => { diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 62a1957e22..334c430968 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -127,6 +127,8 @@ export class Metadata { return true; } else if (filter.language && filter.language !== mdValue.language) { return false; + } else if (filter.authority && filter.authority !== mdValue.authority) { + return false; } else if (filter.value) { let fValue = filter.value; let mValue = mdValue.value; diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index b4c7c8f1a9..308e4f8a2d 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -9,8 +9,7 @@ import { RequestService } from '../data/request.service'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { PaginatedList } from '../data/paginated-list'; -import { SearchResult } from '../../+search-page/search-result.model'; -import { Item } from './item.model'; +import { SearchResult } from '../../shared/search/search-result.model'; import { Router } from '@angular/router'; /** diff --git a/src/app/core/shared/page-info.model.ts b/src/app/core/shared/page-info.model.ts index 4ed281657d..273510da60 100644 --- a/src/app/core/shared/page-info.model.ts +++ b/src/app/core/shared/page-info.model.ts @@ -1,9 +1,11 @@ import { autoserialize, autoserializeAs } from 'cerialize'; +import { hasValue } from '../../shared/empty.util'; /** * Represents the state of a paginated response */ export class PageInfo { + /** * The number of elements on a page */ @@ -42,4 +44,20 @@ export class PageInfo { @autoserialize self: string; + + constructor( + options?: { + elementsPerPage: number, + totalElements: number, + totalPages: number, + currentPage: number + } + ) { + if (hasValue(options)) { + this.elementsPerPage = options.elementsPerPage; + this.totalElements = options.totalElements; + this.totalPages = options.totalPages; + this.currentPage = options.currentPage; + } + } } diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/core/shared/search/search-configuration.service.spec.ts similarity index 91% rename from src/app/+search-page/search-service/search-configuration.service.spec.ts rename to src/app/core/shared/search/search-configuration.service.spec.ts index f1aedd9fe5..b5423e0df0 100644 --- a/src/app/+search-page/search-service/search-configuration.service.spec.ts +++ b/src/app/core/shared/search/search-configuration.service.spec.ts @@ -1,9 +1,9 @@ import { SearchConfigurationService } from './search-configuration.service'; -import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { SearchFilter } from '../search-filter.model'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { SearchFilter } from '../../../shared/search/search-filter.model'; import { of as observableOf } from 'rxjs'; describe('SearchConfigurationService', () => { @@ -30,14 +30,10 @@ describe('SearchConfigurationService', () => { getRouteParameterValue: observableOf('') }); - const fixedFilterService = jasmine.createSpyObj('SearchFixedFilterService', { - getQueryByFilterName: observableOf(''), - }); - const activatedRoute: any = new ActivatedRouteStub(); beforeEach(() => { - service = new SearchConfigurationService(routeService, fixedFilterService, activatedRoute); + service = new SearchConfigurationService(routeService, activatedRoute); }); describe('when the scope is called', () => { beforeEach(() => { diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts similarity index 85% rename from src/app/+search-page/search-service/search-configuration.service.ts rename to src/app/core/shared/search/search-configuration.service.ts index 4aed678645..8ae0855cae 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -1,27 +1,19 @@ import { Injectable, OnDestroy } from '@angular/core'; import { ActivatedRoute, Params } from '@angular/router'; -import { - BehaviorSubject, - combineLatest as observableCombineLatest, - merge as observableMerge, - Observable, - of as observableOf, - Subscription -} from 'rxjs'; -import { filter, flatMap, map, startWith, switchMap, tap } from 'rxjs/operators'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SearchOptions } from '../search-options.model'; -import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { RouteService } from '../../core/services/route.service'; -import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { RemoteData } from '../../core/data/remote-data'; -import { getSucceededRemoteData } from '../../core/shared/operators'; -import { SearchFilter } from '../search-filter.model'; -import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; -import { SearchFixedFilterService } from '../search-filters/search-filter/search-fixed-filter.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { BehaviorSubject, combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; +import { filter, map, startWith, switchMap } from 'rxjs/operators'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchOptions } from '../../../shared/search/search-options.model'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { SearchFilter } from '../../../shared/search/search-filter.model'; +import { RemoteData } from '../../data/remote-data'; +import { DSpaceObjectType } from '../dspace-object-type.model'; +import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; +import { RouteService } from '../../services/route.service'; +import { getSucceededRemoteData } from '../operators'; +import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; /** * Service that performs all actions that have to do with the current search configuration @@ -80,11 +72,9 @@ export class SearchConfigurationService implements OnDestroy { /** * Initialize the search options * @param {RouteService} routeService - * @param {SearchFixedFilterService} fixedFilterService * @param {ActivatedRoute} route */ constructor(protected routeService: RouteService, - protected fixedFilterService: SearchFixedFilterService, protected route: ActivatedRoute) { this.initDefaults(); @@ -96,7 +86,7 @@ export class SearchConfigurationService implements OnDestroy { protected initDefaults() { this.defaults .pipe(getSucceededRemoteData()) - .subscribe((defRD) => { + .subscribe((defRD: RemoteData) => { const defs = defRD.payload; this.paginatedSearchOptions = new BehaviorSubject(defs); this.searchOptions = new BehaviorSubject(defs); @@ -205,6 +195,13 @@ export class SearchConfigurationService implements OnDestroy { })); } + /** + * @returns {Observable} Emits the current fixed filter as a string + */ + getCurrentFixedFilter(): Observable { + return this.routeService.getRouteParameterValue('fixedFilterQuery'); + } + /** * @returns {Observable} Emits the current active filters with their values as they are displayed in the frontend URL */ @@ -224,9 +221,10 @@ export class SearchConfigurationService implements OnDestroy { this.getQueryPart(defaults.query), this.getDSOTypePart(), this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: SearchOptions = this.searchOptions.getValue(); - const updatedValue: SearchOptions = Object.assign(new SearchOptions({}), currentValue, update); + const updatedValue: SearchOptions = Object.assign(new PaginatedSearchOptions({}), currentValue, update); this.searchOptions.next(updatedValue); }); } @@ -245,6 +243,7 @@ export class SearchConfigurationService implements OnDestroy { this.getQueryPart(defaults.query), this.getDSOTypePart(), this.getFiltersPart(), + this.getFixedFilterPart() ).subscribe((update) => { const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); const updatedValue: PaginatedSearchOptions = Object.assign(new PaginatedSearchOptions({}), currentValue, update); @@ -341,4 +340,16 @@ export class SearchConfigurationService implements OnDestroy { return { filters } })); } + + /** + * @returns {Observable} Emits the current fixed filter as a partial SearchOptions object + */ + private getFixedFilterPart(): Observable { + return this.getCurrentFixedFilter().pipe( + isNotEmptyOperator(), + map((fixedFilter) => { + return { fixedFilter } + }), + ); + } } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/core/shared/search/search-filter.service.spec.ts similarity index 93% rename from src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts rename to src/app/core/shared/search/search-filter.service.spec.ts index 0ba1f88cc3..91f53da898 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts +++ b/src/app/core/shared/search/search-filter.service.spec.ts @@ -8,14 +8,13 @@ import { SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction -} from './search-filter.actions'; -import { SearchFiltersState } from './search-filter.reducer'; -import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; -import { FilterType } from '../../search-service/filter-type.model'; -import { SearchFixedFilterService } from './search-fixed-filter.service'; +} from '../../../shared/search/search-filters/search-filter/search-filter.actions'; +import { SearchFiltersState } from '../../../shared/search/search-filters/search-filter/search-filter.reducer'; +import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; +import { FilterType } from '../../../shared/search/filter-type.model'; import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; import { of as observableOf } from 'rxjs'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; describe('SearchFilterService', () => { let service: SearchFilterService; @@ -28,7 +27,6 @@ describe('SearchFilterService', () => { pageSize: 2 }); - const mockFixedFilterService: SearchFixedFilterService = {} as SearchFixedFilterService const value1 = 'random value'; // const value2 = 'another value'; const store: Store = jasmine.createSpyObj('store', { @@ -66,7 +64,7 @@ describe('SearchFilterService', () => { }; beforeEach(() => { - service = new SearchFilterService(store, routeServiceStub, mockFixedFilterService); + service = new SearchFilterService(store, routeServiceStub); }); describe('when the initializeFilter method is triggered', () => { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/core/shared/search/search-filter.service.ts similarity index 94% rename from src/app/+search-page/search-filters/search-filter/search-filter.service.ts rename to src/app/core/shared/search/search-filter.service.ts index a453dc29bf..c1559db525 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/core/shared/search/search-filter.service.ts @@ -1,7 +1,7 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, map, mergeMap } from 'rxjs/operators'; import { Injectable, InjectionToken } from '@angular/core'; -import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; +import { SearchFiltersState, SearchFilterState } from '../../../shared/search/search-filters/search-filter/search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { SearchFilterCollapseAction, @@ -11,13 +11,12 @@ import { SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction -} from './search-filter.actions'; +} from '../../../shared/search/search-filters/search-filter/search-filter.actions'; import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; -import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; +import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; +import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; import { RouteService } from '../../../core/services/route.service'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SearchFixedFilterService } from './search-fixed-filter.service'; import { Params } from '@angular/router'; const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; @@ -32,8 +31,7 @@ export const IN_PLACE_SEARCH: InjectionToken = new InjectionToken, - private routeService: RouteService, - private fixedFilterService: SearchFixedFilterService) { + private routeService: RouteService) { } /** diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/core/shared/search/search.service.spec.ts similarity index 87% rename from src/app/+search-page/search-service/search.service.spec.ts rename to src/app/core/shared/search/search.service.spec.ts index 4f91dde312..0e093d119c 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/core/shared/search/search.service.spec.ts @@ -5,27 +5,27 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { SearchService } from './search.service'; -import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { Router, UrlTree } from '@angular/router'; -import { RequestService } from '../../core/data/request.service'; -import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; -import { RouterStub } from '../../shared/testing/router-stub'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { RequestService } from '../../data/request.service'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { HALEndpointService } from '../hal-endpoint.service'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { RequestEntry } from '../../core/data/request.reducer'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { FacetConfigSuccessResponse, SearchSuccessResponse } from '../../core/cache/response.models'; -import { SearchQueryResponse } from './search-query-response.model'; -import { SearchFilterConfig } from './search-filter-config.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { ViewMode } from '../../core/shared/view-mode.model'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntry } from '../../data/request.reducer'; +import { getMockRequestService } from '../../../shared/mocks/mock-request.service'; +import { FacetConfigSuccessResponse, SearchSuccessResponse } from '../../cache/response.models'; +import { SearchQueryResponse } from '../../../shared/search/search-query-response.model'; +import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; +import { CommunityDataService } from '../../data/community-data.service'; +import { ViewMode } from '../view-mode.model'; +import { DSpaceObjectDataService } from '../../data/dspace-object-data.service'; import { map } from 'rxjs/operators'; -import { RouteService } from '../../core/services/route.service'; -import { routeServiceStub } from '../../shared/testing/route-service-stub'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { RouteService } from '../../services/route.service'; +import { routeServiceStub } from '../../../shared/testing/route-service-stub'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; @Component({ template: '' }) class DummyComponent { diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/core/shared/search/search.service.ts similarity index 84% rename from src/app/+search-page/search-service/search.service.ts rename to src/app/core/shared/search/search.service.ts index c11e5b8be9..f6886c268e 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -1,48 +1,39 @@ import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; -import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; -import { first, map, switchMap, take, tap } from 'rxjs/operators'; -import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; -import { - FacetConfigSuccessResponse, - FacetValueSuccessResponse, - SearchSuccessResponse -} from '../../core/cache/response.models'; -import { PaginatedList } from '../../core/data/paginated-list'; -import { ResponseParsingService } from '../../core/data/parsing.service'; -import { RemoteData } from '../../core/data/remote-data'; -import { GetRequest, RestRequest } from '../../core/data/request.models'; -import { RequestService } from '../../core/data/request.service'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { GenericConstructor } from '../../core/shared/generic-constructor'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { - configureRequest, - filterSuccessfulResponses, - getResponseFromEntry, - getSucceededRemoteData -} from '../../core/shared/operators'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; -import { NormalizedSearchResult } from '../normalized-search-result.model'; -import { SearchOptions } from '../search-options.model'; -import { SearchResult } from '../search-result.model'; -import { FacetValue } from './facet-value.model'; -import { SearchFilterConfig } from './search-filter-config.model'; -import { SearchResponseParsingService } from '../../core/data/search-response-parsing.service'; -import { SearchQueryResponse } from './search-query-response.model'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { getSearchResultFor } from './search-result-element-decorator'; -import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { FacetValueResponseParsingService } from '../../core/data/facet-value-response-parsing.service'; -import { FacetConfigResponseParsingService } from '../../core/data/facet-config-response-parsing.service'; -import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { Community } from '../../core/shared/community.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { ViewMode } from '../../core/shared/view-mode.model'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { RouteService } from '../../core/services/route.service'; -import { RequestEntry } from '../../core/data/request.reducer'; +import { NavigationExtras, Router } from '@angular/router'; +import { first, map, switchMap, tap } from 'rxjs/operators'; +import { FacetConfigSuccessResponse, FacetValueSuccessResponse, SearchSuccessResponse } from '../../cache/response.models'; +import { PaginatedList } from '../../data/paginated-list'; +import { ResponseParsingService } from '../../data/parsing.service'; +import { RemoteData } from '../../data/remote-data'; +import { GetRequest, RestRequest } from '../../data/request.models'; +import { RequestService } from '../../data/request.service'; +import { DSpaceObject } from '../dspace-object.model'; +import { GenericConstructor } from '../generic-constructor'; +import { HALEndpointService } from '../hal-endpoint.service'; +import { URLCombiner } from '../../url-combiner/url-combiner'; +import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; +import { NormalizedSearchResult } from '../../../shared/search/normalized-search-result.model'; +import { SearchOptions } from '../../../shared/search/search-options.model'; +import { SearchResult } from '../../../shared/search/search-result.model'; +import { FacetValue } from '../../../shared/search/facet-value.model'; +import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; +import { SearchResponseParsingService } from '../../data/search-response-parsing.service'; +import { SearchQueryResponse } from '../../../shared/search/search-query-response.model'; +import { PageInfo } from '../page-info.model'; +import { getSearchResultFor } from '../../../shared/search/search-result-element-decorator'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service'; +import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { Community } from '../community.model'; +import { CommunityDataService } from '../../data/community-data.service'; +import { ViewMode } from '../view-mode.model'; +import { DSpaceObjectDataService } from '../../data/dspace-object-data.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { configureRequest, filterSuccessfulResponses, getResponseFromEntry, getSucceededRemoteData } from '../operators'; +import { RouteService } from '../../services/route.service'; +import { RequestEntry } from '../../data/request.reducer'; /** * Service that performs all general actions that have to do with the search page @@ -127,7 +118,7 @@ export class SearchService implements OnDestroy { * @returns {Observable} Emits an observable with the request entries */ searchEntries(searchOptions?: PaginatedSearchOptions, responseMsToLive?:number) - :Observable<{searchOptions:PaginatedSearchOptions, requestEntry:RequestEntry}> { + :Observable<{searchOptions: PaginatedSearchOptions, requestEntry: RequestEntry}> { const hrefObs = this.getEndpoint(searchOptions); diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts index 23f75553c5..0b1110fa24 100644 --- a/src/app/core/submission/models/submission-object.model.ts +++ b/src/app/core/submission/models/submission-object.model.ts @@ -1,7 +1,6 @@ import { Observable } from 'rxjs'; import { CacheableObject } from '../../cache/object-cache.reducer'; -import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { DSpaceObject } from '../../shared/dspace-object.model'; import { EPerson } from '../../eperson/models/eperson.model'; import { RemoteData } from '../../data/remote-data'; @@ -18,7 +17,7 @@ export interface SubmissionObjectError { /** * An abstract model class for a SubmissionObject. */ -export abstract class SubmissionObject extends DSpaceObject implements CacheableObject, ListableObject { +export abstract class SubmissionObject extends DSpaceObject implements CacheableObject { /** * The workspaceitem/workflowitem identifier diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index da3b578fcd..8bc2971922 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -1,4 +1,6 @@ import { Inject, Injectable } from '@angular/core'; +import { deepClone } from 'fast-json-patch'; +import { DSOResponseParsingService } from '../data/dso-response-parsing.service'; import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/request.models'; @@ -76,7 +78,9 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService protected toCache = false; constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected objectCache: ObjectCacheService) { + protected objectCache: ObjectCacheService, + protected dsoParser: DSOResponseParsingService + ) { super(); } @@ -88,6 +92,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService * @returns {RestResponse} */ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + this.dsoParser.parse(deepClone(request), deepClone(data)); if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && this.isSuccessStatus(data.statusCode)) { diff --git a/src/app/core/submission/submission-rest.service.spec.ts b/src/app/core/submission/submission-rest.service.spec.ts index 30fe9f9163..eefc815435 100644 --- a/src/app/core/submission/submission-rest.service.spec.ts +++ b/src/app/core/submission/submission-rest.service.spec.ts @@ -59,12 +59,11 @@ describe('SubmissionRestService test suite', () => { describe('getDataById', () => { it('should configure a new SubmissionRequest', () => { const expected = new SubmissionRequest(requestService.generateRequestId(), resourceHref); - // set cache time to zero - expected.responseMsToLive = 0; expected.forceBypassCache = true; scheduler.schedule(() => service.getDataById(resourceEndpoint, resourceScope).subscribe()); scheduler.flush(); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(resourceHref); expect(requestService.configure).toHaveBeenCalledWith(expected); }); }); diff --git a/src/app/core/submission/submission-rest.service.ts b/src/app/core/submission/submission-rest.service.ts index 58aa507314..b4f8185767 100644 --- a/src/app/core/submission/submission-rest.service.ts +++ b/src/app/core/submission/submission-rest.service.ts @@ -109,11 +109,10 @@ export class SubmissionRestService { filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((endpointURL: string) => new SubmissionRequest(requestId, endpointURL)), - map ((request: RestRequest) => { - request.responseMsToLive = 0; - return request; + tap((request: RestRequest) => { + this.requestService.removeByHrefSubstring(request.href); + this.requestService.configure(request); }), - tap((request: RestRequest) => this.requestService.configure(request)), flatMap(() => this.fetchRequest(requestId)), distinctUntilChanged()); } diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts index 14dbbd7301..1f37548b04 100644 --- a/src/app/core/tasks/models/task-object.model.ts +++ b/src/app/core/tasks/models/task-object.model.ts @@ -2,17 +2,16 @@ import { Observable } from 'rxjs'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { DSpaceObject } from '../../shared/dspace-object.model'; -import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { RemoteData } from '../../data/remote-data'; import { WorkflowItem } from '../../submission/models/workflowitem.model'; -import { Group } from '../../eperson/models/group.model'; -import { EPerson } from '../../eperson/models/eperson.model'; import { ResourceType } from '../../shared/resource-type'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { Group } from '../../eperson/models/group.model'; /** * An abstract model class for a TaskObject. */ -export class TaskObject extends DSpaceObject implements CacheableObject, ListableObject { +export class TaskObject extends DSpaceObject implements CacheableObject { static type = new ResourceType('taskobject'); /** diff --git a/src/app/core/utilities/equals.decorators.ts b/src/app/core/utilities/equals.decorators.ts new file mode 100644 index 0000000000..6fdbd40c0f --- /dev/null +++ b/src/app/core/utilities/equals.decorators.ts @@ -0,0 +1,71 @@ +import { isEmpty } from '../../shared/empty.util'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { EquatableObject } from './equatable'; + +const excludedFromEquals = new Map(); +const fieldsForEqualsMap = new Map(); + +/** + * Decorator function that adds the equatable settings from the given (parent) object + * @param parentCo The constructor of the parent object + */ +export function inheritEquatable(parentCo: GenericConstructor>) { + return function decorator(childCo: GenericConstructor>) { + const parentExcludedFields = getExcludedFromEqualsFor(parentCo) || []; + const excludedFields = getExcludedFromEqualsFor(childCo) || []; + excludedFromEquals.set(childCo, [...excludedFields, ...parentExcludedFields]); + + const mappedFields = fieldsForEqualsMap.get(childCo) || new Map(); + const parentMappedFields = fieldsForEqualsMap.get(parentCo) || new Map(); + Array.from(parentMappedFields.keys()) + .filter((key) => !Array.from(mappedFields.keys()).includes(key)) + .forEach((key) => { + fieldsForEquals(...parentMappedFields.get(key))(new childCo(), key); + }); + } +} + +/** + * Function to mark properties as excluded from the equals method + * @param object The object to exclude the property for + * @param propertyName The name of the property to exclude + */ +export function excludeFromEquals(object: any, propertyName: string): any { + if (!object) { + return; + } + let list = excludedFromEquals.get(object.constructor); + if (isEmpty(list)) { + list = []; + } + excludedFromEquals.set(object.constructor, [...list, propertyName]); +} + +// tslint:disable-next-line:ban-types +export function getExcludedFromEqualsFor(constructor: Function): string[] { + return excludedFromEquals.get(constructor) || []; +} + +/** + * Function to save the fields that are to be used for a certain property in the equals method for the given object + * @param fields The fields to use to equate the property of the object + */ +export function fieldsForEquals(...fields: string[]): any { + return function i(object: any, propertyName: string): any { + if (!object) { + return; + } + let fieldMap = fieldsForEqualsMap.get(object.constructor); + if (isEmpty(fieldMap)) { + fieldMap = new Map(); + } + fieldMap.set(propertyName, fields); + fieldsForEqualsMap.set(object.constructor, fieldMap); + } +} + +// tslint:disable-next-line:ban-types +export function getFieldsForEquals(constructor: Function, field: string): string[] { + const fieldMap = fieldsForEqualsMap.get(constructor) || new Map(); + return fieldMap.get(field); +} diff --git a/src/app/core/utilities/equatable.spec.ts b/src/app/core/utilities/equatable.spec.ts new file mode 100644 index 0000000000..79136cd221 --- /dev/null +++ b/src/app/core/utilities/equatable.spec.ts @@ -0,0 +1,113 @@ +import { excludeFromEquals, fieldsForEquals } from './equals.decorators'; +import { EquatableObject } from './equatable'; +import { cloneDeep } from 'lodash'; + +class Dog extends EquatableObject { + public name: string; + + @excludeFromEquals + public ballsCaught: number; + + public owner: Owner; + + @fieldsForEquals('name') + public favouriteToy: { name: string, colour: string }; +} + +// tslint:disable-next-line:max-classes-per-file +class Owner extends EquatableObject { + @excludeFromEquals + favouriteFood: string; + + constructor( + public name: string, + public age: number, + favouriteFood: string + ) { + super(); + this.favouriteFood = favouriteFood; + } + +} + +describe('equatable', () => { + let dogRoger: Dog; + let dogMissy: Dog; + + beforeEach(() => { + dogRoger = new Dog(); + dogRoger.name = 'Roger'; + dogRoger.ballsCaught = 6; + dogRoger.owner = new Owner('Tommy', 16, 'spaghetti'); + dogRoger.favouriteToy = { name: 'Twinky', colour: 'red' }; + + dogMissy = new Dog(); + dogMissy.name = 'Missy'; + dogMissy.ballsCaught = 9; + dogMissy.owner = new Owner('Jenny', 29, 'pizza'); + dogRoger.favouriteToy = { name: 'McSqueak', colour: 'grey' }; + }); + + it('should return false when the other object is undefined', () => { + const isEqual = dogRoger.equals(undefined); + expect(isEqual).toBe(false); + }); + + it('should return true when the other object is the exact same object', () => { + const isEqual = dogRoger.equals(dogRoger); + expect(isEqual).toBe(true); + }); + + it('should return true when the other object is an exact copy of the first one', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(true); + }); + + it('should return false when the other object differs in all fields', () => { + const isEqual = dogRoger.equals(dogMissy); + expect(isEqual).toBe(false); + }); + + it('should return true when the other object only differs in fields that are marked as excludeFromEquals', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + copyOfDogRoger.ballsCaught = 4; + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(true); + }); + + it('should return false when the other object differs in fields that are not marked as excludeFromEquals', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + copyOfDogRoger.name = 'Elliot'; + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(false); + }); + + it('should return true when the other object\'s nested object only differs in fields that are marked as excludeFromEquals, when the nested object is not marked decorated with @fieldsForEquals', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + copyOfDogRoger.owner.favouriteFood = 'Sushi'; + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(true); + }); + + it('should return false when the other object\'s nested object differs in fields that are not marked as excludeFromEquals, when the nested object is not marked decorated with @fieldsForEquals', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + copyOfDogRoger.owner.age = 36; + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(false); + }); + + it('should return true when the other object\'s nested object does not differ in fields that are listed inside the nested @fieldsForEquals decorator', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + copyOfDogRoger.favouriteToy.colour = 'green'; + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(true); + }); + + it('should return false when the other object\'s nested object differs in fields that are listed inside the nested @fieldsForEquals decorator', () => { + const copyOfDogRoger = cloneDeep(dogRoger); + copyOfDogRoger.favouriteToy.name = 'Mister Bone'; + const isEqual = dogRoger.equals(copyOfDogRoger); + expect(isEqual).toBe(false); + }); +}); diff --git a/src/app/core/utilities/equatable.ts b/src/app/core/utilities/equatable.ts new file mode 100644 index 0000000000..e022297229 --- /dev/null +++ b/src/app/core/utilities/equatable.ts @@ -0,0 +1,52 @@ +import { getExcludedFromEqualsFor, getFieldsForEquals } from './equals.decorators'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; + +/** + * Method to compare fields of two objects against each other + * @param object1 The first object for the comparison + * @param object2 The second object for the comparison + * @param fieldList The list of property/field names to compare + */ +function equalsByFields(object1, object2, fieldList): boolean { + const unequalProperty = fieldList.find((key) => { + if (object1[key] === object2[key]) { + return false; + } + if (hasNoValue(object1[key]) && hasNoValue(object2[key])) { + return false; + } + if (hasNoValue(object1[key]) || hasNoValue(object2[key])) { + return true; + } + const mapping = getFieldsForEquals(object1.constructor, key); + if (hasValue(mapping)) { + return !equalsByFields(object1[key], object2[key], mapping); + } + if (object1[key] instanceof EquatableObject) { + return !object1[key].equals(object2[key]); + } + if (typeof object1[key] === 'object') { + return !equalsByFields(object1[key], object2[key], Object.keys(object1)) + } + return object1[key] !== object2[key]; + }); + return hasNoValue(unequalProperty); +} + +/** + * Abstract class to represent objects that can be compared to each other + * It provides a default way of comparing + */ +export abstract class EquatableObject { + equals(other: T): boolean { + if (hasNoValue(other)) { + return false; + } + if (this as any === other) { + return true; + } + const excludedKeys = getExcludedFromEqualsFor(this.constructor); + const keys = Object.keys(this).filter((key) => !excludedKeys.includes(key)); + return equalsByFields(this, other, keys); + } +} diff --git a/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.ts index 641a0d2238..5a5bfde49e 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.ts +++ b/src/app/entity-groups/journal-entities/item-list-elements/journal-volume/journal-volume-list-element.component.ts @@ -1,8 +1,8 @@ import { Component } from '@angular/core'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { AbstractListableElementComponent } from '../../../../shared/object-collection/shared/object-collection-element/abstract-listable-element.component'; import { Item } from '../../../../core/shared/item.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; @listableObjectComponent('JournalVolume', ViewMode.ListElement) @Component({ diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index 6f1cbf9923..9be8a1f4e9 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('JournalIssue', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index 4219609aab..ee90dd8f5a 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('JournalVolume', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index a475e16637..39d7d9ccce 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -13,6 +13,7 @@ import { isNotEmpty } from '../../../../shared/empty.util'; import { JournalComponent } from './journal.component'; import { GenericItemPageFieldComponent } from '../../../../+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { RelationshipService } from '../../../../core/data/relationship.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -53,7 +54,8 @@ describe('JournalComponent', () => { declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ {provide: ItemDataService, useValue: {}}, - {provide: TruncatableService, useValue: {}} + {provide: TruncatableService, useValue: {}}, + {provide: RelationshipService, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index dbbeb81662..605bd52238 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Journal', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts index d9d4461bfa..6df2d87503 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('OrgUnit', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 15c7184702..9972736b95 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Person', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index 8ac424af5b..4e432e869e 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Project', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index 8829318f34..86c2a375da 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -20,6 +20,11 @@ import { OrgUnitSearchResultGridElementComponent } from './item-grid-elements/se import { ProjectSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component'; import { PersonItemMetadataListElementComponent } from './metadata-representations/person/person-item-metadata-list-element.component'; import { OrgUnitItemMetadataListElementComponent } from './metadata-representations/org-unit/org-unit-item-metadata-list-element.component'; +import { PersonSearchResultListSubmissionElementComponent } from './submission/item-list-elements/person/person-search-result-list-submission-element.component'; +import { PersonInputSuggestionsComponent } from './submission/item-list-elements/person/person-suggestions/person-input-suggestions.component'; +import { NameVariantModalComponent } from './submission/name-variant-modal/name-variant-modal.component'; +import { OrgUnitInputSuggestionsComponent } from './submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component'; +import { OrgUnitSearchResultListSubmissionElementComponent } from './submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component'; const ENTRY_COMPONENTS = [ OrgUnitComponent, @@ -38,7 +43,12 @@ const ENTRY_COMPONENTS = [ ProjectSearchResultListElementComponent, PersonSearchResultGridElementComponent, OrgUnitSearchResultGridElementComponent, - ProjectSearchResultGridElementComponent + ProjectSearchResultGridElementComponent, + PersonSearchResultListSubmissionElementComponent, + PersonInputSuggestionsComponent, + NameVariantModalComponent, + OrgUnitSearchResultListSubmissionElementComponent, + OrgUnitInputSuggestionsComponent ]; @NgModule({ @@ -49,7 +59,7 @@ const ENTRY_COMPONENTS = [ ItemPageModule ], declarations: [ - ...ENTRY_COMPONENTS + ...ENTRY_COMPONENTS, ], entryComponents: [ ...ENTRY_COMPONENTS diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html new file mode 100644 index 0000000000..b0fa714371 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+ + + + , + + + + + +
+
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss new file mode 100644 index 0000000000..8fc6d2138d --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss @@ -0,0 +1,7 @@ +@import '../../../../../../styles/variables'; + +$submission-relationship-thumbnail-width: 80px; + +.person-thumbnail { + width: $submission-relationship-thumbnail-width; +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.spec.ts new file mode 100644 index 0000000000..2a77b64f43 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.spec.ts @@ -0,0 +1,154 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { OrgUnitSearchResultListSubmissionElementComponent } from './org-unit-search-result-list-submission-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { Store } from '@ngrx/store'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../../core/data/paginated-list'; + +let personListElementComponent: OrgUnitSearchResultListSubmissionElementComponent; +let fixture: ComponentFixture; + +let mockItemWithMetadata: ItemSearchResult; +let mockItemWithoutMetadata: ItemSearchResult; + +let nameVariant; +let mockRelationshipService; + +function init() { + mockItemWithMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'organization.address.addressLocality': [ + { + language: 'en_US', + value: 'Europe' + } + ], + 'organization.address.addressCountry': [ + { + language: 'en_US', + value: 'Belgium' + } + ] + } + }) + }); + mockItemWithoutMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } + }) + }); + + nameVariant = 'Doe J.'; + mockRelationshipService = { + getNameVariant: () => observableOf(nameVariant) + }; +} + +describe('OrgUnitSearchResultListSubmissionElementComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [OrgUnitSearchResultListSubmissionElementComponent, TruncatePipe], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: RelationshipService, useValue: mockRelationshipService }, + { provide: NotificationsService, useValue: {} }, + { provide: TranslateService, useValue: {} }, + { provide: NgbModal, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { provide: Store, useValue: {} } + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrgUnitSearchResultListSubmissionElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(OrgUnitSearchResultListSubmissionElementComponent); + personListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a address locality span', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the address locality span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-locality')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no address locality', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the address locality span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-locality')); + expect(jobTitleField).toBeNull(); + }); + }); + + describe('When the item has a address country span', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the address country span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-country')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no address country', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the address country span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-country')); + expect(jobTitleField).toBeNull(); + }); + }); +}); diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts new file mode 100644 index 0000000000..cbddb8d6f9 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts @@ -0,0 +1,98 @@ +import { Component, OnInit } from '@angular/core'; +import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { take } from 'rxjs/operators'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component'; + +@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SubmissionModal) +@Component({ + selector: 'ds-person-search-result-list-submission-element', + styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'], + templateUrl: './org-unit-search-result-list-submission-element.component.html' +}) + +/** + * The component for displaying a list element for an item search result of the type OrgUnit + */ +export class OrgUnitSearchResultListSubmissionElementComponent extends SearchResultListElementComponent implements OnInit { + allSuggestions: string[]; + selectedName: string; + alternativeField = 'dc.title.alternative'; + + constructor(protected truncatableService: TruncatableService, + private relationshipService: RelationshipService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private modalService: NgbModal, + private itemDataService: ItemDataService, + private selectableListService: SelectableListService) { + super(truncatableService); + } + + ngOnInit() { + super.ngOnInit(); + const defaultValue = this.firstMetadataValue('organization.legalName'); + const alternatives = this.allMetadataValues(this.alternativeField); + this.allSuggestions = [defaultValue, ...alternatives]; + + this.relationshipService.getNameVariant(this.listID, this.dso.uuid) + .pipe(take(1)) + .subscribe((nameVariant: string) => { + this.selectedName = nameVariant || defaultValue; + } + ); + } + + select(value) { + this.selectableListService.isObjectSelected(this.listID, this.object) + .pipe(take(1)) + .subscribe((selected) => { + if (!selected) { + this.selectableListService.selectSingle(this.listID, this.object); + } + }); + this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value); + } + + selectCustom(value) { + if (!this.allSuggestions.includes(value)) { + this.openModal(value) + .then(() => { + + const newName: MetadataValue = new MetadataValue(); + newName.value = value; + + const existingNames: MetadataValue[] = this.dso.metadata[this.alternativeField] || []; + const alternativeNames = { [this.alternativeField]: [...existingNames, newName] }; + const updatedItem = + Object.assign({}, this.dso, { + metadata: { + ...this.dso.metadata, + ...alternativeNames + }, + }); + this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); + }) + } + this.select(value); + } + + openModal(value): Promise { + const modalRef = this.modalService.open(NameVariantModalComponent, { centered: true }); + const modalComp = modalRef.componentInstance; + modalComp.value = value; + return modalRef.result; + } +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html new file mode 100644 index 0000000000..e177b2b561 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html @@ -0,0 +1,24 @@ +
+ + + +
\ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss new file mode 100644 index 0000000000..8301e12c5f --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss @@ -0,0 +1,18 @@ +form { + z-index: 1; + &:before { + position: absolute; + font-weight: 900; + font-family: "Font Awesome 5 Free"; + content: "\f0d7"; + top: 7px; + right: 0; + height: 20px; + width: 20px; + z-index: -1; + } + + input.suggestion_input { + background: transparent; + } +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts new file mode 100644 index 0000000000..34b89cc8aa --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts @@ -0,0 +1,64 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { OrgUnitInputSuggestionsComponent } from './org-unit-input-suggestions.component'; +import { FormsModule } from '@angular/forms'; + +let component: OrgUnitInputSuggestionsComponent; +let fixture: ComponentFixture; + +let suggestions: string[]; +let testValue; + +function init() { + suggestions = ['test', 'suggestion', 'example'] + testValue = 'bla'; +} + +describe('OrgUnitInputSuggestionsComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [OrgUnitInputSuggestionsComponent], + imports: [ + FormsModule, + ], + providers: [ + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrgUnitInputSuggestionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(OrgUnitInputSuggestionsComponent); + component = fixture.componentInstance; + component.suggestions = suggestions; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('When the component is initialized', () => { + it('should set the value to the first value of the suggestions', () => { + expect(component.value).toEqual('test'); + }); + }); + + describe('When onSubmit is called', () => { + it('should set the value to parameter of the method', () => { + component.onSubmit(testValue); + expect(component.value).toEqual(testValue); + }); + }); + + describe('When onClickSuggestion is called', () => { + it('should set the value to parameter of the method', () => { + component.onClickSuggestion(testValue); + expect(component.value).toEqual(testValue); + }); + }); + +}); diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.ts new file mode 100644 index 0000000000..c868281e00 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.ts @@ -0,0 +1,48 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { InputSuggestionsComponent } from '../../../../../../shared/input-suggestions/input-suggestions.component'; + +@Component({ + selector: 'ds-org-unit-input-suggestions', + styleUrls: ['./org-unit-input-suggestions.component.scss', './../../../../../../shared/input-suggestions/input-suggestions.component.scss'], + templateUrl: './org-unit-input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => OrgUnitInputSuggestionsComponent), + multi: true + } + ] +}) + +/** + * Component representing a form with a autocomplete functionality + */ +export class OrgUnitInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit { + /** + * The suggestions that should be shown + */ + @Input() suggestions: string[] = []; + + ngOnInit() { + if (this.suggestions.length > 0) { + this.value = this.suggestions[0] + } + } + + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + onClickSuggestion(data) { + this.value = data; + this.clickSuggestion.emit(data); + this.close(); + this.blockReopen = true; + this.queryInput.nativeElement.focus(); + return false; + } +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html new file mode 100644 index 0000000000..df93c2f4f3 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -0,0 +1,16 @@ +
+
+ +
+
+ + + + + + + + +
+
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss new file mode 100644 index 0000000000..8fc6d2138d --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss @@ -0,0 +1,7 @@ +@import '../../../../../../styles/variables'; + +$submission-relationship-thumbnail-width: 80px; + +.person-thumbnail { + width: $submission-relationship-thumbnail-width; +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts new file mode 100644 index 0000000000..a21f0ec075 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts @@ -0,0 +1,124 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { PersonSearchResultListSubmissionElementComponent } from './person-search-result-list-submission-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { Store } from '@ngrx/store'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../../core/data/paginated-list'; + +let personListElementComponent: PersonSearchResultListSubmissionElementComponent; +let fixture: ComponentFixture; + +let mockItemWithMetadata: ItemSearchResult; +let mockItemWithoutMetadata: ItemSearchResult; + +let nameVariant; +let mockRelationshipService; + +function init() { + mockItemWithMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Developer' + } + ] + } + }) + }); + mockItemWithoutMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } + }) + }); + + nameVariant = 'Doe J.'; + mockRelationshipService = { + getNameVariant: () => observableOf(nameVariant) + }; +} + +describe('PersonSearchResultListElementSubmissionComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [PersonSearchResultListSubmissionElementComponent, TruncatePipe], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: RelationshipService, useValue: mockRelationshipService }, + { provide: NotificationsService, useValue: {} }, + { provide: TranslateService, useValue: {} }, + { provide: NgbModal, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { provide: Store, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PersonSearchResultListSubmissionElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PersonSearchResultListSubmissionElementComponent); + personListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a job title', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the job title span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-job-title')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no job title', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the job title span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-job-title')); + expect(jobTitleField).toBeNull(); + }); + }); +}); diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts new file mode 100644 index 0000000000..37fd77649b --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts @@ -0,0 +1,98 @@ +import { Component, OnInit } from '@angular/core'; +import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { take } from 'rxjs/operators'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; + +@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SubmissionModal) +@Component({ + selector: 'ds-person-search-result-list-submission-element', + styleUrls: ['./person-search-result-list-submission-element.component.scss'], + templateUrl: './person-search-result-list-submission-element.component.html' +}) + +/** + * The component for displaying a list element for an item search result of the type Person + */ +export class PersonSearchResultListSubmissionElementComponent extends SearchResultListElementComponent implements OnInit { + allSuggestions: string[]; + selectedName: string; + alternativeField = 'dc.title.alternative'; + + constructor(protected truncatableService: TruncatableService, + private relationshipService: RelationshipService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private modalService: NgbModal, + private itemDataService: ItemDataService, + private selectableListService: SelectableListService) { + super(truncatableService); + } + + ngOnInit() { + super.ngOnInit(); + const defaultValue = this.firstMetadataValue('person.familyName') + ', ' + this.firstMetadataValue('person.givenName'); + const alternatives = this.allMetadataValues(this.alternativeField); + this.allSuggestions = [defaultValue, ...alternatives]; + + this.relationshipService.getNameVariant(this.listID, this.dso.uuid) + .pipe(take(1)) + .subscribe((nameVariant: string) => { + this.selectedName = nameVariant || defaultValue; + } + ); + } + + select(value) { + this.selectableListService.isObjectSelected(this.listID, this.object) + .pipe(take(1)) + .subscribe((selected) => { + if (!selected) { + this.selectableListService.selectSingle(this.listID, this.object); + } + }); + this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value); + } + + selectCustom(value) { + if (!this.allSuggestions.includes(value)) { + this.openModal(value) + .then(() => { + + const newName: MetadataValue = new MetadataValue(); + newName.value = value; + + const existingNames: MetadataValue[] = this.dso.metadata[this.alternativeField] || []; + const alternativeNames = { [this.alternativeField]: [...existingNames, newName] }; + const updatedItem = + Object.assign({}, this.dso, { + metadata: { + ...this.dso.metadata, + ...alternativeNames + }, + }); + this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); + }) + } + this.select(value); + } + + openModal(value): Promise { + const modalRef = this.modalService.open(NameVariantModalComponent, { centered: true }); + const modalComp = modalRef.componentInstance; + modalComp.value = value; + return modalRef.result; + } +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html new file mode 100644 index 0000000000..e177b2b561 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html @@ -0,0 +1,24 @@ +
+ + + +
\ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss new file mode 100644 index 0000000000..8301e12c5f --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss @@ -0,0 +1,18 @@ +form { + z-index: 1; + &:before { + position: absolute; + font-weight: 900; + font-family: "Font Awesome 5 Free"; + content: "\f0d7"; + top: 7px; + right: 0; + height: 20px; + width: 20px; + z-index: -1; + } + + input.suggestion_input { + background: transparent; + } +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts new file mode 100644 index 0000000000..a1802ce1a7 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts @@ -0,0 +1,48 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { InputSuggestionsComponent } from '../../../../../../shared/input-suggestions/input-suggestions.component'; + +@Component({ + selector: 'ds-person-input-suggestions', + styleUrls: ['./person-input-suggestions.component.scss', './../../../../../../shared/input-suggestions/input-suggestions.component.scss'], + templateUrl: './person-input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => PersonInputSuggestionsComponent), + multi: true + } + ] +}) + +/** + * Component representing a form with a autocomplete functionality + */ +export class PersonInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit { + /** + * The suggestions that should be shown + */ + @Input() suggestions: string[] = []; + + ngOnInit() { + if (this.suggestions.length > 0) { + this.value = this.suggestions[0] + } + } + + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + onClickSuggestion(data) { + this.value = data; + this.clickSuggestion.emit(data); + this.close(); + this.blockReopen = true; + this.queryInput.nativeElement.focus(); + return false; + } +} diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.html b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.html new file mode 100644 index 0000000000..13ae884ccb --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.html @@ -0,0 +1,13 @@ + + + diff --git a/src/app/+search-page/search-filters/search-filters.component.scss b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.scss similarity index 100% rename from src/app/+search-page/search-filters/search-filters.component.scss rename to src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.scss diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts new file mode 100644 index 0000000000..b5043ea2d6 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts @@ -0,0 +1,53 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NameVariantModalComponent } from './name-variant-modal.component'; +import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; + +describe('NameVariantModalComponent', () => { + let component: NameVariantModalComponent; + let fixture: ComponentFixture; + let debugElement; + let modal; + + function init() { + modal = jasmine.createSpyObj('modal', ['close', 'dismiss']); + } + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [NameVariantModalComponent], + imports: [NgbModule.forRoot(), TranslateModule.forRoot()], + providers: [{ provide: NgbActiveModal, useValue: modal }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NameVariantModalComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('when close button is clicked, dismiss should be called on the modal', () => { + debugElement.query(By.css('button.close')).triggerEventHandler('click', {}); + expect(modal.dismiss).toHaveBeenCalled(); + }); + + it('when confirm button is clicked, close should be called on the modal', () => { + debugElement.query(By.css('button.confirm-button')).triggerEventHandler('click', {}); + expect(modal.close).toHaveBeenCalled(); + }); + + it('when decline button is clicked, dismiss should be called on the modal', () => { + debugElement.query(By.css('button.decline-button')).triggerEventHandler('click', {}); + expect(modal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.ts b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.ts new file mode 100644 index 0000000000..75817d786a --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +/** + * This component a pop up for when the user selects a custom name variant during submission for a relationship$ + * The user can either choose to decline or accept to save the name variant as a metadata in the entity + */ +@Component({ + selector: 'ds-name-variant-modal', + templateUrl: './name-variant-modal.component.html', + styleUrls: ['./name-variant-modal.component.scss'] +}) +export class NameVariantModalComponent { + @Input() value: string; + + constructor(public modal: NgbActiveModal) { + } +} diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index 234f13f4b1..2de59d614b 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -2,14 +2,14 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { DSOSelectorComponent } from './dso-selector.component'; -import { SearchService } from '../../../+search-page/search-service/search.service'; +import { SearchService } from '../../../core/shared/search/search.service'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model'; import { Item } from '../../../core/shared/item.model'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject$ } from '../../testing/utils'; +import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; describe('DSOSelectorComponent', () => { let component: DSOSelectorComponent; diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index af26f3f04f..3c9d399f8b 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -12,12 +12,12 @@ import { FormControl } from '@angular/forms'; import { Observable } from 'rxjs'; import { debounceTime, startWith, switchMap } from 'rxjs/operators'; -import { SearchService } from '../../../+search-page/search-service/search.service'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; -import { SearchResult } from '../../../+search-page/search-result.model'; +import { SearchResult } from '../../search/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ViewMode } from '../../../core/shared/view-mode.model'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 52a924604f..144848b478 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -9,37 +9,60 @@ -
-
+
+
- + - + -
- {{ message | translate:model.validators }} -
+
+ {{ message | translate:model.validators }} +
+
+ +
+ +
+ +
+ +
-
- -
-
- + +
+
    +
  • + + + + +
  • +
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index e5663e93cb..91c1dbc085 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -1,5 +1,5 @@ import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement, SimpleChange } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement, NgZone, SimpleChange } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; import { By } from '@angular/platform-browser'; @@ -39,10 +39,7 @@ import { } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { - DsDynamicFormControlContainerComponent, - dsDynamicFormControlMapFn -} from './ds-dynamic-form-control-container.component'; +import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './ds-dynamic-form-control-container.component'; import { SharedModule } from '../../../shared.module'; import { DynamicDsDatePickerModel } from './models/date-picker/date-picker.model'; import { DynamicRelationGroupModel } from './models/relation-group/dynamic-relation-group.model'; @@ -65,6 +62,15 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicFormGroupComponent } from './models/form-group/dynamic-form-group.component'; import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Store } from '@ngrx/store'; +import { SubmissionObjectDataService } from '../../../../core/submission/submission-object-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; describe('DsDynamicFormControlContainerComponent test suite', () => { @@ -95,12 +101,15 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicSwitchModel({ id: 'switch' }), new DynamicTextAreaModel({ id: 'textarea' }), new DynamicTimePickerModel({ id: 'timepicker' }), - new DynamicTypeaheadModel({ id: 'typeahead' }), + new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', - authorityOptions: authorityOptions + authorityOptions: authorityOptions, + metadataFields: [], + repeatable: false, + submissionId: '1234' }), - new DynamicTagModel({ id: 'tag' }), + new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicListCheckboxGroupModel({ id: 'checkboxList', authorityOptions: authorityOptions, @@ -119,12 +128,14 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { name: 'relationGroup', relationFields: [], scopeUUID: '', - submissionScope: '' + submissionScope: '', + repeatable: false, + metadataFields: [] }), new DynamicDsDatePickerModel({ id: 'datepicker' }), - new DynamicLookupModel({ id: 'lookup' }), - new DynamicLookupNameModel({ id: 'lookupName' }), - new DynamicQualdropModel({ id: 'combobox', readOnly: false }) + new DynamicLookupModel({ id: 'lookup', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicLookupNameModel({ id: 'lookupName', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicQualdropModel({ id: 'combobox', readOnly: false, required: false }) ]; const testModel = formModel[8]; let formGroup: FormGroup; @@ -132,7 +143,9 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { let component: DsDynamicFormControlContainerComponent; let debugElement: DebugElement; let testElement: DebugElement; - + const testItem: Item = new Item(); + const testWSI: WorkspaceItem = new WorkspaceItem(); + testWSI.item = observableOf(createSuccessfulRemoteDataObject(testItem)); beforeEach(async(() => { TestBed.overrideModule(BrowserDynamicTestingModule, { @@ -151,14 +164,34 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { DynamicFormsCoreModule.forRoot(), SharedModule, TranslateModule.forRoot(), - TextMaskModule + TextMaskModule, + ], + providers: [ + DsDynamicFormControlContainerComponent, + DynamicFormService, + { provide: RelationshipService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RelationshipService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { + provide: SubmissionObjectDataService, + useValue: { + findById: () => observableOf(createSuccessfulRemoteDataObject(testWSI)) + } + }, + { provide: NgZone, useValue: new NgZone({}) } ], - providers: [DsDynamicFormControlContainerComponent, DynamicFormService], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents().then(() => { fixture = TestBed.createComponent(DsDynamicFormControlContainerComponent); + const ngZone = TestBed.get(NgZone); + + // tslint:disable-next-line:ban-types + spyOn(ngZone, 'runOutsideAngular').and.callFake((fn: Function) => fn()); component = fixture.componentInstance; debugElement = fixture.debugElement; }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 455c1075ef..c85ef11e5a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -5,7 +5,9 @@ import { ContentChildren, EventEmitter, Input, - OnChanges, + NgZone, + OnChanges, OnDestroy, + OnInit, Output, QueryList, SimpleChanges, @@ -47,6 +49,7 @@ import { DynamicNGBootstrapTimePickerComponent } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.model'; import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; @@ -55,7 +58,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/dat import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP } from './models/lookup/dynamic-lookup.model'; import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model'; import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model'; -import { isNotEmpty, isNotUndefined } from '../../../empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../../empty.util'; import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-lookup-name.model'; import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; import { DsDatePickerComponent } from './models/date-picker/date-picker.component'; @@ -68,10 +71,37 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-group/dynamic-relation-group.model'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { map, switchMap, take, tap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { SelectableListState } from '../../../object-list/selectable-list/selectable-list.reducer'; +import { SearchResult } from '../../../search/search-result.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; +import { DsDynamicDisabledComponent } from './models/disabled/dynamic-disabled.component'; +import { DYNAMIC_FORM_CONTROL_TYPE_DISABLED } from './models/disabled/dynamic-disabled.model'; +import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, + getSucceededRemoteData +} from '../../../../core/shared/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { RemoveRelationshipAction } from './relation-lookup-modal/relationship.actions'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../app.reducer'; +import { SubmissionObjectDataService } from '../../../../core/submission/submission-object-data.service'; +import { SubmissionObject } from '../../../../core/submission/models/submission-object.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { - case DYNAMIC_FORM_CONTROL_TYPE_ARRAY: return DsDynamicFormArrayComponent; @@ -125,6 +155,9 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< case DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME: return DsDynamicLookupComponent; + case DYNAMIC_FORM_CONTROL_TYPE_DISABLED: + return DsDynamicDisabledComponent; + default: return null; } @@ -136,8 +169,7 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< templateUrl: './ds-dynamic-form-control-container.component.html', changeDetection: ChangeDetectionStrategy.Default }) -export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnChanges { - +export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnInit, OnChanges, OnDestroy { @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; // tslint:disable-next-line:no-input-rename @Input('templates') inputTemplateList: QueryList; @@ -150,6 +182,20 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Input() hasErrorMessaging = false; @Input() layout = null as DynamicFormLayout; @Input() model: any; + relationships$: Observable>>; + hasRelationLookup: boolean; + modalRef: NgbModalRef; + item: Item; + listId: string; + searchConfig: string; + selectedValues$: Observable, + mdRep: MetadataRepresentation + }>>; + /** + * List of subscriptions to unsubscribe from + */ + private subs: Subscription[] = []; /* tslint:disable:no-output-rename */ @Output('dfBlur') blur: EventEmitter = new EventEmitter(); @@ -157,7 +203,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Output('dfFocus') focus: EventEmitter = new EventEmitter(); @Output('ngbEvent') customEvent: EventEmitter = new EventEmitter(); /* tslint:enable:no-output-rename */ - @ViewChild('componentViewContainer', {read: ViewContainerRef}) componentViewContainerRef: ViewContainerRef; + @ViewChild('componentViewContainer', { read: ViewContainerRef }) componentViewContainerRef: ViewContainerRef; private showErrorMessagesPreviousStage: boolean; @@ -165,17 +211,69 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return this.layoutService.getCustomComponentType(this.model) || dsDynamicFormControlMapFn(this.model); } - protected test: boolean; constructor( protected componentFactoryResolver: ComponentFactoryResolver, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, - protected translateService: TranslateService + protected translateService: TranslateService, + private modalService: NgbModal, + private relationService: RelationshipService, + private selectableListService: SelectableListService, + private itemService: ItemDataService, + private relationshipService: RelationshipService, + private zone: NgZone, + private store: Store, + private submissionObjectService: SubmissionObjectDataService ) { - super(componentFactoryResolver, layoutService, validationService); } + /** + * Sets up the necessary variables for when this control can be used to add relationships to the submitted item + */ + ngOnInit(): void { + this.hasRelationLookup = hasValue(this.model.relationship); + if (this.hasRelationLookup) { + this.listId = 'list-' + this.model.relationship.relationshipType; + const item$ = this.submissionObjectService + .findById(this.model.submissionId).pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + + this.subs.push(item$.subscribe((item) => this.item = item)); + + this.relationService.getRelatedItemsByLabel(this.item, this.model.relationship.relationshipType).pipe( + map((items: RemoteData>) => items.payload.page.map((item) => Object.assign(new ItemSearchResult(), { indexableObject: item }))), + ).subscribe((relatedItems: Array>) => this.selectableListService.select(this.listId, relatedItems)); + + this.relationships$ = this.selectableListService.getSelectableList(this.listId).pipe( + map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : []), + ) as Observable>>; + this.selectedValues$ = + observableCombineLatest(item$, this.relationships$).pipe( + map(([item, relatedItems]: [Item, Array>]) => { + return relatedItems + .map((element: SearchResult) => { + const relationMD: MetadataValue = item.firstMetadata(this.model.relationship.metadataField, { value: element.indexableObject.uuid }); + if (hasValue(relationMD)) { + const metadataRepresentationMD: MetadataValue = item.firstMetadata(this.model.metadataFields, { authority: relationMD.authority }); + return { + selectedResult: element, + mdRep: Object.assign( + new ItemMetadataRepresentation(metadataRepresentationMD), + element.indexableObject + ) + }; + } + }).filter(hasValue) + } + ) + ); + + } + } + ngOnChanges(changes: SimpleChanges) { if (changes) { super.ngOnChanges(changes); @@ -212,4 +310,42 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.onChange(event); } } + + public hasResultsSelected(): Observable { + return this.model.value.pipe(map((list: Array>) => isNotEmpty(list))); + } + + /** + * Open a modal where the user can select relationships to be added to item being submitted + */ + openLookup() { + this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, { + size: 'lg' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.repeatable = this.model.repeatable; + modalComp.listId = this.listId; + modalComp.relationshipOptions = this.model.relationship; + modalComp.label = this.model.label; + modalComp.metadataFields = this.model.metadataFields; + modalComp.item = this.item; + } + + /** + * Method to remove a selected relationship from the item + * @param object The second item in the relationship, the submitted item being the first + */ + removeSelection(object: SearchResult) { + this.selectableListService.deselectSingle(this.listId, object); + this.store.dispatch(new RemoveRelationshipAction(this.item, object.indexableObject, this.model.relationship.relationshipType)) + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.html new file mode 100644 index 0000000000..18fddd1446 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.html @@ -0,0 +1,13 @@ +
+
+
+ +
+
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts new file mode 100644 index 0000000000..8e0c6fc20e --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts @@ -0,0 +1,58 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { DsDynamicDisabledComponent } from './dynamic-disabled.component'; +import { FormsModule } from '@angular/forms'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { DynamicDisabledModel } from './dynamic-disabled.model'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('DsDynamicDisabledComponent', () => { + let comp: DsDynamicDisabledComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let el: HTMLElement; + let model; + + function init() { + model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1' }); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicDisabledComponent], + imports: [FormsModule, TranslateModule.forRoot()], + providers: [ + { + provide: DynamicFormLayoutService, + useValue: {} + }, + { + provide: DynamicFormValidationService, + useValue: {} + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicDisabledComponent); + comp = fixture.componentInstance; // DsDynamicDisabledComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + comp.model = model; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); + + it('should have a disabled input', () => { + const input = de.query(By.css('input')); + console.log(input.nativeElement.getAttribute('disabled')); + expect(input.nativeElement.getAttribute('disabled')).toEqual(''); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.ts new file mode 100644 index 0000000000..490be050ef --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.ts @@ -0,0 +1,30 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { DynamicFormControlComponent, DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { FormGroup } from '@angular/forms'; +import { DynamicDisabledModel } from './dynamic-disabled.model'; + +/** + * Component representing a simple disabled input field + */ +@Component({ + selector: 'ds-dynamic-disabled', + templateUrl: './dynamic-disabled.component.html' +}) +export class DsDynamicDisabledComponent extends DynamicFormControlComponent { + + @Input() formId: string; + @Input() group: FormGroup; + @Input() model: DynamicDisabledModel; + modelValuesString = ''; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + constructor(protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts new file mode 100644 index 0000000000..0fa2b3e5ed --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts @@ -0,0 +1,24 @@ +import { DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; + +export const DYNAMIC_FORM_CONTROL_TYPE_DISABLED = 'EMPTY'; + +export interface DsDynamicDisabledModelConfig extends DsDynamicInputModelConfig { + value?: any; +} + +/** + * This model represents the data for a disabled input field + */ +export class DynamicDisabledModel extends DsDynamicInputModel { + + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED; + @serializable() value: any; + + constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + this.readOnly = true; + this.disabled = true; + this.valueUpdates.next(config.value); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts index 66bdf97dad..af05d5bf35 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -5,6 +5,7 @@ import { Subject } from 'rxjs'; import { isNotEmpty } from '../../../../empty.util'; import { DsDynamicInputModel } from './ds-dynamic-input.model'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; export const CONCAT_GROUP_SUFFIX = '_CONCAT_GROUP'; export const CONCAT_FIRST_INPUT_SUFFIX = '_CONCAT_FIRST_INPUT'; @@ -12,12 +13,24 @@ export const CONCAT_SECOND_INPUT_SUFFIX = '_CONCAT_SECOND_INPUT'; export interface DynamicConcatModelConfig extends DynamicFormGroupModelConfig { separator: string; + value?: any; + relationship?: RelationshipOptions; + repeatable: boolean; + required: boolean; + metadataFields: string[]; + submissionId: string; } export class DynamicConcatModel extends DynamicFormGroupModel { @serializable() separator: string; @serializable() hasLanguages = false; + @serializable() relationship?: RelationshipOptions; + @serializable() repeatable?: boolean; + @serializable() required?: boolean; + @serializable() metadataFields: string[]; + @serializable() submissionId: string; + isCustomGroup = true; valueUpdates: Subject; @@ -26,6 +39,11 @@ export class DynamicConcatModel extends DynamicFormGroupModel { super(config, layout); this.separator = config.separator + ' '; + this.relationship = config.relationship; + this.repeatable = config.repeatable; + this.required = config.required; + this.metadataFields = config.metadataFields; + this.submissionId = config.submissionId; this.valueUpdates = new Subject(); this.valueUpdates.subscribe((value: string) => this.value = value); @@ -49,7 +67,7 @@ export class DynamicConcatModel extends DynamicFormGroupModel { let tempValue: string; if (typeof value === 'string') { - tempValue = value; + tempValue = value; } else { tempValue = value.value; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index 4e4a944319..3827df7be6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -1,21 +1,21 @@ -import { - DynamicFormControlLayout, - DynamicInputModel, - DynamicInputModelConfig, - serializable -} from '@ng-dynamic-forms/core'; +import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; import { Subject } from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; import { AuthorityOptions } from '../../../../../core/integration/models/authority-options.model'; import { hasValue } from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { authorityOptions?: AuthorityOptions; languageCodes?: LanguageCode[]; language?: string; value?: any; + relationship?: RelationshipOptions; + repeatable: boolean; + metadataFields: string[]; + submissionId: string; } export class DsDynamicInputModel extends DynamicInputModel { @@ -24,13 +24,21 @@ export class DsDynamicInputModel extends DynamicInputModel { @serializable() private _languageCodes: LanguageCode[]; @serializable() private _language: string; @serializable() languageUpdates: Subject; + @serializable() relationship?: RelationshipOptions; + @serializable() repeatable?: boolean; + @serializable() metadataFields: string[]; + @serializable() submissionId: string; constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - + this.repeatable = config.repeatable; + this.metadataFields = config.metadataFields; this.hint = config.hint; this.readOnly = config.readOnly; this.value = config.value; + this.relationship = config.relationship; + this.submissionId = config.submissionId; + this.language = config.language; if (!this.language) { // TypeAhead @@ -79,5 +87,4 @@ export class DsDynamicInputModel extends DynamicInputModel { this.language = this.languageCodes ? this.languageCodes[0].code : null; } } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts index 5d2cbc58b7..a2ed83f6c1 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts @@ -12,6 +12,7 @@ export interface DsDynamicQualdropModelConfig extends DynamicFormGroupModelConfi languageCodes?: LanguageCode[]; language?: string; readOnly: boolean; + required: boolean; hint?: string; } @@ -22,12 +23,14 @@ export class DynamicQualdropModel extends DynamicFormGroupModel { @serializable() hasLanguages = false; @serializable() readOnly: boolean; @serializable() hint: string; + @serializable() required: boolean; isCustomGroup = true; constructor(config: DsDynamicQualdropModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.readOnly = config.readOnly; + this.required = config.required; this.language = config.language; this.languageCodes = config.languageCodes; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index b91af8f0c9..7de319bf56 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -1,21 +1,18 @@ -import { - DYNAMIC_FORM_CONTROL_TYPE_ARRAY, - DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, - serializable -} from '@ng-dynamic-forms/core'; -import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './tag/dynamic-tag.model'; +import { DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { notRepeatable: boolean; + required: boolean; } export class DynamicRowArrayModel extends DynamicFormArrayModel { @serializable() notRepeatable = false; + @serializable() required = false; isRowArray = true; constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.notRepeatable = config.notRepeatable; + this.required = config.required; } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index 39c2c61efe..b0ed3a1dc2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -27,6 +27,8 @@ import { AuthorityConfidenceStateDirective } from '../../../../../authority-conf import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../../../../config'; import { MOCK_SUBMISSION_CONFIG } from '../../../../../testing/mock-submission-config'; +import { WorkspaceitemsEditPageModule } from '../../../../../../+workspaceitems-edit-page/workspaceitems-edit-page.module'; +import { WorkspaceItem } from '../../../../../../core/submission/models/workspaceitem.model'; let LOOKUP_TEST_MODEL_CONFIG = { authorityOptions: { @@ -47,7 +49,9 @@ let LOOKUP_TEST_MODEL_CONFIG = { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; let LOOKUP_NAME_TEST_MODEL_CONFIG = { @@ -69,7 +73,9 @@ let LOOKUP_NAME_TEST_MODEL_CONFIG = { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; let LOOKUP_TEST_GROUP = new FormGroup({ @@ -100,7 +106,9 @@ describe('Dynamic Lookup component', () => { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; LOOKUP_NAME_TEST_MODEL_CONFIG = { @@ -122,7 +130,9 @@ describe('Dynamic Lookup component', () => { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; LOOKUP_TEST_GROUP = new FormGroup({ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index e1d12650fe..75d30d9d79 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -80,7 +80,9 @@ function init() { required: true, scopeUUID: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', submissionScope: undefined, - validators: { required: null } + validators: { required: null }, + repeatable: false, + metadataFields: [] } as DynamicRelationGroupModelConfig; FORM_GROUP_TEST_GROUP = new FormGroup({ @@ -264,7 +266,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { const chips = new Chips(modelValue, 'value', 'dc.contributor.author'); groupComp.formCollapsed.subscribe((value) => { expect(value).toEqual(true); - }) + }); expect(groupComp.formModel.length).toEqual(formModel.length); expect(groupComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); })); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index 2bcb42a73a..ab923a58fa 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -38,7 +38,9 @@ export const SD_TEST_MODEL_CONFIG = { readOnly: false, required: false, repeatable: false, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; describe('Dynamic Dynamic Scrollable Dropdown component', () => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html new file mode 100644 index 0000000000..52f983e723 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -0,0 +1,45 @@ + + + \ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss new file mode 100644 index 0000000000..4fb77a7590 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts new file mode 100644 index 0000000000..a4f77fd364 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts @@ -0,0 +1,129 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgZone, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf, Subscription } from 'rxjs'; +import { DsDynamicLookupRelationModalComponent } from './dynamic-lookup-relation-modal.component'; +import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; +import { Store } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; +import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; +import { AddRelationshipAction, RemoveRelationshipAction } from './relationship.actions'; + +describe('DsDynamicLookupRelationModalComponent', () => { + let component: DsDynamicLookupRelationModalComponent; + let fixture: ComponentFixture; + let item; + let item1; + let item2; + let searchResult1; + let searchResult2; + let listID; + let selection$; + let selectableListService; + let relationship; + let nameVariant; + let metadataField; + + function init() { + item = Object.assign(new Item(), { uuid: '7680ca97-e2bd-4398-bfa7-139a8673dc42', metadata: {} }); + item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + selection$ = observableOf([searchResult1, searchResult2]); + selectableListService = { getSelectableList: () => selection$ }; + relationship = { filter: 'filter', relationshipType: 'isAuthorOfPublication', nameVariants: true } as RelationshipOptions; + nameVariant = 'Doe, J.'; + metadataField = 'dc.contributor.author'; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicLookupRelationModalComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule.forRoot()], + providers: [ + { + provide: SelectableListService, useValue: selectableListService + }, + { + provide: RelationshipService, useValue: { getNameVariant: () => observableOf(nameVariant) } + }, + { provide: RelationshipTypeService, useValue: {} }, + { + provide: Store, useValue: { + // tslint:disable-next-line:no-empty + dispatch: () => {} + } + }, + { provide: NgZone, useValue: new NgZone({}) }, + NgbActiveModal + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicLookupRelationModalComponent); + component = fixture.componentInstance; + component.listId = listID; + component.relationshipOptions = relationship; + component.item = item; + component.metadataFields = metadataField; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('close', () => { + beforeEach(() => { + spyOn(component.modal, 'close'); + }); + + it('should call close on the modal', () => { + component.close(); + expect(component.modal.close).toHaveBeenCalled(); + }) + }); + + describe('select', () => { + beforeEach(() => { + spyOn((component as any).store, 'dispatch'); + }); + + it('should dispatch an AddRelationshipAction for each selected object', () => { + component.select(searchResult1, searchResult2); + const action = new AddRelationshipAction(component.item, searchResult1.indexableObject, relationship.relationshipType, nameVariant); + const action2 = new AddRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType, nameVariant); + + expect((component as any).store.dispatch).toHaveBeenCalledWith(action); + expect((component as any).store.dispatch).toHaveBeenCalledWith(action2); + }) + }); + + describe('deselect', () => { + beforeEach(() => { + component.subMap[searchResult1.indexableObject.uuid] = new Subscription(); + component.subMap[searchResult2.indexableObject.uuid] = new Subscription(); + spyOn((component as any).store, 'dispatch'); + }); + + it('should dispatch an RemoveRelationshipAction for each deselected object', () => { + component.deselect(searchResult1, searchResult2); + const action = new RemoveRelationshipAction(component.item, searchResult1.indexableObject, relationship.relationshipType); + const action2 = new RemoveRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType); + + expect((component as any).store.dispatch).toHaveBeenCalledWith(action); + expect((component as any).store.dispatch).toHaveBeenCalledWith(action2); + }) + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts new file mode 100644 index 0000000000..f3ed3337a9 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts @@ -0,0 +1,157 @@ +import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { combineLatest, Observable, Subscription } from 'rxjs'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { hasValue } from '../../../../empty.util'; +import { map, skip, switchMap, take } from 'rxjs/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../core/shared/search/search-configuration.service'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { SelectableListState } from '../../../../object-list/selectable-list/selectable-list.reducer'; +import { ListableObject } from '../../../../object-collection/shared/listable-object.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; +import { SearchResult } from '../../../../search/search-result.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators'; +import { AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipAction } from './relationship.actions'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../../app.reducer'; +import { Context } from '../../../../../core/shared/context.model'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; + +@Component({ + selector: 'ds-dynamic-lookup-relation-modal', + styleUrls: ['./dynamic-lookup-relation-modal.component.scss'], + templateUrl: './dynamic-lookup-relation-modal.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Represents a modal where the submitter can select items to be added as a certain relationship type to the object being submitted + */ +export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy { + label: string; + relationshipOptions: RelationshipOptions; + listId: string; + item; + repeatable: boolean; + selection$: Observable; + context: Context; + metadataFields: string; + subMap: { + [uuid: string]: Subscription + } = {}; + + constructor( + public modal: NgbActiveModal, + private selectableListService: SelectableListService, + private relationshipService: RelationshipService, + private relationshipTypeService: RelationshipTypeService, + private zone: NgZone, + private store: Store + ) { + } + + ngOnInit(): void { + this.selection$ = this.selectableListService + .getSelectableList(this.listId) + .pipe(map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : [])); + this.selection$.pipe(take(1)).subscribe((selection) => + selection.map((s: SearchResult) => this.addNameVariantSubscription(s)) + ); + if (this.relationshipOptions.nameVariants) { + this.context = Context.SubmissionModal; + } + + // this.setExistingNameVariants(); + } + + close() { + this.modal.close(); + } + + select(...selectableObjects: Array>) { + this.zone.runOutsideAngular( + () => { + const obs: Observable = combineLatest(...selectableObjects.map((sri: SearchResult) => { + this.addNameVariantSubscription(sri); + return this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid) + .pipe( + take(1), + map((nameVariant: string) => { + return { + item: sri.indexableObject, + nameVariant + } + }) + ) + }) + ); + obs + .subscribe((arr: any[]) => { + return arr.forEach((object: any) => { + this.store.dispatch(new AddRelationshipAction(this.item, object.item, this.relationshipOptions.relationshipType, object.nameVariant)); + } + ); + }) + }); + } + + private addNameVariantSubscription(sri: SearchResult) { + const nameVariant$ = this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid); + this.subMap[sri.indexableObject.uuid] = nameVariant$.pipe( + skip(1), + ).subscribe((nameVariant: string) => this.store.dispatch(new UpdateRelationshipAction(this.item, sri.indexableObject, this.relationshipOptions.relationshipType, nameVariant))) + } + + deselect(...selectableObjects: Array>) { + this.zone.runOutsideAngular( + () => selectableObjects.forEach((object) => { + this.subMap[object.indexableObject.uuid].unsubscribe(); + this.store.dispatch(new RemoveRelationshipAction(this.item, object.indexableObject, this.relationshipOptions.relationshipType)); + }) + ); + } + + private setExistingNameVariants() { + const virtualMDs: MetadataValue[] = this.item.allMetadata(this.metadataFields).filter((mdValue) => mdValue.isVirtual); + + const relatedItemPairs$: Observable> = + combineLatest(virtualMDs.map((md: MetadataValue) => this.relationshipService.findById(md.virtualValue).pipe(getSucceededRemoteData(), getRemoteDataPayload()))) + .pipe( + switchMap((relationships: Relationship[]) => combineLatest(relationships.map((relationship: Relationship) => + combineLatest( + relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), + relationship.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()) + )) + ) + ) + ); + + const relatedItems$: Observable = relatedItemPairs$.pipe( + map(([relatedItemPairs,]: [Array<[Item, Item]>]) => relatedItemPairs.map(([left, right]: [Item, Item]) => left.uuid === this.item.uuid ? left : right)) + ); + + relatedItems$.pipe(take(1)).subscribe((relatedItems) => { + let index = 0; + virtualMDs.forEach( + (md: MetadataValue) => { + this.relationshipService.setNameVariant(this.listId, relatedItems[index].uuid, md.value); + index++; + } + ); + } + ) + } + + ngOnDestroy() { + Object.values(this.subMap).forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions.ts new file mode 100644 index 0000000000..f32836eef1 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions.ts @@ -0,0 +1,62 @@ +/** + * The list of NameVariantAction type definitions + */ +import { type } from '../../../../ngrx/type'; +import { Action } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; + +export const NameVariantActionTypes = { + SET_NAME_VARIANT: type('dspace/name-variant/SET_NAME_VARIANT'), + REMOVE_NAME_VARIANT: type('dspace/name-variant/REMOVE_NAME_VARIANT'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * Abstract class for actions that happen to name variants + */ +export abstract class NameVariantListAction implements Action { + type; + payload: { + listID: string; + itemID: string; + }; + + constructor(listID: string, itemID: string) { + this.payload = { listID, itemID }; + } +} + +/** + * Action for setting a new name on an item in a certain list + */ +export class SetNameVariantAction extends NameVariantListAction { + type = NameVariantActionTypes.SET_NAME_VARIANT; + payload: { + listID: string; + itemID: string; + nameVariant: string; + }; + + constructor(listID: string, itemID: string, nameVariant: string) { + super(listID, itemID); + this.payload.nameVariant = nameVariant; + } +} + +/** + * Action for removing a name on an item in a certain list + */ +export class RemoveNameVariantAction extends NameVariantListAction { + type = NameVariantActionTypes.REMOVE_NAME_VARIANT; + constructor(listID: string, itemID: string) { + super(listID, itemID); + } +} +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all RelationshipActions + */ +export type NameVariantAction + = SetNameVariantAction + | RemoveNameVariantAction diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.spec.ts new file mode 100644 index 0000000000..1ac6cd55d1 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.spec.ts @@ -0,0 +1,93 @@ +import * as deepFreeze from 'deep-freeze'; +import { NameVariantAction, RemoveNameVariantAction, SetNameVariantAction } from './name-variant.actions'; +import { Action } from '@ngrx/store'; +import { nameVariantReducer } from './name-variant.reducer'; + +class NullAction implements Action { + type = null; +} + +let listID1; +let listID2; +let itemID1; +let itemID2; +let variantList1Item1; +let variantList1Item1Update; +let variantList1Item2; + +function init() { + listID1 = 'dbfb81de-2930-4de6-ba2e-ea21c8534ee9'; + listID2 = 'd7f2c48d-e1e2-4996-ab8d-e271cabec78a'; + itemID1 = 'd1c81d4f-6b05-4844-986b-372d2e39c6aa'; + itemID2 = 'fe4ca421-d897-417f-9436-9724262d5c69'; + variantList1Item1 = 'Test Name Variant 1'; + variantList1Item1Update = 'Test Name Variant 1 Update'; + variantList1Item2 = 'Test Name Variant 2'; +} + +describe('nameVariantReducer', () => { + beforeEach(() => { + init(); + }); + + it('should return the current state when no valid actions have been made', () => { + const state = { [listID1]: { [itemID1]: variantList1Item1 } }; + const action = new NullAction() as any; + const newState = nameVariantReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with an empty object', () => { + const state = Object.create({}); + const action = new NullAction() as any; + const initialState = nameVariantReducer(undefined, action); + + // The search filter starts collapsed + expect(initialState).toEqual(state); + }); + + it('should set add a new name variant in response to the SET_NAME_VARIANT' + + ' action with a combination of list and item ID that does not exist yet', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + const action = new SetNameVariantAction(listID1, itemID2, variantList1Item2); + const newState = nameVariantReducer(state, action); + + expect(newState[listID1][itemID1]).toEqual(variantList1Item1); + expect(newState[listID1][itemID2]).toEqual(variantList1Item2); + }); + + it('should set a name variant in response to the SET_NAME_VARIANT' + + ' action with a combination of list and item ID that already exists', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + const action = new SetNameVariantAction(listID1, itemID1, variantList1Item1Update); + const newState = nameVariantReducer(state, action); + + expect(newState[listID1][itemID1]).toEqual(variantList1Item1Update); + }); + + it('should remove a name variant in response to the REMOVE_NAME_VARIANT' + + ' action with a combination of list and item ID that already exists', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + expect(state[listID1][itemID1]).toEqual(variantList1Item1); + + const action = new RemoveNameVariantAction(listID1, itemID1); + const newState = nameVariantReducer(state, action); + + expect(newState[listID1][itemID1]).toBeUndefined(); + }); + + it('should do nothing in response to the REMOVE_NAME_VARIANT' + + ' action with a combination of list and item ID that does not exists', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + + const action = new RemoveNameVariantAction(listID2, itemID1); + const newState = nameVariantReducer(state, action); + + expect(newState).toEqual(state); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.ts new file mode 100644 index 0000000000..9f93cf2dd1 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.ts @@ -0,0 +1,50 @@ +/** + * Represents the state of all lists containing name variants in the store + */ + +import { NameVariantAction, NameVariantActionTypes, SetNameVariantAction } from './name-variant.actions'; +import { hasValue } from '../../../../empty.util'; + +export interface NameVariantListsState { + [listID: string]: NameVariantListState; +} + +/** + * Represents the state of a single list containing nameVariants in the store + */ +export interface NameVariantListState { + [itemID: string]: string; +} + +/** + * Reducer that handles NameVariantAction to update the NameVariantListsState + * @param {NameVariantListsState} state The initial NameVariantListsState + * @param {NameVariantAction} action The Action to be performed on the state + * @returns {NameVariantListsState} The new, reduced NameVariantListsState + */ +export function nameVariantReducer(state: NameVariantListsState = {}, action: NameVariantAction): NameVariantListsState { + switch (action.type) { + case NameVariantActionTypes.SET_NAME_VARIANT: { + const listState: NameVariantListState = state[action.payload.listID] || {}; + const nameVariant = (action as SetNameVariantAction).payload.nameVariant; + const newListState = setNameVariant(listState, action.payload.itemID, nameVariant); + return Object.assign({}, state, { [action.payload.listID]: newListState }); + } + case NameVariantActionTypes.REMOVE_NAME_VARIANT: { + const listState: NameVariantListState = state[action.payload.listID]; + if (hasValue(listState) && hasValue(listState[action.payload.itemID])) { + const newListState = setNameVariant(listState, action.payload.itemID, undefined); + return Object.assign({}, state, { [action.payload.listID]: newListState }); + } else { + return state; + } + } + default: { + return state; + } + } +} + +function setNameVariant(state: NameVariantListState, itemID: string, nameVariant: string) { + return Object.assign({}, state, { [itemID]: nameVariant }); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.actions.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.actions.ts new file mode 100644 index 0000000000..57375bd380 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.actions.ts @@ -0,0 +1,108 @@ +/** + * The list of RelationshipAction type definitions + */ +import { type } from '../../../../ngrx/type'; +import { Action } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; + +export const RelationshipActionTypes = { + ADD_RELATIONSHIP: type('dspace/relationship/ADD_RELATIONSHIP'), + REMOVE_RELATIONSHIP: type('dspace/relationship/REMOVE_RELATIONSHIP'), + UPDATE_RELATIONSHIP: type('dspace/relationship/UPDATE_RELATIONSHIP'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * An ngrx action to create a new relationship + */ +export class AddRelationshipAction implements Action { + type = RelationshipActionTypes.ADD_RELATIONSHIP; + + payload: { + item1: Item; + item2: Item; + relationshipType: string; + nameVariant: string; + }; + + /** + * Create a new AddRelationshipAction + * + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param relationshipType The label of the relationshipType + * @param nameVariant The nameVariant of the relationshipType + */ + constructor( + item1: Item, + item2: Item, + relationshipType: string, + nameVariant?: string + ) { + this.payload = { item1, item2, relationshipType, nameVariant }; + } +} + +export class UpdateRelationshipAction implements Action { + type = RelationshipActionTypes.UPDATE_RELATIONSHIP; + + payload: { + item1: Item; + item2: Item; + relationshipType: string; + nameVariant: string; + }; + + /** + * Create a new UpdateRelationshipAction + * + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param relationshipType The label of the relationshipType + * @param nameVariant The nameVariant of the relationshipType + */ + constructor( + item1: Item, + item2: Item, + relationshipType: string, + nameVariant?: string + ) { + this.payload = { item1, item2, relationshipType, nameVariant }; + } +} + +/** + * An ngrx action to remove an existing relationship + */ +export class RemoveRelationshipAction implements Action { + type = RelationshipActionTypes.REMOVE_RELATIONSHIP; + + payload: { + item1: Item; + item2: Item; + relationshipType: string; + }; + + /** + * Create a new RemoveRelationshipAction + * + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param relationshipType The label of the relationshipType + */ + constructor( + item1: Item, + item2: Item, + relationshipType: string) { + this.payload = { item1, item2, relationshipType }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all RelationshipActions + */ +export type RelationshipAction + = AddRelationshipAction + | RemoveRelationshipAction diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts new file mode 100644 index 0000000000..f9d7dabf9c --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts @@ -0,0 +1,256 @@ +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { RelationshipEffects } from './relationship.effects'; +import { async, TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { AddRelationshipAction, RelationshipActionTypes, RemoveRelationshipAction } from './relationship.actions'; +import { Item } from '../../../../../core/shared/item.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { createSuccessfulRemoteDataObject$, spyOnOperator } from '../../../../testing/utils'; +import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model'; +import { cold, hot } from 'jasmine-marbles'; +import * as operators from 'rxjs/operators'; +import { last } from 'rxjs/operators'; +import { ItemType } from '../../../../../core/shared/item-relationships/item-type.model'; +import { RestResponse } from '../../../../../core/cache/response.models'; + +describe('RelationshipEffects', () => { + let relationEffects: RelationshipEffects; + let actions: Observable; + + let testUUID1; + let testUUID2; + let leftTypeString; + let rightTypeString; + let leftType; + let rightType; + let leftTypeMD; + let rightTypeMD; + let relationshipID; + let identifier; + + let leftItem; + + let rightItem; + + let relationshipType: RelationshipType; + + let relationship; + let mockRelationshipService; + let mockRelationshipTypeService; + + function init() { + testUUID1 = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + testUUID2 = '7f66a4d0-8557-4e77-8b1e-19930895f10a'; + leftTypeString = 'Publication'; + rightTypeString = 'Person'; + leftType = Object.assign(new ItemType(), {label: leftTypeString}); + rightType = Object.assign(new ItemType(), {label: rightTypeString}); + leftTypeMD = Object.assign(new MetadataValue(), { value: leftTypeString }); + rightTypeMD = Object.assign(new MetadataValue(), { value: rightTypeString }); + relationshipID = '1234'; + + leftItem = Object.assign(new Item(), { + uuid: testUUID1, + metadata: { 'relationship.type': [leftTypeMD] } + }); + + rightItem = Object.assign(new Item(), { + uuid: testUUID2, + metadata: { 'relationship.type': [rightTypeMD] } + }); + + relationshipType = Object.assign(new RelationshipType(), { + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor', + leftType: createSuccessfulRemoteDataObject$(leftType), + rightType: createSuccessfulRemoteDataObject$(rightType) + }); + + relationship = Object.assign(new Relationship(), + { + uuid: relationshipID, + id: relationshipID, + leftItem: createSuccessfulRemoteDataObject$(leftItem), + rightItem: createSuccessfulRemoteDataObject$(rightItem), + relationshipType: createSuccessfulRemoteDataObject$(relationshipType) + }); + + mockRelationshipService = { + getRelationshipByItemsAndLabel: + () => observableOf(relationship), + deleteRelationship: () => observableOf(new RestResponse(true, 200, 'OK')), + addRelationship: () => observableOf(new RestResponse(true, 200, 'OK')) + + }; + mockRelationshipTypeService = { + getRelationshipTypeByLabelAndTypes: + () => observableOf(relationshipType) + }; + } + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + providers: [ + RelationshipEffects, + provideMockActions(() => actions), + { provide: RelationshipTypeService, useValue: mockRelationshipTypeService }, + { provide: RelationshipService, useValue: mockRelationshipService } + ], + }); + })); + + beforeEach(() => { + relationEffects = TestBed.get(RelationshipEffects); + identifier = (relationEffects as any).createIdentifier(leftItem, rightItem, relationshipType.leftwardType); + }); + + describe('mapLastActions$', () => { + describe('When an ADD_RELATIONSHIP action is triggered', () => { + describe('When it\'s the first time for this identifier', () => { + let action; + it('should set the current value debounceMap and the value of the initialActionMap to ADD_RELATIONSHIP', () => { + action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When it\'s not the first time for this identifier', () => { + let action; + const testActionType = 'TEST_TYPE'; + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = testActionType; + (relationEffects as any).debounceMap[identifier] = new BehaviorSubject(testActionType); + }); + + it('should set the current value debounceMap to ADD_RELATIONSHIP but not change the value of the initialActionMap', () => { + action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When the initialActionMap contains an ADD_RELATIONSHIP action', () => { + let action; + describe('When the last value in the debounceMap is also an ADD_RELATIONSHIP action', () => { + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.ADD_RELATIONSHIP; + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v); + spyOn((relationEffects as any), 'addRelationship'); + }); + it('should call addRelationship on the effect', () => { + action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).addRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, undefined) + }); + }); + + describe('When the last value in the debounceMap is instead a REMOVE_RELATIONSHIP action', () => { + beforeEach(() => { + /** + * Change debounceTime to last so there's no need to fire a certain amount of actions in the debounce time frame + */ + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v.pipe(last())); + spyOn((relationEffects as any), 'addRelationship'); + spyOn((relationEffects as any), 'removeRelationship'); + }); + it('should not call removeRelationship or addRelationship on the effect', () => { + const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--ab-', { a: actiona, b: actionb }); + const expected = cold('--bb-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).addRelationship).not.toHaveBeenCalled(); + expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('When an REMOVE_RELATIONSHIP action is triggered', () => { + describe('When it\'s the first time for this identifier', () => { + let action; + it('should set the current value debounceMap and the value of the initialActionMap to REMOVE_RELATIONSHIP', () => { + action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When it\'s not the first time for this identifier', () => { + let action; + const testActionType = 'TEST_TYPE'; + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = testActionType; + (relationEffects as any).debounceMap[identifier] = new BehaviorSubject(testActionType); + }); + + it('should set the current value debounceMap to REMOVE_RELATIONSHIP but not change the value of the initialActionMap', () => { + action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When the initialActionMap contains an REMOVE_RELATIONSHIP action', () => { + let action; + describe('When the last value in the debounceMap is also an REMOVE_RELATIONSHIP action', () => { + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.REMOVE_RELATIONSHIP; + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v); + spyOn((relationEffects as any), 'removeRelationship'); + }); + + it('should call removeRelationship on the effect', () => { + action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).removeRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType) + }); + }); + + describe('When the last value in the debounceMap is instead a ADD_RELATIONSHIP action', () => { + beforeEach(() => { + /** + * Change debounceTime to last so there's no need to fire a certain amount of actions in the debounce time frame + */ + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v.pipe(last())); + spyOn((relationEffects as any), 'addRelationship'); + spyOn((relationEffects as any), 'removeRelationship'); + }); + it('should not call addRelationship or removeRelationship on the effect', () => { + const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--ab-', { a: actiona, b: actionb }); + const expected = cold('--bb-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).addRelationship).not.toHaveBeenCalled(); + expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts new file mode 100644 index 0000000000..9402ef6d19 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { debounceTime, map, mergeMap, take, tap } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { AddRelationshipAction, RelationshipAction, RelationshipActionTypes, RemoveRelationshipAction, UpdateRelationshipAction } from './relationship.actions'; +import { Item } from '../../../../../core/shared/item.model'; +import { hasNoValue, hasValue, hasValueOperator } from '../../../../empty.util'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; + +const DEBOUNCE_TIME = 5000; + +/** + * NGRX effects for RelationshipEffects + */ +@Injectable() +export class RelationshipEffects { + /** + * Map that keeps track of the latest RelationshipEffects for each relationship's composed identifier + */ + private debounceMap: { + [identifier: string]: BehaviorSubject + } = {}; + + private nameVariantUpdates: { + [identifier: string]: string + } = {}; + + private initialActionMap: { + [identifier: string]: string + } = {}; + + /** + * Effect that makes sure all last fired RelationshipActions' types are stored in the map of this service, with the object uuid as their key + */ + @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + .pipe( + ofType(RelationshipActionTypes.ADD_RELATIONSHIP, RelationshipActionTypes.REMOVE_RELATIONSHIP), + map((action: RelationshipAction) => { + const { item1, item2, relationshipType } = action.payload; + const identifier: string = this.createIdentifier(item1, item2, relationshipType); + if (hasNoValue(this.debounceMap[identifier])) { + this.initialActionMap[identifier] = action.type; + this.debounceMap[identifier] = new BehaviorSubject(action.type); + this.debounceMap[identifier].pipe( + debounceTime(DEBOUNCE_TIME), + take(1) + ).subscribe( + (type) => { + if (this.initialActionMap[identifier] === type) { + if (type === RelationshipActionTypes.ADD_RELATIONSHIP) { + let nameVariant = (action as AddRelationshipAction).payload.nameVariant; + if (hasValue(this.nameVariantUpdates[identifier])) { + nameVariant = this.nameVariantUpdates[identifier]; + delete this.nameVariantUpdates[identifier]; + } + this.addRelationship(item1, item2, relationshipType, nameVariant) + } else { + this.removeRelationship(item1, item2, relationshipType); + } + } + delete this.debounceMap[identifier]; + delete this.initialActionMap[identifier]; + } + ) + } else { + this.debounceMap[identifier].next(action.type); + } + } + ) + ); + + /** + * Updates the namevariant in a relationship + * If the relationship is currently being added or removed, it will add the name variant to an update map so it will be sent with the next add request instead + * Otherwise the update is done immediately + */ + @Effect({ dispatch: false }) updateNameVariantsActions$ = this.actions$ + .pipe( + ofType(RelationshipActionTypes.UPDATE_RELATIONSHIP), + map((action: UpdateRelationshipAction) => { + const { item1, item2, relationshipType, nameVariant } = action.payload; + const identifier: string = this.createIdentifier(item1, item2, relationshipType); + const inProgress = hasValue(this.debounceMap[identifier]); + if (inProgress) { + this.nameVariantUpdates[identifier] = nameVariant; + } else { + this.relationshipService.updateNameVariant(item1, item2, relationshipType, nameVariant) + .pipe() + .subscribe(); + } + } + ) + ); + + constructor(private actions$: Actions, + private relationshipService: RelationshipService, + private relationshipTypeService: RelationshipTypeService, + ) { + } + + private createIdentifier(item1: Item, item2: Item, relationshipType: string): string { + return `${item1.uuid}-${item2.uuid}-${relationshipType}`; + } + + private addRelationship(item1: Item, item2: Item, relationshipType: string, nameVariant?: string) { + const type1: string = item1.firstMetadataValue('relationship.type'); + const type2: string = item2.firstMetadataValue('relationship.type'); + return this.relationshipTypeService.getRelationshipTypeByLabelAndTypes(relationshipType, type1, type2) + .pipe( + mergeMap((type: RelationshipType) => { + const isSwitched = type.rightwardType === relationshipType; + if (isSwitched) { + return this.relationshipService.addRelationship(type.id, item2, item1, nameVariant, undefined); + } else { + return this.relationshipService.addRelationship(type.id, item1, item2, undefined, nameVariant); + } + } + ) + ).pipe(take(1)) + .subscribe(); + } + + private removeRelationship(item1: Item, item2: Item, relationshipType: string) { + this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe( + take(1), + hasValueOperator(), + mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id)), + take(1) + ).subscribe(); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html new file mode 100644 index 0000000000..4e2da1f12b --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html @@ -0,0 +1,69 @@ +
+ +
+ + + + +
+
+
+
+ + + + +
+
+
+ + +
+ + + + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss new file mode 100644 index 0000000000..4562a95080 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss @@ -0,0 +1,3 @@ +.position-absolute { + right: $spacer; +} \ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts new file mode 100644 index 0000000000..4434684cbb --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts @@ -0,0 +1,144 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DsDynamicLookupRelationSearchTabComponent } from './dynamic-lookup-relation-search-tab.component'; +import { SearchService } from '../../../../../../core/shared/search/search.service'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { RouteService } from '../../../../../../core/services/route.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../../../../utils/var.directive'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { of as observableOf } from 'rxjs'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../testing/utils'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { ItemSearchResult } from '../../../../../object-collection/shared/item-search-result.model'; +import { Item } from '../../../../../../core/shared/item.model'; + +describe('DsDynamicLookupRelationSearchTabComponent', () => { + let component: DsDynamicLookupRelationSearchTabComponent; + let fixture: ComponentFixture; + let relationship; + let pSearchOptions; + let item1; + let item2; + let item3; + let item4; + let searchResult1; + let searchResult2; + let searchResult3; + let searchResult4; + let listID; + let selection$; + + let results; + let selectableListService; + + function init() { + relationship = { filter: 'filter', relationshipType: 'isAuthorOfPublication', nameVariants: true } as RelationshipOptions; + pSearchOptions = new PaginatedSearchOptions({}); + item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + item3 = Object.assign(new Item(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + item4 = Object.assign(new Item(), { uuid: 'f96a385e-de10-45b2-be66-7f10bf52f765' }); + searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + searchResult4 = Object.assign(new ItemSearchResult(), { indexableObject: item4 }); + listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + selection$ = observableOf([searchResult1, searchResult2]); + + results = new PaginatedList(undefined, [searchResult1, searchResult2, searchResult3]); + selectableListService = jasmine.createSpyObj('selectableListService', ['deselect', 'select', 'deselectAll']); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicLookupRelationSearchTabComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results) } }, + { + provide: SelectableListService, useValue: selectableListService + }, + { + provide: SearchConfigurationService, useValue: { + paginatedSearchOptions: observableOf(pSearchOptions) + } + }, + { + provide: RouteService, useValue: { + setParameter: () => { + // do nothing + } + } + }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicLookupRelationSearchTabComponent); + component = fixture.componentInstance; + component.relationship = relationship; + component.selection$ = selection$; + component.listId = listID; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('selectPage', () => { + beforeEach(() => { + spyOn(component.selectObject, 'emit'); + component.selectPage([searchResult1, searchResult2, searchResult4]); + }); + + it('should emit the page filtered from already selected objects and call select on the service for all objects', () => { + expect(component.selectObject.emit).toHaveBeenCalledWith(searchResult4); + expect(selectableListService.select).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult4]); + }); + }); + + describe('deselectPage', () => { + beforeEach(() => { + spyOn(component.deselectObject, 'emit'); + component.deselectPage([searchResult1, searchResult2, searchResult3]); + }); + + it('should emit the page filtered from not yet selected objects and call select on the service for all objects', () => { + expect(component.deselectObject.emit).toHaveBeenCalledWith(searchResult1, searchResult2); + expect(selectableListService.deselect).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult3]); + }); + }); + + describe('selectAll', () => { + beforeEach(() => { + spyOn(component.selectObject, 'emit'); + component.selectAll(); + }); + + it('should emit the page filtered from already selected objects and call select on the service for all objects', () => { + expect(component.selectObject.emit).toHaveBeenCalledWith(searchResult3); + expect(selectableListService.select).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult3]); + }); + }); + + describe('deselectAll', () => { + beforeEach(() => { + spyOn(component.deselectObject, 'emit'); + component.deselectAll(); + }); + + it('should emit the page filtered from not yet selected objects and call select on the service for all objects', () => { + expect(component.deselectObject.emit).toHaveBeenCalledWith(searchResult1, searchResult2); + expect(selectableListService.deselectAll).toHaveBeenCalledWith(listID); + }); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts new file mode 100644 index 0000000000..9c00d64953 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts @@ -0,0 +1,181 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { Item } from '../../../../../../core/shared/item.model'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { SearchResult } from '../../../../../search/search-result.model'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { Observable, ReplaySubject } from 'rxjs'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { SearchService } from '../../../../../../core/shared/search/search.service'; +import { Router } from '@angular/router'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { hasValue, isNotEmpty } from '../../../../../empty.util'; +import { concat, map, multicast, switchMap, take, takeWhile, tap } from 'rxjs/operators'; +import { DSpaceObject } from '../../../../../../core/shared/dspace-object.model'; +import { getSucceededRemoteData } from '../../../../../../core/shared/operators'; +import { RouteService } from '../../../../../../core/services/route.service'; +import { CollectionElementLinkType } from '../../../../../object-collection/collection-element-link.type'; +import { Context } from '../../../../../../core/shared/context.model'; + +@Component({ + selector: 'ds-dynamic-lookup-relation-search-tab', + styleUrls: ['./dynamic-lookup-relation-search-tab.component.scss'], + templateUrl: './dynamic-lookup-relation-search-tab.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Tab for inside the lookup model that represents the items that can be used as a relationship in this submission + */ +export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDestroy { + @Input() relationship: RelationshipOptions; + @Input() listId: string; + @Input() repeatable: boolean; + @Input() selection$: Observable; + @Input() context: Context; + + @Output() deselectObject: EventEmitter = new EventEmitter(); + @Output() selectObject: EventEmitter = new EventEmitter(); + resultsRD$: Observable>>>; + searchConfig: PaginatedSearchOptions; + allSelected: boolean; + someSelected$: Observable; + selectAllLoading: boolean; + subscription; + initialPagination = Object.assign(new PaginationComponentOptions(), { + id: 'submission-relation-list', + pageSize: 5 + }); + linkTypes = CollectionElementLinkType; + + constructor( + private searchService: SearchService, + private router: Router, + private selectableListService: SelectableListService, + private searchConfigService: SearchConfigurationService, + private routeService: RouteService, + ) { + } + + /** + * Sets up the pagination and fixed query parameters + */ + ngOnInit(): void { + this.resetRoute(); + this.routeService.setParameter('fixedFilterQuery', this.relationship.filter); + this.routeService.setParameter('configuration', this.relationship.searchConfiguration); + + this.someSelected$ = this.selection$.pipe(map((selection) => isNotEmpty(selection))); + this.resultsRD$ = this.searchConfigService.paginatedSearchOptions.pipe( + map((options) => { + return Object.assign(new PaginatedSearchOptions({}), options, { fixedFilter: this.relationship.filter, configuration: this.relationship.searchConfiguration }) + }), + switchMap((options) => { + this.searchConfig = options; + return this.searchService.search(options).pipe( + /* Make sure to only listen to the first x results, until loading is finished */ + /* TODO: in Rxjs 6.4.0 and up, we can replace this with takeWhile(predicate, true) - see https://stackoverflow.com/a/44644237 */ + multicast( + () => new ReplaySubject(1), + (subject) => subject.pipe( + takeWhile((rd: RemoteData>>) => rd.isLoading), + concat(subject.pipe(take(1))) + ) + ) as any + ) + }) + ); + } + + /** + * Method to reset the route when the window is opened to make sure no strange pagination issues appears + */ + resetRoute() { + this.router.navigate([], { + queryParams: Object.assign({}, { page: 1, pageSize: this.initialPagination.pageSize }), + }); + } + + /** + * Selects a page in the store + * @param page The page to select + */ + selectPage(page: Array>) { + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => { + const filteredPage = page.filter((pageItem) => selection.findIndex((selected) => selected.equals(pageItem)) < 0); + this.selectObject.emit(...filteredPage); + }); + this.selectableListService.select(this.listId, page); + } + + /** + * Deselects a page in the store + * @param page the page to deselect + */ + deselectPage(page: Array>) { + this.allSelected = false; + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => { + const filteredPage = page.filter((pageItem) => selection.findIndex((selected) => selected.equals(pageItem)) >= 0); + this.deselectObject.emit(...filteredPage); + }); + this.selectableListService.deselect(this.listId, page); + } + + /** + * Select all items that were found using the current search query + */ + selectAll() { + this.allSelected = true; + this.selectAllLoading = true; + const fullPagination = Object.assign(new PaginationComponentOptions(), { + currentPage: 1, + pageSize: 9999 + }); + const fullSearchConfig = Object.assign(this.searchConfig, { pagination: fullPagination }); + const results$ = this.searchService.search(fullSearchConfig) as Observable>>>; + results$.pipe( + getSucceededRemoteData(), + map((resultsRD) => resultsRD.payload.page), + tap(() => this.selectAllLoading = false), + ).subscribe((results) => { + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => { + const filteredResults = results.filter((pageItem) => selection.findIndex((selected) => selected.equals(pageItem)) < 0); + this.selectObject.emit(...filteredResults); + }); + this.selectableListService.select(this.listId, results); + } + ); + } + + /** + * Deselect all items + */ + deselectAll() { + this.allSelected = false; + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => this.deselectObject.emit(...selection)); + this.selectableListService.deselectAll(this.listId); + } + + ngOnDestroy(): void { + if (hasValue(this.subscription)) { + this.subscription.unsubscribe(); + } + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html new file mode 100644 index 0000000000..46ee1727fe --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html @@ -0,0 +1,23 @@ +
+
+

{{ 'submission.sections.describe.relationship-lookup.selection-tab.settings' | translate}}

+ +
+
+
+ {{'submission.sections.describe.relationship-lookup.selection-tab.no-selection' | translate}} +
+
+

{{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + label | translate}}

+ +
+
+
\ No newline at end of file diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.scss similarity index 100% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss rename to src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.scss diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts new file mode 100644 index 0000000000..203a4df0b0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts @@ -0,0 +1,97 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../../../../utils/var.directive'; +import { Observable, of as observableOf } from 'rxjs'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { ItemSearchResult } from '../../../../../object-collection/shared/item-search-result.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import { DsDynamicLookupRelationSelectionTabComponent } from './dynamic-lookup-relation-selection-tab.component'; +import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../testing/utils'; + +describe('DsDynamicLookupRelationSelectionTabComponent', () => { + let component: DsDynamicLookupRelationSelectionTabComponent; + let fixture: ComponentFixture; + let pSearchOptions = new PaginatedSearchOptions({ pagination: new PaginationComponentOptions() }); + let item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + let item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + let searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + let searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + let listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + let selection$; + let selectionRD$; + let router; + + function init() { + pSearchOptions = new PaginatedSearchOptions({ pagination: new PaginationComponentOptions() }); + item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + selection$ = observableOf([searchResult1, searchResult2]); + selectionRD$ = createSelection([searchResult1, searchResult2]); + router = jasmine.createSpyObj('router', ['navigate']) + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicLookupRelationSelectionTabComponent, VarDirective], + imports: [TranslateModule.forRoot()], + providers: [ + { + provide: SearchConfigurationService, useValue: { + paginatedSearchOptions: observableOf(pSearchOptions) + }, + }, + { + provide: Router, useValue: router + } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicLookupRelationSelectionTabComponent); + component = fixture.componentInstance; + component.selection$ = selection$; + component.listId = listID; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.resetRoute(); + expect(router.navigate).toHaveBeenCalled(); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.selectionRD$ = createSelection([]); + fixture.detectChanges(); + const colComponent = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(colComponent).toBe(null); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.selectionRD$ = selectionRD$; + const colComponent = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(colComponent).not.toBe(null); + }); +}); + +function createSelection(content: ListableObject[]): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(undefined, content)); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts new file mode 100644 index 0000000000..8aa3dc3828 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts @@ -0,0 +1,87 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { Observable } from 'rxjs'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { map, switchMap, take } from 'rxjs/operators'; +import { createSuccessfulRemoteDataObject } from '../../../../../testing/utils'; +import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { Router } from '@angular/router'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; +import { Context } from '../../../../../../core/shared/context.model'; + +@Component({ + selector: 'ds-dynamic-lookup-relation-selection-tab', + styleUrls: ['./dynamic-lookup-relation-selection-tab.component.scss'], + templateUrl: './dynamic-lookup-relation-selection-tab.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Tab for inside the lookup model that represents the currently selected relationships + */ +export class DsDynamicLookupRelationSelectionTabComponent { + @Input() label: string; + @Input() listId: string; + @Input() repeatable: boolean; + @Input() selection$: Observable; + @Input() selectionRD$: Observable>>; + @Input() context: Context; + @Output() deselectObject: EventEmitter = new EventEmitter(); + @Output() selectObject: EventEmitter = new EventEmitter(); + + initialPagination = Object.assign(new PaginationComponentOptions(), { + id: 'submission-relation-list', + pageSize: 5 + }); + + constructor(private router: Router, + private searchConfigService: SearchConfigurationService) { + } + + /** + * Set up the selection and pagination on load + */ + ngOnInit() { + this.resetRoute(); + this.selectionRD$ = this.searchConfigService.paginatedSearchOptions + .pipe( + map((options: PaginatedSearchOptions) => options.pagination), + switchMap((pagination: PaginationComponentOptions) => { + return this.selection$.pipe( + take(1), + map((selected) => { + const offset = (pagination.currentPage - 1) * pagination.pageSize; + const end = (offset + pagination.pageSize) > selected.length ? selected.length : offset + pagination.pageSize; + const selection = selected.slice(offset, end); + const pageInfo = new PageInfo( + { + elementsPerPage: pagination.pageSize, + totalElements: selected.length, + currentPage: pagination.currentPage, + totalPages: Math.ceil(selected.length / pagination.pageSize) + }); + return createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, selection)); + }) + ); + }) + ) + } + + /** + * Method to reset the route when the window is opened to make sure no strange pagination issues appears + */ + resetRoute() { + this.router.navigate([], { + queryParams: Object.assign({}, { page: 1, pageSize: this.initialPagination.pageSize }), + }); + } +} diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 496c06629a..ea0957f689 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -195,11 +195,11 @@ describe('FormBuilderService test suite', () => { new DynamicColorPickerModel({id: 'testColorPicker'}), - new DynamicTypeaheadModel({id: 'testTypeahead'}), + new DynamicTypeaheadModel({id: 'testTypeahead', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions}), + new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions, repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicTagModel({id: 'testTag'}), + new DynamicTagModel({id: 'testTag', repeatable: false, metadataFields: [], submissionId: '1234'}), new DynamicListCheckboxGroupModel({id: 'testCheckboxList', authorityOptions: authorityOptions, repeatable: true}), @@ -242,16 +242,18 @@ describe('FormBuilderService test suite', () => { name: 'testRelationGroup', relationFields: [], scopeUUID: '', - submissionScope: '' + submissionScope: '', + repeatable: false, + metadataFields: [] }), new DynamicDsDatePickerModel({id: 'testDate'}), - new DynamicLookupModel({id: 'testLookup'}), + new DynamicLookupModel({id: 'testLookup', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicLookupNameModel({id: 'testLookupName'}), + new DynamicLookupNameModel({id: 'testLookupName', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicQualdropModel({id: 'testCombobox', readOnly: false}), + new DynamicQualdropModel({id: 'testCombobox', readOnly: false, required: false}), new DynamicRowArrayModel( { @@ -263,6 +265,7 @@ describe('FormBuilderService test suite', () => { new DynamicInputModel({id: 'testFormRowArrayGroupInput'}) ]; }, + required: false } ), ]; diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index f867d1f79c..dcc9403d9b 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -10,7 +10,8 @@ import { DynamicFormArrayModel, DynamicFormControlModel, DynamicFormGroupModel, - DynamicFormService, DynamicFormValidationService, + DynamicFormService, + DynamicFormValidationService, DynamicPathable, JSONUtils, } from '@ng-dynamic-forms/core'; @@ -21,10 +22,7 @@ import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qua import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; import { RowParser } from './parsers/row-parser'; -import { - DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP, - DynamicRelationGroupModel -} from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP, DynamicRelationGroupModel } from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { FormFieldMetadataValueObject } from './models/form-field-metadata-value.model'; diff --git a/src/app/shared/form/builder/models/form-field.model.ts b/src/app/shared/form/builder/models/form-field.model.ts index 39f2d6b9f8..718b3f4f0d 100644 --- a/src/app/shared/form/builder/models/form-field.model.ts +++ b/src/app/shared/form/builder/models/form-field.model.ts @@ -1,6 +1,7 @@ import { autoserialize } from 'cerialize'; import { LanguageCode } from './form-field-language-value.model'; import { FormFieldMetadataValueObject } from './form-field-metadata-value.model'; +import { RelationshipOptions } from './relationship-options.model'; import { FormRowModel } from '../../../../core/config/models/config-submission-form.model'; export class FormFieldModel { @@ -32,6 +33,9 @@ export class FormFieldModel { @autoserialize selectableMetadata: FormFieldMetadataValueObject[]; + @autoserialize + selectableRelationship: RelationshipOptions; + @autoserialize rows: FormRowModel[]; diff --git a/src/app/shared/form/builder/models/relationship-options.model.ts b/src/app/shared/form/builder/models/relationship-options.model.ts new file mode 100644 index 0000000000..f1d3d0ae7a --- /dev/null +++ b/src/app/shared/form/builder/models/relationship-options.model.ts @@ -0,0 +1,15 @@ +const RELATION_METADATA_PREFIX = 'relation.' + +/** + * The submission options for fields that can represent relationships + */ +export class RelationshipOptions { + relationshipType: string; + filter: string; + searchConfiguration: string; + nameVariants: boolean; + + get metadataField() { + return RELATION_METADATA_PREFIX + this.relationshipType + } +} diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts index 449827c56b..33a92c726d 100644 --- a/src/app/shared/form/builder/parsers/concat-field-parser.ts +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -1,14 +1,11 @@ import { Inject } from '@angular/core'; -import { - CONFIG_DATA, - FieldParser, - INIT_FORM_VALUES, - PARSER_OPTIONS, - SUBMISSION_ID -} from './field-parser'; import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; -import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig } from '@ng-dynamic-forms/core'; +import { + DynamicFormControlLayout, + DynamicInputModel, + DynamicInputModelConfig +} from '@ng-dynamic-forms/core'; import { CONCAT_FIRST_INPUT_SUFFIX, CONCAT_GROUP_SUFFIX, @@ -18,17 +15,24 @@ import { } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; import { isNotEmpty } from '../../../empty.util'; import { ParserOptions } from './parser-options'; +import { + CONFIG_DATA, + FieldParser, + INIT_FORM_VALUES, + PARSER_OPTIONS, + SUBMISSION_ID +} from './field-parser'; export class ConcatFieldParser extends FieldParser { constructor( - @Inject(SUBMISSION_ID) submissionId: string, - @Inject(CONFIG_DATA) configData: FormFieldModel, - @Inject(INIT_FORM_VALUES) initFormValues, - @Inject(PARSER_OPTIONS) parserOptions: ParserOptions, - protected separator: string, - protected firstPlaceholder: string = null, - protected secondPlaceholder: string = null) { + @Inject(SUBMISSION_ID) submissionId: string, + @Inject(CONFIG_DATA) configData: FormFieldModel, + @Inject(INIT_FORM_VALUES) initFormValues, + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions, + protected separator: string, + protected firstPlaceholder: string = null, + protected secondPlaceholder: string = null) { super(submissionId, configData, initFormValues, parserOptions); this.separator = separator; @@ -49,16 +53,17 @@ export class ConcatFieldParser extends FieldParser { }; const groupId = id.replace(/\./g, '_') + CONCAT_GROUP_SUFFIX; - const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, false, false); + const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, label, false); concatGroup.group = []; concatGroup.separator = this.separator; - const input1ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_FIRST_INPUT_SUFFIX, label, false, false); - const input2ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_SECOND_INPUT_SUFFIX, label, true, false); + const input1ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_FIRST_INPUT_SUFFIX, false, false); + const input2ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_SECOND_INPUT_SUFFIX, false, false); input2ModelConfig.hint = ' '; if (this.configData.mandatory) { + concatGroup.required = true; input1ModelConfig.required = true; } diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts new file mode 100644 index 0000000000..7dce05f18d --- /dev/null +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts @@ -0,0 +1,66 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; +import { DisabledFieldParser } from './disabled-field-parser'; +import { DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model'; + +describe('DisabledFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues: any = {}; + + const submissionId = '1234'; + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: null, + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: '' + }, + label: 'Description', + mandatory: 'false', + repeatable: false, + hints: 'Enter a description.', + selectableMetadata: [ + { + metadata: 'description' + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); + + expect(parser instanceof DisabledFieldParser).toBe(true); + }); + + it('should return a DynamicDisabledModel object when repeatable option is false', () => { + const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicDisabledModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + description: [ + new FormFieldMetadataValueObject('test description'), + ], + }; + const expectedValue ='test description'; + + const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + console.log(fieldModel); + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.ts new file mode 100644 index 0000000000..db3e4ac8b9 --- /dev/null +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.ts @@ -0,0 +1,16 @@ +import { FieldParser } from './field-parser'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DsDynamicDisabledModelConfig, DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model'; + +/** + * Field parser for disabled fields + */ +export class DisabledFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + console.log(fieldValue); + const emptyModelConfig: DsDynamicDisabledModelConfig = this.initModel(null, label); + this.setValues(emptyModelConfig, fieldValue); + return new DynamicDisabledModel(emptyModelConfig) + } +} diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 83bdd14e9c..f7bf12353c 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -1,5 +1,5 @@ import { Inject, InjectionToken } from '@angular/core'; -import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isEmpty } from '../../../empty.util'; import { FormFieldModel } from '../models/form-field.model'; import { uniqueId } from 'lodash'; @@ -13,10 +13,11 @@ import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { setLayout } from './parser.utils'; import { AuthorityOptions } from '../../../../core/integration/models/authority-options.model'; import { ParserOptions } from './parser-options'; +import { RelationshipOptions } from '../models/relationship-options.model'; export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); -export const INIT_FORM_VALUES:InjectionToken = new InjectionToken('initFormValues'); +export const INIT_FORM_VALUES: InjectionToken = new InjectionToken('initFormValues'); export const PARSER_OPTIONS: InjectionToken = new InjectionToken('parserOptions'); export abstract class FieldParser { @@ -28,7 +29,8 @@ export abstract class FieldParser { @Inject(CONFIG_DATA) protected configData: FormFieldModel, @Inject(INIT_FORM_VALUES) protected initFormValues: any, @Inject(PARSER_OPTIONS) protected parserOptions: ParserOptions - ) {} + ) { + } public abstract modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any; @@ -37,6 +39,7 @@ export abstract class FieldParser { && (this.configData.input.type !== 'list') && (this.configData.input.type !== 'tag') && (this.configData.input.type !== 'group') + && isEmpty(this.configData.selectableRelationship) ) { let arrayCounter = 0; let fieldArrayCounter = 0; @@ -46,6 +49,7 @@ export abstract class FieldParser { label: this.configData.label, initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, + required: isNotEmpty(this.configData.mandatory), groupFactory: () => { let model; if ((arrayCounter === 0)) { @@ -81,7 +85,7 @@ export abstract class FieldParser { } else { const model = this.modelFactory(this.getInitFieldValue()); - if (model.hasLanguages) { + if (model.hasLanguages || isNotEmpty(model.relationship)) { setLayout(model, 'grid', 'control', 'col'); } return model; @@ -174,11 +178,11 @@ export abstract class FieldParser { return ids; } } else { - return null; + return [this.configData.selectableRelationship.relationshipType]; } } - protected initModel(id?: string, label = true, labelEmpty = false, setErrors = true) { + protected initModel(id?: string, label = true, setErrors = true) { const controlModel = Object.create(null); @@ -194,9 +198,15 @@ export abstract class FieldParser { // Set read only option controlModel.readOnly = this.parserOptions.readOnly; controlModel.disabled = this.parserOptions.readOnly; + if (hasValue(this.configData.selectableRelationship)) { + controlModel.relationship = Object.assign(new RelationshipOptions(), this.configData.selectableRelationship); + } + controlModel.repeatable = this.configData.repeatable; + controlModel.metadataFields = isNotEmpty(this.configData.selectableMetadata) ? this.configData.selectableMetadata.map((metadataObject) => metadataObject.metadata) : []; + controlModel.submissionId = this.submissionId; // Set label - this.setLabel(controlModel, label, labelEmpty); + this.setLabel(controlModel, label); controlModel.placeholder = this.configData.label; @@ -214,14 +224,14 @@ export abstract class FieldParser { if (this.configData.languageCodes && this.configData.languageCodes.length > 0) { (controlModel as DsDynamicInputModel).languageCodes = this.configData.languageCodes; } -/* (controlModel as DsDynamicInputModel).languageCodes = [{ - display: 'English', - code: 'en_US' - }, - { - display: 'Italian', - code: 'it_IT' - }];*/ + /* (controlModel as DsDynamicInputModel).languageCodes = [{ + display: 'English', + code: 'en_US' + }, + { + display: 'Italian', + code: 'it_IT' + }];*/ return controlModel; } @@ -232,26 +242,26 @@ export abstract class FieldParser { protected addPatternValidator(controlModel) { const regex = new RegExp(this.configData.input.regex); - controlModel.validators = Object.assign({}, controlModel.validators, {pattern: regex}); + controlModel.validators = Object.assign({}, controlModel.validators, { pattern: regex }); controlModel.errorMessages = Object.assign( {}, controlModel.errorMessages, - {pattern: 'error.validation.pattern'}); + { pattern: 'error.validation.pattern' }); } protected markAsRequired(controlModel) { controlModel.required = true; - controlModel.validators = Object.assign({}, controlModel.validators, {required: null}); + controlModel.validators = Object.assign({}, controlModel.validators, { required: null }); controlModel.errorMessages = Object.assign( {}, controlModel.errorMessages, - {required: this.configData.mandatoryMessage}); + { required: this.configData.mandatoryMessage }); } protected setLabel(controlModel, label = true, labelEmpty = false) { if (label) { - controlModel.label = (labelEmpty) ? ' ' : this.configData.label; + controlModel.label = this.configData.label; } } @@ -263,13 +273,13 @@ export abstract class FieldParser { if (key === 0) { controlModel.value = option.metadata; } - controlModel.options.push({label: option.label, value: option.metadata}); + controlModel.options.push({ label: option.label, value: option.metadata }); }); } } public setAuthorityOptions(controlModel, authorityUuid) { - if (isNotEmpty(this.configData.selectableMetadata[0].authority)) { + if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].authority)) { controlModel.authorityOptions = new AuthorityOptions( this.configData.selectableMetadata[0].authority, this.configData.selectableMetadata[0].metadata, diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts index 284656cc95..d69c9d4677 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -56,8 +56,10 @@ export class OneboxFieldParser extends FieldParser { inputSelectGroup.group = []; inputSelectGroup.legend = this.configData.label; inputSelectGroup.hint = this.configData.hints; + this.setLabel(inputSelectGroup, label); + inputSelectGroup.required = isNotEmpty(this.configData.mandatory); - const selectModelConfig: DynamicSelectModelConfig = this.initModel(newId + QUALDROP_METADATA_SUFFIX, label); + const selectModelConfig: DynamicSelectModelConfig = this.initModel(newId + QUALDROP_METADATA_SUFFIX, label, false); selectModelConfig.hint = null; this.setOptions(selectModelConfig); if (isNotEmpty(fieldValue)) { @@ -65,11 +67,11 @@ export class OneboxFieldParser extends FieldParser { } inputSelectGroup.group.push(new DynamicSelectModel(selectModelConfig, clsSelect)); - const inputModelConfig: DsDynamicInputModelConfig = this.initModel(newId + QUALDROP_VALUE_SUFFIX, label, true); + const inputModelConfig: DsDynamicInputModelConfig = this.initModel(newId + QUALDROP_VALUE_SUFFIX, label, false); inputModelConfig.hint = null; this.setValues(inputModelConfig, fieldValue); - inputSelectGroup.readOnly = selectModelConfig.disabled && inputModelConfig.readOnly; + inputSelectGroup.group.push(new DsDynamicInputModel(inputModelConfig, clsInput)); return new DynamicQualdropModel(inputSelectGroup, clsGroup); diff --git a/src/app/shared/form/builder/parsers/parser-factory.ts b/src/app/shared/form/builder/parsers/parser-factory.ts index 67d8f31740..d674007da4 100644 --- a/src/app/shared/form/builder/parsers/parser-factory.ts +++ b/src/app/shared/form/builder/parsers/parser-factory.ts @@ -18,6 +18,7 @@ import { NameFieldParser } from './name-field-parser'; import { SeriesFieldParser } from './series-field-parser'; import { TagFieldParser } from './tag-field-parser'; import { TextareaFieldParser } from './textarea-field-parser'; +import { DisabledFieldParser } from './disabled-field-parser'; const fieldParserDeps = [ SUBMISSION_ID, @@ -26,6 +27,9 @@ const fieldParserDeps = [ PARSER_OPTIONS, ]; +/** + * Method to retrieve a field parder with its providers based on the input type + */ export class ParserFactory { public static getProvider(type: ParserType): StaticProvider { switch (type) { @@ -106,7 +110,13 @@ export class ParserFactory { deps: [...fieldParserDeps] } } - + case undefined: { + return { + provide: FieldParser, + useClass: DisabledFieldParser, + deps: [...fieldParserDeps] + } + } default: { return undefined; } diff --git a/src/app/shared/form/builder/parsers/parser-type.ts b/src/app/shared/form/builder/parsers/parser-type.ts index a9af87d73f..f43d4654a0 100644 --- a/src/app/shared/form/builder/parsers/parser-type.ts +++ b/src/app/shared/form/builder/parsers/parser-type.ts @@ -5,6 +5,7 @@ export enum ParserType { List = 'list', Lookup = 'lookup', LookupName = 'lookup-name', + LookupRelation = 'lookup-relation', Onebox = 'onebox', Name = 'name', Series = 'series', diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts index 72737cfaa9..4938b9859e 100644 --- a/src/app/shared/form/builder/parsers/row-parser.ts +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -27,6 +27,10 @@ export const ROW_ID_PREFIX = 'df-row-group-config-'; @Injectable({ providedIn: 'root' }) + +/** + * Parser the submission data for a single row + */ export class RowParser { constructor(private parentInjector: Injector) { } diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 21d4a81659..510bf7291b 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -13,10 +13,10 @@ - +
-
+
- +
-
+
- +
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/shared/search/search-sidebar/search-sidebar.component.scss similarity index 100% rename from src/app/+search-page/search-sidebar/search-sidebar.component.scss rename to src/app/shared/search/search-sidebar/search-sidebar.component.scss diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.spec.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts similarity index 100% rename from src/app/+search-page/search-sidebar/search-sidebar.component.spec.ts rename to src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.ts similarity index 93% rename from src/app/+search-page/search-sidebar/search-sidebar.component.ts rename to src/app/shared/search/search-sidebar/search-sidebar.component.ts index 9ee0a74942..42e8a444bc 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.ts @@ -34,6 +34,11 @@ export class SearchSidebarComponent { */ @Input() viewModeList; + /** + * Whether to show the view mode switch + */ + @Input() showViewModes = true; + /** * True when the search component should show results on the current page */ diff --git a/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts b/src/app/shared/search/search-switch-configuration/search-configuration-option.model.ts similarity index 100% rename from src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts rename to src/app/shared/search/search-switch-configuration/search-configuration-option.model.ts diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.html similarity index 100% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html rename to src/app/shared/search/search-switch-configuration/search-switch-configuration.component.html diff --git a/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.scss b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.spec.ts similarity index 87% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts rename to src/app/shared/search/search-switch-configuration/search-switch-configuration.component.spec.ts index 602dee33e6..05108905f2 100644 --- a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts +++ b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.spec.ts @@ -6,13 +6,16 @@ import { of as observableOf } from 'rxjs'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { SearchSwitchConfigurationComponent } from './search-switch-configuration.component'; -import { MYDSPACE_ROUTE, SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; -import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; import { NavigationExtras, Router } from '@angular/router'; -import { RouterStub } from '../../shared/testing/router-stub'; -import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; -import { SearchService } from '../search-service/search.service'; +import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service-stub'; +import { RouterStub } from '../../testing/router-stub'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { + MYDSPACE_ROUTE, + SEARCH_CONFIG_SERVICE +} from '../../../+my-dspace-page/my-dspace-page.component'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; describe('SearchSwitchConfigurationComponent', () => { diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.ts similarity index 83% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts rename to src/app/shared/search/search-switch-configuration/search-switch-configuration.component.ts index 1ce1bf84ec..73312e072e 100644 --- a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts +++ b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.ts @@ -3,12 +3,13 @@ import { NavigationExtras, Router } from '@angular/router'; import { Subscription } from 'rxjs'; -import { hasValue } from '../../shared/empty.util'; -import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; -import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; +import { hasValue } from '../../empty.util'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; import { SearchConfigurationOption } from './search-configuration-option.model'; -import { SearchService } from '../search-service/search.service'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { currentPath } from '../../utils/route.utils'; @Component({ selector: 'ds-search-switch-configuration', @@ -87,7 +88,7 @@ export class SearchSwitchConfigurationComponent implements OnDestroy, OnInit { */ public getSearchLink(): string { if (this.inPlaceSearch) { - return './'; + return currentPath(this.router); } return this.searchService.getSearchLink(); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 68a9049cb5..85d001286d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -130,6 +130,30 @@ import { RoleDirective } from './roles/role.directive'; import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; +import { DsDynamicLookupRelationModalComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; +import { SearchResultsComponent } from './search/search-results/search-results.component'; +import { SearchSidebarComponent } from './search/search-sidebar/search-sidebar.component'; +import { SearchSettingsComponent } from './search/search-settings/search-settings.component'; +import { CollectionSearchResultGridElementComponent } from './object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; +import { CommunitySearchResultGridElementComponent } from './object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'; +import { SearchFiltersComponent } from './search/search-filters/search-filters.component'; +import { SearchFilterComponent } from './search/search-filters/search-filter/search-filter.component'; +import { SearchFacetFilterComponent } from './search/search-filters/search-filter/search-facet-filter/search-facet-filter.component'; +import { SearchLabelsComponent } from './search/search-labels/search-labels.component'; +import { SearchFacetFilterWrapperComponent } from './search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component'; +import { SearchRangeFilterComponent } from './search/search-filters/search-filter/search-range-filter/search-range-filter.component'; +import { SearchTextFilterComponent } from './search/search-filters/search-filter/search-text-filter/search-text-filter.component'; +import { SearchHierarchyFilterComponent } from './search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component'; +import { SearchBooleanFilterComponent } from './search/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component'; +import { SearchFacetOptionComponent } from './search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component'; +import { SearchFacetSelectedOptionComponent } from './search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component'; +import { SearchFacetRangeOptionComponent } from './search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; +import { SearchSwitchConfigurationComponent } from './search/search-switch-configuration/search-switch-configuration.component'; +import { SearchAuthorityFilterComponent } from './search/search-filters/search-filter/search-authority-filter/search-authority-filter.component'; +import { DsDynamicDisabledComponent } from './form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component'; +import { DsDynamicLookupRelationSearchTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component'; +import { DsDynamicLookupRelationSelectionTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component'; +import { PageSizeSelectorComponent } from './page-size-selector/page-size-selector.component'; import { ItemSelectComponent } from './object-select/item-select/item-select.component'; import { CollectionSelectComponent } from './object-select/collection-select/collection-select.component'; import { FilterInputSuggestionsComponent } from './input-suggestions/filter-suggestions/filter-input-suggestions.component'; @@ -142,13 +166,14 @@ import { ListableObjectComponentLoaderComponent } from './object-collection/shar import { PublicationSearchResultListElementComponent } from './object-list/search-result-list-element/item-search-result/item-types/publication/publication-search-result-list-element.component'; import { PublicationSearchResultGridElementComponent } from './object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component'; import { ListableObjectDirective } from './object-collection/shared/listable-object/listable-object.directive'; -import { CommunitySearchResultGridElementComponent } from './object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'; -import { CollectionSearchResultGridElementComponent } from './object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; +import { SearchLabelComponent } from './search/search-labels/search-label/search-label.component'; import { ItemMetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; import { PageWithSidebarComponent } from './sidebar/page-with-sidebar.component'; import { SidebarDropdownComponent } from './sidebar/sidebar-dropdown.component'; import { SidebarFilterComponent } from './sidebar/filter/sidebar-filter.component'; import { SidebarFilterSelectedOptionComponent } from './sidebar/filter/sidebar-filter-selected-option.component'; +import { MetadataRepresentationListComponent } from '../+item-page/simple/metadata-representation-list/metadata-representation-list.component'; +import { SelectableListItemControlComponent } from './object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -210,6 +235,8 @@ const COMPONENTS = [ DsDynamicFormControlContainerComponent, DsDynamicListComponent, DsDynamicLookupComponent, + DsDynamicDisabledComponent, + DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, DsDynamicTypeaheadComponent, @@ -268,6 +295,29 @@ const COMPONENTS = [ EditItemSelectorComponent, CommunitySearchResultListElementComponent, CollectionSearchResultListElementComponent, + BrowseByComponent, + SearchResultsComponent, + SearchSidebarComponent, + SearchSettingsComponent, + CollectionSearchResultGridElementComponent, + CommunitySearchResultGridElementComponent, + SearchFiltersComponent, + SearchFilterComponent, + SearchFacetFilterComponent, + SearchLabelsComponent, + SearchLabelComponent, + SearchFacetFilterComponent, + SearchFacetFilterWrapperComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchSwitchConfigurationComponent, + SearchAuthorityFilterComponent, + PageSizeSelectorComponent, CommunitySearchResultGridElementComponent, CollectionSearchResultGridElementComponent, ListableObjectComponentLoaderComponent, @@ -279,7 +329,8 @@ const COMPONENTS = [ ItemTypeBadgeComponent, ItemSelectComponent, CollectionSelectComponent, - MetadataRepresentationLoaderComponent + MetadataRepresentationLoaderComponent, + SelectableListItemControlComponent ]; const ENTRY_COMPONENTS = [ @@ -303,6 +354,8 @@ const ENTRY_COMPONENTS = [ SearchResultGridElementComponent, DsDynamicListComponent, DsDynamicLookupComponent, + DsDynamicDisabledComponent, + DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, DsDynamicTypeaheadComponent, @@ -324,7 +377,21 @@ const ENTRY_COMPONENTS = [ PlainTextMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, - ItemMetadataRepresentationListElementComponent + ItemMetadataRepresentationListElementComponent, + SearchResultsComponent, + CollectionSearchResultGridElementComponent, + CommunitySearchResultGridElementComponent, + SearchFacetFilterComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchAuthorityFilterComponent, + DsDynamicLookupRelationSearchTabComponent, + DsDynamicLookupRelationSelectionTabComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ diff --git a/src/app/shared/sidebar/search-sidebar.effects.spec.ts b/src/app/shared/sidebar/search-sidebar.effects.spec.ts deleted file mode 100644 index da675a38ce..0000000000 --- a/src/app/shared/sidebar/search-sidebar.effects.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import * as fromRouter from '@ngrx/router-store'; -import { SidebarCollapseAction } from './sidebar.actions'; -import { SidebarEffects } from './sidebar-effects.service'; - -describe('SidebarEffects', () => { - let sidebarEffects: SidebarEffects; - let actions: Observable; - const dummyURL = 'http://f4fb15e2-1bd3-4e63-8d0d-486ad8bc714a'; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - SidebarEffects, - provideMockActions(() => actions), - // other providers - ], - }); - - sidebarEffects = TestBed.get(SidebarEffects); - }); - - describe('routeChange$', () => { - - it('should return a COLLAPSE action in response to an UPDATE_LOCATION action to a new route', () => { - actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION, payload: {routerState: {url: dummyURL}} } }); - - const expected = cold('--b-', { b: new SidebarCollapseAction() }); - - expect(sidebarEffects.routeChange$).toBeObservable(expected); - }); - }); -}); diff --git a/src/app/shared/utils/relation-query.utils.spec.ts b/src/app/shared/utils/relation-query.utils.spec.ts new file mode 100644 index 0000000000..f70e904422 --- /dev/null +++ b/src/app/shared/utils/relation-query.utils.spec.ts @@ -0,0 +1,18 @@ +import { getFilterByRelation, getQueryByRelations } from './relation-query.utils'; + +describe('Relation Query Utils', () => { + const relationtype = 'isAuthorOfPublication'; + const itemUUID = 'a7939af0-36ad-430d-af09-7be8b0a4dadd'; + describe('getQueryByRelations', () => { + it('Should return the correct query based on relationtype and uuid', () => { + const result = getQueryByRelations(relationtype, itemUUID); + expect(result).toEqual('query=relation.isAuthorOfPublication:a7939af0-36ad-430d-af09-7be8b0a4dadd'); + }); + }); + describe('getFilterByRelation', () => { + it('Should return the correct query based on relationtype and uuid', () => { + const result = getFilterByRelation(relationtype, itemUUID); + expect(result).toEqual('f.isAuthorOfPublication=a7939af0-36ad-430d-af09-7be8b0a4dadd'); + }); + }); +}); diff --git a/src/app/shared/utils/relation-query.utils.ts b/src/app/shared/utils/relation-query.utils.ts new file mode 100644 index 0000000000..74f9e64cc9 --- /dev/null +++ b/src/app/shared/utils/relation-query.utils.ts @@ -0,0 +1,18 @@ +/** + * Get the query for looking up items by relation type + * @param {string} relationType Relation type + * @param {string} itemUUID Item UUID + * @returns {string} Query + */ +export function getQueryByRelations(relationType: string, itemUUID: string): string { + return `query=relation.${relationType}:${itemUUID}`; +} + +/** + * Get the filter for a relation with the item's UUID + * @param relationType The type of relation e.g. 'isAuthorOfPublication' + * @param itemUUID The item's UUID + */ +export function getFilterByRelation(relationType: string, itemUUID: string): string { + return `f.${relationType}=${itemUUID}`; +} diff --git a/src/app/shared/utils/route.utils.spec.ts b/src/app/shared/utils/route.utils.spec.ts new file mode 100644 index 0000000000..610fd8756d --- /dev/null +++ b/src/app/shared/utils/route.utils.spec.ts @@ -0,0 +1,22 @@ +import { currentPath } from './route.utils'; + +describe('Route Utils', () => { + const urlTree = { + root: { + children: { + primary: { + segments: [ + { path: 'test' }, + { path: 'path' } + ] + } + + } + } + }; + const router = { parseUrl: () => urlTree } as any; + it('Should return the correct current path based on the router', () => { + const result = currentPath(router); + expect(result).toEqual('/test/path'); + }); + }); diff --git a/src/app/shared/utils/route.utils.ts b/src/app/shared/utils/route.utils.ts new file mode 100644 index 0000000000..6510fb8894 --- /dev/null +++ b/src/app/shared/utils/route.utils.ts @@ -0,0 +1,10 @@ +import { Router } from '@angular/router'; + +/** + * Util function to retrieve the current path (without query parameters) the user is on + * @param router The router service + */ +export function currentPath(router: Router) { + const urlTree = router.parseUrl(router.url); + return '/' + urlTree.root.children.primary.segments.map((it) => it.path).join('/') +} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index cc0175231e..146ba2e3c0 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -6,7 +6,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { SearchService } from '../../+search-page/search-service/search.service'; +import { SearchService } from '../../core/shared/search/search.service'; import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { SearchServiceStub } from '../testing/search-service-stub'; import { ViewMode } from '../../core/shared/view-mode.model'; diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.ts index d406573646..4feb8927c2 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.ts @@ -2,9 +2,11 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Subscription } from 'rxjs'; -import { SearchService } from '../../+search-page/search-service/search.service'; +import { SearchService } from '../../core/shared/search/search.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { isEmpty } from '../empty.util'; +import { currentPath } from '../utils/route.utils'; +import { Router } from '@angular/router'; /** * Component to switch between list and grid views. @@ -33,7 +35,7 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { viewModeEnum = ViewMode; private sub: Subscription; - constructor(private searchService: SearchService) { + constructor(private searchService: SearchService, private router: Router) { } /** @@ -76,7 +78,7 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { */ public getSearchLink(): string { if (this.inPlaceSearch) { - return './'; + return currentPath(this.router); } return this.searchService.getSearchLink(); } diff --git a/src/app/statistics/statistics.service.spec.ts b/src/app/statistics/statistics.service.spec.ts index 3a416968f8..c6cc4c10b5 100644 --- a/src/app/statistics/statistics.service.spec.ts +++ b/src/app/statistics/statistics.service.spec.ts @@ -3,10 +3,9 @@ import { RequestService } from '../core/data/request.service'; import { HALEndpointServiceStub } from '../shared/testing/hal-endpoint-service-stub'; import { getMockRequestService } from '../shared/mocks/mock-request.service'; import { TrackRequest } from './track-request.model'; -import { ResourceType } from '../core/shared/resource-type'; -import { SearchOptions } from '../+search-page/search-options.model'; import { isEqual } from 'lodash'; import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; +import { SearchOptions } from '../shared/search/search-options.model'; describe('StatisticsService', () => { let service:StatisticsService; diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index cd89125b3c..004e013164 100644 --- a/src/app/statistics/statistics.service.ts +++ b/src/app/statistics/statistics.service.ts @@ -3,10 +3,10 @@ import { Injectable } from '@angular/core'; import { DSpaceObject } from '../core/shared/dspace-object.model'; import { map, take } from 'rxjs/operators'; import { TrackRequest } from './track-request.model'; -import { SearchOptions } from '../+search-page/search-options.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { HALEndpointService } from '../core/shared/hal-endpoint.service'; import { RestRequest } from '../core/data/request.models'; +import { SearchOptions } from '../shared/search/search-options.model'; /** * The statistics service @@ -15,24 +15,24 @@ import { RestRequest } from '../core/data/request.models'; export class StatisticsService { constructor( - protected requestService:RequestService, - protected halService:HALEndpointService, + protected requestService: RequestService, + protected halService: HALEndpointService, ) { } - private sendEvent(linkPath:string, body:any) { + private sendEvent(linkPath: string, body: any) { const requestId = this.requestService.generateRequestId(); this.halService.getEndpoint(linkPath).pipe( - map((endpoint:string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))), + map((endpoint: string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))), take(1) // otherwise the previous events will fire again - ).subscribe((request:RestRequest) => this.requestService.configure(request)); + ).subscribe((request: RestRequest) => this.requestService.configure(request)); } /** * To track a page view * @param dso: The dso which was viewed */ - trackViewEvent(dso:DSpaceObject) { + trackViewEvent(dso: DSpaceObject) { this.sendEvent('/statistics/viewevents', { targetId: dso.uuid, targetType: (dso as any).type @@ -47,10 +47,10 @@ export class StatisticsService { * @param filters: An array of search filters used to filter the result set */ trackSearchEvent( - searchOptions:SearchOptions, - page:{ size:number, totalElements:number, totalPages:number, number:number }, - sort:{ by:string, order:string }, - filters?:Array<{ filter:string, operator:string, value:string, label:string }> + searchOptions: SearchOptions, + page: { size: number, totalElements: number, totalPages: number, number: number }, + sort: { by: string, order: string }, + filters?: Array<{ filter: string, operator: string, value: string, label: string }> ) { const body = { query: searchOptions.query, @@ -66,13 +66,13 @@ export class StatisticsService { }, }; if (hasValue(searchOptions.configuration)) { - Object.assign(body, {configuration: searchOptions.configuration}) + Object.assign(body, { configuration: searchOptions.configuration }) } if (hasValue(searchOptions.dsoType)) { - Object.assign(body, {dsoType: searchOptions.dsoType.toLowerCase()}) + Object.assign(body, { dsoType: searchOptions.dsoType.toLowerCase() }) } if (hasValue(searchOptions.scope)) { - Object.assign(body, {scope: searchOptions.scope}) + Object.assign(body, { scope: searchOptions.scope }) } if (isNotEmpty(filters)) { const bodyFilters = []; @@ -85,7 +85,7 @@ export class StatisticsService { label: filter.label }) } - Object.assign(body, {appliedFilters: bodyFilters}) + Object.assign(body, { appliedFilters: bodyFilters }) } this.sendEvent('/statistics/searchevents', body); } 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 b95a140b46..454e3f6d75 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 @@ -19,8 +19,8 @@ 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 { Community } from '../../../core/shared/community.model'; 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'; 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..d318bfe687 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -1,28 +1,8 @@ -import { - ChangeDetectorRef, - Component, - EventEmitter, - HostListener, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges -} from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, HostListener, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { FormControl } from '@angular/forms'; import { BehaviorSubject, combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { - debounceTime, - distinctUntilChanged, - filter, - find, - flatMap, - map, - mergeMap, - reduce, - startWith -} from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter, map, mergeMap, reduce, startWith, flatMap, find } from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; @@ -247,7 +227,8 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { } else { return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5); } - })); + }) + ); } } } diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index eb56a92113..be13c14941 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -43,6 +43,9 @@ import { SubmissionSectionError } from '../../objects/submission-objects.reducer import { DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; function getMockSubmissionFormsConfigService(): SubmissionFormsConfigService { return jasmine.createSpyObj('FormOperationsService', { @@ -115,11 +118,11 @@ const testFormConfiguration = { const testFormModel = [ new DynamicRowGroupModel({ id: 'df-row-group-config-1', - group: [new DsDynamicInputModel({ id: 'dc.title' })], + group: [new DsDynamicInputModel({ id: 'dc.title', metadataFields: [], repeatable: false, submissionId: '1234' })], }), new DynamicRowGroupModel({ id: 'df-row-group-config-2', - group: [new DsDynamicInputModel({ id: 'dc.contributor' })], + group: [new DsDynamicInputModel({ id: 'dc.contributor', metadataFields: [], repeatable: false, submissionId: '1234' })], }) ]; @@ -179,6 +182,7 @@ describe('SubmissionSectionformComponent test suite', () => { { provide: 'collectionIdProvider', useValue: collectionId }, { provide: 'sectionDataProvider', useValue: sectionObject }, { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: WorkspaceitemDataService, useValue: {findById: () => observableOf(new RemoteData(false, false, true, null, new WorkspaceItem()))}}, ChangeDetectorRef, SubmissionSectionformComponent ], diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index c1ca394911..49dbaea807 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -15,7 +15,10 @@ import { hasValue, isNotEmpty, isUndefined } from '../../../shared/empty.util'; import { ConfigData } from '../../../core/config/config-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; -import { SubmissionSectionError, SubmissionSectionObject } from '../../objects/submission-objects.reducer'; +import { + SubmissionSectionError, + SubmissionSectionObject +} from '../../objects/submission-objects.reducer'; import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; import { GLOBAL_CONFIG } from '../../../../config'; import { GlobalConfig } from '../../../../config/global-config.interface'; @@ -28,6 +31,11 @@ import { NotificationsService } from '../../../shared/notifications/notification import { SectionsService } from '../sections.service'; import { difference } from '../../../shared/object.util'; import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { combineLatest as combineLatestObservable } from 'rxjs'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; /** * This component represents a section that contains a Form. @@ -100,6 +108,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { */ protected subs: Subscription[] = []; + protected workspaceItem: WorkspaceItem; /** * The FormComponent reference */ @@ -131,6 +140,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { protected sectionService: SectionsService, protected submissionService: SubmissionService, protected translate: TranslateService, + protected workspaceItemDataService: WorkspaceitemDataService, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @Inject('collectionIdProvider') public injectedCollectionId: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, @@ -144,15 +154,19 @@ export class SubmissionSectionformComponent extends SectionModelComponent { onSectionInit() { this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); this.formId = this.formService.getUniqueId(this.sectionData.id); - this.formConfigService.getConfigByHref(this.sectionData.config).pipe( map((configData: ConfigData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), - flatMap(() => this.sectionService.getSectionData(this.submissionId, this.sectionData.id)), + flatMap(() => + combineLatestObservable( + this.sectionService.getSectionData(this.submissionId, this.sectionData.id), + this.workspaceItemDataService.findById(this.submissionId).pipe(getSucceededRemoteData(), map((wsiRD: RemoteData) => wsiRD.payload)) + )), take(1)) - .subscribe((sectionData: WorkspaceitemSectionFormObject) => { + .subscribe(([sectionData, workspaceItem]: [WorkspaceitemSectionFormObject, WorkspaceItem]) => { if (isUndefined(this.formModel)) { this.sectionData.errors = []; + this.workspaceItem = workspaceItem; // Is the first loading so init form this.initForm(sectionData); this.sectionData.data = sectionData; @@ -219,13 +233,15 @@ export class SubmissionSectionformComponent extends SectionModelComponent { this.formConfig, this.collectionId, sectionData, - this.submissionService.getSubmissionScope()); + this.submissionService.getSubmissionScope() + ); } catch (e) { const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString(); const sectionError: SubmissionSectionError = { message: msg, path: '/sections/' + this.sectionData.id }; + console.error(e.stack); this.sectionService.setSectionError(this.submissionId, this.sectionData.id, sectionError); } } diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts index 02e0ba478b..34ecafe42b 100644 --- a/src/app/submission/sections/sections-type.ts +++ b/src/app/submission/sections/sections-type.ts @@ -1,4 +1,5 @@ export enum SectionsType { + Relationships = 'relationships', SubmissionForm = 'submission-form', Upload = 'upload', License = 'license', diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index aed83143a5..f6ad5ef0cf 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -84,7 +84,7 @@ export class SectionsService { } else if (!isEqual(currentErrors, prevErrors)) { // compare previous error list with the current one const dispatchedErrors = []; - // Itereate over the current error list + // Iterate over the current error list currentErrors.forEach((error: SubmissionSectionError) => { const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index d67fb3679a..3a95b0747b 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -37,18 +37,16 @@ import { SaveSubmissionSectionFormAction, SetActiveSectionAction } from './objects/submission-objects.actions'; -import { RemoteData } from '../core/data/remote-data'; import { RemoteDataError } from '../core/data/remote-data-error'; import { throwError as observableThrowError } from 'rxjs/internal/observable/throwError'; import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; import { getMockSearchService } from '../shared/mocks/mock-search-service'; import { getMockRequestService } from '../shared/mocks/mock-request.service'; -import { SearchService } from '../+search-page/search-service/search.service'; import { RequestService } from '../core/data/request.service'; +import { SearchService } from '../core/shared/search/search.service'; describe('SubmissionService test suite', () => { const config = MOCK_SUBMISSION_CONFIG; diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index c9be658b31..fa8024af53 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -28,12 +28,7 @@ import { SaveSubmissionSectionFormAction, SetActiveSectionAction } from './objects/submission-objects.actions'; -import { - SubmissionObjectEntry, - SubmissionSectionEntry, - SubmissionSectionError, - SubmissionSectionObject -} from './objects/submission-objects.reducer'; +import { SubmissionObjectEntry, SubmissionSectionEntry, SubmissionSectionError, SubmissionSectionObject } from './objects/submission-objects.reducer'; import { submissionObjectFromIdSelector } from './selectors'; import { GlobalConfig } from '../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../config'; @@ -55,8 +50,8 @@ import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; -import { SearchService } from '../+search-page/search-service/search.service'; import { RequestService } from '../core/data/request.service'; +import { SearchService } from '../core/shared/search/search.service'; /** * A service that provides methods used in submission process. @@ -74,6 +69,8 @@ export class SubmissionService { */ protected timer$: Observable; + private workspaceLinkPath = 'workspaceitems'; + private workflowLinkPath = 'workflowitems'; /** * Initialize service variables * @param {GlobalConfig} EnvConfig @@ -116,7 +113,7 @@ export class SubmissionService { * observable of SubmissionObject */ createSubmission(): Observable { - return this.restService.postToEndpoint('workspaceitems', {}).pipe( + return this.restService.postToEndpoint(this.workspaceLinkPath, {}).pipe( map((workspaceitem: SubmissionObject) => workspaceitem[0]), catchError(() => observableOf({}))) } @@ -134,7 +131,7 @@ export class SubmissionService { let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'text/uri-list'); options.headers = headers; - return this.restService.postToEndpoint('workflowitems', selfUrl, null, options) as Observable; + return this.restService.postToEndpoint(this.workflowLinkPath, selfUrl, null, options) as Observable; } /** @@ -328,9 +325,9 @@ export class SubmissionService { getSubmissionObjectLinkName(): string { const url = this.router.routerState.snapshot.url; if (url.startsWith('/workspaceitems') || url.startsWith('/submit')) { - return 'workspaceitems'; + return this.workspaceLinkPath; } else if (url.startsWith('/workflowitems')) { - return 'workflowitems'; + return this.workflowLinkPath; } else { return 'edititems'; } @@ -345,10 +342,10 @@ export class SubmissionService { getSubmissionScope(): SubmissionScopeType { let scope: SubmissionScopeType; switch (this.getSubmissionObjectLinkName()) { - case 'workspaceitems': + case this.workspaceLinkPath: scope = SubmissionScopeType.WorkspaceItem; break; - case 'workflowitems': + case this.workflowLinkPath: scope = SubmissionScopeType.WorkflowItem; break; } diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index dbfd2f5a40..448ccf97e2 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -56,7 +56,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { * * @param {ChangeDetectorRef} changeDetectorRef * @param {NotificationsService} notificationsService - * @param {SubmissionService} submissioService + * @param {SubmissionService} submissionService * @param {Router} router * @param {TranslateService} translate * @param {ViewContainerRef} viewContainerRef @@ -64,7 +64,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { constructor(private changeDetectorRef: ChangeDetectorRef, private notificationsService: NotificationsService, private router: Router, - private submissioService: SubmissionService, + private submissionService: SubmissionService, private translate: TranslateService, private viewContainerRef: ViewContainerRef) { } @@ -75,7 +75,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { ngOnInit() { // NOTE execute the code on the browser side only, otherwise it is executed twice this.subs.push( - this.submissioService.createSubmission() + this.submissionService.createSubmission() .subscribe((submissionObject: SubmissionObject) => { // NOTE new submission is created on the browser side only if (isNotNull(submissionObject)) { diff --git a/src/backend/api.ts b/src/backend/api.ts index e1943b5d30..a4763f0be7 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -117,7 +117,7 @@ export function createMockApi() { const id = req.params.item_id; try { req.item_id = id; - req.item = ITEMS.items.find((item) => { + req.itemRD$ = ITEMS.items.find((item) => { return item.id === id; }); next(); @@ -127,7 +127,7 @@ export function createMockApi() { }); router.route('/items/:item_id').get((req, res) => { - res.json(toHALResponse(req, req.item)); + res.json(toHALResponse(req, req.itemRD$)); }); router.route('/bundles').get((req, res) => { diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/themes/mantis/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss deleted file mode 100644 index 42b8e0205b..0000000000 --- a/themes/mantis/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import 'src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss'; - -::ng-deep .noUi-connect { - background: $info; -} diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html rename to themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html rename to themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.html b/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.html rename to themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.scss b/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss similarity index 61% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.scss rename to themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss index 8e9b1d32b1..4d2d29ae41 100644 --- a/themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.scss +++ b/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss @@ -1,4 +1,4 @@ -@import 'src/app/+search-page/search-filters/search-filter/search-filter.component.scss'; +@import 'src/app/shared/search/search-filters/search-filter/search-filter.component.scss'; .facet-filter { background-color: map-get($theme-colors, light); diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss new file mode 100644 index 0000000000..7edcb8f063 --- /dev/null +++ b/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -0,0 +1,5 @@ +@import 'src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss'; + +::ng-deep .noUi-connect { + background: $info; +} diff --git a/themes/mantis/app/+search-page/search-filters/search-filters.component.html b/themes/mantis/app/shared/search/search-filters/search-filters.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filters.component.html rename to themes/mantis/app/shared/search/search-filters/search-filters.component.html diff --git a/themes/mantis/app/+search-page/search-settings/search-settings.component.html b/themes/mantis/app/shared/search/search-settings/search-settings.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-settings/search-settings.component.html rename to themes/mantis/app/shared/search/search-settings/search-settings.component.html diff --git a/themes/mantis/app/+search-page/search-settings/search-settings.component.scss b/themes/mantis/app/shared/search/search-settings/search-settings.component.scss similarity index 65% rename from themes/mantis/app/+search-page/search-settings/search-settings.component.scss rename to themes/mantis/app/shared/search/search-settings/search-settings.component.scss index 602c8ca4c3..073039dae8 100644 --- a/themes/mantis/app/+search-page/search-settings/search-settings.component.scss +++ b/themes/mantis/app/shared/search/search-settings/search-settings.component.scss @@ -1,4 +1,4 @@ -@import 'src/app/+search-page/search-settings/search-settings.component.scss'; +@import 'src/app/shared/search/search-settings/search-settings.component.scss'; .setting-option { background-color: map-get($theme-colors, light); diff --git a/yarn.lock b/yarn.lock index c3f9e59a4d..b4ec416395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8948,6 +8948,11 @@ querystringify@^2.0.0: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.0.0.tgz#fa3ed6e68eb15159457c89b37bc6472833195755" integrity sha512-eTPo5t/4bgaMNZxyjWx6N2a6AuE0mq51KWvpc7nU/MAqixcI6v6KrGUKES0HaomdnolQBBXU/++X6/QQ9KL4tw== +querystringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" + integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -11150,6 +11155,14 @@ url-parse@^1.4.3: querystringify "^2.0.0" requires-port "^1.0.0" +url-parse@^1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" + integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"