diff --git a/package.json b/package.json index 8d3c936212..93968b123b 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@angular/compiler": "~8.2.14", "@angular/core": "~8.2.14", "@angular/forms": "~8.2.14", + "@angular/material": "8.2.3", "@angular/platform-browser": "~8.2.14", "@angular/platform-browser-dynamic": "~8.2.14", "@angular/platform-server": "~8.2.14", diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 2e3914fe03..57b016bc6e 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -4,7 +4,6 @@ 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 @@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree'; imports: [ CommonModule, SharedModule, - CommunityListPageRoutingModule, - CdkTreeModule, + CommunityListPageRoutingModule ], declarations: [ CommunityListPageComponent, diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 67d7db5c5d..bee23758a4 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -2,25 +2,27 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CdkTreeModule } from '@angular/cdk/tree'; +import { DragDropModule } from '@angular/cdk/drag-drop'; + import { NouisliderModule } from 'ng2-nouislider'; - import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; - import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core'; - import { NgxPaginationModule } from 'ngx-pagination'; +import { FileUploadModule } from 'ng2-file-upload'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { TextMaskModule } from 'angular2-text-mask'; +import { MomentModule } from 'ngx-moment'; +import { TooltipModule } from 'ngx-bootstrap'; + import { ComcolRoleComponent } from './comcol-forms/edit-comcol-page/comcol-role/comcol-role.component'; import { PublicationListElementComponent } from './object-list/item-list-element/item-types/publication/publication-list-element.component'; - -import { FileUploadModule } from 'ng2-file-upload'; - -import { InfiniteScrollModule } from 'ngx-infinite-scroll'; - import { EnumKeysPipe } from './utils/enum-keys-pipe'; import { FileSizePipe } from './utils/file-size-pipe'; import { SafeUrlPipe } from './utils/safe-url-pipe'; import { ConsolePipe } from './utils/console.pipe'; - import { CollectionListElementComponent } from './object-list/collection-list-element/collection-list-element.component'; import { CommunityListElementComponent } from './object-list/community-list-element/community-list-element.component'; import { SearchResultListElementComponent } from './object-list/search-result-list-element/search-result-list-element.component'; @@ -53,9 +55,6 @@ import { dsDynamicFormControlMapFn } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; -import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; -import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; -import { TextMaskModule } from 'angular2-text-mask'; import { DragClickDirective } from './utils/drag-click.directive'; import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatableComponent } from './truncatable/truncatable.component'; @@ -95,13 +94,11 @@ import { EmphasizePipe } from './utils/emphasize.pipe'; import { InputSuggestionsComponent } from './input-suggestions/input-suggestions.component'; import { CapitalizePipe } from './utils/capitalize.pipe'; import { ObjectKeysPipe } from './utils/object-keys-pipe'; -import { MomentModule } from 'ngx-moment'; import { AuthorityConfidenceStateDirective } from './authority-confidence/authority-confidence-state.directive'; import { MenuModule } from './menu/menu.module'; import { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { PlainTextMetadataListElementComponent } from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; import { ItemMetadataListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; -import { TooltipModule } from 'ngx-bootstrap'; import { MetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/metadata-representation-list-element.component'; import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component'; import { CreateComColPageComponent } from './comcol-forms/create-comcol-page/create-comcol-page.component'; @@ -177,7 +174,6 @@ import { SelectableListItemControlComponent } from './object-collection/shared/s import { DsDynamicLookupRelationExternalSourceTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component'; import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; -import { DragDropModule } from '@angular/cdk/drag-drop'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { ItemVersionsComponent } from './item/item-versions/item-versions.component'; import { SortablejsModule } from 'ngx-sortablejs'; @@ -202,6 +198,7 @@ import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/reso import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver'; import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component'; import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; +import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -223,7 +220,8 @@ const MODULES = [ MomentModule, TextMaskModule, MenuModule, - DragDropModule + DragDropModule, + CdkTreeModule ]; const ROOT_MODULES = [ @@ -386,7 +384,8 @@ const COMPONENTS = [ ResourcePolicyFormComponent, EpersonGroupListComponent, EpersonSearchBoxComponent, - GroupSearchBoxComponent + GroupSearchBoxComponent, + VocabularyTreeviewComponent ]; const ENTRY_COMPONENTS = [ @@ -459,7 +458,8 @@ const ENTRY_COMPONENTS = [ ClaimedTaskActionsApproveComponent, ClaimedTaskActionsRejectComponent, ClaimedTaskActionsReturnToPoolComponent, - ClaimedTaskActionsEditMetadataComponent + ClaimedTaskActionsEditMetadataComponent, + VocabularyTreeviewComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts new file mode 100644 index 0000000000..60473c77ca --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview-node.model.ts @@ -0,0 +1,44 @@ +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { PageInfo } from '../../core/shared/page-info.model'; + +export const LOAD_MORE = 'LOAD_MORE'; +export const LOAD_MORE_ROOT = 'LOAD_MORE_ROOT'; +export const LOAD_MORE_NODE: any = { id: LOAD_MORE }; +export const LOAD_MORE_ROOT_NODE: any = { id: LOAD_MORE_ROOT }; + +/* tslint:disable:max-classes-per-file */ +/** Nested node */ +export class TreeviewNode { + childrenChange = new BehaviorSubject([]); + + get children(): TreeviewNode[] { + return this.childrenChange.value; + } + + constructor(public item: VocabularyEntryDetail, + public hasChildren = false, + public pageInfo: PageInfo = new PageInfo(), + public loadMoreParentItem: VocabularyEntryDetail | null = null, + public isSearchNode = false, + public isInInitValueHierarchy = false) { + } + + updatePageInfo(pageInfo: PageInfo) { + this.pageInfo = pageInfo + } +} + +/** Flat node with expandable and level information */ +export class TreeviewFlatNode { + constructor(public item: VocabularyEntryDetail, + public level = 1, + public expandable = false, + public pageInfo: PageInfo = new PageInfo(), + public loadMoreParentItem: VocabularyEntryDetail | null = null, + public isSearchNode = false, + public isInInitValueHierarchy = false) { + } +} + +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html new file mode 100644 index 0000000000..9084013c4f --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html @@ -0,0 +1,77 @@ + + diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.scss b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.scss new file mode 100644 index 0000000000..39050ff85b --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.scss @@ -0,0 +1,7 @@ +::ng-deep .tooltip-inner { + text-align:left; +} + +cdk-tree .btn:focus { + box-shadow: none !important; +} diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts new file mode 100644 index 0000000000..39f4274280 --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.spec.ts @@ -0,0 +1,221 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { CdkTreeModule } from '@angular/cdk/tree'; + +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { createTestComponent } from '../testing/utils.test'; +import { VocabularyTreeviewComponent } from './vocabulary-treeview.component'; +import { VocabularyTreeviewService } from './vocabulary-treeview.service'; +import { CoreState } from '../../core/core.reducers'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { TreeviewFlatNode } from './vocabulary-treeview-node.model'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { PageInfo } from '../../core/shared/page-info.model'; + +describe('VocabularyTreeviewComponent test suite', () => { + + let comp: VocabularyTreeviewComponent; + let compAsAny: any; + let fixture: ComponentFixture; + + const item = new VocabularyEntryDetail(); + item.id = 'node1'; + const item2 = new VocabularyEntryDetail(); + item2.id = 'node2'; + const emptyNodeMap = new Map(); + const storedNodeMap = new Map().set('test', new TreeviewFlatNode(item2)); + const nodeMap = new Map().set('test', new TreeviewFlatNode(item)); + const vocabularyOptions = new VocabularyOptions('vocabularyTest', 'metadata.test', '123456'); + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + const vocabularyTreeviewServiceStub = jasmine.createSpyObj('VocabularyTreeviewService', { + initialize: jasmine.createSpy('initialize'), + getData: jasmine.createSpy('getData'), + loadMore: jasmine.createSpy('loadMore'), + loadMoreRoot: jasmine.createSpy('loadMoreRoot'), + isSearching: jasmine.createSpy('isSearching'), + searchByQuery: jasmine.createSpy('searchByQuery'), + restoreNodes: jasmine.createSpy('restoreNodes'), + cleanTree: jasmine.createSpy('cleanTree'), + }); + + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + pipe: observableOf(true), + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CdkTreeModule, + TranslateModule.forRoot() + ], + declarations: [ + VocabularyTreeviewComponent, + TestComponent + ], + providers: [ + { provide: VocabularyTreeviewService, useValue: vocabularyTreeviewServiceStub }, + { provide: NgbActiveModal, useValue: modalStub }, + { provide: Store, useValue: store }, + ChangeDetectorRef, + VocabularyTreeviewComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + vocabularyTreeviewServiceStub.getData.and.returnValue(observableOf([])); + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create VocabularyTreeviewComponent', inject([VocabularyTreeviewComponent], (app: VocabularyTreeviewComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(VocabularyTreeviewComponent); + comp = fixture.componentInstance; + compAsAny = comp; + vocabularyTreeviewServiceStub.getData.and.returnValue(observableOf([])); + vocabularyTreeviewServiceStub.isSearching.and.returnValue(observableOf(false)); + comp.vocabularyOptions = vocabularyOptions; + comp.selectedItem = null; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it('should should init component properly', () => { + fixture.detectChanges(); + expect(comp.dataSource.data).toEqual([]); + expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalled(); + }); + + it('should should init component properly with init value as FormFieldMetadataValueObject', () => { + comp.selectedItem = new FormFieldMetadataValueObject('test', null, 'auth001'); + fixture.detectChanges(); + expect(comp.dataSource.data).toEqual([]); + expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), 'auth001'); + }); + + it('should should init component properly with init value as AuthorityEntry', () => { + const authority = new VocabularyEntryDetail(); + authority.id = 'auth001'; + comp.selectedItem = authority; + fixture.detectChanges(); + expect(comp.dataSource.data).toEqual([]); + expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), 'auth001'); + }); + + it('should call loadMore function', () => { + comp.loadMore(item); + fixture.detectChanges(); + expect(vocabularyTreeviewServiceStub.loadMore).toHaveBeenCalledWith(item); + }); + + it('should call loadMoreRoot function', () => { + const node = new TreeviewFlatNode(item); + comp.loadMoreRoot(node); + fixture.detectChanges(); + expect(vocabularyTreeviewServiceStub.loadMoreRoot).toHaveBeenCalledWith(node); + }); + + it('should call loadChildren function', () => { + const node = new TreeviewFlatNode(item); + comp.loadChildren(node); + fixture.detectChanges(); + expect(vocabularyTreeviewServiceStub.loadMore).toHaveBeenCalledWith(node.item, true); + }); + + it('should emit select event', () => { + spyOn(comp, 'onSelect'); + comp.onSelect(item); + + expect(comp.onSelect).toHaveBeenCalledWith(item); + }); + + it('should call searchByQuery function and set storedNodeMap properly', () => { + comp.searchText = 'test search'; + comp.nodeMap.set('test', new TreeviewFlatNode(item)); + comp.search(); + fixture.detectChanges(); + expect(vocabularyTreeviewServiceStub.searchByQuery).toHaveBeenCalledWith('test search'); + expect(comp.storedNodeMap).toEqual(nodeMap); + expect(comp.nodeMap).toEqual(emptyNodeMap); + }); + + it('should call searchByQuery function and not set storedNodeMap', () => { + comp.searchText = 'test search'; + comp.nodeMap.set('test', new TreeviewFlatNode(item)); + comp.storedNodeMap.set('test', new TreeviewFlatNode(item2)); + comp.search(); + fixture.detectChanges(); + expect(vocabularyTreeviewServiceStub.searchByQuery).toHaveBeenCalledWith('test search'); + expect(comp.storedNodeMap).toEqual(storedNodeMap); + expect(comp.nodeMap).toEqual(emptyNodeMap); + }); + + it('should call restoreNodes function and restore nodeMap properly', () => { + comp.nodeMap.set('test', new TreeviewFlatNode(item)); + comp.storedNodeMap.set('test', new TreeviewFlatNode(item2)); + comp.reset(); + fixture.detectChanges(); + expect(vocabularyTreeviewServiceStub.restoreNodes).toHaveBeenCalled(); + expect(comp.storedNodeMap).toEqual(emptyNodeMap); + expect(comp.nodeMap).toEqual(storedNodeMap); + expect(comp.searchText).toEqual(''); + }); + + it('should clear search string', () => { + comp.nodeMap.set('test', new TreeviewFlatNode(item)); + comp.reset(); + fixture.detectChanges(); + expect(comp.storedNodeMap).toEqual(emptyNodeMap); + expect(comp.nodeMap).toEqual(nodeMap); + expect(comp.searchText).toEqual(''); + }); + + it('should call cleanTree method on destroy', () => { + compAsAny.ngOnDestroy(); + expect(vocabularyTreeviewServiceStub.cleanTree).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', 'metadata.test', '123456'); + preloadLevel = 2; + +} diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts new file mode 100644 index 0000000000..3236824376 --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.ts @@ -0,0 +1,296 @@ +import { FlatTreeControl } from '@angular/cdk/tree'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { filter, find, startWith } from 'rxjs/operators'; +import { Observable, Subscription } from 'rxjs'; +import { select, Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; + +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; +import { isAuthenticated } from '../../core/auth/selectors'; +import { CoreState } from '../../core/core.reducers'; +import { VocabularyTreeviewService } from './vocabulary-treeview.service'; +import { LOAD_MORE, LOAD_MORE_ROOT, TreeviewFlatNode, TreeviewNode } from './vocabulary-treeview-node.model'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { PageInfo } from '../../core/shared/page-info.model'; + +/** + * Component that show a hierarchical vocabulary in a tree view + */ +@Component({ + selector: 'ds-authority-treeview', + templateUrl: './vocabulary-treeview.component.html', + styleUrls: ['./vocabulary-treeview.component.scss'] +}) +export class VocabularyTreeviewComponent implements OnDestroy, OnInit { + + /** + * The {@link VocabularyOptions} object + */ + @Input() vocabularyOptions: VocabularyOptions; + + /** + * Representing how many tree level load at initialization + */ + @Input() preloadLevel = 2; + + /** + * The vocabulary entry already selected, if any + */ + @Input() selectedItem: any = null; + + /** + * Contain a descriptive message for this vocabulary retrieved from i18n files + */ + description: Observable; + + /** + * A map containing the current node showed by the tree + */ + nodeMap = new Map(); + + /** + * A map containing all the node already created for building the tree + */ + storedNodeMap = new Map(); + + /** + * Flat tree control object. Able to expand/collapse a subtree recursively for flattened tree. + */ + treeControl: FlatTreeControl; + + /** + * Tree flattener object. Able to convert a normal type of node to node with children and level information. + */ + treeFlattener: MatTreeFlattener; + + /** + * Flat tree data source + */ + dataSource: MatTreeFlatDataSource; + + /** + * The content of the search box used to search for a vocabulary entry + */ + searchText: string; + + /** + * A boolean representing if a search operation is pending + */ + searching: Observable; + + /** + * An event fired when a vocabulary entry is selected. + * Event's payload equals to {@link VocabularyEntryDetail} selected. + */ + @Output() select: EventEmitter = new EventEmitter(null); + + /** + * A boolean representing if user is authenticated + */ + private isAuthenticated: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {NgbActiveModal} activeModal + * @param {VocabularyTreeviewService} vocabularyTreeviewService + * @param {Store} store + * @param {TranslateService} translate + */ + constructor( + public activeModal: NgbActiveModal, + private vocabularyTreeviewService: VocabularyTreeviewService, + private store: Store, + private translate: TranslateService + ) { + this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, + this.isExpandable, this.getChildren); + + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + } + + /** + * Get children for a given node + * @param node The node for which to retrieve the children + */ + getChildren = (node: TreeviewNode): Observable => node.childrenChange; + + /** + * Transform a {@link TreeviewNode} to {@link TreeviewFlatNode} + * @param node The node to transform + * @param level The node level information + */ + transformer = (node: TreeviewNode, level: number) => { + const existingNode = this.nodeMap.get(node.item.id); + + if (existingNode && existingNode.item.id !== LOAD_MORE && existingNode.item.id !== LOAD_MORE_ROOT) { + return existingNode; + } + + const newNode: TreeviewFlatNode = new TreeviewFlatNode( + node.item, + level, + node.hasChildren, + node.pageInfo, + node.loadMoreParentItem, + node.isSearchNode, + node.isInInitValueHierarchy + ); + this.nodeMap.set(node.item.id, newNode); + + if ((((level + 1) < this.preloadLevel) && newNode.expandable) || newNode.isSearchNode || newNode.isInInitValueHierarchy) { + if (!newNode.isSearchNode) { + this.loadChildren(newNode); + } + this.treeControl.expand(newNode); + } + return newNode; + }; + + /** + * Get tree level for a given node + * @param node The node for which to retrieve the level + */ + getLevel = (node: TreeviewFlatNode) => node.level; + + /** + * Check if a given node is expandable + * @param node The node for which to retrieve the information + */ + isExpandable = (node: TreeviewFlatNode) => node.expandable; + + /** + * Check if a given node has children + * @param _nodeData The node for which to retrieve the information + */ + hasChildren = (_: number, _nodeData: TreeviewFlatNode) => _nodeData.expandable; + + /** + * Check if a given node has more children to load + * @param _nodeData The node for which to retrieve the information + */ + isLoadMore = (_: number, _nodeData: TreeviewFlatNode) => _nodeData.item.id === LOAD_MORE; + + /** + * Check if there are more node to load at root level + * @param _nodeData The node for which to retrieve the information + */ + isLoadMoreRoot = (_: number, _nodeData: TreeviewFlatNode) => _nodeData.item.id === LOAD_MORE_ROOT; + + /** + * Initialize the component, setting up the data to build the tree + */ + ngOnInit(): void { + this.subs.push( + this.vocabularyTreeviewService.getData().subscribe((data) => { + this.dataSource.data = data; + }) + ); + + const descriptionLabel = 'tree.description.' + this.vocabularyOptions.name; + this.description = this.translate.get(descriptionLabel).pipe( + filter((msg) => msg !== descriptionLabel), + startWith('') + ); + + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + + this.searching = this.vocabularyTreeviewService.isSearching(); + + this.isAuthenticated.pipe( + find((isAuth) => isAuth) + ).subscribe(() => { + const valueId: string = (this.selectedItem) ? (this.selectedItem.authority || this.selectedItem.id) : null; + this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), valueId); + }); + } + + /** + * Expand a node whose children are not loaded + * @param item The VocabularyEntryDetail for which to load more nodes + */ + loadMore(item: VocabularyEntryDetail) { + this.vocabularyTreeviewService.loadMore(item); + } + + /** + * Expand the root node whose children are not loaded + * @param node The TreeviewFlatNode for which to load more nodes + */ + loadMoreRoot(node: TreeviewFlatNode) { + this.vocabularyTreeviewService.loadMoreRoot(node); + } + + /** + * Load children nodes for a node + * @param node The TreeviewFlatNode for which to load children nodes + */ + loadChildren(node: TreeviewFlatNode) { + this.vocabularyTreeviewService.loadMore(node.item, true); + } + + /** + * Method called on entry select + * Emit a new select Event + */ + onSelect(item: VocabularyEntryDetail) { + this.select.emit(item); + this.activeModal.close(item); + } + + /** + * Search for a vocabulary entry by query + */ + search() { + if (isNotEmpty(this.searchText)) { + if (isEmpty(this.storedNodeMap)) { + this.storedNodeMap = this.nodeMap; + } + this.nodeMap = new Map(); + this.vocabularyTreeviewService.searchByQuery(this.searchText); + } + } + + /** + * Check if search box contains any text + */ + isSearchEnabled() { + return isNotEmpty(this.searchText); + } + + + /** + * Reset tree resulting from a previous search + */ + reset() { + if (isNotEmpty(this.storedNodeMap)) { + this.nodeMap = this.storedNodeMap; + this.storedNodeMap = new Map(); + this.vocabularyTreeviewService.restoreNodes(); + } + + this.searchText = ''; + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.vocabularyTreeviewService.cleanTree(); + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts new file mode 100644 index 0000000000..32ef635792 --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -0,0 +1,329 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { TestScheduler } from 'rxjs/testing'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { getTestScheduler, hot } from 'jasmine-marbles'; + +import { VocabularyTreeviewService } from './vocabulary-treeview.service'; +import { VocabularyService } from '../../core/submission/vocabularies/vocabulary.service'; +import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { LOAD_MORE_NODE, LOAD_MORE_ROOT_NODE, TreeviewFlatNode, TreeviewNode } from './vocabulary-treeview-node.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; + +describe('VocabularyTreeviewService test suite', () => { + + let scheduler: TestScheduler; + let service: VocabularyTreeviewService; + let serviceAsAny: any; + let loadMoreNode: TreeviewNode; + let loadMoreRootNode: TreeviewNode; + let loadMoreRootFlatNode: TreeviewFlatNode; + let item: VocabularyEntryDetail; + let itemNode: TreeviewNode; + let item2: VocabularyEntryDetail; + let itemNode2: TreeviewNode; + let item3: VocabularyEntryDetail; + let itemNode3: TreeviewNode; + let item5: VocabularyEntryDetail; + let itemNode5: TreeviewNode; + let child: VocabularyEntryDetail; + let childNode: TreeviewNode; + let child2: VocabularyEntryDetail; + let childNode2: TreeviewNode; + let child3: VocabularyEntryDetail; + let childNode3: TreeviewNode; + let searchItemNode: TreeviewNode; + let searchChildNode: TreeviewNode; + let searchChildNode3: TreeviewNode; + let initValueChildNode: TreeviewNode; + let initValueChildNode2: TreeviewNode; + + let treeNodeList: TreeviewNode[]; + let treeNodeListWithChildren: TreeviewNode[]; + let treeNodeListWithLoadMore: TreeviewNode[]; + let treeNodeListWithLoadMoreRoot: TreeviewNode[]; + let nodeMap: Map; + let nodeMapWithChildren: Map; + let searchNodeMap: Map; + let vocabularyOptions; + + const vocabularyServiceStub = jasmine.createSpyObj('VocabularyService', { + getVocabularyEntriesByValue: jasmine.createSpy('getVocabularyEntriesByValue'), + getEntryDetailParent: jasmine.createSpy('getEntryDetailParent'), + findEntryDetailByValue: jasmine.createSpy('findEntryDetailByValue'), + searchTopEntries: jasmine.createSpy('searchTopEntries'), + getEntryDetailChildren: jasmine.createSpy('getEntryDetailChildren') + }); + + function init() { + + loadMoreNode = new TreeviewNode(LOAD_MORE_NODE, false, new PageInfo(), item); + loadMoreRootNode = new TreeviewNode(LOAD_MORE_ROOT_NODE, false, new PageInfo(), null); + loadMoreRootFlatNode = new TreeviewFlatNode(LOAD_MORE_ROOT_NODE, 1, false, new PageInfo(), null); + item = new VocabularyEntryDetail(); + item.id = item.value = item.display = 'root1'; + item.otherInformation = { children: 'root1-child1::root1-child2', id: 'root1' }; + itemNode = new TreeviewNode(item, true); + searchItemNode = new TreeviewNode(item, true, new PageInfo(), null, true); + + item2 = new VocabularyEntryDetail(); + item2.id = item2.value = item2.display = 'root2'; + item2.otherInformation = { id: 'root2' }; + itemNode2 = new TreeviewNode(item2); + + item3 = new VocabularyEntryDetail(); + item3.id = item3.value = item3.display = 'root3'; + item3.otherInformation = { id: 'root3' }; + itemNode3 = new TreeviewNode(item3); + + child = new VocabularyEntryDetail(); + child.id = child.value = child.display = 'root1-child1'; + child.otherInformation = { parent: 'root1', children: 'root1-child1-child1', id: 'root1-child1' }; + childNode = new TreeviewNode(child); + searchChildNode = new TreeviewNode(child, true, new PageInfo(), item, true); + + child3 = new VocabularyEntryDetail(); + child3.id = child3.value = child3.display = 'root1-child1-child1'; + child3.otherInformation = { parent: 'root1-child1', id: 'root1-child1-child1' }; + childNode3 = new TreeviewNode(child3); + searchChildNode3 = new TreeviewNode(child3, false, new PageInfo(), child, true); + + child2 = new VocabularyEntryDetail(); + child2.id = child2.value = child2.display = 'root1-child2'; + child2.otherInformation = { parent: 'root1', id: 'root1-child2' }; + childNode2 = new TreeviewNode(child2, true); + initValueChildNode2 = new TreeviewNode(child2, false, new PageInfo(), item, false, true); + initValueChildNode = new TreeviewNode(child, true, new PageInfo(), item, false, true); + initValueChildNode.childrenChange.next([initValueChildNode2]); + + item5 = new VocabularyEntryDetail(); + item5.id = item5.value = item5.display = 'root4'; + item5.otherInformation = { id: 'root4' }; + itemNode5 = new TreeviewNode(item5); + + treeNodeList = [ + itemNode, + itemNode2, + itemNode3 + ]; + treeNodeListWithChildren = [ + itemNode, + itemNode2, + itemNode3, + childNode + ]; + treeNodeListWithLoadMoreRoot = treeNodeList; + treeNodeListWithLoadMore = treeNodeListWithChildren; + treeNodeListWithLoadMoreRoot.push(loadMoreRootNode); + treeNodeListWithLoadMore.push(loadMoreNode); + + nodeMap = new Map([ + [item.id, itemNode], + [item2.id, itemNode2], + [item3.id, itemNode3] + ]); + + nodeMapWithChildren = new Map([ + [item.id, itemNode], + [item2.id, itemNode2], + [item3.id, itemNode3], + [child.id, childNode], + ]); + + searchNodeMap = new Map([ + [item.id, searchItemNode], + ]); + vocabularyOptions = new VocabularyOptions('vocabularyTest', 'metadata.test', '123456'); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + providers: [ + { provide: VocabularyService, useValue: vocabularyServiceStub }, + VocabularyTreeviewService, + TranslateService + ] + }).compileComponents(); + })); + + beforeEach(() => { + service = TestBed.get(VocabularyTreeviewService); + serviceAsAny = service; + scheduler = getTestScheduler(); + init(); + }); + + describe('initialize', () => { + it('should set vocabularyName and call retrieveTopNodes method', () => { + const pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 1, + totalElements: 3, + totalPages: 1, + currentPage: 1 + }); + serviceAsAny.vocabularyService.searchTopEntries.and.returnValue(hot('-a', { + a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [item, item2, item3])) + })); + + scheduler.schedule(() => service.initialize(vocabularyOptions, pageInfo)); + scheduler.flush(); + + expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name); + expect(serviceAsAny.dataChange.value).toEqual([itemNode, itemNode2, itemNode3]); + }); + + it('should set initValueHierarchy', () => { + const pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 1, + totalElements: 3, + totalPages: 1, + currentPage: 1 + }); + serviceAsAny.vocabularyService.searchTopEntries.and.returnValue(hot('-c', { + a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [item, item2, item3])) + })); + serviceAsAny.vocabularyService.findEntryDetailByValue.and.returnValue( + hot('-a', { + a: createSuccessfulRemoteDataObject(child2) + }) + ); + serviceAsAny.vocabularyService.getEntryDetailParent.and.returnValue( + hot('-b', { + b: createSuccessfulRemoteDataObject(item) + }) + ); + scheduler.schedule(() => service.initialize(vocabularyOptions, pageInfo, 'root2')); + scheduler.flush(); + + expect(serviceAsAny.vocabularyName).toEqual(vocabularyOptions.name); + expect(serviceAsAny.initValueHierarchy).toEqual(['root1', 'root1-child2']); + }); + }); + + describe('getData', () => { + it('should return dataChange', () => { + const result = service.getData(); + + expect(result).toEqual(serviceAsAny.dataChange); + }); + }); + + describe('loadMoreRoot', () => { + it('should call retrieveTopNodes properly', () => { + spyOn(serviceAsAny, 'retrieveTopNodes'); + service.initialize(vocabularyOptions, new PageInfo()); + serviceAsAny.dataChange.next(treeNodeListWithLoadMoreRoot); + service.loadMoreRoot(loadMoreRootFlatNode); + + expect(serviceAsAny.retrieveTopNodes).toHaveBeenCalledWith(loadMoreRootFlatNode.pageInfo, treeNodeList); + }); + }); + + describe('loadMore', () => { + + beforeEach(() => { + init(); + itemNode.childrenChange.next([childNode]); + }); + + it('should add children nodes properly', () => { + const pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 1, + totalElements: 2, + totalPages: 2, + currentPage: 2 + }); + spyOn(serviceAsAny, 'getChildrenNodesByParent').and.returnValue(hot('a', { + a: new PaginatedList(pageInfo, [child2]) + })); + + serviceAsAny.dataChange.next(treeNodeListWithLoadMore); + serviceAsAny.nodeMap = nodeMapWithChildren; + treeNodeListWithChildren.push(new TreeviewNode(child2, false, new PageInfo(), item)); + + scheduler.schedule(() => service.loadMore(item)); + scheduler.flush(); + + expect(serviceAsAny.dataChange.value).toEqual(treeNodeListWithChildren); + }); + + it('should add loadMore node properly', () => { + const pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 1, + totalElements: 2, + totalPages: 2, + currentPage: 1 + }); + spyOn(serviceAsAny, 'getChildrenNodesByParent').and.returnValue(hot('a', { + a: new PaginatedList(pageInfo, [child2]) + })); + + serviceAsAny.dataChange.next(treeNodeListWithLoadMore); + serviceAsAny.nodeMap = nodeMapWithChildren; + treeNodeListWithChildren.push(childNode2); + treeNodeListWithChildren.push(loadMoreNode); + + scheduler.schedule(() => service.loadMore(item)); + scheduler.flush(); + + expect(serviceAsAny.dataChange.value).toEqual(treeNodeListWithChildren); + }); + + }); + + describe('searchByQuery', () => { + it('should set tree data properly after a search', () => { + const pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 1, + totalElements: 1, + totalPages: 1, + currentPage: 1 + }); + serviceAsAny.vocabularyService.getVocabularyEntriesByValue.and.returnValue(hot('-a', { + a: createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, [child3])) + })); + + serviceAsAny.vocabularyService.getEntryDetailParent.and.returnValues( + hot('-a', { + a: createSuccessfulRemoteDataObject(child) + }), + hot('-b', { + b: createSuccessfulRemoteDataObject(item) + }) + ); + vocabularyOptions.query = 'root1-child1-child1'; + + scheduler.schedule(() => service.searchByQuery(vocabularyOptions)); + scheduler.flush(); + searchChildNode.childrenChange.next([searchChildNode3]); + searchItemNode.childrenChange.next([searchChildNode]); + expect(serviceAsAny.dataChange.value.length).toEqual(1); + expect(serviceAsAny.dataChange.value).toEqual([searchItemNode]); + }); + }); + + describe('restoreNodes', () => { + it('should restore nodes properly', () => { + serviceAsAny.storedNodes = treeNodeList; + serviceAsAny.storedNodeMap = nodeMap; + serviceAsAny.nodeMap = searchNodeMap; + + service.restoreNodes(); + + expect(serviceAsAny.nodeMap).toEqual(nodeMap); + expect(serviceAsAny.dataChange.value).toEqual(treeNodeList); + }); + }); +}); diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts new file mode 100644 index 0000000000..bfaddf414e --- /dev/null +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.ts @@ -0,0 +1,366 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { flatMap, map, merge, scan, take, tap } from 'rxjs/operators'; +import { findIndex } from 'lodash'; + +import { LOAD_MORE_NODE, LOAD_MORE_ROOT_NODE, TreeviewFlatNode, TreeviewNode } from './vocabulary-treeview-node.model'; +import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { VocabularyService } from '../../core/submission/vocabularies/vocabulary.service'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { isEmpty, isNotEmpty } from '../empty.util'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../../core/shared/operators'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; + +/** + * A service that provides methods to deal with vocabulary tree + */ +@Injectable() +export class VocabularyTreeviewService { + + /** + * A map containing the current node showed by the tree + */ + nodeMap = new Map(); + + /** + * A map containing all the node already created for building the tree + */ + storedNodeMap = new Map(); + + /** + * An array containing all the node already created for building the tree + */ + storedNodes: TreeviewNode[] = []; + + /** + * The {@link VocabularyOptions} object + */ + private vocabularyOptions: VocabularyOptions; + + /** + * The vocabulary name + */ + private vocabularyName = ''; + + /** + * Contains the current tree data + */ + private dataChange = new BehaviorSubject([]); + + /** + * Array containing node'ids hierarchy + */ + private initValueHierarchy: string[] = []; + + /** + * A boolean representing if a search operation is pending + */ + private searching = new BehaviorSubject(false); + + /** + * An observable to change the searching status + */ + private hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.searching.next(false)); + + /** + * Initialize instance variables + * + * @param {VocabularyService} vocabularyService + */ + constructor(private vocabularyService: VocabularyService) { + } + + /** + * Remove nodes saved from maps and array + */ + cleanTree() { + this.nodeMap = new Map(); + this.storedNodeMap = new Map(); + this.storedNodes = []; + this.initValueHierarchy = []; + this.dataChange.next([]); + } + + /** + * Initialize the tree's nodes + * + * @param options The {@link VocabularyOptions} object + * @param pageInfo The {@link PageInfo} object + * @param initValueId The entry id of the node to mark as selected, if any + */ + initialize(options: VocabularyOptions, pageInfo: PageInfo, initValueId?: string): void { + this.vocabularyOptions = options; + this.vocabularyName = options.name; + if (isNotEmpty(initValueId)) { + this.getNodeHierarchyById(initValueId) + .subscribe((hierarchy: string[]) => { + this.initValueHierarchy = hierarchy; + this.retrieveTopNodes(pageInfo, []); + }) + } else { + this.retrieveTopNodes(pageInfo, []); + } + } + + /** + * Returns array of the tree's nodes + */ + getData(): Observable { + return this.dataChange + } + + /** + * Expand the root node whose children are not loaded + * @param node The root node + */ + loadMoreRoot(node: TreeviewFlatNode) { + const nodes = this.dataChange.value; + nodes.pop(); + this.retrieveTopNodes(node.pageInfo, nodes); + } + + /** + * Expand a node whose children are not loaded + * @param item + * @param onlyFirstTime + */ + loadMore(item: VocabularyEntryDetail, onlyFirstTime = false) { + if (!this.nodeMap.has(item.id)) { + return; + } + const parent: TreeviewNode = this.nodeMap.get(item.id)!; + const children = this.nodeMap.get(item.id)!.children || []; + children.pop(); + this.getChildrenNodesByParent(item.id, parent.pageInfo).subscribe((list: PaginatedList) => { + + if (onlyFirstTime && parent.children!.length > 0) { + return; + } + + const newNodes: TreeviewNode[] = list.page.map((entry) => this._generateNode(entry)); + children.push(...newNodes); + + if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) { + // Update page info + const newPageInfo: PageInfo = Object.assign(new PageInfo(), list.pageInfo, { + currentPage: list.pageInfo.currentPage + 1 + }); + parent.updatePageInfo(newPageInfo); + + // Need a new load more node + children.push(new TreeviewNode(LOAD_MORE_NODE, false, newPageInfo, item)); + } + parent.childrenChange.next(children); + this.dataChange.next(this.dataChange.value); + }) + + } + + /** + * Check if a search operation is pending + */ + isSearching(): Observable { + return this.searching; + } + + /** + * Perform a search operation by query + */ + searchByQuery(query: string) { + this.searching.next(true); + if (isEmpty(this.storedNodes)) { + this.storedNodes = this.dataChange.value; + this.storedNodeMap = this.nodeMap; + } + this.nodeMap = new Map(); + this.dataChange.next([]); + + this.vocabularyService.getVocabularyEntriesByValue(query, false, this.vocabularyOptions, new PageInfo()).pipe( + getFirstSucceededRemoteListPayload(), + flatMap((result: VocabularyEntry[]) => (result.length > 0) ? result : observableOf(null)), + flatMap((entry: VocabularyEntry) => this.getNodeHierarchy(entry)), + scan((acc: TreeviewNode[], value: TreeviewNode) => { + if (isEmpty(value) || findIndex(acc, (node) => node.item.id === value.item.id) !== -1) { + return acc; + } else { + return [...acc, value] + } + }, []), + merge(this.hideSearchingWhenUnsubscribed$) + ).subscribe((nodes: TreeviewNode[]) => { + this.dataChange.next(nodes); + this.searching.next(false); + }) + } + + /** + * Reset tree state with the one before the search + */ + restoreNodes() { + this.searching.next(false); + this.dataChange.next(this.storedNodes); + this.nodeMap = this.storedNodeMap; + + this.storedNodeMap = new Map(); + this.storedNodes = []; + } + + /** + * Generate a {@link TreeviewNode} object from vocabulary entry + * + * @param entry The vocabulary entry + * @param isSearchNode A Boolean representing if given entry is the result of a search + * @param toStore A Boolean representing if the node created is to store or not + * @return TreeviewNode + */ + private _generateNode(entry: VocabularyEntry, isSearchNode = false, toStore = true): TreeviewNode { + const entryId = entry.otherInformation.id; + if (this.nodeMap.has(entryId)) { + return this.nodeMap.get(entryId)!; + } + const entryDetail: VocabularyEntryDetail = Object.assign(new VocabularyEntryDetail(), entry, { + id: entryId + }); + const hasChildren = entry.hasOtherInformation() && isNotEmpty((entry.otherInformation as any).children); + const pageInfo: PageInfo = new PageInfo(); + const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId); + const result = new TreeviewNode( + entryDetail, + hasChildren, + pageInfo, + null, + isSearchNode, + isInInitValueHierarchy); + + if (toStore) { + this.nodeMap.set(entryId, result); + } + return result; + } + + /** + * Return the node Hierarchy by a given node's id + * @param id The node id + * @return Observable + */ + private getNodeHierarchyById(id: string): Observable { + return this.getById(id).pipe( + flatMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, [], false)), + map((node: TreeviewNode) => this.getNodeHierarchyIds(node)) + ); + } + + /** + * Return the vocabulary entry's children + * @param parentId The node id + * @param pageInfo The {@link PageInfo} object + * @return Observable> + */ + private getChildrenNodesByParent(parentId: string, pageInfo: PageInfo): Observable> { + return this.vocabularyService.getEntryDetailChildren(parentId, this.vocabularyName, pageInfo).pipe( + getFirstSucceededRemoteDataPayload() + ); + } + + /** + * Return the vocabulary entry's parent + * @param entryId The entry id + */ + private getParentNode(entryId: string): Observable { + return this.vocabularyService.getEntryDetailParent(entryId, this.vocabularyName).pipe( + getFirstSucceededRemoteDataPayload() + ); + } + + /** + * Return the vocabulary entry by id + * @param entryId The entry id + * @return Observable + */ + private getById(entryId: string): Observable { + return this.vocabularyService.findEntryDetailByValue(entryId, this.vocabularyName).pipe( + getFirstSucceededRemoteDataPayload() + ); + } + + /** + * Retrieve the top level vocabulary entries + * @param pageInfo The {@link PageInfo} object + * @param nodes The top level nodes already loaded, if any + */ + private retrieveTopNodes(pageInfo: PageInfo, nodes: TreeviewNode[]): void { + this.vocabularyService.searchTopEntries(this.vocabularyName, pageInfo).pipe( + getFirstSucceededRemoteDataPayload() + ).subscribe((list: PaginatedList) => { + const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry)) + nodes.push(...newNodes); + if ((list.pageInfo.currentPage + 1) <= list.pageInfo.totalPages) { + // Need a new load more node + const newPageInfo: PageInfo = Object.assign(new PageInfo(), list.pageInfo, { + currentPage: list.pageInfo.currentPage + 1 + }); + const loadMoreNode = new TreeviewNode(LOAD_MORE_ROOT_NODE, false, newPageInfo); + loadMoreNode.updatePageInfo(newPageInfo); + nodes.push(loadMoreNode); + } + // Notify the change. + this.dataChange.next(nodes); + }); + } + + /** + * Build and return the tree node hierarchy by a given vocabulary entry + * + * @param item The vocabulary entry + * @param children The vocabulary entry + * @param toStore A Boolean representing if the node created is to store or not + * @return Observable + */ + private getNodeHierarchy(item: VocabularyEntry, children?: TreeviewNode[], toStore = true): Observable { + if (isEmpty(item)) { + return observableOf(null); + } + const node = this._generateNode(item, toStore, toStore); + + if (isNotEmpty(children)) { + const newChildren = children + .filter((entry: TreeviewNode) => { + const ii = findIndex(node.children, (nodeEntry) => nodeEntry.item.id === entry.item.id); + return ii === -1; + }); + newChildren.forEach((entry: TreeviewNode) => { + entry.loadMoreParentItem = node.item + }); + node.children.push(...newChildren); + } + + if (node.item.hasOtherInformation() && isNotEmpty(node.item.otherInformation.parent)) { + return this.getParentNode(node.item.id).pipe( + flatMap((parentItem: VocabularyEntryDetail) => this.getNodeHierarchy(parentItem, [node], toStore)) + ) + } else { + return observableOf(node); + } + } + + /** + * Build and return the node Hierarchy ids by a given node + * + * @param node The given node + * @param hierarchyIds The ids already present in the Hierarchy's array + * @return string[] + */ + private getNodeHierarchyIds(node: TreeviewNode, hierarchyIds: string[] = []): string[] { + if (!hierarchyIds.includes(node.item.id)) { + hierarchyIds.push(node.item.id); + } + if (isNotEmpty(node.children)) { + return this.getNodeHierarchyIds(node.children[0], hierarchyIds); + } else { + return hierarchyIds; + } + } +} diff --git a/yarn.lock b/yarn.lock index 12346a8e51..52745a1ef9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -211,6 +211,13 @@ resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-8.2.14.tgz#e18b27a6841577ce489ad31540150da5a444ca37" integrity sha512-7EhN9JJbAJcH2xCa+rIOmekjiEuB0qwPdHuD5qn/wwMfRzMZo+Db4hHbR9KHrLH6H82PTwYKye/LLpDaZqoHOA== +"@angular/material@8.2.3": + version "8.2.3" + resolved "https://registry.yarnpkg.com/@angular/material/-/material-8.2.3.tgz#16543e4e06a3fde2651a25cfe126e88e714ae105" + integrity sha512-SOczkIaqes+r+9XF/UUiokidfFKBpHkOPIaFK857sFD0FBNPvPEpOr5oHKCG3feERRwAFqHS7Wo2ohVEWypb5A== + dependencies: + tslib "^1.7.1" + "@angular/platform-browser-dynamic@~8.2.14": version "8.2.14" resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.14.tgz#4439a79fe10ec45170e6940a28835e9ff0918950"