diff --git a/package.json b/package.json index a664c8daa4..dbdc10a8ca 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ "rollup-plugin-node-globals": "1.2.1", "rollup-plugin-node-resolve": "^3.0.3", "rollup-plugin-terser": "^2.0.2", - "sass-loader": "7.1.0", + "sass-loader": "^7.1.0", "script-ext-html-webpack-plugin": "2.0.1", "source-map": "0.7.3", "source-map-loader": "0.2.4", diff --git a/resources/fonts/README.md b/resources/fonts/README.md new file mode 100644 index 0000000000..e4817b8572 --- /dev/null +++ b/resources/fonts/README.md @@ -0,0 +1,3 @@ +# Supported font formats + +DSpace supports EOT, TTF, OTF, SVG, WOFF and WOFF2 fonts. diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index ff5ca4f93e..299c2afe63 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -340,6 +340,14 @@ + "communityList.tabTitle": "DSpace - Community List", + + "communityList.title": "List of Communities", + + "communityList.showMore": "Show More", + + + "community.create.head": "Create a Community", "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", @@ -821,9 +829,17 @@ "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", + + "item.page.relationships.isJournalOfPublication": "Publications", + + "item.page.relationships.isOrgUnitOfPerson": "Authors", + + "item.page.relationships.isOrgUnitOfProject": "Research Projects", "item.page.subject": "Keywords", @@ -1275,6 +1291,8 @@ "project.page.titleprefix": "Research Project: ", + "project.search.results.head": "Project Search Results", + "publication.listelement.badge": "Publication", @@ -1547,6 +1565,10 @@ "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Volume": "Search for Journal Volumes", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding Agency": "Search for Funding Agencies", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding": "Search for Funding", + "submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Current Selection ({{ count }})", "submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues", @@ -1557,6 +1579,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/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index cb7aa1ef91..ec4003c108 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -5,7 +5,7 @@ import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; -import { FindAllOptions } from '../../../core/data/request.models'; +import { FindListOptions } from '../../../core/data/request.models'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -35,7 +35,7 @@ export class BitstreamFormatsComponent implements OnInit { * The current pagination configuration for the page used by the FindAll method * Currently simply renders all bitstream formats */ - config: FindAllOptions = Object.assign(new FindAllOptions(), { + config: FindListOptions = Object.assign(new FindListOptions(), { elementsPerPage: 20 }); @@ -145,7 +145,7 @@ export class BitstreamFormatsComponent implements OnInit { * @param event The page change event */ onPageChange(event) { - this.config = Object.assign(new FindAllOptions(), this.config, { + this.config = Object.assign(new FindListOptions(), this.config, { currentPage: event, }); this.pageConfig.currentPage = event; diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 28460f567a..5c54becdde 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -26,7 +26,9 @@ import { MetadataRepresentationListComponent } from './simple/metadata-represent import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; 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: [ @@ -55,7 +57,9 @@ import { StatisticsModule } from '../statistics/statistics.module'; ItemComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, - RelatedEntitiesSearchComponent + RelatedEntitiesSearchComponent, + TabbedRelatedEntitiesSearchComponent, + AbstractIncrementalListComponent ], exports: [ ItemComponent, @@ -65,7 +69,8 @@ import { StatisticsModule } from '../statistics/statistics.module'; RelatedEntitiesSearchComponent, RelatedItemsComponent, MetadataRepresentationListComponent, - ItemPageTitleFieldComponent + ItemPageTitleFieldComponent, + TabbedRelatedEntitiesSearchComponent ], entryComponents: [ PublicationComponent 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-entities/related-entities-search/related-entities-search.component.html b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html index ebf3a0fd6e..75f3b7aaad 100644 --- a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.html @@ -1,6 +1,7 @@ - - \ No newline at end of file + diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts index 65385b0442..d9e5dd9dce 100644 --- a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.spec.ts @@ -14,7 +14,7 @@ describe('RelatedEntitiesSearchComponent', () => { id: 'id1' }); const mockRelationType = 'publicationsOfAuthor'; - const mockRelationEntityType = 'publication'; + const mockConfiguration = 'publication'; const mockFilter= `f.${mockRelationType}=${mockItem.id}`; beforeEach(async(() => { @@ -30,7 +30,7 @@ describe('RelatedEntitiesSearchComponent', () => { comp = fixture.componentInstance; comp.relationType = mockRelationType; comp.item = mockItem; - comp.relationEntityType = mockRelationEntityType; + comp.configuration = mockConfiguration; fixture.detectChanges(); }); @@ -40,7 +40,7 @@ describe('RelatedEntitiesSearchComponent', () => { it('should create a configuration$', () => { comp.configuration$.subscribe((configuration) => { - expect(configuration).toEqual(mockRelationEntityType); + expect(configuration).toEqual(mockConfiguration); }) }); diff --git a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts index d20bee2d4a..595734ed9f 100644 --- a/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts +++ b/src/app/+item-page/simple/related-entities/related-entities-search/related-entities-search.component.ts @@ -22,18 +22,16 @@ export class RelatedEntitiesSearchComponent implements OnInit { */ @Input() relationType: string; + /** + * An optional configuration to use for the search options + */ + @Input() configuration: string; + /** * The item to render relationships for */ @Input() item: Item; - /** - * The entity type of the relationship items to be displayed - * e.g. 'publication' - * This determines the title of the search results (if search is enabled) - */ - @Input() relationEntityType: string; - /** * Whether or not the search bar and title should be displayed (defaults to true) * @type {boolean} @@ -53,8 +51,8 @@ export class RelatedEntitiesSearchComponent implements OnInit { if (isNotEmpty(this.relationType) && isNotEmpty(this.item)) { this.fixedFilter = getFilterByRelation(this.relationType, this.item.id); } - if (isNotEmpty(this.relationEntityType)) { - this.configuration$ = of(this.relationEntityType); + if (isNotEmpty(this.configuration)) { + this.configuration$ = of(this.configuration); } } diff --git a/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html new file mode 100644 index 0000000000..f9642d2c01 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html @@ -0,0 +1,22 @@ + + + +
+ + +
+
+
+
+
+ + +
diff --git a/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts new file mode 100644 index 0000000000..2d2e682196 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts @@ -0,0 +1,82 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../../core/shared/item.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TabbedRelatedEntitiesSearchComponent } from './tabbed-related-entities-search.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MockRouter } from '../../../../shared/mocks/mock-router'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { of as observableOf } from 'rxjs'; + +describe('TabbedRelatedEntitiesSearchComponent', () => { + let comp: TabbedRelatedEntitiesSearchComponent; + let fixture: ComponentFixture; + + const mockItem = Object.assign(new Item(), { + id: 'id1' + }); + const mockRelationType = 'publications'; + const relationTypes = [ + { + label: mockRelationType, + filter: mockRelationType + } + ]; + + const router = new MockRouter(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, NgbModule.forRoot()], + declarations: [TabbedRelatedEntitiesSearchComponent, VarDirective], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParams: observableOf({ tab: mockRelationType }) + }, + }, + { provide: Router, useValue: router } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TabbedRelatedEntitiesSearchComponent); + comp = fixture.componentInstance; + comp.item = mockItem; + comp.relationTypes = relationTypes; + fixture.detectChanges(); + }); + + it('should initialize the activeTab depending on the current query parameters', () => { + comp.activeTab$.subscribe((activeTab) => { + expect(activeTab).toEqual(mockRelationType); + }); + }); + + describe('onTabChange', () => { + const event = { + currentId: mockRelationType, + nextId: 'nextTab' + }; + + beforeEach(() => { + comp.onTabChange(event); + }); + + it('should call router natigate with the correct arguments', () => { + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: (comp as any).route, + queryParams: { + tab: event.nextId + }, + queryParamsHandling: 'merge' + }); + }); + }); + +}); diff --git a/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts new file mode 100644 index 0000000000..b01eb70720 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'ds-tabbed-related-entities-search', + templateUrl: './tabbed-related-entities-search.component.html' +}) +/** + * A component to show related items as search results, split into tabs by relationship-type + * Related items can be facetted, or queried using an + * optional search box. + */ +export class TabbedRelatedEntitiesSearchComponent implements OnInit { + /** + * The types of relationships to fetch items for + * e.g. 'isAuthorOfPublication' + */ + @Input() relationTypes: Array<{ + label: string, + filter: string, + configuration?: string + }>; + + /** + * The item to render relationships for + */ + @Input() item: Item; + + /** + * Whether or not the search bar and title should be displayed (defaults to true) + * @type {boolean} + */ + @Input() searchEnabled = true; + + /** + * The ratio of the sidebar's width compared to the search results (1-12) (defaults to 4) + * @type {number} + */ + @Input() sideBarWidth = 4; + + /** + * The active tab + */ + activeTab$: Observable; + + constructor(private route: ActivatedRoute, + private router: Router) { + } + + /** + * If the url contains a "tab" query parameter, set this tab to be the active tab + */ + ngOnInit(): void { + this.activeTab$ = this.route.queryParams.pipe( + map((params) => params.tab) + ); + } + + /** + * Add a "tab" query parameter to the URL when changing tabs + * @param event + */ + onTabChange(event) { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + tab: event.nextId + }, + queryParamsHandling: 'merge' + }); + } + +} 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 ce502468e9..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 { FindAllOptions } from '../../../core/data/request.models'; +import { FindListOptions } from '../../../core/data/request.models'; import { ViewMode } from '../../../core/shared/view-mode.model'; import { RelationshipService } from '../../../core/data/relationship.service'; -import { Subscription } from 'rxjs'; +import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; @Component({ selector: 'ds-related-items', @@ -17,7 +17,7 @@ import { Subscription } from 'rxjs'; * 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 FindAllOptions(), { 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 FindAllOptions(), { 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 f7d7edcffc..33d99a9cd2 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -34,6 +34,12 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements */ @Input() configuration: string; + /** + * The actual query for the fixed filter. + * If empty, the query will be determined by the route parameter called 'filter' + */ + @Input() fixedFilterQuery: string; + constructor(protected service: SearchService, protected sidebarService: SidebarService, protected windowService: HostWindowService, diff --git a/src/app/+search-page/filtered-search-page.component.spec.ts b/src/app/+search-page/filtered-search-page.component.spec.ts deleted file mode 100644 index e25cbd2e12..0000000000 --- a/src/app/+search-page/filtered-search-page.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { FilteredSearchPageComponent } from './filtered-search-page.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { configureSearchComponentTestingModule } from './search.component.spec'; -import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; - -describe('FilteredSearchPageComponent', () => { - let comp: FilteredSearchPageComponent; - let fixture: ComponentFixture; - let searchConfigService: SearchConfigurationService; - - beforeEach(async(() => { - configureSearchComponentTestingModule(FilteredSearchPageComponent); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FilteredSearchPageComponent); - comp = fixture.componentInstance; - searchConfigService = (comp as any).searchConfigService; - fixture.detectChanges(); - }); -}); diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts deleted file mode 100644 index c36dd0bf3c..0000000000 --- a/src/app/+search-page/filtered-search-page.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { HostWindowService } from '../shared/host-window.service'; -import { SearchService } from '../core/shared/search/search.service'; -import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { SearchComponent } from './search.component'; -import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; -import { pushInOut } from '../shared/animations/push'; -import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; -import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; -import { Router } from '@angular/router'; -import { hasValue } from '../shared/empty.util'; -import { RouteService } from '../core/services/route.service'; - -/** - * This component renders a simple item page. - * The route parameter 'id' is used to request the item it represents. - * All fields of the item that should be displayed, are defined in its template. - */ -@Component({ - selector: 'ds-filtered-search-page', - styleUrls: ['./search.component.scss'], - templateUrl: './search.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [pushInOut], - providers: [ - { - provide: SEARCH_CONFIG_SERVICE, - useClass: SearchConfigurationService - } - ] -}) - -export class FilteredSearchPageComponent extends SearchComponent implements OnInit { - /** - * The actual query for the fixed filter. - * If empty, the query will be determined by the route parameter called 'fixedFilterQuery' - */ - @Input() fixedFilterQuery: string; - - constructor(protected service: SearchService, - protected sidebarService: SidebarService, - protected windowService: HostWindowService, - @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, - protected routeService: RouteService, - protected router: Router) { - super(service, sidebarService, windowService, searchConfigService, routeService, router); - } - - /** - * Listening to changes in the paginated search options - * If something changes, update the search results - * - * Listen to changes in the scope - * If something changes, update the list of scopes for the dropdown - */ - ngOnInit(): void { - super.ngOnInit(); - if (hasValue(this.fixedFilterQuery)) { - this.routeService.setParameter('fixedFilterQuery', this.fixedFilterQuery); - } - } -} diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index 083a1b4410..315e15a593 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { SearchComponent } from './search.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { SearchPageComponent } from './search-page.component'; diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index ccea62eae5..0f96431bb1 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -6,8 +6,6 @@ import { SearchPageRoutingModule } from './search-page-routing.module'; import { SearchPageComponent } from './search-page.component'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; -import { FilteredSearchPageComponent } from './filtered-search-page.component'; -import { EffectsModule } from '@ngrx/effects'; import { SearchComponent } from './search.component'; import { SearchTrackerComponent } from './search-tracker.component'; import { StatisticsModule } from '../statistics/statistics.module'; @@ -15,7 +13,6 @@ import { StatisticsModule } from '../statistics/statistics.module'; const components = [ SearchPageComponent, SearchComponent, - FilteredSearchPageComponent, ConfigurationSearchPageComponent, SearchTrackerComponent, diff --git a/src/app/+search-page/search.component.ts b/src/app/+search-page/search.component.ts index 5b5787c91f..b27ebf625f 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'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 5085633a5b..bd29db4ab8 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -27,6 +27,7 @@ export function getAdminModulePath() { RouterModule.forRoot([ { path: '', redirectTo: '/home', pathMatch: 'full' }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' }, + { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, 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 8f841280f5..ad9247799b 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,6 +1,7 @@ import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; +import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; import { 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'; @@ -34,6 +35,7 @@ export interface AppState { objectSelection: ObjectSelectionListState; selectableLists: SelectableListsState; relationshipLists: NameVariantListsState; + communityList: CommunityListState; } export const appReducers: ActionReducerMap = { @@ -52,7 +54,8 @@ export const appReducers: ActionReducerMap = { menus: menusReducer, objectSelection: objectSelectionReducer, selectableLists: selectableListReducer, - relationshipLists: nameVariantReducer + relationshipLists: nameVariantReducer, + communityList: CommunityListReducer, }; export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/community-list-page/community-list-adapter.ts b/src/app/community-list-page/community-list-adapter.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts new file mode 100644 index 0000000000..3a9d9f2077 --- /dev/null +++ b/src/app/community-list-page/community-list-datasource.ts @@ -0,0 +1,40 @@ +import { CommunityListService, FlatNode } from './community-list-service'; +import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; +import { BehaviorSubject, Observable, } from 'rxjs'; +import { finalize, take, } from 'rxjs/operators'; + +/** + * DataSource object needed by a CDK Tree to render its nodes. + * The list of FlatNodes that this DataSource object represents gets created in the CommunityListService at + * the beginning (initial page-limited top communities) and re-calculated any time the tree state changes + * (a node gets expanded or page-limited result become larger by triggering a show more node) + */ +export class CommunityListDatasource implements DataSource { + + private communityList$ = new BehaviorSubject([]); + public loading$ = new BehaviorSubject(false); + + constructor(private communityListService: CommunityListService) { + } + + connect(collectionViewer: CollectionViewer): Observable { + return this.communityList$.asObservable(); + } + + loadCommunities(expandedNodes: FlatNode[]) { + this.loading$.next(true); + + this.communityListService.loadCommunities(expandedNodes).pipe( + take(1), + finalize(() => this.loading$.next(false)), + ).subscribe((flatNodes: FlatNode[]) => { + this.communityList$.next(flatNodes); + }); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.communityList$.complete(); + this.loading$.complete(); + } + +} diff --git a/src/app/community-list-page/community-list-page.component.html b/src/app/community-list-page/community-list-page.component.html new file mode 100644 index 0000000000..08accdc0e5 --- /dev/null +++ b/src/app/community-list-page/community-list-page.component.html @@ -0,0 +1,4 @@ +
+

{{ 'communityList.title' | translate }}

+ +
diff --git a/src/app/community-list-page/community-list-page.component.spec.ts b/src/app/community-list-page/community-list-page.component.spec.ts new file mode 100644 index 0000000000..0aa4afce7f --- /dev/null +++ b/src/app/community-list-page/community-list-page.component.spec.ts @@ -0,0 +1,41 @@ +import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; + +import { CommunityListPageComponent } from './community-list-page.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../shared/mocks/mock-translate-loader'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +describe('CommunityListPageComponent', () => { + let component: CommunityListPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + }, + }), + ], + declarations: [CommunityListPageComponent], + providers: [ + CommunityListPageComponent, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityListPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', inject([CommunityListPageComponent], (comp: CommunityListPageComponent) => { + expect(comp).toBeTruthy(); + })); + +}); diff --git a/src/app/community-list-page/community-list-page.component.ts b/src/app/community-list-page/community-list-page.component.ts new file mode 100644 index 0000000000..5ab3cce5de --- /dev/null +++ b/src/app/community-list-page/community-list-page.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +/** + * Page with title and the community list tree, as described in community-list.component; + * navigated to with community-list.page.routing.module + */ +@Component({ + selector: 'ds-community-list-page', + templateUrl: './community-list-page.component.html', +}) +export class CommunityListPageComponent { + +} diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts new file mode 100644 index 0000000000..2e3914fe03 --- /dev/null +++ b/src/app/community-list-page/community-list-page.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { CommunityListPageComponent } from './community-list-page.component'; +import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; +import { CommunityListComponent } from './community-list/community-list.component'; +import { CdkTreeModule } from '@angular/cdk/tree'; + +/** + * The page which houses a title and the community list, as described in community-list.component + */ +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CommunityListPageRoutingModule, + CdkTreeModule, + ], + declarations: [ + CommunityListPageComponent, + CommunityListComponent + ] +}) +export class CommunityListPageModule { + +} diff --git a/src/app/community-list-page/community-list-page.routing.module.ts b/src/app/community-list-page/community-list-page.routing.module.ts new file mode 100644 index 0000000000..fe250cb96d --- /dev/null +++ b/src/app/community-list-page/community-list-page.routing.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { CdkTreeModule } from '@angular/cdk/tree'; + +import { CommunityListPageComponent } from './community-list-page.component'; +import { CommunityListService } from './community-list-service'; + +/** + * RouterModule to help navigate to the page with the community list tree + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: CommunityListPageComponent, + pathMatch: 'full', + data: { title: 'communityList.tabTitle' } + } + ]), + CdkTreeModule, + ], + providers: [CommunityListService] +}) +export class CommunityListPageRoutingModule { +} diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts new file mode 100644 index 0000000000..a150277d20 --- /dev/null +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -0,0 +1,574 @@ +import { of as observableOf } from 'rxjs'; +import { TestBed, inject, async } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { AppState } from '../app.reducer'; +import { MockStore } from '../shared/testing/mock-store'; +import { CommunityListService, FlatNode, toFlatNode } from './community-list-service'; +import { CollectionDataService } from '../core/data/collection-data.service'; +import { PaginatedList } from '../core/data/paginated-list'; +import { PageInfo } from '../core/shared/page-info.model'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$ +} from '../shared/testing/utils'; +import { Community } from '../core/shared/community.model'; +import { Collection } from '../core/shared/collection.model'; +import { take } from 'rxjs/operators'; +import { FindListOptions } from '../core/data/request.models'; + +describe('CommunityListService', () => { + let store: MockStore; + const standardElementsPerPage = 2; + let collectionDataServiceStub: any; + let communityDataServiceStub: any; + const mockSubcommunities1Page1 = [Object.assign(new Community(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + }), + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + }) + ]; + const mockCollectionsPage1 = [ + Object.assign(new Collection(), { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + uuid: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + name: 'Collection 1' + }), + Object.assign(new Collection(), { + id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + name: 'Collection 2' + }) + ]; + const mockCollectionsPage2 = [ + Object.assign(new Collection(), { + id: 'a5159760-f362-4659-9e81-e3253ad91ede', + uuid: 'a5159760-f362-4659-9e81-e3253ad91ede', + name: 'Collection 3' + }), + Object.assign(new Collection(), { + id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', + uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', + name: 'Collection 4' + }) + ]; + const mockListOfTopCommunitiesPage1 = [ + Object.assign(new Community(), { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockSubcommunities1Page1)), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + }), + Object.assign(new Community(), { + id: '9076bd16-e69a-48d6-9e41-0238cb40d863', + uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), + }), + Object.assign(new Community(), { + id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + }), + ]; + const mockListOfTopCommunitiesPage2 = [ + Object.assign(new Community(), { + id: 'c2e04392-3b8a-4dfa-976d-d76fb1b8a4b6', + uuid: 'c2e04392-3b8a-4dfa-976d-d76fb1b8a4b6', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + }), + ]; + const mockTopCommunitiesWithChildrenArraysPage1 = [ + { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: mockSubcommunities1Page1, + collections: [], + }, + { + id: '9076bd16-e69a-48d6-9e41-0238cb40d863', + uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', + subcommunities: [], + collections: [...mockCollectionsPage1, ...mockCollectionsPage2], + }, + { + id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + subcommunities: [], + collections: [], + }]; + const mockTopCommunitiesWithChildrenArraysPage2 = [ + { + id: 'c2e04392-3b8a-4dfa-976d-d76fb1b8a4b6', + uuid: 'c2e04392-3b8a-4dfa-976d-d76fb1b8a4b6', + subcommunities: [], + collections: [], + }]; + + const allCommunities = [...mockTopCommunitiesWithChildrenArraysPage1, ...mockTopCommunitiesWithChildrenArraysPage2, ...mockSubcommunities1Page1]; + + let service: CommunityListService; + + beforeEach(async(() => { + communityDataServiceStub = { + findTop(options: FindListOptions = {}) { + const allTopComs = [...mockListOfTopCommunitiesPage1, ...mockListOfTopCommunitiesPage2]; + let currentPage = options.currentPage; + const elementsPerPage = 3; + if (currentPage === undefined) { + currentPage = 1 + } + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > allTopComs.length) { + endPageIndex = allTopComs.length; + } + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), allTopComs.slice(startPageIndex, endPageIndex))); + }, + findByParent(parentUUID: string, options: FindListOptions = {}) { + const foundCom = allCommunities.find((community) => (community.id === parentUUID)); + let currentPage = options.currentPage; + let elementsPerPage = options.elementsPerPage; + if (currentPage === undefined) { + currentPage = 1 + } + if (elementsPerPage === 0) { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), (foundCom.subcommunities as [Community]))); + } + elementsPerPage = standardElementsPerPage; + if (foundCom !== undefined && foundCom.subcommunities !== undefined) { + const coms = foundCom.subcommunities as [Community]; + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > coms.length) { + endPageIndex = coms.length; + } + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), coms.slice(startPageIndex, endPageIndex))); + } else { + return createFailedRemoteDataObject$(); + } + } + }; + collectionDataServiceStub = { + findByParent(parentUUID: string, options: FindListOptions = {}) { + const foundCom = allCommunities.find((community) => (community.id === parentUUID)); + let currentPage = options.currentPage; + let elementsPerPage = options.elementsPerPage; + if (currentPage === undefined) { + currentPage = 1 + } + if (elementsPerPage === 0) { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), (foundCom.collections as [Collection]))); + } + elementsPerPage = standardElementsPerPage; + if (foundCom !== undefined && foundCom.collections !== undefined) { + const colls = foundCom.collections as [Collection]; + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > colls.length) { + endPageIndex = colls.length; + } + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), colls.slice(startPageIndex, endPageIndex))); + } else { + return createFailedRemoteDataObject$(); + } + } + }; + TestBed.configureTestingModule({ + providers: [CommunityListService, + { provide: CollectionDataService, useValue: collectionDataServiceStub }, + { provide: CommunityDataService, useValue: communityDataServiceStub }, + { provide: Store, useValue: MockStore }, + ], + }); + store = TestBed.get(Store); + service = new CommunityListService(communityDataServiceStub, collectionDataServiceStub, store); + })); + + afterAll(() => service = new CommunityListService(communityDataServiceStub, collectionDataServiceStub, store)); + + it('should create', inject([CommunityListService], (serviceIn: CommunityListService) => { + expect(serviceIn).toBeTruthy(); + })); + + describe('getNextPageTopCommunities', () => { + describe('also load in second page of top communities', () => { + let flatNodeList; + describe('None expanded: should return list containing only flatnodes of the test top communities page 1 and 2', () => { + let findTopSpy; + beforeEach(() => { + findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough(); + service.getNextPageTopCommunities(); + + const sub = service.loadCommunities(null) + .subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('flatnode list should contain just flatnodes of top community list page 1 and 2', () => { + expect(findTopSpy).toHaveBeenCalled(); + expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockListOfTopCommunitiesPage2.length); + mockListOfTopCommunitiesPage1.map((community) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === community.id))).toBeTruthy(); + }); + mockListOfTopCommunitiesPage2.map((community) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === community.id))).toBeTruthy(); + }); + }); + }); + }); + }); + + describe('loadCommunities', () => { + describe('should transform all communities in a list of flatnodes with possible subcoms and collections as subflatnodes if they\'re expanded', () => { + let flatNodeList; + describe('None expanded: should return list containing only flatnodes of the test top communities', () => { + beforeEach(() => { + const sub = service.loadCommunities(null) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be as big as top community list', () => { + expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length); + }); + it('flatnode list should contain flatNode representations of top communities', () => { + mockListOfTopCommunitiesPage1.map((community) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === community.id))).toBeTruthy(); + }); + }); + it('none of the flatnodes in the list should be expanded', () => { + flatNodeList.map((flatnode: FlatNode) => { + expect(flatnode.isExpanded).toEqual(false); + }); + }); + }); + describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { + beforeEach(() => { + const expandedNodes = []; + mockListOfTopCommunitiesPage1.map((community: Community) => { + const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); + communityFlatNode.currentCollectionPage = 1; + communityFlatNode.currentCommunityPage = 1; + expandedNodes.push(communityFlatNode); + }); + const sub = service.loadCommunities(expandedNodes) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be as big as top community list and size of its possible page-limited children', () => { + expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); + }); + it('flatnode list should contain flatNode representations of all page-limited children', () => { + mockSubcommunities1Page1.map((subcommunity) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === subcommunity.id))).toBeTruthy(); + }); + mockCollectionsPage1.map((collection) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === collection.id))).toBeTruthy(); + }); + }); + }); + describe('Just first top comm expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { + beforeEach(() => { + const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[0], observableOf(true), 0, true, null); + communityFlatNode.currentCollectionPage = 1; + communityFlatNode.currentCommunityPage = 1; + const expandedNodes = [communityFlatNode]; + const sub = service.loadCommunities(expandedNodes) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be as big as top community list and size of page-limited children of first top community', () => { + expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length); + }); + it('flatnode list should contain flatNode representations of all page-limited children of first top community', () => { + mockSubcommunities1Page1.map((subcommunity) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === subcommunity.id))).toBeTruthy(); + }); + }); + }); + describe('Just second top comm expanded, collections at page 2: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { + beforeEach(() => { + const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[1], observableOf(true), 0, true, null); + communityFlatNode.currentCollectionPage = 2; + communityFlatNode.currentCommunityPage = 1; + const expandedNodes = [communityFlatNode]; + const sub = service.loadCommunities(expandedNodes) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be as big as top community list and size of page-limited children of second top community', () => { + expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockCollectionsPage1.length + mockCollectionsPage2.length); + }); + it('flatnode list should contain flatNode representations of all page-limited children of first top community', () => { + mockCollectionsPage1.map((collection) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === collection.id))).toBeTruthy(); + }); + mockCollectionsPage2.map((collection) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === collection.id))).toBeTruthy(); + }); + }); + }); + }); + }); + + describe('transformListOfCommunities', () => { + describe('should transform list of communities in a list of flatnodes with possible subcoms and collections as subflatnodes if they\'re expanded', () => { + describe('list of communities with possible children', () => { + const listOfCommunities = mockListOfTopCommunitiesPage1; + let flatNodeList; + describe('None expanded: should return list containing only flatnodes of the communities in the test list', () => { + beforeEach(() => { + const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be as big as community test list', () => { + expect(flatNodeList.length).toEqual(listOfCommunities.length); + }); + it('flatnode list should contain flatNode representations of all communities from test list', () => { + listOfCommunities.map((community) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === community.id))).toBeTruthy(); + }); + }); + it('none of the flatnodes in the list should be expanded', () => { + flatNodeList.map((flatnode: FlatNode) => { + expect(flatnode.isExpanded).toEqual(false); + }); + }); + }); + describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { + beforeEach(() => { + const expandedNodes = []; + listOfCommunities.map((community: Community) => { + const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); + communityFlatNode.currentCollectionPage = 1; + communityFlatNode.currentCommunityPage = 1; + expandedNodes.push(communityFlatNode); + }); + const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be as big as community test list and size of its possible children', () => { + expect(flatNodeList.length).toEqual(listOfCommunities.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); + }); + it('flatnode list should contain flatNode representations of all children', () => { + mockSubcommunities1Page1.map((subcommunity) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === subcommunity.id))).toBeTruthy(); + }); + mockSubcommunities1Page1.map((collection) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === collection.id))).toBeTruthy(); + }); + }); + }); + }); + }); + + }); + + describe('transformCommunity', () => { + describe('should transform community in list of flatnodes with possible subcoms and collections as subflatnodes if its expanded', () => { + describe('topcommunity without subcoms or collections, unexpanded', () => { + const communityWithNoSubcomsOrColls = Object.assign(new Community(), { + id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.description': [{ language: 'en_US', value: 'no subcoms, 2 coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 2' }] + } + }); + let flatNodeList; + describe('should return list containing only flatnode corresponding to that community', () => { + beforeEach(() => { + const sub = service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be 1', () => { + expect(flatNodeList.length).toEqual(1); + }); + it('flatnode list only element should be flatNode of test community', () => { + expect(flatNodeList[0].id).toEqual(communityWithNoSubcomsOrColls.id); + }); + it('flatnode from test community is not expanded', () => { + expect(flatNodeList[0].isExpanded).toEqual(false); + }); + }); + }); + describe('topcommunity with subcoms or collections, unexpanded', () => { + const communityWithSubcoms = Object.assign(new Community(), { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockSubcommunities1Page1)), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 1' }] + } + }); + let flatNodeList; + describe('should return list containing only flatnode corresponding to that community', () => { + beforeAll(() => { + const sub = service.transformCommunity(communityWithSubcoms, 0, null, null) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('length of flatnode list should be 1', () => { + expect(flatNodeList.length).toEqual(1); + }); + it('flatnode list only element should be flatNode of test community', () => { + expect(flatNodeList[0].id).toEqual(communityWithSubcoms.id); + }); + it('flatnode from test community is not expanded', () => { + expect(flatNodeList[0].isExpanded).toEqual(false); + }); + }); + }); + describe('topcommunity with subcoms, expanded, first page for all', () => { + describe('should return list containing flatnodes of that community, its possible subcommunities and its possible collections', () => { + const communityWithSubcoms = Object.assign(new Community(), { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockSubcommunities1Page1)), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 1' }] + } + }); + let flatNodeList; + beforeEach(() => { + const communityFlatNode = toFlatNode(communityWithSubcoms, observableOf(true), 0, true, null); + communityFlatNode.currentCollectionPage = 1; + communityFlatNode.currentCommunityPage = 1; + const expandedNodes = [communityFlatNode]; + const sub = service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('list of flatnodes is length is 1 + nrOfSubcoms & first flatnode is of expanded test community', () => { + expect(flatNodeList.length).toEqual(1 + mockSubcommunities1Page1.length); + expect(flatNodeList[0].isExpanded).toEqual(true); + expect(flatNodeList[0].id).toEqual(communityWithSubcoms.id); + }); + it('list of flatnodes contains flatnodes for all subcoms of test community', () => { + mockSubcommunities1Page1.map((subcommunity) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === subcommunity.id))).toBeTruthy(); + }); + }); + it('the subcoms of the test community are a level higher than the parent community', () => { + mockSubcommunities1Page1.map((subcommunity) => { + expect((flatNodeList.find((flatnode) => (flatnode.id === subcommunity.id))).level).toEqual(flatNodeList[0].level + 1); + }); + }); + }); + }); + describe('topcommunity with collections, expanded, on second page of collections', () => { + describe('should return list containing flatnodes of that community, its collections of the first two pages', () => { + const communityWithCollections = Object.assign(new Community(), { + id: '9076bd16-e69a-48d6-9e41-0238cb40d863', + uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), + metadata: { + 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 1' }] + } + }); + let flatNodeList; + beforeEach(() => { + const communityFlatNode = toFlatNode(communityWithCollections, observableOf(true), 0, true, null); + communityFlatNode.currentCollectionPage = 2; + communityFlatNode.currentCommunityPage = 1; + const expandedNodes = [communityFlatNode]; + const sub = service.transformCommunity(communityWithCollections, 0, null, expandedNodes) + .pipe(take(1)).subscribe((value) => flatNodeList = value); + sub.unsubscribe(); + }); + it('list of flatnodes is length is 1 + nrOfCollections & first flatnode is of expanded test community', () => { + expect(flatNodeList.length).toEqual(1 + mockCollectionsPage1.length + mockCollectionsPage2.length); + expect(flatNodeList[0].isExpanded).toEqual(true); + expect(flatNodeList[0].id).toEqual(communityWithCollections.id); + }); + it('list of flatnodes contains flatnodes for all subcolls (first 2 pages) of test community', () => { + mockCollectionsPage1.map((collection) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === collection.id))).toBeTruthy(); + }); + mockCollectionsPage2.map((collection) => { + expect(flatNodeList.find((flatnode) => (flatnode.id === collection.id))).toBeTruthy(); + }) + }); + it('the collections of the test community are a level higher than the parent community', () => { + mockCollectionsPage1.map((collection) => { + expect((flatNodeList.find((flatnode) => (flatnode.id === collection.id))).level).toEqual(flatNodeList[0].level + 1); + }); + mockCollectionsPage2.map((collection) => { + expect((flatNodeList.find((flatnode) => (flatnode.id === collection.id))).level).toEqual(flatNodeList[0].level + 1); + }) + }); + }); + }); + }); + + }); + + describe('getIsExpandable', () => { + describe('should return true', () => { + it('if community has subcommunities', () => { + const communityWithSubcoms = Object.assign(new Community(), { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockSubcommunities1Page1)), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 1' }] + } + }); + service.getIsExpandable(communityWithSubcoms).pipe(take(1)).subscribe((result) => { + expect(result).toEqual(true); + }); + }); + it('if community has collections', () => { + const communityWithCollections = Object.assign(new Community(), { + id: '9076bd16-e69a-48d6-9e41-0238cb40d863', + uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockCollectionsPage1)), + metadata: { + 'dc.description': [{ language: 'en_US', value: 'no subcoms, 2 coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 2' }] + } + }); + service.getIsExpandable(communityWithCollections).pipe(take(1)).subscribe((result) => { + expect(result).toEqual(true); + }); + }); + }); + describe('should return false', () => { + it('if community has neither subcommunities nor collections', () => { + const communityWithNoSubcomsOrColls = Object.assign(new Community(), { + id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.description': [{ language: 'en_US', value: 'no subcoms, no coll' }], + 'dc.title': [{ language: 'en_US', value: 'Community 3' }] + } + }); + service.getIsExpandable(communityWithNoSubcomsOrColls).pipe(take(1)).subscribe((result) => { + expect(result).toEqual(false); + }); + }); + }); + + }); + +}); diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts new file mode 100644 index 0000000000..a25dbd2689 --- /dev/null +++ b/src/app/community-list-page/community-list-service.ts @@ -0,0 +1,335 @@ +import { Injectable } from '@angular/core'; +import { createSelector, Store } from '@ngrx/store'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { Observable, of as observableOf } from 'rxjs'; +import { AppState } from '../app.reducer'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; +import { Community } from '../core/shared/community.model'; +import { Collection } from '../core/shared/collection.model'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { RemoteData } from '../core/data/remote-data'; +import { PaginatedList } from '../core/data/paginated-list'; +import { getCommunityPageRoute } from '../+community-page/community-page-routing.module'; +import { getCollectionPageRoute } from '../+collection-page/collection-page-routing.module'; +import { CollectionDataService } from '../core/data/collection-data.service'; +import { CommunityListSaveAction } from './community-list.actions'; +import { CommunityListState } from './community-list.reducer'; + +/** + * Each node in the tree is represented by a flatNode which contains info about the node itself and its position and + * state in the tree. There are nodes representing communities, collections and show more links. + */ +export interface FlatNode { + isExpandable$: Observable; + name: string; + id: string; + level: number; + isExpanded?: boolean; + parent?: FlatNode; + payload: Community | Collection | ShowMoreFlatNode; + isShowMoreNode: boolean; + route?: string; + currentCommunityPage?: number; + currentCollectionPage?: number; +} + +/** + * The show more links in the community tree are also represented by a flatNode so we know where in + * the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) + */ +export class ShowMoreFlatNode { +} + +// Helper method to combine an flatten an array of observables of flatNode arrays +export const combineAndFlatten = (obsList: Array>): Observable => + observableCombineLatest(...obsList).pipe( + map((matrix: FlatNode[][]) => + matrix.reduce((combinedList, currentList: FlatNode[]) => [...combinedList, ...currentList])) + ); + +/** + * Creates a flatNode from a community or collection + * @param c The community or collection this flatNode represents + * @param isExpandable Whether or not this node is expandable (true if it has children) + * @param level Level indicating how deep in the tree this node should be rendered + * @param isExpanded Whether or not this node already is expanded + * @param parent Parent of this node (flatNode representing its parent community) + */ +export const toFlatNode = ( + c: Community | Collection, + isExpandable: Observable, + level: number, + isExpanded: boolean, + parent?: FlatNode +): FlatNode => ({ + isExpandable$: isExpandable, + name: c.name, + id: c.id, + level: level, + isExpanded, + parent, + payload: c, + isShowMoreNode: false, + route: c instanceof Community ? getCommunityPageRoute(c.id) : getCollectionPageRoute(c.id), +}); + +/** + * Creates a show More flatnode where only the level and parent are of importance + */ +export const showMoreFlatNode = ( + id: string, + level: number, + parent: FlatNode +): FlatNode => ({ + isExpandable$: observableOf(false), + name: 'Show More Flatnode', + id: id, + level: level, + isExpanded: false, + parent: parent, + payload: new ShowMoreFlatNode(), + isShowMoreNode: true, +}); + +// Selectors the get the communityList data out of the store +const communityListStateSelector = (state: AppState) => state.communityList; +const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); +const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); + +/** + * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource + * and connection to the store to retrieve and save the state of the community list + */ +// tslint:disable-next-line:max-classes-per-file +@Injectable() +export class CommunityListService { + + // page-limited list of top-level communities + payloads$: Array>>; + + topCommunitiesConfig: PaginationComponentOptions; + topCommunitiesSortConfig: SortOptions; + + maxSubCommunitiesPerPage: number; + maxCollectionsPerPage: number; + + constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, + private store: Store) { + this.topCommunitiesConfig = new PaginationComponentOptions(); + this.topCommunitiesConfig.id = 'top-level-pagination'; + this.topCommunitiesConfig.pageSize = 10; + this.topCommunitiesConfig.currentPage = 1; + this.topCommunitiesSortConfig = new SortOptions('dc.title', SortDirection.ASC); + this.initTopCommunityList(); + + this.maxSubCommunitiesPerPage = 3; + this.maxCollectionsPerPage = 3; + } + + saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void { + this.store.dispatch(new CommunityListSaveAction(expandedNodes, loadingNode)); + } + + getExpandedNodesFromStore(): Observable { + return this.store.select(expandedNodesSelector); + } + + getLoadingNodeFromStore(): Observable { + return this.store.select(loadingNodeSelector); + } + + /** + * Increases the payload so it contains the next page of top level communities + */ + getNextPageTopCommunities(): void { + this.topCommunitiesConfig.currentPage = this.topCommunitiesConfig.currentPage + 1; + this.payloads$ = [...this.payloads$, this.communityDataService.findTop({ + currentPage: this.topCommunitiesConfig.currentPage, + elementsPerPage: this.topCommunitiesConfig.pageSize, + sort: { + field: this.topCommunitiesSortConfig.field, + direction: this.topCommunitiesSortConfig.direction + } + }).pipe( + take(1), + map((results) => results.payload), + )]; + } + + /** + * Gets all top communities, limited by page, and transforms this in a list of flatNodes. + * @param expandedNodes List of expanded nodes; if a node is not expanded its subCommunities and collections need + * not be added to the list + */ + loadCommunities(expandedNodes: FlatNode[]): Observable { + const res = this.payloads$.map((payload) => { + return payload.pipe( + take(1), + switchMap((result: PaginatedList) => { + return this.transformListOfCommunities(result, 0, null, expandedNodes); + }), + catchError(() => observableOf([])), + ); + }); + return combineAndFlatten(res); + }; + + /** + * Puts the initial top level communities in a list to be called upon + */ + private initTopCommunityList(): void { + this.payloads$ = [this.communityDataService.findTop({ + currentPage: this.topCommunitiesConfig.currentPage, + elementsPerPage: this.topCommunitiesConfig.pageSize, + sort: { + field: this.topCommunitiesSortConfig.field, + direction: this.topCommunitiesSortConfig.direction + } + }).pipe( + take(1), + map((results) => results.payload), + )]; + } + + /** + * Transforms a list of communities to a list of FlatNodes according to the instructions detailed in transformCommunity + * @param listOfPaginatedCommunities Paginated list of communities to be transformed + * @param level Level the tree is currently at + * @param parent FlatNode of the parent of this list of communities + * @param expandedNodes List of expanded nodes; if a node is not expanded its subcommunities and collections need not be added to the list + */ + public transformListOfCommunities(listOfPaginatedCommunities: PaginatedList, + level: number, + parent: FlatNode, + expandedNodes: FlatNode[]): Observable { + if (isNotEmpty(listOfPaginatedCommunities.page)) { + let currentPage = this.topCommunitiesConfig.currentPage; + if (isNotEmpty(parent)) { + currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage; + } + const isNotAllCommunities = (listOfPaginatedCommunities.totalElements > (listOfPaginatedCommunities.elementsPerPage * currentPage)); + let obsList = listOfPaginatedCommunities.page + .map((community: Community) => { + return this.transformCommunity(community, level, parent, expandedNodes) + }); + if (isNotAllCommunities && listOfPaginatedCommunities.currentPage > currentPage) { + obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; + } + + return combineAndFlatten(obsList); + } else { + return observableOf([]); + } + } + + /** + * Transforms a community in a list of FlatNodes containing firstly a flatnode of the community itself, + * followed by flatNodes of its possible subcommunities and collection + * It gets called recursively for each subcommunity to add its subcommunities and collections to the list + * Number of subcommunities and collections added, is dependant on the current page the parent is at for respectively subcommunities and collections. + * @param community Community being transformed + * @param level Depth of the community in the list, subcommunities and collections go one level deeper + * @param parent Flatnode of the parent community + * @param expandedNodes List of nodes which are expanded, if node is not expanded, it need not add its page-limited subcommunities or collections + */ + public transformCommunity(community: Community, level: number, parent: FlatNode, expandedNodes: FlatNode[]): Observable { + let isExpanded = false; + if (isNotEmpty(expandedNodes)) { + isExpanded = hasValue(expandedNodes.find((node) => (node.id === community.id))); + } + + const isExpandable$ = this.getIsExpandable(community); + + const communityFlatNode = toFlatNode(community, isExpandable$, level, isExpanded, parent); + + let obsList = [observableOf([communityFlatNode])]; + + if (isExpanded) { + const currentCommunityPage = expandedNodes.find((node: FlatNode) => node.id === community.id).currentCommunityPage; + let subcoms = []; + for (let i = 1; i <= currentCommunityPage; i++) { + const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { + elementsPerPage: this.maxSubCommunitiesPerPage, + currentPage: i + }) + .pipe( + filter((rd: RemoteData>) => rd.hasSucceeded), + take(1), + switchMap((rd: RemoteData>) => + this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes)) + ); + + subcoms = [...subcoms, nextSetOfSubcommunitiesPage]; + } + + obsList = [...obsList, combineAndFlatten(subcoms)]; + + const currentCollectionPage = expandedNodes.find((node: FlatNode) => node.id === community.id).currentCollectionPage; + let collections = []; + for (let i = 1; i <= currentCollectionPage; i++) { + const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { + elementsPerPage: this.maxCollectionsPerPage, + currentPage: i + }) + .pipe( + filter((rd: RemoteData>) => rd.hasSucceeded), + take(1), + map((rd: RemoteData>) => { + let nodes = rd.payload.page + .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); + if ((rd.payload.elementsPerPage * currentCollectionPage) < rd.payload.totalElements && rd.payload.currentPage > currentCollectionPage) { + nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; + } + return nodes; + }), + ); + collections = [...collections, nextSetOfCollectionsPage]; + } + obsList = [...obsList, combineAndFlatten(collections)]; + } + + return combineAndFlatten(obsList); + } + + /** + * Checks if a community has subcommunities or collections by querying the respective services with a pageSize = 0 + * Returns an observable that combines the result.payload.totalElements fo the observables that the + * respective services return when queried + * @param community Community being checked whether it is expandable (if it has subcommunities or collections) + */ + public getIsExpandable(community: Community): Observable { + let hasSubcoms$: Observable; + let hasColls$: Observable; + hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 }) + .pipe( + filter((rd: RemoteData>) => rd.hasSucceeded), + take(1), + map((results) => results.payload.totalElements > 0), + ); + + hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 }) + .pipe( + filter((rd: RemoteData>) => rd.hasSucceeded), + take(1), + map((results) => results.payload.totalElements > 0), + ); + + let hasChildren$: Observable; + hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe( + take(1), + map((result: [boolean]) => { + if (result[0] || result[1]) { + return true; + } else { + return false; + } + }) + ); + + return hasChildren$; + } + +} diff --git a/src/app/community-list-page/community-list.actions.ts b/src/app/community-list-page/community-list.actions.ts new file mode 100644 index 0000000000..bfce6fba34 --- /dev/null +++ b/src/app/community-list-page/community-list.actions.ts @@ -0,0 +1,35 @@ +import { Action } from '@ngrx/store'; +import { type } from '../shared/ngrx/type'; +import { FlatNode } from './community-list-service'; + +/** + * All the action types of the community-list + */ + +export const CommunityListActionTypes = { + SAVE: type('dspace/community-list-page/SAVE') +}; + +/** + * Community list SAVE action + */ +export class CommunityListSaveAction implements Action { + + type = CommunityListActionTypes.SAVE; + + payload: { + expandedNodes: FlatNode[]; + loadingNode: FlatNode; + }; + + constructor(expandedNodes: FlatNode[], loadingNode: FlatNode) { + this.payload = { expandedNodes, loadingNode } + } +}; + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ + +export type CommunityListActions = CommunityListSaveAction; diff --git a/src/app/community-list-page/community-list.reducer.spec.ts b/src/app/community-list-page/community-list.reducer.spec.ts new file mode 100644 index 0000000000..63eaaccc03 --- /dev/null +++ b/src/app/community-list-page/community-list.reducer.spec.ts @@ -0,0 +1,45 @@ +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { PaginatedList } from '../core/data/paginated-list'; +import { Community } from '../core/shared/community.model'; +import { PageInfo } from '../core/shared/page-info.model'; +import { createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; +import { toFlatNode } from './community-list-service'; +import { CommunityListSaveAction } from './community-list.actions'; +import { CommunityListReducer } from './community-list.reducer'; + +describe('communityListReducer', () => { + const mockSubcommunities1Page1 = [Object.assign(new Community(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'subcommunity1', + })]; + const mockFlatNodeOfCommunity = toFlatNode( + Object.assign(new Community(), { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockSubcommunities1Page1)), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + name: 'community1', + }), observableOf(true), 0, false, null + ); + + it ('should set init state of the expandedNodes and loadingNode', () => { + const state = { + expandedNodes: [], + loadingNode: null, + }; + const action = new CommunityListSaveAction([], null); + const newState = CommunityListReducer(null, action); + expect(newState).toEqual(state); + }); + + it ('should save new state of the expandedNodes and loadingNode at a save action', () => { + const state = { + expandedNodes: [mockFlatNodeOfCommunity], + loadingNode: null, + }; + const action = new CommunityListSaveAction([mockFlatNodeOfCommunity], null); + const newState = CommunityListReducer(null, action); + expect(newState).toEqual(state); + }); +}); diff --git a/src/app/community-list-page/community-list.reducer.ts b/src/app/community-list-page/community-list.reducer.ts new file mode 100644 index 0000000000..b455fc496a --- /dev/null +++ b/src/app/community-list-page/community-list.reducer.ts @@ -0,0 +1,36 @@ +import { FlatNode } from './community-list-service'; +import { CommunityListActions, CommunityListActionTypes, CommunityListSaveAction } from './community-list.actions'; + +/** + * States we wish to put in store concerning the community list + */ +export interface CommunityListState { + expandedNodes: FlatNode[]; + loadingNode: FlatNode; +} + +/** + * Initial starting state of the list of expandedNodes and the current loading node of the community list + */ +const initialState: CommunityListState = { + expandedNodes: [], + loadingNode: null, +}; + +/** + * Reducer to interact with store concerning objects for the community list + * @constructor + */ +export function CommunityListReducer(state = initialState, action: CommunityListActions) { + switch (action.type) { + case CommunityListActionTypes.SAVE: { + return Object.assign({}, state, { + expandedNodes: (action as CommunityListSaveAction).payload.expandedNodes, + loadingNode: (action as CommunityListSaveAction).payload.loadingNode, + }) + } + default: { + return state; + } + } +} diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html new file mode 100644 index 0000000000..c179715bf1 --- /dev/null +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -0,0 +1,91 @@ + + + + + + +
+
+
+
+
+ + +
+ +
+ + {{node.name}} + +
+
+ +
+
+ + + {{node.payload.shortDescription}} + +
+
+
+
+ + +
+
+ + +
+ +
+ + {{node.name}} + +
+
+ +
+
+ + + {{node.payload.shortDescription}} + +
+
+
+
+
diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts new file mode 100644 index 0000000000..c04aadda37 --- /dev/null +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -0,0 +1,336 @@ +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; + +import { CommunityListComponent } from './community-list.component'; +import { + CommunityListService, + FlatNode, + showMoreFlatNode, + toFlatNode +} from '../community-list-service'; +import { CdkTreeModule } from '@angular/cdk/tree'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Community } from '../../core/shared/community.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { Collection } from '../../core/shared/collection.model'; +import { of as observableOf } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; + +describe('CommunityListComponent', () => { + let component: CommunityListComponent; + let fixture: ComponentFixture; + + const mockSubcommunities1Page1 = [Object.assign(new Community(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'subcommunity1', + }), + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + name: 'subcommunity2', + }) + ]; + const mockCollectionsPage1 = [ + Object.assign(new Collection(), { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + uuid: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + name: 'collection1', + }), + Object.assign(new Collection(), { + id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + name: 'collection2', + }) + ]; + const mockCollectionsPage2 = [ + Object.assign(new Collection(), { + id: 'a5159760-f362-4659-9e81-e3253ad91ede', + uuid: 'a5159760-f362-4659-9e81-e3253ad91ede', + name: 'collection3', + }), + Object.assign(new Collection(), { + id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', + uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', + name: 'collection4', + }) + ]; + + const mockTopCommunitiesWithChildrenArrays = [ + { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: mockSubcommunities1Page1, + collections: [], + }, + { + id: '9076bd16-e69a-48d6-9e41-0238cb40d863', + uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', + subcommunities: [], + collections: [...mockCollectionsPage1, ...mockCollectionsPage2], + }, + { + id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + subcommunities: [], + collections: [], + }]; + + const mockTopFlatnodesUnexpanded: FlatNode[] = [ + toFlatNode( + Object.assign(new Community(), { + id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), mockSubcommunities1Page1)), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + name: 'community1', + }), observableOf(true), 0, false, null + ), + toFlatNode( + Object.assign(new Community(), { + id: '9076bd16-e69a-48d6-9e41-0238cb40d863', + uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), + name: 'community2', + }), observableOf(true), 0, false, null + ), + toFlatNode( + Object.assign(new Community(), { + id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', + subcommunities: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + collections: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + name: 'community3', + }), observableOf(false), 0, false, null + ), + ]; + let communityListServiceStub; + + beforeEach(async(() => { + communityListServiceStub = { + topPageSize: 2, + topCurrentPage: 1, + collectionPageSize: 2, + subcommunityPageSize: 2, + expandedNodes: [], + loadingNode: null, + getNextPageTopCommunities() { + this.topCurrentPage++; + }, + getLoadingNodeFromStore() { + return observableOf(this.loadingNode); + }, + getExpandedNodesFromStore() { + return observableOf(this.expandedNodes); + }, + saveCommunityListStateToStore(expandedNodes, loadingNode) { + this.expandedNodes = expandedNodes; + this.loadingNode = loadingNode; + }, + loadCommunities(expandedNodes) { + let flatnodes; + let showMoreTopComNode = false; + flatnodes = [...mockTopFlatnodesUnexpanded]; + const currentPage = this.topCurrentPage; + const elementsPerPage = this.topPageSize; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex >= flatnodes.length) { + endPageIndex = flatnodes.length; + } else { + showMoreTopComNode = true; + } + if (expandedNodes === null || isEmpty(expandedNodes)) { + if (showMoreTopComNode) { + return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode('community', 0, null)]); + } else { + return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex)); + } + } else { + flatnodes = []; + const topFlatnodes = mockTopFlatnodesUnexpanded.slice(0, endPageIndex); + topFlatnodes.map((topNode: FlatNode) => { + flatnodes = [...flatnodes, topNode]; + const expandedParent: FlatNode = expandedNodes.find((expandedNode: FlatNode) => expandedNode.id === topNode.id); + if (isNotEmpty(expandedParent)) { + const matchingTopComWithArrays = mockTopCommunitiesWithChildrenArrays.find((topcom) => topcom.id === topNode.id); + if (isNotEmpty(matchingTopComWithArrays)) { + const possibleSubcoms: Community[] = matchingTopComWithArrays.subcommunities; + let subComFlatnodes = []; + possibleSubcoms.map((subcom: Community) => { + subComFlatnodes = [...subComFlatnodes, toFlatNode(subcom, observableOf(false), topNode.level + 1, false, topNode)]; + }); + const possibleColls: Collection[] = matchingTopComWithArrays.collections; + let collFlatnodes = []; + possibleColls.map((coll: Collection) => { + collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)]; + }); + if (isNotEmpty(subComFlatnodes)) { + const endSubComIndex = this.subcommunityPageSize * expandedParent.currentCommunityPage; + flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; + if (subComFlatnodes.length > endSubComIndex) { + flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; + } + } + if (isNotEmpty(collFlatnodes)) { + const endColIndex = this.collectionPageSize * expandedParent.currentCollectionPage; + flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; + if (collFlatnodes.length > endColIndex) { + flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; + } + } + } + } + }); + if (showMoreTopComNode) { + flatnodes = [...flatnodes, showMoreFlatNode('community', 0, null)]; + } + return observableOf(flatnodes); + } + } + }; + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + }, + }), + CdkTreeModule, + RouterTestingModule], + declarations: [CommunityListComponent], + providers: [CommunityListComponent, + { provide: CommunityListService, useValue: communityListServiceStub },], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', inject([CommunityListComponent], (comp: CommunityListComponent) => { + expect(comp).toBeTruthy(); + })); + + it('should render a cdk tree with the first elementsPerPage (2) nr of top level communities, unexpanded', () => { + const expandableNodesFound = fixture.debugElement.queryAll(By.css('.expandable-node a')); + const childlessNodesFound = fixture.debugElement.queryAll(By.css('.childless-node a')); + const allNodes = [...expandableNodesFound, ...childlessNodesFound]; + expect(allNodes.length).toEqual(2); + mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { + expect(allNodes.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === topFlatnode.name); + })).toBeTruthy(); + }); + }); + + it('show more node is present at end of nodetree', () => { + const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node')); + expect(showMoreEl.length).toEqual(1); + expect(showMoreEl).toBeTruthy(); + }); + + describe('when show more of top communities is clicked', () => { + beforeEach(fakeAsync(() => { + const showMoreLink = fixture.debugElement.query(By.css('.show-more-node a')); + showMoreLink.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('tree contains maximum of currentPage (2) * (2) elementsPerPage of first top communities, or less if there are less communities (3)', () => { + const expandableNodesFound = fixture.debugElement.queryAll(By.css('.expandable-node a')); + const childlessNodesFound = fixture.debugElement.queryAll(By.css('.childless-node a')); + const allNodes = [...expandableNodesFound, ...childlessNodesFound]; + expect(allNodes.length).toEqual(3); + mockTopFlatnodesUnexpanded.map((topFlatnode: FlatNode) => { + expect(allNodes.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === topFlatnode.name); + })).toBeTruthy(); + }); + }); + it('show more node is gone from end of nodetree', () => { + const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node')); + expect(showMoreEl.length).toEqual(0); + }); + }); + + describe('when first expandable node is expanded', () => { + let allNodes; + beforeEach(fakeAsync(() => { + const chevronExpand = fixture.debugElement.query(By.css('.expandable-node button')); + const chevronExpandSpan = fixture.debugElement.query(By.css('.expandable-node button span')); + if (chevronExpandSpan.nativeElement.classList.contains('fa-chevron-right')) { + chevronExpand.nativeElement.click(); + tick(); + fixture.detectChanges(); + } + + const expandableNodesFound = fixture.debugElement.queryAll(By.css('.expandable-node a')); + const childlessNodesFound = fixture.debugElement.queryAll(By.css('.childless-node a')); + allNodes = [...expandableNodesFound, ...childlessNodesFound]; + })); + describe('children of first expandable node are added to tree (page-limited)', () => { + it('tree contains page-limited topcoms (2) and children of first expandable node (2subcoms)', () => { + expect(allNodes.length).toEqual(4); + mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { + expect(allNodes.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === topFlatnode.name); + })).toBeTruthy(); + }); + mockSubcommunities1Page1.map((subcom) => { + expect(allNodes.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === subcom.name); + })).toBeTruthy(); + }) + }); + }); + }); + + describe('second top community node is expanded and has more children (collections) than page size of collection', () => { + describe('children of second top com are added (page-limited pageSize 2)', () => { + let allNodes; + beforeEach(fakeAsync(() => { + const chevronExpand = fixture.debugElement.queryAll(By.css('.expandable-node button')); + const chevronExpandSpan = fixture.debugElement.queryAll(By.css('.expandable-node button span')); + if (chevronExpandSpan[1].nativeElement.classList.contains('fa-chevron-right')) { + chevronExpand[1].nativeElement.click(); + tick(); + fixture.detectChanges(); + } + + const expandableNodesFound = fixture.debugElement.queryAll(By.css('.expandable-node a')); + const childlessNodesFound = fixture.debugElement.queryAll(By.css('.childless-node a')); + allNodes = [...expandableNodesFound, ...childlessNodesFound]; + })); + it('tree contains 2 (page-limited) top com, 2 (page-limited) coll of 2nd top com, a show more for those page-limited coll and show more for page-limited top com', () => { + mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { + expect(allNodes.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === topFlatnode.name); + })).toBeTruthy(); + }); + mockCollectionsPage1.map((coll) => { + expect(allNodes.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === coll.name); + })).toBeTruthy(); + }); + expect(allNodes.length).toEqual(4); + const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node')); + expect(showMoreEl.length).toEqual(2); + }); + }); + }); + +}); diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts new file mode 100644 index 0000000000..ddcd49cd1c --- /dev/null +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -0,0 +1,104 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { CommunityListService, FlatNode } from '../community-list-service'; +import { CommunityListDatasource } from '../community-list-datasource'; +import { FlatTreeControl } from '@angular/cdk/tree'; +import { isEmpty } from '../../shared/empty.util'; + +/** + * A tree-structured list of nodes representing the communities, their subCommunities and collections. + * Initially only the page-restricted top communities are shown. + * Each node can be expanded to show its children and all children are also page-limited. + * More pages of a page-limited result can be shown by pressing a show more node/link. + * Which nodes were expanded is kept in the store, so this persists across pages. + */ +@Component({ + selector: 'ds-community-list', + templateUrl: './community-list.component.html', +}) +export class CommunityListComponent implements OnInit, OnDestroy { + + private expandedNodes: FlatNode[] = []; + public loadingNode: FlatNode; + + treeControl = new FlatTreeControl( + (node) => node.level, (node) => true + ); + + dataSource: CommunityListDatasource; + + constructor(private communityListService: CommunityListService) { + } + + ngOnInit() { + this.dataSource = new CommunityListDatasource(this.communityListService); + this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => { + this.loadingNode = result; + }); + this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => { + this.expandedNodes = [...result]; + this.dataSource.loadCommunities(this.expandedNodes); + }); + } + + ngOnDestroy(): void { + this.communityListService.saveCommunityListStateToStore(this.expandedNodes, this.loadingNode); + } + + // whether or not this node has children (subcommunities or collections) + hasChild(_: number, node: FlatNode) { + return node.isExpandable$; + } + + // whether or not it is a show more node (contains no data, but is indication that there are more topcoms, subcoms or collections + isShowMore(_: number, node: FlatNode) { + return node.isShowMoreNode; + } + + /** + * Toggles the expanded variable of a node, adds it to the exapanded nodes list and reloads the tree so this node is expanded + * @param node Node we want to expand + */ + toggleExpanded(node: FlatNode) { + this.loadingNode = node; + if (node.isExpanded) { + this.expandedNodes = this.expandedNodes.filter((node2) => node2.name !== node.name); + node.isExpanded = false; + } else { + this.expandedNodes.push(node); + node.isExpanded = true; + if (isEmpty(node.currentCollectionPage)) { + node.currentCollectionPage = 1; + } + if (isEmpty(node.currentCommunityPage)) { + node.currentCommunityPage = 1; + } + } + this.dataSource.loadCommunities(this.expandedNodes); + } + + /** + * Makes sure the next page of a node is added to the tree (top community, sub community of collection) + * > Finds its parent (if not top community) and increases its corresponding collection/subcommunity currentPage + * > Reloads tree with new page added to corresponding top community lis, sub community list or collection list + * @param node The show more node indicating whether it's an increase in top communities, sub communities or collections + */ + getNextPage(node: FlatNode): void { + this.loadingNode = node; + if (node.parent != null) { + if (node.id === 'collection') { + const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); + parentNodeInExpandedNodes.currentCollectionPage++; + } + if (node.id === 'community') { + const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); + parentNodeInExpandedNodes.currentCommunityPage++; + } + this.dataSource.loadCommunities(this.expandedNodes); + } else { + this.communityListService.getNextPageTopCommunities(); + this.dataSource.loadCommunities(this.expandedNodes); + } + } + +} diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts index c613c59a0c..9b7edf70c0 100644 --- a/src/app/core/cache/models/normalized-item.model.ts +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -65,7 +65,7 @@ export class NormalizedItem extends NormalizedDSpaceObject { @relationship(Bundle, true) bundles: string[]; - @autoserialize + @deserialize @relationship(Relationship, true) relationships: string[]; diff --git a/src/app/core/cache/models/search-param.model.ts b/src/app/core/cache/models/search-param.model.ts index a33bbee5e6..3881dbe8b7 100644 --- a/src/app/core/cache/models/search-param.model.ts +++ b/src/app/core/cache/models/search-param.model.ts @@ -1,6 +1,6 @@ /** - * Class representing a query parameter (query?fieldName=fieldValue) used in FindAllOptions object + * Class representing a query parameter (query?fieldName=fieldValue) used in FindListOptions object */ export class SearchParam { constructor(public fieldName: string, public fieldValue: any) { diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index 87add6b656..402ee88b81 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -3,7 +3,7 @@ import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { ConfigService } from './config.service'; import { RequestService } from '../data/request.service'; -import { ConfigRequest, FindAllOptions } from '../data/request.models'; +import { ConfigRequest, FindListOptions } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; @@ -27,7 +27,7 @@ describe('ConfigService', () => { let requestService: RequestService; let halService: any; - const findOptions: FindAllOptions = new FindAllOptions(); + const findOptions: FindListOptions = new FindListOptions(); const scopeName = 'traditional'; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 340a7a97d6..db14c4a256 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -2,7 +2,7 @@ import { merge as observableMerge, Observable, throwError as observableThrowErro import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { RequestService } from '../data/request.service'; import { ConfigSuccessResponse } from '../cache/response.models'; -import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models'; +import { ConfigRequest, FindListOptions, RestRequest } from '../data/request.models'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigData } from './config-data'; @@ -35,7 +35,7 @@ export abstract class ConfigService { return `${endpoint}/${resourceName}`; } - protected getConfigSearchHref(endpoint, options: FindAllOptions = {}): string { + protected getConfigSearchHref(endpoint, options: FindListOptions = {}): string { let result; const args = []; @@ -93,7 +93,7 @@ export abstract class ConfigService { distinctUntilChanged()); } - public getConfigBySearch(options: FindAllOptions = {}): Observable { + public getConfigBySearch(options: FindListOptions = {}): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getConfigSearchHref(endpoint, options)), filter((href: string) => isNotEmpty(href)), diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index dedf6104d9..4fdef02357 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'; @@ -137,6 +137,10 @@ import { SelectableListService } from '../shared/object-list/selectable-list/sel import { RelationshipTypeService } from './data/relationship-type.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; +/** + * When not in production, endpoint responses can be mocked for testing purposes + * If there is no mock version available for the endpoint, the actual REST response will be used just like in production mode + */ export const restServiceFactory = (cfg: GlobalConfig, mocks: MockResponseMap, http: HttpClient) => { if (ENV_CONFIG.production) { return new DSpaceRESTv2Service(http); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index bdf9b16acf..7255ed3663 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -11,7 +11,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { DeleteByIDRequest, FindAllOptions, PostRequest, PutRequest } from './request.models'; +import { DeleteByIDRequest, FindListOptions, PostRequest, PutRequest } from './request.models'; import { Observable } from 'rxjs'; import { find, map, tap } from 'rxjs/operators'; import { configureRequest, getResponseFromEntry } from '../shared/operators'; @@ -54,10 +54,10 @@ export class BitstreamFormatDataService extends DataService { /** * Get the endpoint for browsing bitstream formats - * @param {FindAllOptions} options + * @param {FindListOptions} options * @returns {Observable} */ - getBrowseEndpoint(options: FindAllOptions = {}, linkPath?: string): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { return this.halService.getEndpoint(this.linkPath); } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 5962488c4f..280f727aad 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -11,7 +11,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { FindAllOptions } from './request.models'; +import { FindListOptions } from './request.models'; import { Observable } from 'rxjs/internal/Observable'; /** @@ -37,10 +37,10 @@ export class BundleDataService extends DataService { /** * Get the endpoint for browsing bundles - * @param {FindAllOptions} options + * @param {FindListOptions} options * @returns {Observable} */ - getBrowseEndpoint(options: FindAllOptions = {}, linkPath?: string): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { return this.halService.getEndpoint(this.linkPath); } } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 9e4962ee71..0c032e6766 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -16,7 +16,7 @@ import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { Observable } from 'rxjs/internal/Observable'; -import { FindAllOptions, GetRequest } from './request.models'; +import {FindListOptions, FindListRequest, GetRequest} from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; import { configureRequest } from '../shared/operators'; @@ -50,11 +50,11 @@ export class CollectionDataService extends ComColDataService { /** * Get all collections the user is authorized to submit to * - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @return Observable>> * collection list */ - getAuthorizedCollection(options: FindAllOptions = {}): Observable>> { + getAuthorizedCollection(options: FindListOptions = {}): Observable>> { const searchHref = 'findAuthorized'; return this.searchBy(searchHref, options).pipe( @@ -65,11 +65,11 @@ export class CollectionDataService extends ComColDataService { * Get all collections the user is authorized to submit to, by community * * @param communityId The community id - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, options: FindAllOptions = {}): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable>> { const searchHref = 'findAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [new SearchParam('uuid', communityId)] @@ -87,7 +87,7 @@ export class CollectionDataService extends ComColDataService { */ hasAuthorizedCollection(): Observable { const searchHref = 'findAuthorized'; - const options = new FindAllOptions(); + const options = new FindListOptions(); options.elementsPerPage = 1; return this.searchBy(searchHref, options).pipe( @@ -138,4 +138,10 @@ export class CollectionDataService extends ComColDataService { return this.rdbService.buildList(href$); } + protected getFindByParentHref(parentUUID: string): Observable { + return this.halService.getEndpoint('communities').pipe( + switchMap((communityEndpointHref: string) => + this.halService.getEndpoint('collections', `${communityEndpointHref}/${parentUUID}`)), + ); + } } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 5cc474dff9..a7fcd205d4 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -8,12 +8,12 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; -import { FindAllOptions, FindByIDRequest } from './request.models'; +import { FindListOptions, FindByIDRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestEntry } from './request.reducer'; -import { of as observableOf } from 'rxjs'; +import {Observable, of as observableOf} from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; @@ -45,6 +45,11 @@ class TestService extends ComColDataService { ) { super(); } + + protected getFindByParentHref(parentUUID: string): Observable { + // implementation in subclasses for communities/collections + return undefined; + } } /* tslint:enable:max-classes-per-file */ @@ -66,7 +71,7 @@ describe('ComColDataService', () => { const dataBuildService = {} as NormalizedObjectBuildService; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; - const options = Object.assign(new FindAllOptions(), { + const options = Object.assign(new FindListOptions(), { scopeID: scopeID }); const getRequestEntry$ = (successful: boolean) => { diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 68eb3e4880..867ee24fc1 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,12 +1,23 @@ -import { distinctUntilChanged, filter, map, mergeMap, share, take, tap } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, first, + map, + mergeMap, + share, + switchMap, + take, + tap +} from 'rxjs/operators'; import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CommunityDataService } from './community-data.service'; import { DataService } from './data.service'; -import { FindAllOptions, FindByIDRequest } from './request.models'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { FindListOptions, FindByIDRequest } from './request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { getResponseFromEntry } from '../shared/operators'; import { CacheableObject } from '../cache/object-cache.reducer'; @@ -26,7 +37,7 @@ export abstract class ComColDataService extends DataS * @return { Observable } * an Observable containing the scoped URL */ - public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { if (isEmpty(options.scopeID)) { return this.halService.getEndpoint(linkPath); } else { @@ -57,4 +68,12 @@ export abstract class ComColDataService extends DataS return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged(), share()); } } + + protected abstract getFindByParentHref(parentUUID: string): Observable; + + public findByParent(parentUUID: string, options: FindListOptions = {}): Observable>> { + const href$ = this.buildHrefFromFindOptions(this.getFindByParentHref(parentUUID), [], options); + return this.findList(href$, options); + } + } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index cc55fe6869..57bf64678f 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,4 +1,4 @@ -import { filter, take } from 'rxjs/operators'; +import { filter, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; @@ -9,7 +9,7 @@ import { Community } from '../shared/community.model'; import { ComColDataService } from './comcol-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions, FindAllRequest } from './request.models'; +import { FindListOptions, FindListRequest } from './request.models'; import { RemoteData } from './remote-data'; import { hasValue } from '../../shared/empty.util'; import { Observable } from 'rxjs'; @@ -43,16 +43,24 @@ export class CommunityDataService extends ComColDataService { return this.halService.getEndpoint(this.linkPath); } - findTop(options: FindAllOptions = {}): Observable>> { + findTop(options: FindListOptions = {}): Observable>> { const hrefObs = this.getFindAllHref(options, this.topLinkPath); hrefObs.pipe( filter((href: string) => hasValue(href)), take(1)) .subscribe((href: string) => { - const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); this.requestService.configure(request); }); return this.rdbService.buildList(hrefObs) as Observable>>; } + + protected getFindByParentHref(parentUUID: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((communityEndpointHref: string) => + this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)) + ); + } + } diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index b690492c61..ca5f2cc12e 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -6,7 +6,7 @@ import { CoreState } from '../core.reducers'; import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Observable, of as observableOf } from 'rxjs'; -import { FindAllOptions } from './request.models'; +import { FindListOptions } from './request.models'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { compare, Operation } from 'fast-json-patch'; @@ -42,7 +42,7 @@ class TestService extends DataService { super(); } - public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return observableOf(endpoint); } } @@ -56,7 +56,7 @@ class DummyChangeAnalyzer implements ChangeAnalyzer { describe('DataService', () => { let service: TestService; - let options: FindAllOptions; + let options: FindListOptions; const requestService = {generateRequestId: () => uuidv4()} as RequestService; const halService = {} as HALEndpointService; const rdbService = {} as RemoteDataBuildService; @@ -192,7 +192,7 @@ describe('DataService', () => { dso2.self = selfLink; dso2.metadata = [{ key: 'dc.title', value: name2 }]; - spyOn(service, 'findByHref').and.returnValues(createSuccessfulRemoteDataObject$(dso)); + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); spyOn(objectCache, 'addPatch'); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ddf2c3a1d5..ce9a01a569 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -11,7 +11,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; -import { CreateRequest, DeleteByIDRequest, FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './request.models'; +import { + CreateRequest, + DeleteByIDRequest, + FindListOptions, + FindListRequest, + FindByIDRequest, + GetRequest +} from './request.models'; import { RequestService } from './request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; @@ -47,17 +54,17 @@ export abstract class DataService { */ protected responseMsToLive: number; - public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable + public abstract getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable /** * Create the HREF with given options object * - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @param linkPath The link path for the object * @return {Observable} * Return an observable that emits created HREF */ - protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable { + protected getFindAllHref(options: FindListOptions = {}, linkPath?: string): Observable { let result: Observable; const args = []; @@ -70,11 +77,11 @@ export abstract class DataService { * Create the HREF for a specific object's search method with given options object * * @param searchMethod The search method for the object - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @return {Observable} * Return an observable that emits created HREF */ - protected getSearchByHref(searchMethod: string, options: FindAllOptions = {}): Observable { + protected getSearchByHref(searchMethod: string, options: FindListOptions = {}): Observable { let result: Observable; const args = []; @@ -94,11 +101,11 @@ export abstract class DataService { * * @param href$ The HREF to which the query string should be appended * @param args Array with additional params to combine with query string - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @return {Observable} * Return an observable that emits created HREF */ - protected buildHrefFromFindOptions(href$: Observable, args: string[], options: FindAllOptions): Observable { + protected buildHrefFromFindOptions(href$: Observable, args: string[], options: FindListOptions): Observable { if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ @@ -120,20 +127,22 @@ export abstract class DataService { } } - findAll(options: FindAllOptions = {}): Observable>> { - const hrefObs = this.getFindAllHref(options); + findAll(options: FindListOptions = {}): Observable>> { + return this.findList(this.getFindAllHref(options), options); + } - hrefObs.pipe( + protected findList(href$, options: FindListOptions) { + href$.pipe( first((href: string) => hasValue(href))) .subscribe((href: string) => { - const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); if (hasValue(this.responseMsToLive)) { request.responseMsToLive = this.responseMsToLive; } this.requestService.configure(request); }); - return this.rdbService.buildList(hrefObs) as Observable>>; + return this.rdbService.buildList(href$) as Observable>>; } /** @@ -148,7 +157,7 @@ export abstract class DataService { findById(id: string): Observable> { const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id)))); + map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id)))); hrefObs.pipe( find((href: string) => hasValue(href))) @@ -184,14 +193,14 @@ export abstract class DataService { } /** - * Make a new FindAllRequest with given search method + * Make a new FindListRequest with given search method * * @param searchMethod The search method for the object - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @return {Observable>} * Return an observable that emits response from the server */ - protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { + protected searchBy(searchMethod: string, options: FindListOptions = {}): Observable>> { const hrefObs = this.getSearchByHref(searchMethod, options); @@ -199,7 +208,7 @@ export abstract class DataService { find((href: string) => hasValue(href)), tap((href: string) => { this.requestService.removeByHrefSubstring(href); - const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); request.responseMsToLive = 10 * 1000; this.requestService.configure(request); diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts index 7e71f82bbf..f4999637b3 100644 --- a/src/app/core/data/dso-redirect-data.service.ts +++ b/src/app/core/data/dso-redirect-data.service.ts @@ -8,7 +8,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { RequestService } from './request.service'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { FindAllOptions, FindByIDRequest, IdentifierType } from './request.models'; +import { FindListOptions, FindByIDRequest, IdentifierType } from './request.models'; import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @@ -40,7 +40,7 @@ export class DsoRedirectDataService extends DataService { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index bb02afbcd1..002ac3cdbc 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -8,7 +8,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DataService } from './data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { FindAllOptions } from './request.models'; +import { FindListOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; @@ -32,7 +32,7 @@ class DataServiceImpl extends DataService { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 36b8e6b3c5..44c5f48cfe 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -2,14 +2,13 @@ import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { BrowseService } from '../browse/browse.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; import { ItemDataService } from './item-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DeleteRequest, - FindAllOptions, + FindListOptions, GetRequest, MappedCollectionsRequest, PostRequest, @@ -58,7 +57,7 @@ describe('ItemDataService', () => { } as HALEndpointService; const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; - const options = Object.assign(new FindAllOptions(), { + const options = Object.assign(new FindListOptions(), { scopeID: scopeID, sort: { field: '', diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index e616cb8020..b729c0fafe 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -14,7 +14,7 @@ import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DeleteRequest, - FindAllOptions, + FindListOptions, MappedCollectionsRequest, PatchRequest, PostRequest, PutRequest, @@ -59,10 +59,10 @@ export class ItemDataService extends DataService { /** * Get the endpoint for browsing items * (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued') - * @param {FindAllOptions} options + * @param {FindListOptions} options * @returns {Observable} */ - public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { let field = 'dc.date.issued'; if (options.sort && options.sort.field) { field = options.sort.field; @@ -247,4 +247,14 @@ export class ItemDataService extends DataService { map((request: RequestEntry) => request.response) ); } + + /** + * Get the endpoint for an item's bitstreams + * @param itemId + */ + public getBitstreamsEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) + ); + } } diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index b15dd6865f..662eaa6c7c 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -7,7 +7,7 @@ import { CoreState } from '../core.reducers'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions } from './request.models'; +import { FindListOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { HttpClient } from '@angular/common/http'; @@ -33,7 +33,7 @@ class DataServiceImpl extends DataService { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } } diff --git a/src/app/core/data/relationship-type.service.ts b/src/app/core/data/relationship-type.service.ts index a30dd5d57e..627fc4863f 100644 --- a/src/app/core/data/relationship-type.service.ts +++ b/src/app/core/data/relationship-type.service.ts @@ -4,7 +4,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { filter, find, map, switchMap, tap } from 'rxjs/operators'; import { configureRequest, getSucceededRemoteData } from '../shared/operators'; -import { FindAllOptions, FindAllRequest } from './request.models'; import { Observable } from 'rxjs/internal/Observable'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RemoteData } from './remote-data'; @@ -12,6 +11,7 @@ import { PaginatedList } from './paginated-list'; import { combineLatest as observableCombineLatest } from 'rxjs'; import { ItemType } from '../shared/item-relationships/item-type.model'; import { isNotUndefined } from '../../shared/empty.util'; +import { FindListOptions, FindListRequest } from './request.models'; /** * The service handling all relationship requests @@ -35,11 +35,11 @@ export class RelationshipTypeService { ); } - getAllRelationshipTypes(options: FindAllOptions): Observable>> { + getAllRelationshipTypes(options: FindListOptions): Observable>> { const link$ = this.halService.getEndpoint(this.linkPath); return link$ .pipe( - map((endpointURL: string) => new FindAllRequest(this.requestService.generateRequestId(), endpointURL, options)), + map((endpointURL: string) => new FindListRequest(this.requestService.generateRequestId(), endpointURL, options)), configureRequest(this.requestService), switchMap(() => this.rdbService.buildList(link$)) ); diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index b33db80fbe..9287935f59 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -123,8 +123,8 @@ describe('RelationshipService', () => { it('should clear the related items their cache', () => { expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); expect(objectCache.remove).toHaveBeenCalledWith(item.self); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid); }); }); diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 9bd59ce151..325bb59399 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -3,39 +3,20 @@ import { Injectable } from '@angular/core'; import { MemoizedSelector, select, Store } from '@ngrx/store'; import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; -import { - distinctUntilChanged, - filter, - map, - mergeMap, - startWith, - switchMap, - take, - tap -} from 'rxjs/operators'; -import { - compareArraysUsingIds, - paginatedRelationsToItems, - relationsToItems -} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; import { AppState, keySelector } from '../../app.reducer'; -import { - hasValue, - hasValueOperator, - isNotEmpty, - isNotEmptyOperator -} from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; -import { - RemoveNameVariantAction, - SetNameVariantAction -} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; +import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; +import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -43,18 +24,11 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; import { Item } from '../shared/item.model'; -import { - configureRequest, - getRemoteDataPayload, - getResponseFromEntry, - getSucceededRemoteData -} from '../shared/operators'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { ItemDataService } from './item-data.service'; import { PaginatedList } from './paginated-list'; import { RemoteData, RemoteDataState } from './remote-data'; -import { DeleteRequest, FindAllOptions, PostRequest, RestRequest } from './request.models'; import { RequestService } from './request.service'; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; @@ -89,7 +63,7 @@ export class RelationshipService extends DataService { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } @@ -119,6 +93,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(); @@ -139,6 +121,10 @@ 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(), @@ -155,12 +141,16 @@ 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.uuid); combineLatest( this.objectCache.hasBySelfLinkObservable(item.self), - this.requestService.hasByHrefObservable(item.self) + this.requestService.hasByHrefObservable(item.uuid) ).pipe( filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC), take(1), @@ -229,7 +219,7 @@ export class RelationshipService extends DataService { * @param label * @param options */ - getRelatedItemsByLabel(item: Item, label: string, options?: FindAllOptions): Observable>> { + getRelatedItemsByLabel(item: Item, label: string, options?: FindListOptions): Observable>> { return this.getItemRelationshipsByLabel(item, label, options).pipe(paginatedRelationsToItems(item.uuid)); } @@ -240,18 +230,18 @@ export class RelationshipService extends DataService { * @param label * @param options */ - getItemRelationshipsByLabel(item: Item, label: string, options?: FindAllOptions): Observable>> { - let findAllOptions = new FindAllOptions(); + getItemRelationshipsByLabel(item: Item, label: string, options?: FindListOptions): Observable>> { + let findListOptions = new FindListOptions(); if (options) { - findAllOptions = Object.assign(new FindAllOptions(), options); + findListOptions = Object.assign(new FindListOptions(), options); } const searchParams = [new SearchParam('label', label), new SearchParam('dso', item.id)]; - if (findAllOptions.searchParams) { - findAllOptions.searchParams = [...findAllOptions.searchParams, ...searchParams]; + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; } else { - findAllOptions.searchParams = searchParams; + findListOptions.searchParams = searchParams; } - return this.searchBy('byLabel', findAllOptions); + return this.searchBy('byLabel', findListOptions); } /** @@ -285,6 +275,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( @@ -314,24 +310,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> { const update$ = this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel) .pipe( diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index a7d11089df..ca864f99de 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -138,7 +138,7 @@ export class FindByIDRequest extends GetRequest { } } -export class FindAllOptions { +export class FindListOptions { scopeID?: string; elementsPerPage?: number; currentPage?: number; @@ -147,11 +147,11 @@ export class FindAllOptions { startsWith?: string; } -export class FindAllRequest extends GetRequest { +export class FindListRequest extends GetRequest { constructor( uuid: string, href: string, - public body?: FindAllOptions, + public body?: FindListOptions, ) { super(uuid, href); } diff --git a/src/app/core/data/resource-policy.service.ts b/src/app/core/data/resource-policy.service.ts index 1574111232..017e5cf5ee 100644 --- a/src/app/core/data/resource-policy.service.ts +++ b/src/app/core/data/resource-policy.service.ts @@ -6,7 +6,7 @@ import { Observable } from 'rxjs'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; -import { FindAllOptions } from '../data/request.models'; +import { FindListOptions } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ResourcePolicy } from '../shared/resource-policy.model'; import { RemoteData } from '../data/remote-data'; @@ -36,7 +36,7 @@ class DataServiceImpl extends DataService { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } } diff --git a/src/app/core/data/site-data.service.spec.ts b/src/app/core/data/site-data.service.spec.ts index 3059ab9948..6148135f50 100644 --- a/src/app/core/data/site-data.service.spec.ts +++ b/src/app/core/data/site-data.service.spec.ts @@ -13,7 +13,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { of as observableOf } from 'rxjs'; import { RestResponse } from '../cache/response.models'; import { RequestEntry } from './request.reducer'; -import { FindAllOptions } from './request.models'; +import { FindListOptions } from './request.models'; import { TestScheduler } from 'rxjs/testing'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; @@ -31,7 +31,7 @@ describe('SiteDataService', () => { }); const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; - const options = Object.assign(new FindAllOptions(), {}); + const options = Object.assign(new FindListOptions(), {}); const getRequestEntry$ = (successful: boolean, statusCode: number, statusText: string) => { return observableOf({ diff --git a/src/app/core/data/site-data.service.ts b/src/app/core/data/site-data.service.ts index ba395b40ed..c1a1b2069b 100644 --- a/src/app/core/data/site-data.service.ts +++ b/src/app/core/data/site-data.service.ts @@ -10,7 +10,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { FindAllOptions } from './request.models'; +import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { RemoteData } from './remote-data'; @@ -42,10 +42,10 @@ export class SiteDataService extends DataService {​ /** * Get the endpoint for browsing the site object - * @param {FindAllOptions} options + * @param {FindListOptions} options * @param {Observable} linkPath */ - getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable { + getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable { return this.halService.getEndpoint(this.linkPath); } diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts index 70ecf3f59e..81ae532e3b 100644 --- a/src/app/core/eperson/eperson.service.ts +++ b/src/app/core/eperson/eperson.service.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs'; -import { FindAllOptions } from '../data/request.models'; +import { FindListOptions } from '../data/request.models'; import { DataService } from '../data/data.service'; import { CacheableObject } from '../cache/object-cache.reducer'; @@ -8,7 +8,7 @@ import { CacheableObject } from '../cache/object-cache.reducer'; */ export abstract class EpersonService extends DataService { - public getBrowseEndpoint(options: FindAllOptions): Observable { + public getBrowseEndpoint(options: FindListOptions): Observable { return this.halService.getEndpoint(this.linkPath); } } diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts index 2014e6120a..c8a2a78917 100644 --- a/src/app/core/eperson/group-eperson.service.ts +++ b/src/app/core/eperson/group-eperson.service.ts @@ -7,7 +7,7 @@ import { filter, map, take } from 'rxjs/operators'; import { EpersonService } from './eperson.service'; import { RequestService } from '../data/request.service'; -import { FindAllOptions } from '../data/request.models'; +import { FindListOptions } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Group } from './models/group.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -52,7 +52,7 @@ export class GroupEpersonService extends EpersonService { */ isMemberOf(groupName: string): Observable { const searchHref = 'isMemberOf'; - const options = new FindAllOptions(); + const options = new FindListOptions(); options.searchParams = [new SearchParam('groupName', groupName)]; return this.searchBy(searchHref, options).pipe( diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts index 8bca76f7d2..b29c491cb0 100644 --- a/src/app/core/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -195,6 +195,9 @@ export class RouteService { 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/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index a93d54db64..117cc074ca 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -43,8 +43,8 @@ export class HALEndpointService { ); } - public getEndpoint(linkPath: string): Observable { - return this.getEndpointAt(this.getRootHref(), ...linkPath.split('/')); + public getEndpoint(linkPath: string, startHref?: string): Observable { + return this.getEndpointAt(startHref || this.getRootHref(), ...linkPath.split('/')); } /** diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 0d1aa74591..308e4f8a2d 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -125,9 +125,3 @@ export const getFirstOccurrence = () => source.pipe( map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) ); - -export const obsLog = (logString?: string) => - (source: Observable): Observable => - source.pipe( - tap((t) => console.log(logString || '', t)) - ); diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 43c4aecafe..47195ed0a1 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -8,7 +8,7 @@ import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions } from '../data/request.models'; +import { FindListOptions } from '../data/request.models'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -35,7 +35,7 @@ export class WorkflowItemDataService extends DataService { super(); } - public getBrowseEndpoint(options: FindAllOptions) { + public getBrowseEndpoint(options: FindListOptions) { return this.halService.getEndpoint(this.linkPath); } diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 4d388ec513..3f782b74a2 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -7,7 +7,7 @@ import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions } from '../data/request.models'; +import { FindListOptions } from '../data/request.models'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -35,7 +35,7 @@ export class WorkspaceitemDataService extends DataService { super(); } - public getBrowseEndpoint(options: FindAllOptions) { + public getBrowseEndpoint(options: FindListOptions) { return this.halService.getEndpoint(this.linkPath); } diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts index f39b144c6a..cf23bfd74b 100644 --- a/src/app/core/tasks/tasks.service.ts +++ b/src/app/core/tasks/tasks.service.ts @@ -4,7 +4,7 @@ import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; import { DataService } from '../data/data.service'; -import { DeleteRequest, FindAllOptions, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { DeleteRequest, FindListOptions, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; import { isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { ProcessTaskResponse } from './models/process-task-response'; @@ -18,7 +18,7 @@ import { CacheableObject } from '../cache/object-cache.reducer'; */ export abstract class TasksService extends DataService { - public getBrowseEndpoint(options: FindAllOptions): Observable { + public getBrowseEndpoint(options: FindListOptions): Observable { return this.halService.getEndpoint(this.linkPath); } 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/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index ae6c3a8914..e86ab35e0e 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -36,8 +36,11 @@
- - + +
diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 4d97868b58..1b23d567f5 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -24,16 +24,6 @@
- - - -
+
+ + +
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index ff675ab057..97a3cf416e 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -53,8 +53,11 @@
- - + +
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/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts new file mode 100644 index 0000000000..34b89cc8aa --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts @@ -0,0 +1,64 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { OrgUnitInputSuggestionsComponent } from './org-unit-input-suggestions.component'; +import { FormsModule } from '@angular/forms'; + +let component: OrgUnitInputSuggestionsComponent; +let fixture: ComponentFixture; + +let suggestions: string[]; +let testValue; + +function init() { + suggestions = ['test', 'suggestion', 'example'] + testValue = 'bla'; +} + +describe('OrgUnitInputSuggestionsComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [OrgUnitInputSuggestionsComponent], + imports: [ + FormsModule, + ], + providers: [ + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrgUnitInputSuggestionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(OrgUnitInputSuggestionsComponent); + component = fixture.componentInstance; + component.suggestions = suggestions; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('When the component is initialized', () => { + it('should set the value to the first value of the suggestions', () => { + expect(component.value).toEqual('test'); + }); + }); + + describe('When onSubmit is called', () => { + it('should set the value to parameter of the method', () => { + component.onSubmit(testValue); + expect(component.value).toEqual(testValue); + }); + }); + + describe('When onClickSuggestion is called', () => { + it('should set the value to parameter of the method', () => { + component.onClickSuggestion(testValue); + expect(component.value).toEqual(testValue); + }); + }); + +}); diff --git a/src/app/entity-groups/research-entities/submission/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..75817d786a 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,6 +1,10 @@ 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', diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 4c7c3cd030..b2ba10fb98 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -53,17 +53,18 @@ export class NavbarComponent extends MenuComponent implements OnInit { } as TextMenuItemModel, index: 0 }, - // { - // id: 'browse_global_communities_and_collections', - // parentID: 'browse_global', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.browse_global_communities_and_collections', - // link: '#' - // } as LinkMenuItemModel, - // }, + /* Communities & Collections tree */ + { + id: `browse_global_communities_and_collections`, + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: `menu.section.browse_global_communities_and_collections`, + link: `/community-list` + } as LinkMenuItemModel + }, /* Statistics */ { 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 1abc2eebef..253c597f7b 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 @@ -226,6 +226,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) { @@ -310,6 +313,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' diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts index 7172653557..1b2f471f0b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.spec.ts @@ -1,6 +1,9 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ExistingMetadataListElementComponent } from './existing-metadata-list-element.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { Store } from '@ngrx/store'; describe('ExistingMetadataListElementComponent', () => { let component: ExistingMetadataListElementComponent; @@ -8,9 +11,14 @@ describe('ExistingMetadataListElementComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ ExistingMetadataListElementComponent ] + declarations: [ExistingMetadataListElementComponent], + providers: [ + { provide: SelectableListService, useValue: {} }, + { provide: Store, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts index b9f074f4bb..4981fd5a3c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.ts @@ -25,6 +25,7 @@ import { RelationshipOptions } from '../../models/relationship-options.model'; import { DynamicConcatModel } from '../models/ds-dynamic-concat.model'; import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions'; +// tslint:disable:max-classes-per-file export abstract class Reorderable { constructor(public oldIndex?: number, public newIndex?: number) { @@ -174,3 +175,4 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro } } +// tslint:enable:max-classes-per-file 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..490be050ef 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,8 +3,10 @@ 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' @@ -21,8 +23,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 9937fb6010..4aab8ff325 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 @@ -30,6 +30,9 @@ import { Context } from '../../../../../core/shared/context.model'; ] }) +/** + * Represents a modal where the submitter can select items to be added as a certain relationship type to the object being submitted + */ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy { label: string; relationshipOptions: RelationshipOptions; 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 97ea58183b..3690f0402e 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 @@ -73,6 +73,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.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts index 241d022dc3..4434684cbb 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts @@ -24,9 +24,11 @@ describe('DsDynamicLookupRelationSearchTabComponent', () => { let item1; let item2; let item3; + let item4; let searchResult1; let searchResult2; let searchResult3; + let searchResult4; let listID; let selection$; @@ -39,9 +41,11 @@ describe('DsDynamicLookupRelationSearchTabComponent', () => { item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); item3 = Object.assign(new Item(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + item4 = Object.assign(new Item(), { uuid: 'f96a385e-de10-45b2-be66-7f10bf52f765' }); searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + searchResult4 = Object.assign(new ItemSearchResult(), { indexableObject: item4 }); listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; selection$ = observableOf([searchResult1, searchResult2]); @@ -93,12 +97,12 @@ describe('DsDynamicLookupRelationSearchTabComponent', () => { describe('selectPage', () => { beforeEach(() => { spyOn(component.selectObject, 'emit'); - component.selectPage([searchResult1, searchResult2, searchResult3]); + component.selectPage([searchResult1, searchResult2, searchResult4]); }); it('should emit the page filtered from already selected objects and call select on the service for all objects', () => { - expect(component.selectObject.emit).toHaveBeenCalledWith(searchResult3); - expect(selectableListService.select).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult3]); + expect(component.selectObject.emit).toHaveBeenCalledWith(searchResult4); + expect(selectableListService.select).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult4]); }); }); 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 c7bb7104b5..9c00d64953 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 @@ -33,6 +33,9 @@ import { Context } from '../../../../../../core/shared/context.model'; ] }) +/** + * Tab for inside the lookup model that represents the items that can be used as a relationship in this submission + */ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDestroy { @Input() relationship: RelationshipOptions; @Input() listId: string; @@ -63,6 +66,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); @@ -90,12 +96,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({}, { page: 1, pageSize: this.initialPagination.pageSize }), }); } + /** + * Selects a page in the store + * @param page The page to select + */ selectPage(page: Array>) { this.selection$ .pipe(take(1)) @@ -106,6 +119,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$ @@ -117,6 +134,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; @@ -142,6 +162,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.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts index 32c995ba94..203a4df0b0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts @@ -1,46 +1,59 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { VarDirective } from '../../../../../utils/var.directive'; -import { of as observableOf } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; import { ItemSearchResult } from '../../../../../object-collection/shared/item-search-result.model'; import { Item } from '../../../../../../core/shared/item.model'; import { DsDynamicLookupRelationSelectionTabComponent } from './dynamic-lookup-relation-selection-tab.component'; import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../testing/utils'; describe('DsDynamicLookupRelationSelectionTabComponent', () => { let component: DsDynamicLookupRelationSelectionTabComponent; let fixture: ComponentFixture; - let pSearchOptions = new PaginatedSearchOptions({pagination: new PaginationComponentOptions()}); + let pSearchOptions = new PaginatedSearchOptions({ pagination: new PaginationComponentOptions() }); let item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); let item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); let searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); let searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); let listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; - let selection$ = observableOf([searchResult1, searchResult2]); + let selection$; + let selectionRD$; + let router; function init() { - pSearchOptions = new PaginatedSearchOptions({pagination: new PaginationComponentOptions()}); + pSearchOptions = new PaginatedSearchOptions({ pagination: new PaginationComponentOptions() }); item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; selection$ = observableOf([searchResult1, searchResult2]); + selectionRD$ = createSelection([searchResult1, searchResult2]); + router = jasmine.createSpyObj('router', ['navigate']) } + beforeEach(async(() => { init(); TestBed.configureTestingModule({ declarations: [DsDynamicLookupRelationSelectionTabComponent, VarDirective], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + imports: [TranslateModule.forRoot()], providers: [ { provide: SearchConfigurationService, useValue: { paginatedSearchOptions: observableOf(pSearchOptions) - } + }, + }, + { + provide: Router, useValue: router } ], schemas: [NO_ERRORS_SCHEMA] @@ -59,4 +72,26 @@ describe('DsDynamicLookupRelationSelectionTabComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should call navigate on the router when is called resetRoute', () => { + component.resetRoute(); + expect(router.navigate).toHaveBeenCalled(); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.selectionRD$ = createSelection([]); + fixture.detectChanges(); + const colComponent = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(colComponent).toBe(null); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.selectionRD$ = selectionRD$; + const colComponent = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(colComponent).not.toBe(null); + }); }); + +function createSelection(content: ListableObject[]): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(undefined, content)); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts index b47207a957..8aa3dc3828 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,6 +25,9 @@ import { Context } from '../../../../../../core/shared/context.model'; ] }) +/** + * Tab for inside the lookup model that represents the currently selected relationships + */ export class DsDynamicLookupRelationSelectionTabComponent { @Input() label: string; @Input() listId: string; @@ -44,6 +47,9 @@ export class DsDynamicLookupRelationSelectionTabComponent { private searchConfigService: SearchConfigurationService) { } + /** + * Set up the selection and pagination on load + */ ngOnInit() { this.resetRoute(); this.selectionRD$ = this.searchConfigService.paginatedSearchOptions @@ -70,6 +76,9 @@ export class DsDynamicLookupRelationSelectionTabComponent { ) } + /** + * Method to reset the route when the window is opened to make sure no strange pagination issues appears + */ resetRoute() { this.router.navigate([], { queryParams: Object.assign({}, { page: 1, pageSize: this.initialPagination.pageSize }), diff --git a/src/app/shared/form/builder/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 58% 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..a7fab782da 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,10 +1,15 @@ 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 {}; export const MOCK_RESPONSE_MAP: InjectionToken = new InjectionToken('mockResponseMap'); +/** + * List of endpoints with their matching mock response + * Note that this list is only used in development mode + * In production the actual endpoints on the REST server will be called + */ export const mockResponseMap: MockResponseMap = new Map([ // [ '/config/submissionforms/traditionalpageone', mockSubmissionResponse ] ]); 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..d47e05c8fe 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 @@ -10,7 +10,7 @@ import { Observable } from 'rxjs'; templateUrl: './selectable-list-item-control.component.html' }) /** - * Component for determining what component to use depending on the item's relationship type (relationship.type) + * Component for rendering list item that has a control (checkbox or radio button) because it's selectable */ export class SelectableListItemControlComponent implements OnInit { /** @@ -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/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index 1be0f69106..6ca7adb3f9 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -1,21 +1,11 @@ -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - Output, - ViewEncapsulation -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { RemoteData } from '../../core/data/remote-data'; import { fadeIn } from '../animations/fade'; import { ListableObject } from '../object-collection/shared/listable-object.model'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { SearchResult } from '../search/search-result.model'; import { SelectableListService } from './selectable-list/selectable-list.service'; -import { map, take, tap } from 'rxjs/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; import { Context } from '../../core/shared/context.model'; import { CollectionElementLinkType } from '../object-collection/collection-element-link.type'; @@ -60,9 +50,6 @@ export class ObjectListComponent { @Input() hidePagerWhenSinglePage = true; @Input() selectable = false; @Input() selectionConfig: { repeatable: boolean, listId: string }; - // @Input() previousSelection: ListableObject[] = []; - // allSelected = false; - // selectAllLoading = false; /** * The link type of the listable elements 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..010ae5609d 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 a single object in a the selectable list + */ export class SelectableListDeselectSingleAction extends SelectableListAction { payload: ListableObject; @@ -58,6 +66,9 @@ export class SelectableListDeselectSingleAction extends SelectableListAction { } } +/** + * Action to deselect objects 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/page-size-selector/page-size-selector.component.ts b/src/app/shared/page-size-selector/page-size-selector.component.ts index 799993d35d..b200c337f8 100644 --- a/src/app/shared/page-size-selector/page-size-selector.component.ts +++ b/src/app/shared/page-size-selector/page-size-selector.component.ts @@ -14,7 +14,7 @@ import { map } from 'rxjs/operators'; }) /** - * This component represents the part of the search sidebar that contains the general search settings. + * This component represents the part of the search sidebar that contains the page size settings. */ export class PageSizeSelectorComponent implements OnInit { /** 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/shared/search/search-filters/search-filters.component.ts b/src/app/shared/search/search-filters/search-filters.component.ts index e9b5f46fa8..78c40501e6 100644 --- a/src/app/shared/search/search-filters/search-filters.component.ts +++ b/src/app/shared/search/search-filters/search-filters.component.ts @@ -8,7 +8,7 @@ import { RemoteData } from '../../../core/data/remote-data'; import { SearchFilterConfig } from '../search-filter-config.model'; import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; import { SearchFilterService } from '../../../core/shared/search/search-filter.service'; -import { getSucceededRemoteData, obsLog } from '../../../core/shared/operators'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; import { currentPath } from '../../utils/route.utils'; import { Router } from '@angular/router'; diff --git a/src/app/shared/search/search-labels/search-label/search-label.component.spec.ts b/src/app/shared/search/search-labels/search-label/search-label.component.spec.ts index 0b382015af..5de87be3bc 100644 --- a/src/app/shared/search/search-labels/search-label/search-label.component.spec.ts +++ b/src/app/shared/search/search-labels/search-label/search-label.component.spec.ts @@ -4,7 +4,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Observable, of as observableOf } from 'rxjs'; -import { Params } from '@angular/router'; +import { Params, Router } from '@angular/router'; import { SearchLabelComponent } from './search-label.component'; import { ObjectKeysPipe } from '../../../utils/object-keys-pipe'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; @@ -39,7 +39,8 @@ describe('SearchLabelComponent', () => { declarations: [SearchLabelComponent, ObjectKeysPipe], providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, - { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: Router, useValue: {}} // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/shared/search/search-labels/search-label/search-label.component.ts b/src/app/shared/search/search-labels/search-label/search-label.component.ts index bc3a8b2897..956b5b81de 100644 --- a/src/app/shared/search/search-labels/search-label/search-label.component.ts +++ b/src/app/shared/search/search-labels/search-label/search-label.component.ts @@ -26,7 +26,8 @@ export class SearchLabelComponent implements OnInit { * Initialize the instance variable */ constructor( - private searchService: SearchService, private router: Router) { + private searchService: SearchService, + private router: Router) { } ngOnInit(): void { diff --git a/src/app/shared/utils/relation-query.utils.spec.ts b/src/app/shared/utils/relation-query.utils.spec.ts new file mode 100644 index 0000000000..f70e904422 --- /dev/null +++ b/src/app/shared/utils/relation-query.utils.spec.ts @@ -0,0 +1,18 @@ +import { getFilterByRelation, getQueryByRelations } from './relation-query.utils'; + +describe('Relation Query Utils', () => { + const relationtype = 'isAuthorOfPublication'; + const itemUUID = 'a7939af0-36ad-430d-af09-7be8b0a4dadd'; + describe('getQueryByRelations', () => { + it('Should return the correct query based on relationtype and uuid', () => { + const result = getQueryByRelations(relationtype, itemUUID); + expect(result).toEqual('query=relation.isAuthorOfPublication:a7939af0-36ad-430d-af09-7be8b0a4dadd'); + }); + }); + describe('getFilterByRelation', () => { + it('Should return the correct query based on relationtype and uuid', () => { + const result = getFilterByRelation(relationtype, itemUUID); + expect(result).toEqual('f.isAuthorOfPublication=a7939af0-36ad-430d-af09-7be8b0a4dadd'); + }); + }); +}); diff --git a/src/app/shared/utils/route.utils.spec.ts b/src/app/shared/utils/route.utils.spec.ts new file mode 100644 index 0000000000..610fd8756d --- /dev/null +++ b/src/app/shared/utils/route.utils.spec.ts @@ -0,0 +1,22 @@ +import { currentPath } from './route.utils'; + +describe('Route Utils', () => { + const urlTree = { + root: { + children: { + primary: { + segments: [ + { path: 'test' }, + { path: 'path' } + ] + } + + } + } + }; + const router = { parseUrl: () => urlTree } as any; + it('Should return the correct current path based on the router', () => { + const result = currentPath(router); + expect(result).toEqual('/test/path'); + }); + }); diff --git a/src/app/shared/utils/route.utils.ts b/src/app/shared/utils/route.utils.ts index b0771d4f13..6510fb8894 100644 --- a/src/app/shared/utils/route.utils.ts +++ b/src/app/shared/utils/route.utils.ts @@ -1,5 +1,9 @@ import { Router } from '@angular/router'; +/** + * Util function to retrieve the current path (without query parameters) the user is on + * @param router The router service + */ export function currentPath(router: Router) { const urlTree = router.parseUrl(router.url); return '/' + urlTree.root.children.primary.segments.map((it) => it.path).join('/') diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index 0c16dc4755..004e013164 100644 --- a/src/app/statistics/statistics.service.ts +++ b/src/app/statistics/statistics.service.ts @@ -66,13 +66,13 @@ export class StatisticsService { }, }; if (hasValue(searchOptions.configuration)) { - Object.assign(body, {configuration: searchOptions.configuration}) + Object.assign(body, { configuration: searchOptions.configuration }) } if (hasValue(searchOptions.dsoType)) { - Object.assign(body, {dsoType: searchOptions.dsoType.toLowerCase()}) + Object.assign(body, { dsoType: searchOptions.dsoType.toLowerCase() }) } if (hasValue(searchOptions.scope)) { - Object.assign(body, {scope: searchOptions.scope}) + Object.assign(body, { scope: searchOptions.scope }) } if (isNotEmpty(filters)) { const bodyFilters = []; @@ -85,7 +85,7 @@ export class StatisticsService { label: filter.label }) } - Object.assign(body, {appliedFilters: bodyFilters}) + Object.assign(body, { appliedFilters: bodyFilters }) } this.sendEvent('/statistics/searchevents', body); } diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 0bd24cc304..f84764d6a4 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -1,8 +1,28 @@ -import { ChangeDetectorRef, Component, EventEmitter, HostListener, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; import { FormControl } from '@angular/forms'; import { BehaviorSubject, combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, map, mergeMap, reduce, startWith, flatMap, find } from 'rxjs/operators'; +import { + debounceTime, + distinctUntilChanged, + filter, + find, + flatMap, + map, + mergeMap, + reduce, + startWith +} from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; @@ -16,7 +36,7 @@ import { SubmissionService } from '../../submission.service'; import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { FindAllOptions } from '../../../core/data/request.models'; +import { FindListOptions } from '../../../core/data/request.models'; /** * An interface to represent a collection entry @@ -185,7 +205,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { map((collectionRD: RemoteData) => collectionRD.payload.name) ); - const findOptions: FindAllOptions = { + const findOptions: FindListOptions = { elementsPerPage: 1000 }; @@ -197,23 +217,21 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { find((communities: RemoteData>) => isNotEmpty(communities.payload)), mergeMap((communities: RemoteData>) => communities.payload.page)); - const listCollection$ = observableOf([]); - - // const listCollection$ = communities$.pipe( - // flatMap((communityData: Community) => { - // return this.collectionDataService.getAuthorizedCollectionByCommunity(communityData.uuid, findOptions).pipe( - // find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), - // mergeMap((collections: RemoteData>) => collections.payload.page), - // filter((collectionData: Collection) => isNotEmpty(collectionData)), - // map((collectionData: Collection) => ({ - // communities: [{ id: communityData.id, name: communityData.name }], - // collection: { id: collectionData.id, name: collectionData.name } - // })) - // ); - // }), - // reduce((acc: any, value: any) => [...acc, ...value], []), - // startWith([]) - // ); + const listCollection$ = communities$.pipe( + flatMap((communityData: Community) => { + return this.collectionDataService.getAuthorizedCollectionByCommunity(communityData.uuid, findOptions).pipe( + find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded), + mergeMap((collections: RemoteData>) => collections.payload.page), + filter((collectionData: Collection) => isNotEmpty(collectionData)), + map((collectionData: Collection) => ({ + communities: [{ id: communityData.id, name: communityData.name }], + collection: { id: collectionData.id, name: collectionData.name } + })) + ); + }), + reduce((acc: any, value: any) => [...acc, ...value], []), + startWith([]) + ); const searchTerm$ = this.searchField.valueChanges.pipe( debounceTime(200), @@ -229,8 +247,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { } else { return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5); } - }) - ); + })); } } } diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 6c8d7c5468..49dbaea807 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -166,8 +166,8 @@ export class SubmissionSectionformComponent extends SectionModelComponent { .subscribe(([sectionData, workspaceItem]: [WorkspaceitemSectionFormObject, WorkspaceItem]) => { if (isUndefined(this.formModel)) { this.sectionData.errors = []; - // Is the first loading so init form this.workspaceItem = workspaceItem; + // Is the first loading so init form this.initForm(sectionData); this.sectionData.data = sectionData; this.subscriptions(); diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 99d92d2af8..907f70b941 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -4,7 +4,7 @@ diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.html b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html similarity index 79% rename from themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.html rename to themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 15529a1bd5..ee78d9c653 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.html +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -53,18 +53,6 @@
- - - -
+
+
+ + +
+
diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss similarity index 86% rename from themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss rename to themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss index 54651aede0..4a1d2516da 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss @@ -1,4 +1,4 @@ -@import 'src/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss'; +@import 'src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss'; :host { > * { diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html b/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html index bb5cb1b787..1679f9354d 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -79,7 +79,10 @@

{{"item.page.person.search.title" | translate}}

- - + +
diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 028815d958..e63ae024ed 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -15,6 +15,9 @@ module.exports = (env) => { let copyWebpackOptions = [{ from: path.join(__dirname, '..', 'node_modules', '@fortawesome', 'fontawesome-free', 'webfonts'), to: path.join('assets', 'fonts') + }, { + from: path.join(__dirname, '..', 'resources', 'fonts'), + to: path.join('assets', 'fonts') }, { from: path.join(__dirname, '..', 'resources', 'images'), to: path.join('assets', 'images') @@ -24,6 +27,15 @@ module.exports = (env) => { } ]; + const themeFonts = path.join(themePath, 'resources', 'fonts'); + if(theme && fs.existsSync(themeFonts)) { + copyWebpackOptions.push({ + from: themeFonts, + to: path.join('assets', 'fonts') , + force: true, + }); + } + const themeImages = path.join(themePath, 'resources', 'images'); if(theme && fs.existsSync(themeImages)) { copyWebpackOptions.push({ @@ -107,12 +119,6 @@ module.exports = (env) => { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { @@ -120,6 +126,12 @@ module.exports = (env) => { includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, { loader: 'sass-resources-loader', options: { @@ -145,23 +157,23 @@ module.exports = (env) => { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { sourceMap: true, includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } - } + }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, ] }, { - test: /\.html$/, + test: /\.(html|eot|ttf|otf|svg|woff|woff2)$/, loader: 'raw-loader' } ] diff --git a/webpack/webpack.test.js b/webpack/webpack.test.js index 83e6e44e79..de53de31c4 100644 --- a/webpack/webpack.test.js +++ b/webpack/webpack.test.js @@ -160,12 +160,6 @@ module.exports = function (env) { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { @@ -173,6 +167,12 @@ module.exports = function (env) { includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, { loader: 'sass-resources-loader', options: { @@ -198,19 +198,19 @@ module.exports = function (env) { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { sourceMap: true, includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } - } + }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, ] }, diff --git a/yarn.lock b/yarn.lock index 884f820c1d..98b39370e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2102,15 +2102,14 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" -clone-deep@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713" - integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ== +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== dependencies: - for-own "^1.0.0" is-plain-object "^2.0.4" - kind-of "^6.0.0" - shallow-clone "^1.0.0" + kind-of "^6.0.2" + shallow-clone "^3.0.0" clone-stats@^0.0.1: version "0.0.1" @@ -4130,11 +4129,6 @@ font-awesome@4.7.0: resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" integrity sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM= -for-in@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= - for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4147,13 +4141,6 @@ for-own@^0.1.4: dependencies: for-in "^1.0.1" -for-own@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" - integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= - dependencies: - for-in "^1.0.1" - forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -6157,7 +6144,7 @@ loader-utils@^0.2.12, loader-utils@^0.2.15, loader-utils@^0.2.16: json5 "^0.5.0" object-assign "^4.0.1" -loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: +loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" integrity sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0= @@ -6166,7 +6153,7 @@ loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1 emojis-list "^2.0.0" json5 "^0.5.0" -loader-utils@^1.0.4: +loader-utils@^1.0.1, loader-utils@^1.0.4: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" dependencies: @@ -6380,11 +6367,6 @@ lodash.startswith@^4.2.1: resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c" integrity sha1-xZjErc4YiiflMUVzHNxsDnF3YAw= -lodash.tail@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" - integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= - lodash.template@^3.0.0: version "3.6.2" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" @@ -6858,14 +6840,6 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mixin-object@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= - dependencies: - for-in "^0.1.3" - is-extendable "^0.1.1" - mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -9673,17 +9647,16 @@ sass-graph@^2.2.4: scss-tokenizer "^0.2.3" yargs "^7.0.0" -sass-loader@7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.1.0.tgz#16fd5138cb8b424bf8a759528a1972d72aad069d" - integrity sha512-+G+BKGglmZM2GUSfT9TLuEp6tzehHPjAMoRRItOojWIqIGPloVCMhNIQuG639eJ+y033PaGTSjLaTHts8Kw79w== +sass-loader@^7.1.0: + version "7.3.1" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.3.1.tgz#a5bf68a04bcea1c13ff842d747150f7ab7d0d23f" + integrity sha512-tuU7+zm0pTCynKYHpdqaPpe+MMTQ76I9TPZ7i4/5dZsigE350shQWe5EZNl5dBidM49TPET75tNqRbcsUZWeNA== dependencies: - clone-deep "^2.0.1" + clone-deep "^4.0.1" loader-utils "^1.0.1" - lodash.tail "^4.1.1" neo-async "^2.5.0" - pify "^3.0.0" - semver "^5.5.0" + pify "^4.0.1" + semver "^6.3.0" sass-resources-loader@^2.0.0: version "2.0.0" @@ -9788,7 +9761,7 @@ semver-intersect@^1.1.2: dependencies: semver "^5.0.0" -"semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", semver@^5.0.0, semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0: +"semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", semver@^5.0.0, semver@^5.0.3, semver@^5.1.0, semver@^5.3.0: version "5.5.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== @@ -9798,7 +9771,12 @@ semver@^5.0.1: resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== -semver@^6.1.1: +semver@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.1.1, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -9929,14 +9907,12 @@ sha.js@^2.4.0, sha.js@^2.4.8: inherits "^2.0.1" safe-buffer "^5.0.1" -shallow-clone@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571" - integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA== +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== dependencies: - is-extendable "^0.1.1" - kind-of "^5.0.0" - mixin-object "^2.0.1" + kind-of "^6.0.2" shebang-command@^1.2.0: version "1.2.0"