diff --git a/config/environment.default.js b/config/environment.default.js index 24386d6cf7..df4f89a2fe 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -9,11 +9,10 @@ module.exports = { }, // The REST API server settings. rest: { - ssl: true, - host: 'dspace7.4science.cloud', - port: 443, - // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/server/api' + ssl: true, + host: 'dspace7-entities.atmire.com', + port: 443, + nameSpace: '/server/api' }, // Caching settings cache: { diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index cee53de8ec..a2b4ec3c3a 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -829,9 +829,9 @@ "item.page.person.search.title": "Articles by this author", - "item.page.related-items.view-more": "View more", + "item.page.related-items.view-more": "Show {{ amount }} more", - "item.page.related-items.view-less": "View less", + "item.page.related-items.view-less": "Hide last {{ amount }}", "item.page.relationships.isAuthorOfPublication": "Publications", @@ -1634,6 +1634,10 @@ "submission.sections.describe.relationship-lookup.search-tab.tab-title.lcname": "LC Names ({{ count }})", + "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.selection-tab.tab-title": "Current Selection ({{ count }})", "submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues", @@ -1644,6 +1648,10 @@ "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", diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 940868a0c6..5c54becdde 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -28,6 +28,7 @@ import { MetadataValuesComponent } from './field-components/metadata-values/meta import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { StatisticsModule } from '../statistics/statistics.module'; +import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component'; @NgModule({ imports: [ @@ -57,7 +58,8 @@ import { StatisticsModule } from '../statistics/statistics.module'; GenericItemPageFieldComponent, MetadataRepresentationListComponent, RelatedEntitiesSearchComponent, - TabbedRelatedEntitiesSearchComponent + TabbedRelatedEntitiesSearchComponent, + AbstractIncrementalListComponent ], exports: [ ItemComponent, diff --git a/src/app/+item-page/simple/abstract-incremental-list/abstract-incremental-list.component.ts b/src/app/+item-page/simple/abstract-incremental-list/abstract-incremental-list.component.ts new file mode 100644 index 0000000000..e2c0823bf8 --- /dev/null +++ b/src/app/+item-page/simple/abstract-incremental-list/abstract-incremental-list.component.ts @@ -0,0 +1,73 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-abstract-incremental-list', + template: ``, +}) +/** + * An abstract component for displaying an incremental list of objects + */ +export class AbstractIncrementalListComponent implements OnInit, OnDestroy { + /** + * The amount to increment the list by + * Define this amount in the child component overriding this component + */ + incrementBy: number; + + /** + * All pages of objects to display as an array + */ + objects: T[]; + + /** + * A list of open subscriptions + */ + subscriptions: Subscription[]; + + ngOnInit(): void { + this.objects = []; + this.subscriptions = []; + this.increase(); + } + + /** + * Get a specific page + * > Override this method to return a specific page + * @param page The page to fetch + */ + getPage(page: number): T { + return undefined; + } + + /** + * Increase the amount displayed + */ + increase() { + const page = this.getPage(this.objects.length + 1); + if (hasValue(page)) { + this.objects.push(page); + } + } + + /** + * Decrease the amount displayed + */ + decrease() { + if (this.objects.length > 1) { + this.objects.pop(); + } + } + + /** + * Unsubscribe from any open subscriptions + */ + ngOnDestroy(): void { + if (isNotEmpty(this.subscriptions)) { + this.subscriptions.forEach((sub: Subscription) => { + sub.unsubscribe(); + }); + } + } +} diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html index 750029b58b..d1281f450a 100644 --- a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.html @@ -1,11 +1,20 @@ - - - - - + + + + + + + + + diff --git a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts index 7beabdceba..ad62ce4418 100644 --- a/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts +++ b/src/app/+item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -7,6 +7,8 @@ import { Item } from '../../../core/shared/item.model'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; import { TranslateModule } from '@ngx-translate/core'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { of as observableOf } from 'rxjs'; const itemType = 'Person'; const metadataField = 'dc.contributor.author'; @@ -64,7 +66,7 @@ describe('MetadataRepresentationListComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [MetadataRepresentationListComponent], + declarations: [MetadataRepresentationListComponent, VarDirective], providers: [ { provide: RelationshipService, useValue: relationshipService } ], @@ -88,33 +90,29 @@ describe('MetadataRepresentationListComponent', () => { expect(fields.length).toBe(2); }); - it('should initialize the original limit', () => { - expect(comp.originalLimit).toEqual(comp.limit); + it('should contain one page of items', () => { + expect(comp.objects.length).toEqual(1); }); - describe('when viewMore is called', () => { + describe('when increase is called', () => { beforeEach(() => { - comp.viewMore(); + comp.increase(); }); - it('should set the limit to a high number in order to retrieve all metadata representations', () => { - expect(comp.limit).toBeGreaterThanOrEqual(999); + it('should add a new page to the list', () => { + expect(comp.objects.length).toEqual(2); }); }); - describe('when viewLess is called', () => { - let originalLimit; - + describe('when decrease is called', () => { beforeEach(() => { - // Store the original value of limit - originalLimit = comp.limit; - // Set limit to a random number - comp.limit = 458; - comp.viewLess(); + // Add a second page + comp.objects.push(observableOf(undefined)); + comp.decrease(); }); - it('should reset the limit to the original value', () => { - expect(comp.limit).toEqual(originalLimit); + it('should decrease the list of pages', () => { + expect(comp.objects.length).toEqual(1); }); }); 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 1fa623f6c9..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,16 +1,16 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; import { RelationshipService } from '../../../core/data/relationship.service'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { switchMap } from 'rxjs/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 { map, filter } from 'rxjs/operators'; +import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; @Component({ selector: 'ds-metadata-representation-list', @@ -22,7 +22,7 @@ import { map, filter } from 'rxjs/operators'; * It expects an itemType to resolve the metadata to a an item * It expects a label to put on top of the list */ -export class MetadataRepresentationListComponent implements OnInit { +export class MetadataRepresentationListComponent extends AbstractIncrementalListComponent> { /** * The parent of the list of related items to display */ @@ -44,22 +44,11 @@ export class MetadataRepresentationListComponent implements OnInit { @Input() label: string; /** - * The max amount of representations to display + * The amount to increment the list by when clicking "view more" * Defaults to 10 * The default can optionally be overridden by providing the limit as input to the component */ - @Input() limit = 10; - - /** - * A list of metadata-representations to display - */ - representations$: Observable; - - /** - * The originally provided limit - * Used for resetting the limit to the original value when collapsing the list - */ - originalLimit: number; + @Input() incrementBy = 10; /** * The total amount of metadata values available @@ -67,30 +56,28 @@ export class MetadataRepresentationListComponent implements OnInit { total: number; constructor(public relationshipService: RelationshipService) { - } - - ngOnInit(): void { - this.originalLimit = this.limit; - this.setRepresentations(); + super(); } /** - * Initialize the metadata representations + * Get a specific page + * @param page The page to fetch */ - setRepresentations() { + getPage(page: number): Observable { const metadata = this.parentItem.findMetadataSortedByPlace(this.metadataField); this.total = metadata.length; - this.representations$ = this.resolveMetadataRepresentations(metadata); + return this.resolveMetadataRepresentations(metadata, page); } /** * Resolve a list of metadata values to a list of metadata representations - * @param metadata + * @param metadata The list of all metadata values + * @param page The page to return representations for */ - resolveMetadataRepresentations(metadata: MetadataValue[]): Observable { + resolveMetadataRepresentations(metadata: MetadataValue[], page: number): Observable { return observableZip( ...metadata - .slice(0, this.limit) + .slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy) .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) .map((metadatum: MetadataValue) => { if (metadatum.isVirtual) { @@ -115,20 +102,4 @@ export class MetadataRepresentationListComponent implements OnInit { }) ); } - - /** - * Expand the list to display all metadata representations - */ - viewMore() { - this.limit = 9999; - this.setRepresentations(); - } - - /** - * Collapse the list to display the originally displayed metadata representations - */ - viewLess() { - this.limit = this.originalLimit; - this.setRepresentations(); - } } 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 fc8af6e899..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 @@ -1,12 +1,12 @@ -import { Component, HostBinding, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; 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 { Subscription } from 'rxjs/internal/Subscription'; 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({ selector: 'ds-related-items', @@ -17,7 +17,7 @@ import { ViewMode } from '../../../core/shared/view-mode.model'; * This component is used for displaying relations between items * It expects a parent item and relationship type, as well as a label to display on top */ -export class RelatedItemsComponent implements OnInit, OnDestroy { +export class RelatedItemsComponent extends AbstractIncrementalListComponent>>> { /** * The parent of the list of related items to display */ @@ -30,79 +30,38 @@ export class RelatedItemsComponent implements OnInit, OnDestroy { @Input() relationType: string; /** - * Default options to start a search request with - * Optional input, should you wish a different page size (or other options) + * The amount to increment the list by when clicking "view more" + * Defaults to 5 + * The default can optionally be overridden by providing the limit as input to the component */ - @Input() options = Object.assign(new FindListOptions(), { elementsPerPage: 5 }); + @Input() incrementBy = 5; + + /** + * Default options to start a search request with + * Optional input + */ + @Input() options = new FindListOptions(); /** * An i18n label to use as a title for the list (usually describes the relation) */ @Input() label: string; - /** - * Completely hide the component until there's at least one item visible - */ - @HostBinding('class.d-none') hidden = true; - - /** - * The list of related items - */ - items$: Observable>>; - - /** - * Search options for displaying all elements in a list - */ - allOptions = Object.assign(new FindListOptions(), { elementsPerPage: 9999 }); - /** * The view-mode we're currently on * @type {ViewMode} */ viewMode = ViewMode.ListElement; - /** - * Whether or not the list is currently expanded to show all related items - */ - showingAll = false; - - /** - * Subscription on items used to update the "hidden" property of this component - */ - itemSub: Subscription; - constructor(public relationshipService: RelationshipService) { - } - - ngOnInit(): void { - this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.options); - this.itemSub = this.items$.subscribe((itemsRD: RemoteData>) => { - this.hidden = !(itemsRD.hasSucceeded && itemsRD.payload && itemsRD.payload.page.length > 0); - }); + super(); } /** - * Expand the list to display all related items + * Get a specific page + * @param page The page to fetch */ - viewMore() { - this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.allOptions); - this.showingAll = true; - } - - /** - * Collapse the list to display the originally displayed items - */ - viewLess() { - this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.options); - this.showingAll = false; - } - - /** - * Unsubscribe from the item subscription - */ - ngOnDestroy(): void { - if (this.itemSub) { - this.itemSub.unsubscribe(); - } + getPage(page: number): Observable>> { + return this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, Object.assign(this.options, { elementsPerPage: this.incrementBy, currentPage: page })); } } diff --git a/src/app/+item-page/simple/related-items/related-items.component.html b/src/app/+item-page/simple/related-items/related-items.component.html index dab85ee0e5..11cedc4040 100644 --- a/src/app/+item-page/simple/related-items/related-items.component.html +++ b/src/app/+item-page/simple/related-items/related-items.component.html @@ -1,11 +1,20 @@ - - - - - + + + + + + + + + diff --git a/src/app/+item-page/simple/related-items/related-items.component.spec.ts b/src/app/+item-page/simple/related-items/related-items.component.spec.ts index 4a751a31b8..5b1f33c64d 100644 --- a/src/app/+item-page/simple/related-items/related-items.component.spec.ts +++ b/src/app/+item-page/simple/related-items/related-items.component.spec.ts @@ -9,6 +9,8 @@ import { createRelationshipsObservable } from '../item-types/shared/item.compone import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; import { RelationshipService } from '../../../core/data/relationship.service'; import { TranslateModule } from '@ngx-translate/core'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { of as observableOf } from 'rxjs'; const parentItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), @@ -42,7 +44,7 @@ describe('RelatedItemsComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [RelatedItemsComponent], + declarations: [RelatedItemsComponent, VarDirective], providers: [ { provide: RelationshipService, useValue: relationshipService } ], @@ -65,31 +67,33 @@ describe('RelatedItemsComponent', () => { expect(fields.length).toBe(mockItems.length); }); - describe('when viewMore is called', () => { + it('should contain one page of items', () => { + expect(comp.objects.length).toEqual(1); + }); + + describe('when increase is called', () => { beforeEach(() => { - comp.viewMore(); + comp.increase(); }); - it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments', () => { - expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.allOptions); + it('should add a new page to the list', () => { + expect(comp.objects.length).toEqual(2); }); - it('should set showingAll to true', () => { - expect(comp.showingAll).toEqual(true); + it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments (second page)', () => { + expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, Object.assign(comp.options, { elementsPerPage: comp.incrementBy, currentPage: 2 })); }); }); - describe('when viewLess is called', () => { + describe('when decrease is called', () => { beforeEach(() => { - comp.viewLess(); + // Add a second page + comp.objects.push(observableOf(undefined)); + comp.decrease(); }); - it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments', () => { - expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.options); - }); - - it('should set showingAll to false', () => { - expect(comp.showingAll).toEqual(false); + it('should decrease the list of pages', () => { + expect(comp.objects.length).toEqual(1); }); }); diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts index 63f3e0bd2d..2cde216c05 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -8,6 +8,7 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu import { hasValue } from '../shared/empty.util'; import { RouteService } from '../core/services/route.service'; import { SearchService } from '../core/shared/search/search.service'; +import { Router } from '@angular/router'; /** * This component renders a search page using a configuration as input. @@ -43,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); } /** 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-tracker.component.ts b/src/app/+search-page/search-tracker.component.ts index af8bec76eb..58867e3f03 100644 --- a/src/app/+search-page/search-tracker.component.ts +++ b/src/app/+search-page/search-tracker.component.ts @@ -9,6 +9,7 @@ import { RouteService } from '../core/services/route.service'; import { hasValue } from '../shared/empty.util'; import { SearchSuccessResponse } from '../core/cache/response.models'; 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'; @@ -30,14 +31,15 @@ import { SearchQueryResponse } from '../shared/search/search-query-response.mode 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..a6e83d2b64 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.ts b/src/app/+search-page/search.component.ts index 358ce21e10..bfb99755d8 100644 --- a/src/app/+search-page/search.component.ts +++ b/src/app/+search-page/search.component.ts @@ -1,5 +1,5 @@ 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'; @@ -11,10 +11,12 @@ import { hasValue, isNotEmpty } from '../shared/empty.util'; 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 { SearchResult } from '../shared/search/search-result.model'; 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.effects.ts b/src/app/app.effects.ts index 22df36e1ac..64573609c7 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -1,8 +1,8 @@ import { StoreEffects } from './store.effects'; import { NotificationsEffects } from './shared/notifications/notifications.effects'; import { NavbarEffects } from './navbar/navbar.effects'; -import { RelationshipEffects } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.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, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a6d89d789a..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; diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 8e5661070f..ad9247799b 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,7 +1,7 @@ import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; -import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; +import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; import { formReducer, FormState } from './shared/form/form.reducer'; import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; diff --git a/src/app/core/cache/models/normalized-external-source-entry.model.ts b/src/app/core/cache/models/normalized-external-source-entry.model.ts index d09d1fe7ea..de262949e7 100644 --- a/src/app/core/cache/models/normalized-external-source-entry.model.ts +++ b/src/app/core/cache/models/normalized-external-source-entry.model.ts @@ -4,6 +4,9 @@ import { ExternalSourceEntry } from '../../shared/external-source-entry.model'; import { mapsTo } from '../builders/build-decorators'; import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; +/** + * Normalized model class for an external source entry + */ @mapsTo(ExternalSourceEntry) @inheritSerialization(NormalizedObject) export class NormalizedExternalSourceEntry extends NormalizedObject { diff --git a/src/app/core/cache/models/normalized-external-source.model.ts b/src/app/core/cache/models/normalized-external-source.model.ts index dbcaeaf0de..fd9a42fb72 100644 --- a/src/app/core/cache/models/normalized-external-source.model.ts +++ b/src/app/core/cache/models/normalized-external-source.model.ts @@ -3,6 +3,9 @@ import { NormalizedObject } from './normalized-object.model'; import { ExternalSource } from '../../shared/external-source.model'; import { mapsTo } from '../builders/build-decorators'; +/** + * Normalized model class for an external source + */ @mapsTo(ExternalSource) @inheritSerialization(NormalizedObject) export class NormalizedExternalSource extends NormalizedObject { diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 294dc97aaf..efd83d33d5 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -128,8 +128,8 @@ import { MOCK_RESPONSE_MAP, MockResponseMap, mockResponseMap -} from './dspace-rest-v2/mocks/mock-response-map'; -import { EndpointMockingRestService } from './dspace-rest-v2/endpoint-mocking-rest.service'; +} 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'; @@ -141,6 +141,10 @@ import { NormalizedExternalSourceEntry } from './cache/models/normalized-externa import { ExternalSourceService } from './data/external-source.service'; import { LookupRelationService } from './data/lookup-relation.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); diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source.service.ts index 2ef1fa3d0e..c32c13a20f 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source.service.ts @@ -21,6 +21,9 @@ import { PaginatedList } from './paginated-list'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +/** + * A service handling all external source requests + */ @Injectable() export class ExternalSourceService extends DataService { protected linkPath = 'externalsources'; @@ -38,6 +41,11 @@ export class ExternalSourceService extends DataService { super(); } + /** + * Get the endpoint to browse external sources + * @param options + * @param linkPath + */ getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 978eb3aa5f..d624238bb8 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -12,8 +12,7 @@ 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, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; @@ -93,6 +92,14 @@ export class RelationshipService extends DataService { ); } + /** + * 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 + */ addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -113,11 +120,15 @@ export class RelationshipService extends DataService { ); } + /** + * Method to remove two items of a relationship from the cache using the identifier of the relationship + * @param relationshipId The identifier of the relationship + */ private removeRelationshipItemsFromCacheByRelationship(relationshipId: string) { this.findById(relationshipId).pipe( getSucceededRemoteData(), getRemoteDataPayload(), - switchMap((relationship: Relationship) => observableCombineLatest( + switchMap((relationship: Relationship) => combineLatest( relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), relationship.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()) ) @@ -129,10 +140,14 @@ export class RelationshipService extends DataService { }) } + /** + * Method to remove an item that's part of a relationship from the cache + * @param item The item to remove from the cache + */ private removeRelationshipItemsFromCache(item) { this.objectCache.remove(item.self); this.requestService.removeByHrefSubstring(item.self); - observableCombineLatest( + combineLatest( this.objectCache.hasBySelfLinkObservable(item.self), this.requestService.hasByHrefObservable(item.self) ).pipe( @@ -259,6 +274,12 @@ export class RelationshipService extends DataService { ); } + /** + * 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( @@ -288,24 +309,51 @@ export class RelationshipService extends DataService { ); } + /** + * 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( diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts index 8bca76f7d2..92595582cd 100644 --- a/src/app/core/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -187,14 +187,27 @@ export class RouteService { ); } + /** + * Add a parameter to the current route + * @param key The parameter name + * @param value The parameter value + */ public addParameter(key, value) { this.store.dispatch(new AddParameterAction(key, value)); } + /** + * Set a parameter in the current route (overriding the previous value) + * @param key The parameter name + * @param value The parameter 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)) diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 35ca11cffc..f6886c268e 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -118,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/utilities/equals.decorators.ts b/src/app/core/utilities/equals.decorators.ts index 6dde05922e..6fdbd40c0f 100644 --- a/src/app/core/utilities/equals.decorators.ts +++ b/src/app/core/utilities/equals.decorators.ts @@ -5,6 +5,10 @@ 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) || []; @@ -21,6 +25,11 @@ export function inheritEquatable(parentCo: GenericConstructor { if (object1[key] === object2[key]) { @@ -27,6 +33,10 @@ function equalsByFields(object1, object2, fieldList): boolean { 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)) { diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html index 5f34d8ccd0..9d4a3566ad 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html @@ -1,4 +1,4 @@
{{object.display}}
- +
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index 8891a179c3..4612996e91 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -13,6 +13,9 @@ import { MetadataValue } from '../../../../../core/shared/metadata.models'; styleUrls: ['./external-source-entry-list-submission-element.component.scss'], templateUrl: './external-source-entry-list-submission-element.component.html' }) +/** + * The component for displaying a list element of an external source entry + */ export class ExternalSourceEntryListSubmissionElementComponent extends AbstractListableElementComponent implements OnInit { /** * The metadata value for the object's uri 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 index 4a2a41aae5..cbddb8d6f9 100644 --- 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 @@ -24,7 +24,7 @@ import { NameVariantModalComponent } from '../../name-variant-modal/name-variant }) /** - * The component for displaying a list element for an item search result of the type Person + * 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[]; 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 index 9d2139621c..13ae884ccb 100644 --- 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 @@ -8,6 +8,6 @@ {{'submission.sections.describe.relationship-lookup.name-variant.notification.content' | translate: { value: value } }} 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 index 4af7b8161a..b5043ea2d6 100644 --- 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 @@ -3,16 +3,23 @@ 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: [NgbActiveModal] + providers: [{ provide: NgbActiveModal, useValue: modal }] }) .compileComponents(); })); @@ -20,10 +27,27 @@ describe('NameVariantModalComponent', () => { 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 index 34eab47b47..eb6f7d01ac 100644 --- 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 @@ -1,12 +1,22 @@ 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'] }) +/** + * The component for the modal to add a name variant to an item + */ export class NameVariantModalComponent { + /** + * The name variant + */ @Input() value: string; constructor(public modal: NgbActiveModal) { 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 d6e3a97a11..4c9dad7fe9 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 @@ -232,6 +232,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo 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) { @@ -321,6 +324,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo 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' @@ -335,12 +341,13 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo modalComp.collection = this.collection; } + /** + * 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)) - - // this.zone.runOutsideAngular( - // () => ); } /** 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 index 173509acf9..9032cb48cb 100644 --- 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 @@ -3,12 +3,17 @@ 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'; -import { RelationshipTypeService } from '../../../../../../core/data/relationship-type.service'; +/** + * Component representing a simple disabled input field + */ @Component({ selector: 'ds-dynamic-disabled', templateUrl: './dynamic-disabled.component.html' }) +/** + * Component for displaying a form input with a disabled property + */ export class DsDynamicDisabledComponent extends DynamicFormControlComponent { @Input() formId: string; @@ -21,8 +26,7 @@ export class DsDynamicDisabledComponent extends DynamicFormControlComponent { @Output() focus: EventEmitter = new EventEmitter(); constructor(protected layoutService: DynamicFormLayoutService, - protected validationService: DynamicFormValidationService, - protected relationshipTypeService: RelationshipTypeService + 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 index eb1f3660e6..0fa2b3e5ed 100644 --- 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 @@ -7,6 +7,9 @@ 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; @@ -14,7 +17,6 @@ export class DynamicDisabledModel extends DsDynamicInputModel { 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/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 index 4845ffb398..67ef68599c 100644 --- 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 @@ -42,16 +42,58 @@ import { ExternalSourceService } from '../../../../../core/data/external-source. ] }) +/** + * 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 { + /** + * The label to use to display i18n messages (describing the type of relationship) + */ label: string; + + /** + * Options for searching related items + */ relationshipOptions: RelationshipOptions; + + /** + * The ID of the list to add/remove selected items to/from + */ listId: string; + + /** + * The item we're adding relationships to + */ item; + + /** + * The collection we're submitting an item to + */ collection; + + /** + * Is the selection repeatable? + */ repeatable: boolean; + + /** + * The list of selected items + */ selection$: Observable; + + /** + * The context to display lists + */ context: Context; + + /** + * The metadata-fields describing these relationships + */ metadataFields: string; + + /** + * A map of subscriptions within this component + */ subMap: { [uuid: string]: Subscription } = {}; @@ -105,6 +147,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy this.modal.close(); } + /** + * Select (a list of) objects and add them to the store + * @param selectableObjects + */ select(...selectableObjects: Array>) { this.zone.runOutsideAngular( () => { @@ -132,6 +178,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy }); } + /** + * Add a subscription updating relationships with name variants + * @param sri The search result to track name variants for + */ private addNameVariantSubscription(sri: SearchResult) { const nameVariant$ = this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid); this.subMap[sri.indexableObject.uuid] = nameVariant$.pipe( @@ -139,6 +189,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy ).subscribe((nameVariant: string) => this.store.dispatch(new UpdateRelationshipAction(this.item, sri.indexableObject, this.relationshipOptions.relationshipType, nameVariant))) } + /** + * Deselect (a list of) objects and remove them from the store + * @param selectableObjects + */ deselect(...selectableObjects: Array>) { this.zone.runOutsideAngular( () => selectableObjects.forEach((object) => { @@ -148,6 +202,9 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy ); } + /** + * Set existing name variants for items by the item's virtual metadata + */ private setExistingNameVariants() { const virtualMDs: MetadataValue[] = this.item.allMetadata(this.metadataFields).filter((mdValue) => mdValue.isVirtual); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts index ec036e4469..c8b3b3d311 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts @@ -49,7 +49,7 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit @Input() label: string; /** - * The ID of the list of selected entries + * The ID of the list to add/remove selected items to/from */ @Input() listId: string; 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 index dbd0938945..f32836eef1 100644 --- 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 @@ -11,7 +11,9 @@ export const NameVariantActionTypes = { }; /* tslint:disable:max-classes-per-file */ - +/** + * Abstract class for actions that happen to name variants + */ export abstract class NameVariantListAction implements Action { type; payload: { @@ -24,6 +26,9 @@ export abstract class NameVariantListAction implements Action { } } +/** + * Action for setting a new name on an item in a certain list + */ export class SetNameVariantAction extends NameVariantListAction { type = NameVariantActionTypes.SET_NAME_VARIANT; payload: { @@ -38,6 +43,9 @@ export class SetNameVariantAction extends NameVariantListAction { } } +/** + * 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) { 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 index 7b530f39c5..9402ef6d19 100644 --- 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 @@ -72,6 +72,11 @@ export class RelationshipEffects { ) ); + /** + * 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), 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 index fb2884cb37..9484631610 100644 --- 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 @@ -34,24 +34,81 @@ import { LookupRelationService } from '../../../../../../core/data/lookup-relati ] }) +/** + * 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 { + /** + * Options for searching related items + */ @Input() relationship: RelationshipOptions; + + /** + * The ID of the list to add/remove selected items to/from + */ @Input() listId: string; + + /** + * Is the selection repeatable? + */ @Input() repeatable: boolean; + + /** + * The list of selected items + */ @Input() selection$: Observable; + + /** + * The context to display lists + */ @Input() context: Context; + /** + * Send an event to deselect an object from the list + */ @Output() deselectObject: EventEmitter = new EventEmitter(); + + /** + * Send an event to select an object from the list + */ @Output() selectObject: EventEmitter = new EventEmitter(); + + /** + * Search results + */ resultsRD$: Observable>>>; + + /** + * Are all results selected? + */ allSelected: boolean; + + /** + * Are some results selected? + */ someSelected$: Observable; + + /** + * Is it currently loading to select all results? + */ selectAllLoading: boolean; + + /** + * Subscription to unsubscribe from + */ subscription; + + /** + * The initial pagination to use + */ initialPagination = Object.assign(new PaginationComponentOptions(), { id: 'submission-relation-list', pageSize: 5 }); + + /** + * The type of links to display + */ linkTypes = CollectionElementLinkType; constructor( @@ -65,6 +122,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest ) { } + /** + * Sets up the pagination and fixed query parameters + */ ngOnInit(): void { this.resetRoute(); this.routeService.setParameter('fixedFilterQuery', this.relationship.filter); @@ -76,12 +136,19 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest ); } + /** + * 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({}, { pageSize: this.initialPagination.pageSize }, this.route.snapshot.queryParams, { page: 1 }) }); } + /** + * Selects a page in the store + * @param page The page to select + */ selectPage(page: Array>) { this.selection$ .pipe(take(1)) @@ -92,6 +159,10 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest 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$ @@ -103,6 +174,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest this.selectableListService.deselect(this.listId, page); } + /** + * Select all items that were found using the current search query + */ selectAll() { this.allSelected = true; this.selectAllLoading = true; @@ -128,6 +202,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest ); } + /** + * Deselect all items + */ deselectAll() { this.allSelected = false; this.selection$ 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 index 3dcde9ea8e..f4746853f6 100644 --- 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 @@ -25,20 +25,65 @@ import { Context } from '../../../../../../core/shared/context.model'; ] }) +/** + * Tab for inside the lookup model that represents the currently selected relationships + */ export class DsDynamicLookupRelationSelectionTabComponent { + /** + * The label to use to display i18n messages (describing the type of relationship) + */ @Input() label: string; + + /** + * The ID of the list to add/remove selected items to/from + */ @Input() listId: string; + + /** + * Is the selection repeatable? + */ @Input() repeatable: boolean; + + /** + * The list of selected items + */ @Input() selection$: Observable; + + /** + * The paginated list of selected items + */ @Input() selectionRD$: Observable>>; + + /** + * The context to display lists + */ @Input() context: Context; + + /** + * Send an event to deselect an object from the list + */ @Output() deselectObject: EventEmitter = new EventEmitter(); + + /** + * Send an event to select an object from the list + */ @Output() selectObject: EventEmitter = new EventEmitter(); + /** + * The initial pagination to use + */ + 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.selectionRD$ = this.searchConfigService.paginatedSearchOptions .pipe( diff --git a/src/app/shared/form/builder/models/relationship-options.model.ts b/src/app/shared/form/builder/models/relationship-options.model.ts index 7d9542794b..f1d3d0ae7a 100644 --- a/src/app/shared/form/builder/models/relationship-options.model.ts +++ b/src/app/shared/form/builder/models/relationship-options.model.ts @@ -1,5 +1,8 @@ const RELATION_METADATA_PREFIX = 'relation.' +/** + * The submission options for fields that can represent relationships + */ export class RelationshipOptions { relationshipType: string; filter: string; 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 index 5cccff4591..db3e4ac8b9 100644 --- a/src/app/shared/form/builder/parsers/disabled-field-parser.ts +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.ts @@ -2,10 +2,15 @@ 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/parser-factory.ts b/src/app/shared/form/builder/parsers/parser-factory.ts index 1d3ace320f..d674007da4 100644 --- a/src/app/shared/form/builder/parsers/parser-factory.ts +++ b/src/app/shared/form/builder/parsers/parser-factory.ts @@ -27,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) { 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/core/dspace-rest-v2/endpoint-mocking-rest.service.spec.ts b/src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.spec.ts similarity index 93% rename from src/app/core/dspace-rest-v2/endpoint-mocking-rest.service.spec.ts rename to src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.spec.ts index a53762e8ce..e0dae08470 100644 --- a/src/app/core/dspace-rest-v2/endpoint-mocking-rest.service.spec.ts +++ b/src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.spec.ts @@ -1,7 +1,7 @@ import { HttpHeaders, HttpResponse } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { RestRequestMethod } from '../data/rest-request-method'; +import { GlobalConfig } from '../../../../config/global-config.interface'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; import { EndpointMockingRestService } from './endpoint-mocking-rest.service'; import { MockResponseMap } from './mocks/mock-response-map'; diff --git a/src/app/core/dspace-rest-v2/endpoint-mocking-rest.service.ts b/src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.ts similarity index 85% rename from src/app/core/dspace-rest-v2/endpoint-mocking-rest.service.ts rename to src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.ts index 86ec5986c6..b0e89b80b5 100644 --- a/src/app/core/dspace-rest-v2/endpoint-mocking-rest.service.ts +++ b/src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.ts @@ -1,12 +1,12 @@ import { HttpClient, HttpHeaders } from '@angular/common/http' import { Inject, Injectable } from '@angular/core'; import { Observable, of as observableOf } from 'rxjs'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { isEmpty } from '../../shared/empty.util'; -import { RestRequestMethod } from '../data/rest-request-method'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { isEmpty } from '../../empty.util'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; -import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; -import { DSpaceRESTv2Service, HttpOptions } from './dspace-rest-v2.service'; +import { DSpaceRESTV2Response } from '../../../core/dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Service, HttpOptions } from '../../../core/dspace-rest-v2/dspace-rest-v2.service'; import { MOCK_RESPONSE_MAP, MockResponseMap } from './mocks/mock-response-map'; import * as URL from 'url-parse'; @@ -14,6 +14,8 @@ import * as URL from 'url-parse'; * Service to access DSpace's REST API. * * If a URL is found in this.mockResponseMap, it returns the mock response instead + * This service can be used for mocking REST responses when developing new features + * This is especially useful, when a REST endpoint is broken or does not exist yet */ @Injectable() export class EndpointMockingRestService extends DSpaceRESTv2Service { diff --git a/src/app/core/dspace-rest-v2/mocks/mock-response-map.ts b/src/app/shared/mocks/dspace-rest-v2/mocks/mock-response-map.ts similarity index 82% rename from src/app/core/dspace-rest-v2/mocks/mock-response-map.ts rename to src/app/shared/mocks/dspace-rest-v2/mocks/mock-response-map.ts index cea526b078..1d1b47ee78 100644 --- a/src/app/core/dspace-rest-v2/mocks/mock-response-map.ts +++ b/src/app/shared/mocks/dspace-rest-v2/mocks/mock-response-map.ts @@ -1,5 +1,5 @@ import { InjectionToken } from '@angular/core'; -import mockSubmissionResponse from '../mocks/mock-submission-response.json'; +import mockSubmissionResponse from './mock-submission-response.json'; export class MockResponseMap extends Map {}; diff --git a/src/app/core/dspace-rest-v2/mocks/mock-submission-response.json b/src/app/shared/mocks/dspace-rest-v2/mocks/mock-submission-response.json similarity index 100% rename from src/app/core/dspace-rest-v2/mocks/mock-submission-response.json rename to src/app/shared/mocks/dspace-rest-v2/mocks/mock-submission-response.json diff --git a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html index 4455cedeb9..92d85d03f4 100644 --- a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html +++ b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.html @@ -3,10 +3,10 @@ [name]="'checkbox' + index" [id]="'object' + index" [ngModel]="selected$ | async" - (ngModelChange)="selectCheckbox($event, object)"> + (ngModelChange)="selectCheckbox($event)"> - \ No newline at end of file + (click)="selectRadio(!checked)"> + diff --git a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.spec.ts b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.spec.ts new file mode 100644 index 0000000000..25cf6b15f0 --- /dev/null +++ b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed, async, tick, fakeAsync } from '@angular/core/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; +import { SelectableListItemControlComponent } from './selectable-list-item-control.component'; +import { Item } from '../../../../core/shared/item.model'; +import { FormsModule } from '@angular/forms'; +import { VarDirective } from '../../../utils/var.directive'; +import { of as observableOf } from 'rxjs'; +import { ListableObject } from '../listable-object.model'; + +describe('SelectableListItemControlComponent', () => { + let comp: SelectableListItemControlComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let el: HTMLElement; + let object; + let otherObject; + let selectionConfig; + let listId; + let index; + let selectionService; + let selection: ListableObject[]; + let uuid1: string; + let uuid2: string; + + function init() { + uuid1 = '0beb44f8-d2ed-459a-a1e7-ffbe059089a9'; + uuid2 = 'e1dc80aa-c269-4aa5-b6bd-008d98056247'; + listId = 'Test List ID'; + object = Object.assign(new Item(), {uuid: uuid1}); + otherObject = Object.assign(new Item(), {uuid: uuid2}); + selectionConfig = {repeatable: false, listId}; + index = 0; + selection = [otherObject]; + selectionService = jasmine.createSpyObj('selectionService', { + selectSingle: jasmine.createSpy('selectSingle'), + deselectSingle: jasmine.createSpy('deselectSingle'), + isObjectSelected: observableOf(true), + getSelectableList: observableOf({ selection }) + } + ); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [SelectableListItemControlComponent, VarDirective], + imports: [FormsModule], + providers: [ + { + provide: SelectableListService, + useValue: selectionService + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SelectableListItemControlComponent); + comp = fixture.componentInstance; // SelectableListItemControlComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + comp.object = object; + comp.selectionConfig = selectionConfig; + comp.index = index; + fixture.detectChanges(); + }); + + it('should call deselectSingle on the service when the object when selectCheckbox is called with value false', () => { + comp.selectCheckbox(false); + expect(selectionService.deselectSingle).toHaveBeenCalledWith(listId, object); + }); + + it('should call selectSingle on the service when the object when selectCheckbox is called with value false', () => { + comp.selectCheckbox(true); + expect(selectionService.selectSingle).toHaveBeenCalledWith(listId, object); + }); + + it('should call selectSingle on the service when the object when selectRadio is called with value true and deselect all others in the selection', () => { + comp.selectRadio(true ); + expect(selectionService.deselectSingle).toHaveBeenCalledWith(listId, selection[0]); + expect(selectionService.selectSingle).toHaveBeenCalledWith(listId, object); + }); + + it('should not call selectSingle on the service when the object when selectRadio is called with value false and not deselect all others in the selection', () => { + comp.selectRadio(false ); + expect(selectionService.deselectSingle).not.toHaveBeenCalledWith(listId, selection[0]); + expect(selectionService.selectSingle).not.toHaveBeenCalledWith(listId, object); + }); +}); diff --git a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.ts b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.ts index 735f06fe5f..d1536c56e6 100644 --- a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.ts +++ b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.ts @@ -49,29 +49,30 @@ export class SelectableListItemControlComponent implements OnInit { }) } - selectCheckbox(value: boolean, object: ListableObject) { + selectCheckbox(value: boolean) { if (value) { - this.selectionService.selectSingle(this.selectionConfig.listId, object); + this.selectionService.selectSingle(this.selectionConfig.listId, this.object); } else { - this.selectionService.deselectSingle(this.selectionConfig.listId, object); + this.selectionService.deselectSingle(this.selectionConfig.listId, this.object); } } - selectRadio(value: boolean, object: ListableObject) { - const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId); - selected$.pipe( - take(1), - map((selected) => selected ? selected.selection : []) - ).subscribe((selection) => { - // First deselect any existing selections, this is a radio button - selection.forEach((selectedObject) => { - this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject); - this.deselectObject.emit(selectedObject); - }); - if (value) { - this.selectionService.selectSingle(this.selectionConfig.listId, object); - this.selectObject.emit(object); - } - }); + selectRadio(value: boolean) { + if (value) { + const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId); + selected$.pipe( + take(1), + map((selected) => selected ? selected.selection : []) + ).subscribe((selection) => { + // First deselect any existing selections, this is a radio button + selection.forEach((selectedObject) => { + this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject); + this.deselectObject.emit(selectedObject); + }); + this.selectionService.selectSingle(this.selectionConfig.listId, this.object); + this.selectObject.emit(this.object); + } + ); + } } } diff --git a/src/app/shared/object-list/selectable-list/selectable-list.actions.ts b/src/app/shared/object-list/selectable-list/selectable-list.actions.ts index 7b868c99ff..3dedf7e6a2 100644 --- a/src/app/shared/object-list/selectable-list/selectable-list.actions.ts +++ b/src/app/shared/object-list/selectable-list/selectable-list.actions.ts @@ -19,6 +19,9 @@ export const SelectableListActionTypes = { DESELECT_ALL: type('dspace/selectable-lists/DESELECT_ALL') }; +/** + * Abstract action class for actions on selectable lists + */ /* tslint:disable:max-classes-per-file */ export abstract class SelectableListAction implements Action { // tslint:disable-next-line:no-shadowed-variable @@ -27,7 +30,7 @@ export abstract class SelectableListAction implements Action { } /** - * Used to select an item in a the selectable list + * Action to select objects in a the selectable list */ export class SelectableListSelectAction extends SelectableListAction { payload: ListableObject[]; @@ -37,7 +40,9 @@ export class SelectableListSelectAction extends SelectableListAction { this.payload = objects; } } - +/** + * Action to select a single object in a the selectable list + */ export class SelectableListSelectSingleAction extends SelectableListAction { payload: { object: ListableObject, @@ -49,6 +54,9 @@ export class SelectableListSelectSingleAction extends SelectableListAction { } } +/** + * Action to deselect objects in a the selectable list + */ export class SelectableListDeselectSingleAction extends SelectableListAction { payload: ListableObject; @@ -58,6 +66,9 @@ export class SelectableListDeselectSingleAction extends SelectableListAction { } } +/** + * Action to deselect a single object in a the selectable list + */ export class SelectableListDeselectAction extends SelectableListAction { payload: ListableObject[]; @@ -67,6 +78,9 @@ export class SelectableListDeselectAction extends SelectableListAction { } } +/** + * Action to set a new or overwrite an existing selection + */ export class SelectableListSetSelectionAction extends SelectableListAction { payload: ListableObject[]; @@ -76,6 +90,9 @@ export class SelectableListSetSelectionAction extends SelectableListAction { } } +/** + * Action to deselect all currently selected objects + */ export class SelectableListDeselectAllAction extends SelectableListAction { constructor(id: string) { super(SelectableListActionTypes.DESELECT_ALL, id); diff --git a/src/app/shared/object-list/selectable-list/selectable-list.reducer.spec.ts b/src/app/shared/object-list/selectable-list/selectable-list.reducer.spec.ts new file mode 100644 index 0000000000..29b60cdc02 --- /dev/null +++ b/src/app/shared/object-list/selectable-list/selectable-list.reducer.spec.ts @@ -0,0 +1,112 @@ +import { + SelectableListAction, + SelectableListDeselectAction, SelectableListDeselectAllAction, + SelectableListDeselectSingleAction, + SelectableListSelectAction, + SelectableListSelectSingleAction, + SelectableListSetSelectionAction +} from './selectable-list.actions'; +import { selectableListReducer } from './selectable-list.reducer'; +import { ListableObject } from '../../object-collection/shared/listable-object.model'; +import { hasValue } from '../../empty.util'; + +// tslint:disable:max-classes-per-file +class SelectableObject extends ListableObject { + constructor(private value: string) { + super(); + } + + equals(other: SelectableObject): boolean { + return hasValue(this.value) && hasValue(other.value) && this.value === other.value; + } + + getRenderTypes() { + return ['selectable']; + } +} + +class NullAction extends SelectableListAction { + type = null; + + constructor() { + super(undefined, undefined); + } +} + +// tslint:enable:max-classes-per-file +const listID1 = 'id1'; +const listID2 = 'id2'; +const value1 = 'Selected object'; +const value2 = 'Another selected object'; +const value3 = 'Selection'; +const value4 = 'Selected object numero 4'; + +const selected1 = new SelectableObject(value1); +const selected2 = new SelectableObject(value2); +const selected3 = new SelectableObject(value3); +const selected4 = new SelectableObject(value4); +const testState = { [listID1]: { id: listID1, selection: [selected1, selected2] } }; + +describe('selectableListReducer', () => { + + it('should return the current state when no valid actions have been made', () => { + const state = {}; + state[listID1] = {}; + state[listID1] = { id: listID1, selection: [selected1, selected2] }; + const action = new NullAction(); + const newState = selectableListReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with an empty object', () => { + const state = {}; + const action = new NullAction(); + const newState = selectableListReducer(undefined, action); + + expect(newState).toEqual(state); + }); + + it('should add the payload to the existing list in response to the SELECT action for the given id', () => { + const action = new SelectableListSelectAction(listID1, [selected3, selected4]); + const newState = selectableListReducer(testState, action); + + expect(newState[listID1].selection).toEqual([selected1, selected2, selected3, selected4]); + }); + + it('should add the payload to the existing list in response to the SELECT_SINGLE action for the given id', () => { + const action = new SelectableListSelectSingleAction(listID1, selected4); + const newState = selectableListReducer(testState, action); + + expect(newState[listID1].selection).toEqual([selected1, selected2, selected4]); + }); + + it('should remove the payload from the existing list in response to the DESELECT action for the given id', () => { + const action = new SelectableListDeselectAction(listID1, [selected1, selected2]); + const newState = selectableListReducer(testState, action); + + expect(newState[listID1].selection).toEqual([]); + }); + + it('should remove the payload from the existing list in response to the DESELECT_SINGLE action for the given id', () => { + const action = new SelectableListDeselectSingleAction(listID1, selected2); + const newState = selectableListReducer(testState, action); + + expect(newState[listID1].selection).toEqual([selected1]); + }); + + it('should set the list to the payload in response to the SET_SELECTION action for the given id', () => { + const action = new SelectableListSetSelectionAction(listID2, [selected2, selected4]); + const newState = selectableListReducer(testState, action); + + expect(newState[listID1].selection).toEqual(testState[listID1].selection); + expect(newState[listID2].selection).toEqual([selected2, selected4]); + }); + + it('should remove the payload from the existing list in response to the DESELECT action for the given id', () => { + const action = new SelectableListDeselectAllAction(listID1); + const newState = selectableListReducer(testState, action); + + expect(newState[listID1].selection).toEqual([]); + }); +}); diff --git a/src/app/shared/object-list/selectable-list/selectable-list.reducer.ts b/src/app/shared/object-list/selectable-list/selectable-list.reducer.ts index 927e20ff21..4c7251e563 100644 --- a/src/app/shared/object-list/selectable-list/selectable-list.reducer.ts +++ b/src/app/shared/object-list/selectable-list/selectable-list.reducer.ts @@ -63,12 +63,22 @@ export function selectableListReducer(state: SelectableListsState = {}, action: } } +/** + * Adds multiple objects to the existing selection state + * @param state The current state + * @param action The action to perform + */ function select(state: SelectableListState, action: SelectableListSelectAction) { const filteredNewObjects = action.payload.filter((object) => !isObjectInSelection(state.selection, object)); const newSelection = [...state.selection, ...filteredNewObjects]; return Object.assign({}, state, { selection: newSelection }); } +/** + * Adds a single object to the existing selection state + * @param state The current state + * @param action The action to perform + */ function selectSingle(state: SelectableListState, action: SelectableListSelectSingleAction) { let newSelection = state.selection; if (!isObjectInSelection(state.selection, action.payload.object)) { @@ -77,11 +87,21 @@ function selectSingle(state: SelectableListState, action: SelectableListSelectSi return Object.assign({}, state, { selection: newSelection }); } +/** + * Removes multiple objects in the existing selection state + * @param state The current state + * @param action The action to perform + */ function deselect(state: SelectableListState, action: SelectableListDeselectAction) { const newSelection = state.selection.filter((selected) => hasNoValue(action.payload.find((object) => object.equals(selected)))); return Object.assign({}, state, { selection: newSelection }); } +/** Removes a single object from the existing selection state + * + * @param state The current state + * @param action The action to perform + */ function deselectSingle(state: SelectableListState, action: SelectableListDeselectSingleAction) { const newSelection = state.selection.filter((selected) => { return !selected.equals(action.payload); @@ -89,14 +109,29 @@ function deselectSingle(state: SelectableListState, action: SelectableListDesele return Object.assign({}, state, { selection: newSelection }); } +/** + * Sets the selection state of the list + * @param state The current state + * @param action The action to perform + */ function setList(state: SelectableListState, action: SelectableListSetSelectionAction) { return Object.assign({}, state, { selection: action.payload }); } +/** + * Clears the selection + * @param state The current state + * @param action The action to perform + */ function clearSelection(id: string) { return { id: id, selection: [] }; } +/** + * Checks whether the object is in currently in the selection + * @param state The current state + * @param action The action to perform + */ function isObjectInSelection(selection: ListableObject[], object: ListableObject) { return selection.findIndex((selected) => selected.equals(object)) >= 0 } diff --git a/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts b/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts new file mode 100644 index 0000000000..7699541fe3 --- /dev/null +++ b/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts @@ -0,0 +1,98 @@ +import { Store } from '@ngrx/store'; +import { async, TestBed } from '@angular/core/testing'; +import { SelectableListService } from './selectable-list.service'; +import { SelectableListsState } from './selectable-list.reducer'; +import { ListableObject } from '../../object-collection/shared/listable-object.model'; +import { hasValue } from '../../empty.util'; +import { SelectableListDeselectAction, SelectableListDeselectSingleAction, SelectableListSelectAction, SelectableListSelectSingleAction } from './selectable-list.actions'; + +class SelectableObject extends ListableObject { + constructor(private value: string) { + super(); + } + + equals(other: SelectableObject): boolean { + return hasValue(this.value) && hasValue(other.value) && this.value === other.value; + } + + getRenderTypes() { + return ['selectable']; + } +} + +describe('SelectableListService', () => { + const listID1 = 'id1'; + const value1 = 'Selected object'; + const value2 = 'Another selected object'; + const value3 = 'Selection'; + const value4 = 'Selected object numero 4'; + + const selected1 = new SelectableObject(value1); + const selected2 = new SelectableObject(value2); + const selected3 = new SelectableObject(value3); + const selected4 = new SelectableObject(value4); + + let service: SelectableListService; + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + }); + beforeEach(async(() => { + TestBed.configureTestingModule({ + + providers: [ + { + provide: Store, useValue: store + } + ] + }).compileComponents(); + })); + + beforeEach(() => { + service = new SelectableListService(store); + }); + + describe('when the selectSingle method is triggered', () => { + beforeEach(() => { + service.selectSingle(listID1, selected3); + }); + + it('SelectableListSelectSingleAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListSelectSingleAction(listID1, selected3)); + }); + + }); + + describe('when the select method is triggered', () => { + beforeEach(() => { + service.select(listID1, [selected1, selected4]); + }); + + it('SelectableListSelectAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListSelectAction(listID1, [selected1, selected4])); + }); + }); + + describe('when the deselectSingle method is triggered', () => { + beforeEach(() => { + service.deselectSingle(listID1, selected4); + }); + + it('SelectableListDeselectSingleAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListDeselectSingleAction(listID1, selected4)); + }); + + }); + + describe('when the deselect method is triggered', () => { + beforeEach(() => { + service.deselect(listID1, [selected2, selected4]); + }); + + it('SelectableListDeselectAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListDeselectAction(listID1, [selected2, selected4])); + }); + }); + +}); diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 03081e909e..74ed4bb913 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -3,7 +3,6 @@ import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; import { SearchFormComponent } from './search-form.component'; import { FormsModule } from '@angular/forms'; -import { ResourceType } from '../../core/shared/resource-type'; import { RouterTestingModule } from '@angular/router/testing'; import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index ae9118e1b7..004e013164 100644 --- a/src/app/statistics/statistics.service.ts +++ b/src/app/statistics/statistics.service.ts @@ -15,24 +15,24 @@ import { SearchOptions } from '../shared/search/search-options.model'; 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); }