diff --git a/config/environment.default.js b/config/environment.default.js index 24386d6cf71f8e14e799e0c938b88bc60c2977f4..df4f89a2fe1766d86f20a4620c3614724721f6b4 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -9,11 +9,10 @@ module.exports = { }, // The REST API server settings. rest: { - ssl: true, - host: 'dspace7.4science.cloud', - port: 443, - // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/server/api' + ssl: true, + host: 'dspace7-entities.atmire.com', + port: 443, + nameSpace: '/server/api' }, // Caching settings cache: { diff --git a/package.json b/package.json index a664c8daa43dbafab4c9cfb78193a542a257792a..dbdc10a8cab6eb82879ca12c29eaf3ad9294dcab 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 0000000000000000000000000000000000000000..e4817b8572c8b6f57625d13de27da22a641659f3 --- /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 ff5ca4f93ea7502329e8cf400251766a500888ca..299c2afe634626e79f0f017cec35d230b8e2ac74 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": "Hide last {{ amount }}", + + "item.page.relationships.isAuthorOfPublication": "Publications", - "item.page.related-items.view-less": "View less", + "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 cb7aa1ef91d7e88a39177f3bf3e1df0f4d14d518..ec4003c108fb11f59a997e48cbd1f80cdf029c5b 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 28460f567a4650b940793af60e94170043a261b3..5c54becddee4bf51b8998faa0b855ae9961f7dae 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 0000000000000000000000000000000000000000..e2c0823bf803fa334ccd46baf7b1e787da44fd9b --- /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<T> 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 750029b58b81f5f17daaf3f54770afec882b6f24..d1281f450a3d879efcaa43a0239535aaf91ae5f8 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 @@ -<ds-metadata-field-wrapper *ngIf="representations$ && (representations$ | async)?.length > 0" [label]="label"> - <ds-metadata-representation-loader *ngFor="let rep of (representations$ | async)" - [mdRepresentation]="rep"> - </ds-metadata-representation-loader> - <div *ngIf="(representations$ | async)?.length < total" class="mt-2"> - <a [routerLink]="" (click)="viewMore()">{{'item.page.related-items.view-more' | translate}}</a> - </div> - <div *ngIf="limit > originalLimit" class="mt-2"> - <a [routerLink]="" (click)="viewLess()">{{'item.page.related-items.view-less' | translate}}</a> - </div> +<ds-metadata-field-wrapper [label]="label"> + <ng-container *ngFor="let objectPage of objects; let i = index"> + <ng-container *ngVar="(objectPage | async) as representations"> + <ds-metadata-representation-loader *ngFor="let rep of representations" + [mdRepresentation]="rep"> + </ds-metadata-representation-loader> + <ds-loading *ngIf="(i + 1) === objects.length && (i > 0) && (!representations || representations?.length === 0)" message="{{'loading.default' | translate}}"></ds-loading> + <div class="d-inline-block w-100 mt-2" *ngIf="(i + 1) === objects.length && representations?.length > 0"> + <div *ngIf="(objects.length * incrementBy) < total" class="float-left"> + <a [routerLink]="" (click)="increase()">{{'item.page.related-items.view-more' | + translate:{ amount: (total - (objects.length * incrementBy) < incrementBy) ? total - (objects.length * incrementBy) : incrementBy } }}</a> + </div> + <div *ngIf="objects.length > 1" class="float-right"> + <a [routerLink]="" (click)="decrease()">{{'item.page.related-items.view-less' | + translate:{ amount: representations?.length } }}</a> + </div> + </div> + </ng-container> + </ng-container> </ds-metadata-field-wrapper> 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 7beabdceba231482714e3e1566035fdefffba8b9..ad62ce44180be4aedaf63b6ee34ee343e786ff2e 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 1fa623f6c9088c3af8081a133ae5522b7d28db98..23484f22e06a73d5e7f45da07ea85b0d3eb04406 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<Observable<MetadataRepresentation[]>> { /** * 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<MetadataRepresentation[]>; - - /** - * 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<MetadataRepresentation[]> { 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<MetadataRepresentation[]> { + resolveMetadataRepresentations(metadata: MetadataValue[], page: number): Observable<MetadataRepresentation[]> { 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 ebf3a0fd6e54658a8a09e147efe8fbe1c2c5d3f0..75f3b7aaad3de0b4bb4b951acf04d4cf619d83ff 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 @@ -<ds-filtered-search-page +<ds-configuration-search-page [fixedFilterQuery]="fixedFilter" + [configuration]="configuration" [configuration$]="configuration$" [searchEnabled]="searchEnabled" [sideBarWidth]="sideBarWidth"> -</ds-filtered-search-page> \ No newline at end of file +</ds-configuration-search-page> 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 65385b0442fddd50da4aa1e218dbcd408e3964fb..d9e5dd9dce3c0a408b32dfc221434a64a9e55294 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 d20bee2d4aa059391dd992ef440773688fc0fa50..595734ed9fdfffaaf841c79c58c6987f287f6674 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 @@ -23,16 +23,14 @@ export class RelatedEntitiesSearchComponent implements OnInit { @Input() relationType: string; /** - * The item to render relationships for + * An optional configuration to use for the search options */ - @Input() item: Item; + @Input() configuration: string; /** - * 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) + * The item to render relationships for */ - @Input() relationEntityType: string; + @Input() item: Item; /** * Whether or not the search bar and title should be displayed (defaults to true) @@ -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 0000000000000000000000000000000000000000..f9642d2c01caf97a357a8ac22ba5d707bf6fdf51 --- /dev/null +++ b/src/app/+item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.html @@ -0,0 +1,22 @@ +<ngb-tabset *ngIf="relationTypes.length > 1" [destroyOnHide]="true" #tabs="ngbTabset" [activeId]="activeTab$ | async" (tabChange)="onTabChange($event)"> + <ngb-tab *ngFor="let relationType of relationTypes" title="{{'item.page.relationships.' + relationType.label | translate}}" [id]="relationType.filter"> + <ng-template ngbTabContent> + <div class="mt-4"> + <ds-related-entities-search [item]="item" + [relationType]="relationType.filter" + [configuration]="relationType.configuration" + [searchEnabled]="searchEnabled" + [sideBarWidth]="sideBarWidth"> + </ds-related-entities-search> + </div> + </ng-template> + </ngb-tab> +</ngb-tabset> +<div *ngIf="relationTypes.length === 1" class="mt-4"> + <ds-related-entities-search *ngVar="relationTypes[0] as relationType" [item]="item" + [relationType]="relationType.filter" + [configuration]="relationType.configuration" + [searchEnabled]="searchEnabled" + [sideBarWidth]="sideBarWidth"> + </ds-related-entities-search> +</div> 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 0000000000000000000000000000000000000000..2d2e682196d0ef2ee12c44f43bf510d1bf622f3b --- /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<TabbedRelatedEntitiesSearchComponent>; + + 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 0000000000000000000000000000000000000000..b01eb707201241ab324f0333adc53bac88568e99 --- /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<string>; + + 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 ce502468e9fd311e14a794e294e42d50764d4a3d..0446e53be57ab668895dcbe069837c30ac70d135 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<Observable<RemoteData<PaginatedList<Item>>>> { /** * The parent of the list of related items to display */ @@ -30,30 +30,22 @@ 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) - */ - @Input() options = Object.assign(new FindAllOptions(), { elementsPerPage: 5 }); - - /** - * 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 + * 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 */ - @HostBinding('class.d-none') hidden = true; + @Input() incrementBy = 5; /** - * The list of related items + * Default options to start a search request with + * Optional input */ - items$: Observable<RemoteData<PaginatedList<Item>>>; + @Input() options = new FindListOptions(); /** - * Search options for displaying all elements in a list + * An i18n label to use as a title for the list (usually describes the relation) */ - allOptions = Object.assign(new FindAllOptions(), { elementsPerPage: 9999 }); + @Input() label: string; /** * The view-mode we're currently on @@ -61,48 +53,15 @@ export class RelatedItemsComponent implements OnInit, OnDestroy { */ 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<PaginatedList<Item>>) => { - this.hidden = !(itemsRD.hasSucceeded && itemsRD.payload && itemsRD.payload.page.length > 0); - }); - } - - /** - * Expand the list to display all related items - */ - 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; + super(); } /** - * Unsubscribe from the item subscription + * Get a specific page + * @param page The page to fetch */ - ngOnDestroy(): void { - if (this.itemSub) { - this.itemSub.unsubscribe(); - } + getPage(page: number): Observable<RemoteData<PaginatedList<Item>>> { + 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 dab85ee0e57d906880d600bca45297663175d284..11cedc40409f7ad8e0efacb5d6aa1d401a312ed7 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 @@ -<ds-metadata-field-wrapper *ngIf="(items$ | async)?.payload?.page?.length > 0" [label]="label"> - <ds-listable-object-component-loader *ngFor="let item of (items$ | async)?.payload?.page" - [object]="item" [viewMode]="viewMode"> - </ds-listable-object-component-loader> - <div *ngIf="(items$ | async)?.payload?.page?.length < (items$ | async)?.payload?.totalElements" class="mt-2" id="view-more"> - <a [routerLink]="" (click)="viewMore()">{{'item.page.related-items.view-more' | translate}}</a> - </div> - <div *ngIf="showingAll" class="mt-2" id="view-less"> - <a [routerLink]="" (click)="viewLess()">{{'item.page.related-items.view-less' | translate}}</a> - </div> +<ds-metadata-field-wrapper [label]="label"> + <ng-container *ngFor="let objectPage of objects; let i = index"> + <ng-container *ngVar="(objectPage | async) as itemsRD"> + <ds-listable-object-component-loader *ngFor="let item of itemsRD?.payload?.page" + [object]="item" [viewMode]="viewMode"> + </ds-listable-object-component-loader> + <ds-loading *ngIf="(i + 1) === objects.length && (itemsRD || i > 0) && !(itemsRD?.hasSucceeded && itemsRD?.payload && itemsRD?.payload?.page?.length > 0)" message="{{'loading.default' | translate}}"></ds-loading> + <div class="d-inline-block w-100 mt-2" *ngIf="(i + 1) === objects.length && itemsRD?.payload?.page?.length > 0"> + <div *ngIf="itemsRD?.payload?.totalPages > objects.length" class="float-left" id="view-more"> + <a [routerLink]="" (click)="increase()">{{'item.page.related-items.view-more' | + translate:{ amount: (itemsRD?.payload?.totalElements - (incrementBy * objects.length) < incrementBy) ? itemsRD?.payload?.totalElements - (incrementBy * objects.length) : incrementBy } }}</a> + </div> + <div *ngIf="objects.length > 1" class="float-right" id="view-less"> + <a [routerLink]="" (click)="decrease()">{{'item.page.related-items.view-less' | + translate:{ amount: itemsRD?.payload?.page?.length } }}</a> + </div> + </div> + </ng-container> + </ng-container> </ds-metadata-field-wrapper> 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 4a751a31b89798430690047584ee407699190929..5b1f33c64df0d9fe2b1eff520e069410a232fd54 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(); - }); - - it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments', () => { - expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.options); + // Add a second page + comp.objects.push(observableOf(undefined)); + comp.decrease(); }); - 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 f7d7edcffc36d16c2b9c8a73e200dd2e1766e344..33d99a9cd2bc394e35cd020015c5ebf4e2d67498 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 e25cbd2e1266e3ca73cd6c218d613d967d1f063d..0000000000000000000000000000000000000000 --- 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<FilteredSearchPageComponent>; - 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 c36dd0bf3ce766b42dbefc8c3f9c5a11af21ebdc..0000000000000000000000000000000000000000 --- 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 083a1b441083612abf942d193e20dba55305d9c4..315e15a593b4ddaa277fb20d8e213834860d6b3e 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 ccea62eae5185ea62c6dd4c8dcc61c2745516943..0f96431bb1e927b162005bbaf24b0b5c014dca58 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 5b5787c91fb3ccfdf1e72f0030af957c49be78f7..b27ebf625f56bc3d9b6060e55936caac6c90367f 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 5085633a5b02c4cd50b5b12ca5ec04178340d20a..bd29db4ab8e469015655f01b83daeb939edb2c7b 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 22df36e1ac3c025ee12270c8d15957fd0649ab8d..64573609c7a2cb8919c6ed15853cd4c70705f1ee 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 a6d89d789a7155d14f0b993efa118d9ffe61286d..926575d711b715b85ce5c425a779556927e0753e 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 8f841280f52e3b6ea207a6141b3ba85248a44f15..ad9247799b11d13684643c541de4232aa5a567a7 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<AppState> = { @@ -52,7 +54,8 @@ export const appReducers: ActionReducerMap<AppState> = { 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..3a9d9f2077d0e1f31c37e389b93f7fdf141200cd --- /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<FlatNode> { + + private communityList$ = new BehaviorSubject<FlatNode[]>([]); + public loading$ = new BehaviorSubject<boolean>(false); + + constructor(private communityListService: CommunityListService) { + } + + connect(collectionViewer: CollectionViewer): Observable<FlatNode[]> { + 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 0000000000000000000000000000000000000000..08accdc0e5b1ddf11502b86fb0afddab28d4f3da --- /dev/null +++ b/src/app/community-list-page/community-list-page.component.html @@ -0,0 +1,4 @@ +<div class="container"> + <h2>{{ 'communityList.title' | translate }}</h2> + <ds-community-list></ds-community-list> +</div> 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 0000000000000000000000000000000000000000..0aa4afce7f8f0dc9c324df6445541b2fbfb17201 --- /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<CommunityListPageComponent>; + + 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 0000000000000000000000000000000000000000..5ab3cce5de3e5cef4d5352b9494a5e87dc96b219 --- /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 0000000000000000000000000000000000000000..2e3914fe0314314ca626b02bbbffbecc2c45246e --- /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 0000000000000000000000000000000000000000..fe250cb96d64121a3e0674d5a64748c4a8637f26 --- /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 0000000000000000000000000000000000000000..a150277d200d9f90f6fd1f191aa259a519de93f2 --- /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<AppState>; + 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 0000000000000000000000000000000000000000..a25dbd2689604626c1a958a3bf9a8fd4305cd048 --- /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<boolean>; + 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<FlatNode[]>>): Observable<FlatNode[]> => + 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<boolean>, + 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<Observable<PaginatedList<Community>>>; + + topCommunitiesConfig: PaginationComponentOptions; + topCommunitiesSortConfig: SortOptions; + + maxSubCommunitiesPerPage: number; + maxCollectionsPerPage: number; + + constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, + private store: Store<any>) { + 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<FlatNode[]> { + return this.store.select(expandedNodesSelector); + } + + getLoadingNodeFromStore(): Observable<FlatNode> { + 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<FlatNode[]> { + const res = this.payloads$.map((payload) => { + return payload.pipe( + take(1), + switchMap((result: PaginatedList<Community>) => { + 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<Community>, + level: number, + parent: FlatNode, + expandedNodes: FlatNode[]): Observable<FlatNode[]> { + 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<FlatNode[]> { + 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<PaginatedList<Community>>) => rd.hasSucceeded), + take(1), + switchMap((rd: RemoteData<PaginatedList<Community>>) => + 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<PaginatedList<Collection>>) => rd.hasSucceeded), + take(1), + map((rd: RemoteData<PaginatedList<Collection>>) => { + 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<boolean> { + let hasSubcoms$: Observable<boolean>; + let hasColls$: Observable<boolean>; + hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 }) + .pipe( + filter((rd: RemoteData<PaginatedList<Community>>) => rd.hasSucceeded), + take(1), + map((results) => results.payload.totalElements > 0), + ); + + hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 }) + .pipe( + filter((rd: RemoteData<PaginatedList<Community>>) => rd.hasSucceeded), + take(1), + map((results) => results.payload.totalElements > 0), + ); + + let hasChildren$: Observable<boolean>; + 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 0000000000000000000000000000000000000000..bfce6fba34d676f2d4e1a9d0899950b5a400c66e --- /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 0000000000000000000000000000000000000000..63eaaccc036fc42eea1a84b51ce02efaabe2bdb5 --- /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 0000000000000000000000000000000000000000..b455fc496a7ce9c42eb6f5cb5f2ce22410597079 --- /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 0000000000000000000000000000000000000000..c179715bf153de2a381423d08454cb77585f64b4 --- /dev/null +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -0,0 +1,91 @@ +<ds-loading *ngIf="(dataSource.loading$ | async) && loadingNode === undefined " class="ds-loading"></ds-loading> + +<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl"> + <!-- This is the tree node template for show more node --> + <cdk-tree-node *cdkTreeNodeDef="let node; when: isShowMore" cdkTreeNodePadding + class="example-tree-node show-more-node"> + <div class="btn-group"> + <button type="button" class="btn btn-default" cdkTreeNodeToggle> + <span class="fa fa-chevron-right invisible" aria-hidden="true"></span> + </button> + <div class="align-middle pt-2"> + <a *ngIf="node!==loadingNode" [routerLink]="" (click)="getNextPage(node)" + class="btn btn-outline-secondary btn-sm"> + {{ 'communityList.showMore' | translate }} + </a> + <ds-loading *ngIf="node===loadingNode && dataSource.loading$ | async" class="ds-loading"></ds-loading> + </div> + </div> + <div class="text-muted" cdkTreeNodePadding> + <div class="d-flex"> + </div> + </div> + </cdk-tree-node> + <!-- This is the tree node template for expandable nodes (coms and subcoms with children) --> + <cdk-tree-node *cdkTreeNodeDef="let node; when: hasChild" cdkTreeNodePadding + class="example-tree-node expandable-node"> + <div class="btn-group"> + <button type="button" class="btn btn-default" cdkTreeNodeToggle + [attr.aria-label]="'toggle ' + node.name" + (click)="toggleExpanded(node)" + [ngClass]="(node.isExpandable$ | async) ? 'visible' : 'invisible'"> + <span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" + aria-hidden="true"></span> + </button> + <h5 class="align-middle pt-2"> + <a [routerLink]="node.route" class="lead"> + {{node.name}} + </a> + </h5> + </div> + <ds-truncatable [id]="node.id"> + <div class="text-muted" cdkTreeNodePadding> + <div class="d-flex" *ngIf="node.payload.shortDescription"> + <button type="button" class="btn btn-default invisible"> + <span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" + aria-hidden="true"></span> + </button> + <ds-truncatable-part [id]="node.id" [minLines]="3"> + <span>{{node.payload.shortDescription}}</span> + </ds-truncatable-part> + </div> + </div> + </ds-truncatable> + <div class="d-flex" *ngIf="node===loadingNode && dataSource.loading$ | async" + cdkTreeNodePadding> + <button type="button" class="btn btn-default invisible"> + <span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" + aria-hidden="true"></span> + </button> + <ds-loading class="ds-loading"></ds-loading> + </div> + </cdk-tree-node> + <!-- This is the tree node template for leaf nodes (collections and (sub)coms without children) --> + <cdk-tree-node *cdkTreeNodeDef="let node; when: !(hasChild && isShowMore)" cdkTreeNodePadding + class="example-tree-node childless-node"> + <div class="btn-group"> + <button type="button" class="btn btn-default" cdkTreeNodeToggle> + <span class="fa fa-chevron-right invisible" + aria-hidden="true"></span> + </button> + <h6 class="align-middle pt-2"> + <a [routerLink]="node.route" class="lead"> + {{node.name}} + </a> + </h6> + </div> + <ds-truncatable [id]="node.id"> + <div class="text-muted" cdkTreeNodePadding> + <div class="d-flex" *ngIf="node.payload.shortDescription"> + <button type="button" class="btn btn-default invisible"> + <span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" + aria-hidden="true"></span> + </button> + <ds-truncatable-part [id]="node.id" [minLines]="3"> + <span>{{node.payload.shortDescription}}</span> + </ds-truncatable-part> + </div> + </div> + </ds-truncatable> + </cdk-tree-node> +</cdk-tree> 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 0000000000000000000000000000000000000000..c04aadda37f9701b81a6e00a3246d26519332f78 --- /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<CommunityListComponent>; + + 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 0000000000000000000000000000000000000000..ddcd49cd1cc470f16f3565fa03dea853874adff0 --- /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<FlatNode>( + (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 c613c59a0c629fa0c38c670c2210f5c07b5b28d5..9b7edf70c060c7767139ac254c375f318e80cb54 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<Item> { @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 a33bbee5e6a6ffdec959cd7bc97507548588058b..3881dbe8b7b25d14ed9bd070d1efe11f5c9fc057 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 87add6b656a7708037f70536f12fa1835ca17bca..402ee88b81e381d833bd385f7d85dde91c20fce2 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 340a7a97d6d9a7c481e0ac3d130936a9764fcdd5..db14c4a256b06de61da8e3965b480e6f635f9722 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<ConfigData> { + public getConfigBySearch(options: FindListOptions = {}): Observable<ConfigData> { 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 dedf6104d9b97e02f476ec7f3c1b20aea34e21bb..4fdef02357e396383a4dc53b672cb00091350e19 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 bdf9b16acfdf23703b6fdba715f4fb9967a89243..7255ed366339189803676296c3a9e291e4581cc4 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<BitstreamFormat> { /** * Get the endpoint for browsing bitstream formats - * @param {FindAllOptions} options + * @param {FindListOptions} options * @returns {Observable<string>} */ - getBrowseEndpoint(options: FindAllOptions = {}, linkPath?: string): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> { 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 5962488c4f44c3db0045b10e1bb97d825819fd55..280f727aadc7d93c484bc9594cd6397e4eacbbdc 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<Bundle> { /** * Get the endpoint for browsing bundles - * @param {FindAllOptions} options + * @param {FindListOptions} options * @returns {Observable<string>} */ - getBrowseEndpoint(options: FindAllOptions = {}, linkPath?: string): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> { 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 9e4962ee714e5a479551924b9926d447017b828a..0c032e676655ad69ae2ab7a4eddc0471270cdf0b 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<Collection> { /** * Get all collections the user is authorized to submit to * - * @param options The [[FindAllOptions]] object + * @param options The [[FindListOptions]] object * @return Observable<RemoteData<PaginatedList<Collection>>> * collection list */ - getAuthorizedCollection(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> { + getAuthorizedCollection(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> { const searchHref = 'findAuthorized'; return this.searchBy(searchHref, options).pipe( @@ -65,11 +65,11 @@ export class CollectionDataService extends ComColDataService<Collection> { * 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<RemoteData<PaginatedList<Collection>>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> { + getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> { const searchHref = 'findAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [new SearchParam('uuid', communityId)] @@ -87,7 +87,7 @@ export class CollectionDataService extends ComColDataService<Collection> { */ hasAuthorizedCollection(): Observable<boolean> { 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<Collection> { return this.rdbService.buildList(href$); } + protected getFindByParentHref(parentUUID: string): Observable<string> { + 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 5cc474dff9fbdfadb1d111e5fff918339cc2773c..a7fcd205d4ace43ddbbbb61f80e21dc4f6addd48 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<any> { ) { super(); } + + protected getFindByParentHref(parentUUID: string): Observable<string> { + // 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 68eb3e488065b8852a7d607e2e5ea6acf8cea5c1..867ee24fc102f584dc2af65a3f70e7a6ef4a0acd 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<T extends CacheableObject> extends DataS * @return { Observable<string> } * an Observable<string> containing the scoped URL */ - public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { if (isEmpty(options.scopeID)) { return this.halService.getEndpoint(linkPath); } else { @@ -57,4 +68,12 @@ export abstract class ComColDataService<T extends CacheableObject> extends DataS return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged(), share()); } } + + protected abstract getFindByParentHref(parentUUID: string): Observable<string>; + + public findByParent(parentUUID: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<T>>> { + 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 cc55fe68693938f808ae8cb8d84489a6955102dc..57bf64678f727427e51edf94311a5fec79e2b147 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<Community> { return this.halService.getEndpoint(this.linkPath); } - findTop(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Community>>> { + findTop(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Community>>> { 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<Community>(hrefObs) as Observable<RemoteData<PaginatedList<Community>>>; } + + protected getFindByParentHref(parentUUID: string): Observable<string> { + 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 b690492c61e3b697249095b8be68b90cbcc07ae9..98e5f7afaa17f2304beef43d2021200511abe47f 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<any> { super(); } - public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { return observableOf(endpoint); } } @@ -56,7 +56,7 @@ class DummyChangeAnalyzer implements ChangeAnalyzer<NormalizedTestObject> { 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; diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ddf2c3a1d5c79061e152ba647e4490a37b63eb30..ce9a01a569eb1b079e1e860930710277fad859b9 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<T extends CacheableObject> { */ protected responseMsToLive: number; - public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable<string> + public abstract getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable<string> /** * 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<string>} * Return an observable that emits created HREF */ - protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable<string> { + protected getFindAllHref(options: FindListOptions = {}, linkPath?: string): Observable<string> { let result: Observable<string>; const args = []; @@ -70,11 +77,11 @@ export abstract class DataService<T extends CacheableObject> { * 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<string>} * Return an observable that emits created HREF */ - protected getSearchByHref(searchMethod: string, options: FindAllOptions = {}): Observable<string> { + protected getSearchByHref(searchMethod: string, options: FindListOptions = {}): Observable<string> { let result: Observable<string>; const args = []; @@ -94,11 +101,11 @@ export abstract class DataService<T extends CacheableObject> { * * @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<string>} * Return an observable that emits created HREF */ - protected buildHrefFromFindOptions(href$: Observable<string>, args: string[], options: FindAllOptions): Observable<string> { + protected buildHrefFromFindOptions(href$: Observable<string>, args: string[], options: FindListOptions): Observable<string> { 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<T extends CacheableObject> { } } - findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<T>>> { - const hrefObs = this.getFindAllHref(options); + findAll(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<T>>> { + 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<T>(hrefObs) as Observable<RemoteData<PaginatedList<T>>>; + return this.rdbService.buildList<T>(href$) as Observable<RemoteData<PaginatedList<T>>>; } /** @@ -148,7 +157,7 @@ export abstract class DataService<T extends CacheableObject> { findById(id: string): Observable<RemoteData<T>> { 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<T extends CacheableObject> { } /** - * 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<RemoteData<PaginatedList<T>>} * Return an observable that emits response from the server */ - protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<T>>> { + protected searchBy(searchMethod: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<T>>> { const hrefObs = this.getSearchByHref(searchMethod, options); @@ -199,7 +208,7 @@ export abstract class DataService<T extends CacheableObject> { 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 7e71f82bbf0e06c778998ec09ecc2485d711737c..f4999637b344e45c74b1cb3de838fa9080394408 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<any> { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { 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 bb02afbcd179d3a60380c99083134a02d59763bb..002ac3cdbc90835e791b1e2924d213825b94a1ba 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<DSpaceObject> { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { 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 36b8e6b3c5925583fdc18e83fdb13bec662deb0f..44c5f48cfe2d703098d45f98616015de263e234d 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 e616cb8020a348da5c83dc0675c1a5e2f3646fee..b729c0fafe0eada6f7713282cb0856038a218702 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<Item> { /** * 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<string>} */ - public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { let field = 'dc.date.issued'; if (options.sort && options.sort.field) { field = options.sort.field; @@ -247,4 +247,14 @@ export class ItemDataService extends DataService<Item> { map((request: RequestEntry) => request.response) ); } + + /** + * Get the endpoint for an item's bitstreams + * @param itemId + */ + public getBitstreamsEndpoint(itemId: string): Observable<string> { + 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 b15dd6865f6b604baa3758201f9cedcc99214804..662eaa6c7c31e23f04c5bc4a798b36a20bd78dd5 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<MetadataSchema> { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { 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 a30dd5d57e88faf44eb4bdf33c25d7842456679a..627fc4863f053cc14238b47bf127a5aa0f10f587 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<RemoteData<PaginatedList<RelationshipType>>> { + getAllRelationshipTypes(options: FindListOptions): Observable<RemoteData<PaginatedList<RelationshipType>>> { 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.ts b/src/app/core/data/relationship.service.ts index 9bd59ce15114aea817967c3ace7b412b40463a76..e155b1f90b12a906bf527d265a3d348a520123ad 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<Relationship> { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { return this.halService.getEndpoint(linkPath); } @@ -119,6 +93,14 @@ export class RelationshipService extends DataService<Relationship> { ); } + /** + * 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<RestResponse> { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -139,6 +121,10 @@ export class RelationshipService extends DataService<Relationship> { ); } + /** + * 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,6 +141,10 @@ export class RelationshipService extends DataService<Relationship> { }) } + /** + * 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); @@ -229,7 +219,7 @@ export class RelationshipService extends DataService<Relationship> { * @param label * @param options */ - getRelatedItemsByLabel(item: Item, label: string, options?: FindAllOptions): Observable<RemoteData<PaginatedList<Item>>> { + getRelatedItemsByLabel(item: Item, label: string, options?: FindListOptions): Observable<RemoteData<PaginatedList<Item>>> { return this.getItemRelationshipsByLabel(item, label, options).pipe(paginatedRelationsToItems(item.uuid)); } @@ -240,18 +230,18 @@ export class RelationshipService extends DataService<Relationship> { * @param label * @param options */ - getItemRelationshipsByLabel(item: Item, label: string, options?: FindAllOptions): Observable<RemoteData<PaginatedList<Relationship>>> { - let findAllOptions = new FindAllOptions(); + getItemRelationshipsByLabel(item: Item, label: string, options?: FindListOptions): Observable<RemoteData<PaginatedList<Relationship>>> { + 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<Relationship> { ); } + /** + * 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<Relationship> { return this.getItemRelationshipsByLabel(item1, label) .pipe( @@ -314,24 +310,51 @@ export class RelationshipService extends DataService<Relationship> { ); } + /** + * 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<string> { 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<RemoteData<Relationship>> { 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 a7d11089df8082fb5b4c373e7c2531e64b1eab75..ca864f99dee71f9c33f1930dc02a616992c1783f 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 1574111232703f804a127d7fd0e86a5b7c3a9ecc..017e5cf5ee1b02652d99b9c998d26c194e34c2e2 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<ResourcePolicy> { super(); } - getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { + getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { 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 3059ab9948a70966b4c2fb6ce9c193e3a2c2de5c..6148135f50ab269183f9bae94942c8840172a3ab 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 ba395b40ed7860b4e2a334c53059ed9ebdef018c..c1a1b2069bdef7ffbc701f6dfaedb8526a578341 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<Site> {​ /** * Get the endpoint for browsing the site object - * @param {FindAllOptions} options + * @param {FindListOptions} options * @param {Observable<string>} linkPath */ - getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable<string> { + getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable<string> { 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 70ecf3f59e0d0a4a124383d8c33b0bdf810c3d39..81ae532e3b536bea8501805201036050963043fb 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<TDomain extends CacheableObject> extends DataService<TDomain> { - public getBrowseEndpoint(options: FindAllOptions): Observable<string> { + public getBrowseEndpoint(options: FindListOptions): Observable<string> { 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 2014e6120a20a3811f7ca495aac886bc6a9658bf..c8a2a78917198ab325ce699746551d21328085a9 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<Group> { */ isMemberOf(groupName: string): Observable<boolean> { 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 8bca76f7d29798e9f771f333eeccf298e76ab002..b29c491cb09ab486e664ae196e1b33707837f939 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 a93d54db64974b1e86d95c54e8ce81aa6d1d4f69..117cc074ca995f44be17c5048ce1bb720bc2cf2c 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<string> { - return this.getEndpointAt(this.getRootHref(), ...linkPath.split('/')); + public getEndpoint(linkPath: string, startHref?: string): Observable<string> { + return this.getEndpointAt(startHref || this.getRootHref(), ...linkPath.split('/')); } /** diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 43c4aecafefa108f08b6e7d241a759ef35f9384c..47195ed0a11ef682f057930ca77ba739fb3caff7 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<WorkflowItem> { 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 4d388ec5138cc2fdf7e0d5f5d0ddcccd2afb6552..3f782b74a2fd3813c5b7ab117e12a50ea5e993b6 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<WorkspaceItem> { 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 f39b144c6aab45a9c550d2c30a4d72a661021989..cf23bfd74b88c32ff4515e063e1cb9fc5185a3ce 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<T extends CacheableObject> extends DataService<T> { - public getBrowseEndpoint(options: FindAllOptions): Observable<string> { + public getBrowseEndpoint(options: FindListOptions): Observable<string> { 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 6dde05922e48f08f494cfea164165deb946e3c10..6fdbd40c0fce36625b8ad9eb03a6995e89f22841 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<EquatableObject<any>>) { return function decorator(childCo: GenericConstructor<EquatableObject<any>>) { const parentExcludedFields = getExcludedFromEqualsFor(parentCo) || []; @@ -21,6 +25,11 @@ export function inheritEquatable(parentCo: GenericConstructor<EquatableObject<an } } +/** + * Function to mark properties as excluded from the equals method + * @param object The object to exclude the property for + * @param propertyName The name of the property to exclude + */ export function excludeFromEquals(object: any, propertyName: string): any { if (!object) { return; @@ -37,6 +46,10 @@ export function getExcludedFromEqualsFor(constructor: Function): string[] { return excludedFromEquals.get(constructor) || []; } +/** + * Function to save the fields that are to be used for a certain property in the equals method for the given object + * @param fields The fields to use to equate the property of the object + */ export function fieldsForEquals(...fields: string[]): any { return function i(object: any, propertyName: string): any { if (!object) { diff --git a/src/app/core/utilities/equatable.ts b/src/app/core/utilities/equatable.ts index 1029a295ba2f4979a5b67c4da944fb5338be8aab..e022297229b33d3a40e7d44753bd0485b911c2c8 100644 --- a/src/app/core/utilities/equatable.ts +++ b/src/app/core/utilities/equatable.ts @@ -1,6 +1,12 @@ import { getExcludedFromEqualsFor, getFieldsForEquals } from './equals.decorators'; import { hasNoValue, hasValue } from '../../shared/empty.util'; +/** + * Method to compare fields of two objects against each other + * @param object1 The first object for the comparison + * @param object2 The second object for the comparison + * @param fieldList The list of property/field names to compare + */ function equalsByFields(object1, object2, fieldList): boolean { const unequalProperty = fieldList.find((key) => { if (object1[key] === object2[key]) { @@ -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<T> { 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 ae6c3a8914785ee8edbd16892bf4da56ceb50be9..e86ab35e0ee62235de5199e3790518da3b936119 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 @@ </div> </div> <div class="mt-5 w-100"> - <ds-related-entities-search [item]="object" - [relationType]="'isJournalOfPublication'"> - </ds-related-entities-search> + <ds-tabbed-related-entities-search [item]="object" + [relationTypes]="[{ + label: 'isJournalOfPublication', + filter: 'isJournalOfPublication' + }]"> + </ds-tabbed-related-entities-search> </div> </div> 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 4d97868b585787d772addbd07a226dc2c11c3da1..1b23d567f529ba3c5bbe756edc6d6acfd5bcb26d 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 @@ </ds-generic-item-page-field> </div> <div class="col-xs-12 col-md-6"> - <ds-related-items - [parentItem]="object" - [relationType]="'isPersonOfOrgUnit'" - [label]="'relationships.isPersonOf' | translate"> - </ds-related-items> - <ds-related-items - [parentItem]="object" - [relationType]="'isProjectOfOrgUnit'" - [label]="'relationships.isProjectOf' | translate"> - </ds-related-items> <ds-related-items [parentItem]="object" [relationType]="'isPublicationOfOrgUnit'" @@ -49,4 +39,18 @@ </a> </div> </div> + <div class="mt-5 w-100"> + <ds-tabbed-related-entities-search [item]="object" + [relationTypes]="[{ + label: 'isOrgUnitOfPerson', + filter: 'isOrgUnitOfPerson', + configuration: 'person' + }, + { + label: 'isOrgUnitOfProject', + filter: 'isOrgUnitOfProject', + configuration: 'project' + }]"> + </ds-tabbed-related-entities-search> + </div> </div> 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 ff675ab0575efecdb3043233b37363479c279b04..97a3cf416ef252ac26d86c5492b4adc9ef93afad 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 @@ </div> </div> <div class="mt-5 w-100"> - <ds-related-entities-search [item]="object" - [relationType]="'isAuthorOfPublication'"> - </ds-related-entities-search> + <ds-tabbed-related-entities-search [item]="object" + [relationTypes]="[{ + label: 'isAuthorOfPublication', + filter: 'isAuthorOfPublication' + }]"> + </ds-tabbed-related-entities-search> </div> </div> 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 4a2a41aae5157a64fc527fecd993a6ef1a17ba8c..cbddb8d6f9c535f37c5178881ef0e5c49d72333c 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<ItemSearchResult, Item> 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 0000000000000000000000000000000000000000..34b89cc8aacc0981c2df345ffac137382ab7c87d --- /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<OrgUnitInputSuggestionsComponent>; + +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 9d2139621c27c2127719610f8ae1b1afcab5f154..13ae884ccb4d8b7bade6f8e693c84e4ede9684a7 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 } }} </div> <div class="modal-footer justify-content-between"> - <button type="button" class="btn btn-light" (click)="modal.close()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.confirm' | translate }}</button> - <button type="button" class="btn btn-light" (click)="modal.dismiss()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.decline' | translate }}</button> + <button type="button" class="btn btn-light confirm-button" (click)="modal.close()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.confirm' | translate }}</button> + <button type="button" class="btn btn-light decline-button" (click)="modal.dismiss()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.decline' | translate }}</button> </div> 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 4af7b8161a8c6ba6916fc44acfd509fa997d7bab..b5043ea2d67e2b37f18a06f9cc46c4a15417bbc5 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<NameVariantModalComponent>; + 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 34eab47b4700c9872059dbf133d8819a414ef9bd..75817d786a3bfb1495b3c1c3b9ce6713dce16526 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 4c7c3cd0302b89ee4661399498282ba6e9ac7a7a..b2ba10fb989c4f7c2d503bda42eb751cb212b5d2 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 f1b8bd40b58549bd5e38444649da1356531eb982..c8903b19eea4608d472ba1e9a68cf648aeeede11 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 @@ -225,6 +225,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); this.reorderables = []; @@ -324,6 +327,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return this.model.value.pipe(map((list: Array<SearchResult<DSpaceObject>>) => 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/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 0000000000000000000000000000000000000000..8e0c6fc20eceb8ac9823ec134fa0e363a7217dd6 --- /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<DsDynamicDisabledComponent>; + 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 173509acf9791c47c35bcfc54e973a7808a71a4d..490be050efa20c9dd046650b3fbcf9d63d642725 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<any> = new EventEmitter<any>(); 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 eb1f3660e62fa68849955d2310682684b84464b8..0fa2b3e5ed27f07887b376ca8fe33fb2f64e18c6 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 9937fb6010954004ddcc2b6765ca22cc757f602b..4aab8ff32528b8d3d0077a28e33b74c45b7d7d06 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 dbd0938945dbcfa1aa6b77685dac8ea4e5bdfccc..f32836eef16169790168c58bee9da998a9721376 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 a17e042c2b5f38d86d1a265d6558f5ac0a38716e..e26abf94c1de7dc57a3f2a2e8384e7985ff44f72 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.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts index c7bb7104b5b0622485f332885d3259261b593459..9c00d64953da5b29ea5c12f997f9e80a1e2d07b9 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<SearchResult<Item>>) { 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<SearchResult<Item>>) { 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.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts index b47207a957c68fca4db5952cdbaa07cdb56ddde9..8aa3dc3828743342acbd86964e433aa90e09ec45 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 7d9542794b1bd8a875537b7aea0e2971ac624b57..f1d3d0ae7ad8879b24bb891b8598347a7bc2b4f7 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 0000000000000000000000000000000000000000..7dce05f18d8fce6eb72b5813d0fdadd896b2d8ef --- /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 5cccff45912a49926267f87425917d3af86ce283..db3e4ac8b979df915a897785462197cf2fe087da 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 1d3ace320ff663034833f38a539896256d8c55de..d674007da45c190274a9728aa18b5bc6432b95e7 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 72737cfaa93051beb0dcd8e2f5a8e10c4908a659..4938b9859ec0f8aca4ae61cde3d5c1cd1d4f4bbd 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 a53762e8ce04be3dbe42265f40a36d3aace4e609..e0dae08470177aeff57004d66db3a92609d465c4 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 86ec5986c65da4fbd419f6580dd67b8d53157e1d..b0e89b80b5dcf177af0c1670a7b034c78d2a1b1c 100644 --- a/src/app/core/dspace-rest-v2/endpoint-mocking-rest.service.ts +++ b/src/app/shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service.ts @@ -1,12 +1,12 @@ import { HttpClient, HttpHeaders } from '@angular/common/http' import { Inject, Injectable } from '@angular/core'; import { Observable, of as observableOf } from 'rxjs'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { isEmpty } from '../../shared/empty.util'; -import { RestRequestMethod } from '../data/rest-request-method'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { isEmpty } from '../../empty.util'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; -import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; -import { DSpaceRESTv2Service, HttpOptions } from './dspace-rest-v2.service'; +import { DSpaceRESTV2Response } from '../../../core/dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Service, HttpOptions } from '../../../core/dspace-rest-v2/dspace-rest-v2.service'; import { MOCK_RESPONSE_MAP, MockResponseMap } from './mocks/mock-response-map'; import * as URL from 'url-parse'; @@ -14,6 +14,8 @@ import * as URL from 'url-parse'; * Service to access DSpace's REST API. * * If a URL is found in this.mockResponseMap, it returns the mock response instead + * This service can be used for mocking REST responses when developing new features + * This is especially useful, when a REST endpoint is broken or does not exist yet */ @Injectable() export class EndpointMockingRestService extends DSpaceRESTv2Service { diff --git a/src/app/core/dspace-rest-v2/mocks/mock-response-map.ts b/src/app/shared/mocks/dspace-rest-v2/mocks/mock-response-map.ts similarity index 82% rename from src/app/core/dspace-rest-v2/mocks/mock-response-map.ts rename to src/app/shared/mocks/dspace-rest-v2/mocks/mock-response-map.ts index cea526b078f313c2248ec302844da2f39e1c1344..1d1b47ee78d7e2bf6e459af783c68dd9f925c158 100644 --- a/src/app/core/dspace-rest-v2/mocks/mock-response-map.ts +++ b/src/app/shared/mocks/dspace-rest-v2/mocks/mock-response-map.ts @@ -1,5 +1,5 @@ import { InjectionToken } from '@angular/core'; -import mockSubmissionResponse from '../mocks/mock-submission-response.json'; +import mockSubmissionResponse from './mock-submission-response.json'; export class MockResponseMap extends Map<string, any> {}; 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 4455cedeb93b9195636ee672c808f438da63a45c..92d85d03f452c13377c58d02c008eec8241f985c 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)"> <input *ngIf="!selectionConfig.repeatable" class="form-check-input" type="radio" [name]="'radio' + index" [id]="'object' + index" [checked]="selected$ | async" - (click)="selectRadio(!checked, object)"> -</ng-container> \ No newline at end of file + (click)="selectRadio(!checked)"> +</ng-container> 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 0000000000000000000000000000000000000000..25cf6b15f0eff998ffd40cb552c32e451b5e4fcd --- /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<SelectableListItemControlComponent>; + 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 735f06fe5ff0737b0269de50fc8b7383d8afe5e8..d1536c56e6f8e46d0232dd49c2afb8f02a7022c9 100644 --- a/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.ts +++ b/src/app/shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component.ts @@ -49,29 +49,30 @@ export class SelectableListItemControlComponent implements OnInit { }) } - selectCheckbox(value: boolean, object: ListableObject) { + selectCheckbox(value: boolean) { if (value) { - this.selectionService.selectSingle(this.selectionConfig.listId, object); + this.selectionService.selectSingle(this.selectionConfig.listId, this.object); } else { - this.selectionService.deselectSingle(this.selectionConfig.listId, object); + this.selectionService.deselectSingle(this.selectionConfig.listId, this.object); } } - selectRadio(value: boolean, object: ListableObject) { - const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId); - selected$.pipe( - take(1), - map((selected) => selected ? selected.selection : []) - ).subscribe((selection) => { - // First deselect any existing selections, this is a radio button - selection.forEach((selectedObject) => { - this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject); - this.deselectObject.emit(selectedObject); - }); - if (value) { - this.selectionService.selectSingle(this.selectionConfig.listId, object); - this.selectObject.emit(object); - } - }); + selectRadio(value: boolean) { + if (value) { + const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId); + selected$.pipe( + take(1), + map((selected) => selected ? selected.selection : []) + ).subscribe((selection) => { + // First deselect any existing selections, this is a radio button + selection.forEach((selectedObject) => { + this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject); + this.deselectObject.emit(selectedObject); + }); + this.selectionService.selectSingle(this.selectionConfig.listId, this.object); + this.selectObject.emit(this.object); + } + ); + } } } diff --git a/src/app/shared/object-list/selectable-list/selectable-list.actions.ts b/src/app/shared/object-list/selectable-list/selectable-list.actions.ts index 7b868c99ff753793c77de9117ad6ad4c3b345d93..3dedf7e6a2363695c81799df3f4ed4169edab015 100644 --- a/src/app/shared/object-list/selectable-list/selectable-list.actions.ts +++ b/src/app/shared/object-list/selectable-list/selectable-list.actions.ts @@ -19,6 +19,9 @@ export const SelectableListActionTypes = { DESELECT_ALL: type('dspace/selectable-lists/DESELECT_ALL') }; +/** + * Abstract action class for actions on selectable lists + */ /* tslint:disable:max-classes-per-file */ export abstract class SelectableListAction implements Action { // tslint:disable-next-line:no-shadowed-variable @@ -27,7 +30,7 @@ export abstract class SelectableListAction implements Action { } /** - * Used to select an item in a the selectable list + * Action to select objects in a the selectable list */ export class SelectableListSelectAction extends SelectableListAction { payload: ListableObject[]; @@ -37,7 +40,9 @@ export class SelectableListSelectAction extends SelectableListAction { this.payload = objects; } } - +/** + * Action to select a single object in a the selectable list + */ export class SelectableListSelectSingleAction extends SelectableListAction { payload: { object: ListableObject, @@ -49,6 +54,9 @@ export class SelectableListSelectSingleAction extends SelectableListAction { } } +/** + * Action to deselect objects in a the selectable list + */ export class SelectableListDeselectSingleAction extends SelectableListAction { payload: ListableObject; @@ -58,6 +66,9 @@ export class SelectableListDeselectSingleAction extends SelectableListAction { } } +/** + * Action to deselect a single object in a the selectable list + */ export class SelectableListDeselectAction extends SelectableListAction { payload: ListableObject[]; @@ -67,6 +78,9 @@ export class SelectableListDeselectAction extends SelectableListAction { } } +/** + * Action to set a new or overwrite an existing selection + */ export class SelectableListSetSelectionAction extends SelectableListAction { payload: ListableObject[]; @@ -76,6 +90,9 @@ export class SelectableListSetSelectionAction extends SelectableListAction { } } +/** + * Action to deselect all currently selected objects + */ export class SelectableListDeselectAllAction extends SelectableListAction { constructor(id: string) { super(SelectableListActionTypes.DESELECT_ALL, id); diff --git a/src/app/shared/object-list/selectable-list/selectable-list.reducer.spec.ts b/src/app/shared/object-list/selectable-list/selectable-list.reducer.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..29b60cdc02c99e5e3d62687954bf7dd26b7c2d5b --- /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 927e20ff21a80316be4af852653f0797b0ecd8a5..4c7251e563d37488ce305b0b51de14f8d44cfa0f 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 0000000000000000000000000000000000000000..7699541fe352e8fecf378879eda8adbf45c7e65f --- /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<SelectableListsState> = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + }); + beforeEach(async(() => { + TestBed.configureTestingModule({ + + providers: [ + { + provide: Store, useValue: store + } + ] + }).compileComponents(); + })); + + beforeEach(() => { + service = new SelectableListService(store); + }); + + describe('when the selectSingle method is triggered', () => { + beforeEach(() => { + service.selectSingle(listID1, selected3); + }); + + it('SelectableListSelectSingleAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListSelectSingleAction(listID1, selected3)); + }); + + }); + + describe('when the select method is triggered', () => { + beforeEach(() => { + service.select(listID1, [selected1, selected4]); + }); + + it('SelectableListSelectAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListSelectAction(listID1, [selected1, selected4])); + }); + }); + + describe('when the deselectSingle method is triggered', () => { + beforeEach(() => { + service.deselectSingle(listID1, selected4); + }); + + it('SelectableListDeselectSingleAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListDeselectSingleAction(listID1, selected4)); + }); + + }); + + describe('when the deselect method is triggered', () => { + beforeEach(() => { + service.deselect(listID1, [selected2, selected4]); + }); + + it('SelectableListDeselectAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SelectableListDeselectAction(listID1, [selected2, selected4])); + }); + }); + +}); diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 03081e909e0fcba012d41e25a8af1247775db984..74ed4bb913e7179523e2d64401ed387468dceab2 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/utils/relation-query.utils.spec.ts b/src/app/shared/utils/relation-query.utils.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f70e90442281c6ff4f4fb26cd2bdf582128c87e4 --- /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 0000000000000000000000000000000000000000..610fd8756d6bb3afd4582c9f3b704f5035bf16b0 --- /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/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index 0c16dc475543ce2ae0b4e86969a6cb95b2f03fbc..004e013164ba20b19a5d2b9773ac8d36afe89830 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 0bd24cc3049da618f774cdb3262ee5006fdaa68c..88bc4904d3a62d2623fc85f272ce750451c31110 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -16,7 +16,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 +185,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { map((collectionRD: RemoteData<Collection>) => collectionRD.payload.name) ); - const findOptions: FindAllOptions = { + const findOptions: FindListOptions = { elementsPerPage: 1000 }; 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 99d92d2af812da515a4a05a785bdb1ed3054be7d..907f70b9411ac6feac7cafb58b47f93c0fad5c6c 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 @@ <div class="col-12 col-md-2 d-flex flex-md-column justify-content-between"> <ds-metadata-field-wrapper> - <ds-thumbnail [thumbnail]="item.getThumbnail() | async"></ds-thumbnail> + <ds-thumbnail [thumbnail]="object.getThumbnail() | async"></ds-thumbnail> </ds-metadata-field-wrapper> <div> <a class="btn btn-secondary" diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index 1b8a283da9fed56fefd81a92b9fd0c53d5087aa7..089511804edad61a39a36d2b8ed8d14335c3e411 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -61,7 +61,10 @@ <div class="container search-container"> <h3 class="h2">{{"item.page.journal.search.title" | translate}}</h3> </div> - <ds-related-entities-search [item]="object" - [relationType]="'isJournalOfPublication'"> - </ds-related-entities-search> + <ds-tabbed-related-entities-search [item]="object" + [relationTypes]="[{ + label: 'isJournalOfPublication', + filter: 'isJournalOfPublication' + }]"> + </ds-tabbed-related-entities-search> </div> 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 15529a1bd537b76dc2a2f7f79cea990f7fa9b97b..ee78d9c653696608464157ec202e26e49366faf2 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 @@ <div class="relationships-item-page"> <div class="container"> <div class="row"> - <ds-related-items - class="col-12 col-md-4" - [parentItem]="object" - [relationType]="'isPersonOfOrgUnit'" - [label]="'relationships.isPersonOf' | translate"> - </ds-related-items> - <ds-related-items - class="col-12 col-md-4" - [parentItem]="object" - [relationType]="'isProjectOfOrgUnit'" - [label]="'relationships.isProjectOf' | translate"> - </ds-related-items> <ds-related-items class="col-12 col-md-4" [parentItem]="object" @@ -74,3 +62,20 @@ </div> </div> </div> +<div class="container"> + <div class="row"> + <ds-tabbed-related-entities-search class="w-100" + [item]="object" + [relationTypes]="[{ + label: 'isOrgUnitOfPerson', + filter: 'isOrgUnitOfPerson', + configuration: 'person' + }, + { + label: 'isOrgUnitOfProject', + filter: 'isOrgUnitOfProject', + configuration: 'project' + }]"> + </ds-tabbed-related-entities-search> + </div> +</div> 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 54651aede0f5c7487ed59528d20b1a3d1fb84af5..4a1d2516da01be6d5400d963ec22fa04f9ce821b 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 bb5cb1b7871b340567f8737b8db27110f0320eec..1679f9354da9a1b8488a57c8b0c08df4ab4b5f17 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 @@ <div class="container search-container"> <h3 class="h2">{{"item.page.person.search.title" | translate}}</h3> </div> -<ds-related-entities-search [item]="object" - [relationType]="'isAuthorOfPublication'"> -</ds-related-entities-search> +<ds-tabbed-related-entities-search [item]="object" + [relationTypes]="[{ + label: 'isAuthorOfPublication', + filter: 'isAuthorOfPublication' + }]"> +</ds-tabbed-related-entities-search> </div> diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 028815d958b8e9b987c057177c66ea52348524c0..e63ae024ed0928067e74dfa6fb45b0b9d6b2e801 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 83e6e44e792e9fef1ed4e2a6f1aead2bcd9097a2..de53de31c4f7bb0e6c65b745d637e8cd15d57266 100644 --- a/webpack/webpack.test.js +++ b/webpack/webpack.test.js @@ -161,16 +161,16 @@ module.exports = function (env) { } }, { - loader: 'resolve-url-loader', + loader: 'sass-loader', options: { - sourceMap: true + sourceMap: true, + includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } }, { - loader: 'sass-loader', + loader: 'resolve-url-loader', options: { - sourceMap: true, - includePaths: [projectRoot('./'), path.join(themePath, 'styles')] + sourceMap: true } }, { @@ -199,18 +199,18 @@ module.exports = function (env) { } }, { - loader: 'resolve-url-loader', + loader: 'sass-loader', options: { - sourceMap: true + sourceMap: true, + includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } }, { - loader: 'sass-loader', + loader: 'resolve-url-loader', options: { - sourceMap: true, - includePaths: [projectRoot('./'), path.join(themePath, 'styles')] + sourceMap: true } - } + }, ] }, diff --git a/yarn.lock b/yarn.lock index 884f820c1d1edd3fdc6dbfe9b014e9bbe0f7ee89..98b39370e444ffd1fd376c4623c9e79e2bc6ae39 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"