[CST-3088] Added component to show hierarchical vocabulary as a tree

This commit is contained in:
Giuseppe Digilio
2020-07-01 15:16:14 +02:00
parent ce61addc9b
commit e0604026c5
11 changed files with 1367 additions and 21 deletions

View File

@@ -62,6 +62,7 @@
"@angular/compiler": "~8.2.14", "@angular/compiler": "~8.2.14",
"@angular/core": "~8.2.14", "@angular/core": "~8.2.14",
"@angular/forms": "~8.2.14", "@angular/forms": "~8.2.14",
"@angular/material": "8.2.3",
"@angular/platform-browser": "~8.2.14", "@angular/platform-browser": "~8.2.14",
"@angular/platform-browser-dynamic": "~8.2.14", "@angular/platform-browser-dynamic": "~8.2.14",
"@angular/platform-server": "~8.2.14", "@angular/platform-server": "~8.2.14",

View File

@@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module';
import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageComponent } from './community-list-page.component';
import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module';
import { CommunityListComponent } from './community-list/community-list.component'; 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 * 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: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
CommunityListPageRoutingModule, CommunityListPageRoutingModule
CdkTreeModule,
], ],
declarations: [ declarations: [
CommunityListPageComponent, CommunityListPageComponent,

View File

@@ -2,25 +2,27 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 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 { NouisliderModule } from 'ng2-nouislider';
import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core'; import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core';
import { NgxPaginationModule } from 'ngx-pagination'; 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 { 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 { 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 { EnumKeysPipe } from './utils/enum-keys-pipe';
import { FileSizePipe } from './utils/file-size-pipe'; import { FileSizePipe } from './utils/file-size-pipe';
import { SafeUrlPipe } from './utils/safe-url-pipe'; import { SafeUrlPipe } from './utils/safe-url-pipe';
import { ConsolePipe } from './utils/console.pipe'; import { ConsolePipe } from './utils/console.pipe';
import { CollectionListElementComponent } from './object-list/collection-list-element/collection-list-element.component'; 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 { 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'; import { SearchResultListElementComponent } from './object-list/search-result-list-element/search-result-list-element.component';
@@ -53,9 +55,6 @@ import {
dsDynamicFormControlMapFn dsDynamicFormControlMapFn
} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; } 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 { 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 { DragClickDirective } from './utils/drag-click.directive';
import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatePipe } from './utils/truncate.pipe';
import { TruncatableComponent } from './truncatable/truncatable.component'; 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 { InputSuggestionsComponent } from './input-suggestions/input-suggestions.component';
import { CapitalizePipe } from './utils/capitalize.pipe'; import { CapitalizePipe } from './utils/capitalize.pipe';
import { ObjectKeysPipe } from './utils/object-keys-pipe'; import { ObjectKeysPipe } from './utils/object-keys-pipe';
import { MomentModule } from 'ngx-moment';
import { AuthorityConfidenceStateDirective } from './authority-confidence/authority-confidence-state.directive'; import { AuthorityConfidenceStateDirective } from './authority-confidence/authority-confidence-state.directive';
import { MenuModule } from './menu/menu.module'; import { MenuModule } from './menu/menu.module';
import { LangSwitchComponent } from './lang-switch/lang-switch.component'; 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 { 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 { 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 { MetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/metadata-representation-list-element.component';
import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component'; import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component';
import { CreateComColPageComponent } from './comcol-forms/create-comcol-page/create-comcol-page.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 { 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 { 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 { 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 { 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 { ItemVersionsComponent } from './item/item-versions/item-versions.component';
import { SortablejsModule } from 'ngx-sortablejs'; 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 { 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 { 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 { 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 = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -223,7 +220,8 @@ const MODULES = [
MomentModule, MomentModule,
TextMaskModule, TextMaskModule,
MenuModule, MenuModule,
DragDropModule DragDropModule,
CdkTreeModule
]; ];
const ROOT_MODULES = [ const ROOT_MODULES = [
@@ -386,7 +384,8 @@ const COMPONENTS = [
ResourcePolicyFormComponent, ResourcePolicyFormComponent,
EpersonGroupListComponent, EpersonGroupListComponent,
EpersonSearchBoxComponent, EpersonSearchBoxComponent,
GroupSearchBoxComponent GroupSearchBoxComponent,
VocabularyTreeviewComponent
]; ];
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
@@ -459,7 +458,8 @@ const ENTRY_COMPONENTS = [
ClaimedTaskActionsApproveComponent, ClaimedTaskActionsApproveComponent,
ClaimedTaskActionsRejectComponent, ClaimedTaskActionsRejectComponent,
ClaimedTaskActionsReturnToPoolComponent, ClaimedTaskActionsReturnToPoolComponent,
ClaimedTaskActionsEditMetadataComponent ClaimedTaskActionsEditMetadataComponent,
VocabularyTreeviewComponent
]; ];
const SHARED_ITEM_PAGE_COMPONENTS = [ const SHARED_ITEM_PAGE_COMPONENTS = [

View File

@@ -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<TreeviewNode[]>([]);
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 */

View File

@@ -0,0 +1,77 @@
<div class="modal-header">
<h4 class="modal-title">{{'treeview.header' | translate}}</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="p-3">
<ds-alert *ngIf="description | async" [content]="description | async" [type]="'alert-info'"></ds-alert>
<div class="treeview-header row">
<div class="col-12">
<div class="input-group">
<input type="text" class="form-control" [(ngModel)]="searchText" (keyup.enter)="search()">
<div class="input-group-append" id="button-addon4">
<button class="btn btn-outline-primary" type="button" (click)="search()" [disabled]="!isSearchEnabled()">
{{'treeview.search.form.search' | translate}}
</button>
<button class="btn btn-outline-secondary" type="button" (click)="reset()">
{{'treeview.search.form.reset' | translate}}
</button>
</div>
</div>
</div>
</div>
<div class="treeview-container">
<ds-loading *ngIf="searching | async" [showMessage]="false"></ds-loading>
<h4 *ngIf="!(searching | async) && dataSource.data.length === 0" class="text-center text-muted mt-4" >
<span>{{'treeview.search.no-result' | translate}}</span>
</h4>
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl">
<!-- Leaf node -->
<cdk-tree-node *cdkTreeNodeDef="let node" cdkTreeNodePadding class="d-flex">
<button type="button" class="btn btn-default" cdkTreeNodeToggle>
<span class="fas fa-angle-right fa-2x invisible" aria-hidden="true"></span>
</button>
<button class="btn btn-outline-link btn-sm text-left"
[class.text-success]="node.item?.value === selectedItem?.value"
[disabled]="!node.item?.selectable"
[ngbTooltip]="node.item?.otherInformation?.note"
[openDelay]="500"
container="body"
(click)="onSelect(node.item)">{{node.item.display}}</button>
</cdk-tree-node>
<!-- expandable node -->
<cdk-tree-node *cdkTreeNodeDef="let node; when: hasChildren" cdkTreeNodePadding class="d-flex">
<button type="button" class="btn btn-default" cdkTreeNodeToggle
[attr.aria-label]="'toggle ' + node.name"
(click)="loadChildren(node)">
<span class="fas {{treeControl.isExpanded(node) ? 'fa-angle-down' : 'fa-angle-right'}} fa-2x"
aria-hidden="true"></span>
</button>
<button class="btn btn-outline-link btn-sm text-left"
[class.text-success]="node.item?.value === selectedItem?.value"
[disabled]="!node.item?.selectable"
[ngbTooltip]="node.item?.otherInformation?.note"
[openDelay]="500"
container="body"
(click)="onSelect(node.item)">{{node.item.display}}</button>
</cdk-tree-node>
<cdk-tree-node *cdkTreeNodeDef="let node; when: isLoadMore" cdkTreeNodePadding>
<button class="btn btn-outline-secondary btn-sm" (click)="loadMore(node.loadMoreParentItem)">
{{'treeview.load-more' | translate}}...
</button>
</cdk-tree-node>
<cdk-tree-node *cdkTreeNodeDef="let node; when: isLoadMoreRoot">
<button class="btn btn-outline-secondary btn-sm" (click)="loadMoreRoot(node)">
{{'treeview.load-more' | translate}}...
</button>
</cdk-tree-node>
</cdk-tree>
</div>
</div>
</div>

View File

@@ -0,0 +1,7 @@
::ng-deep .tooltip-inner {
text-align:left;
}
cdk-tree .btn:focus {
box-shadow: none !important;
}

View File

@@ -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<VocabularyTreeviewComponent>;
const item = new VocabularyEntryDetail();
item.id = 'node1';
const item2 = new VocabularyEntryDetail();
item2.id = 'node2';
const emptyNodeMap = new Map<string, TreeviewFlatNode>();
const storedNodeMap = new Map<string, TreeviewFlatNode>().set('test', new TreeviewFlatNode(item2));
const nodeMap = new Map<string, TreeviewFlatNode>().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<CoreState> = 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<TestComponent>;
// synchronous beforeEach
beforeEach(() => {
const html = `
<ds-authority-treeview [vocabularyOptions]="vocabularyOptions" [preloadLevel]="preloadLevel"></ds-authority-treeview>`;
testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
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;
}

View File

@@ -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<string>;
/**
* A map containing the current node showed by the tree
*/
nodeMap = new Map<string, TreeviewFlatNode>();
/**
* A map containing all the node already created for building the tree
*/
storedNodeMap = new Map<string, TreeviewFlatNode>();
/**
* Flat tree control object. Able to expand/collapse a subtree recursively for flattened tree.
*/
treeControl: FlatTreeControl<TreeviewFlatNode>;
/**
* Tree flattener object. Able to convert a normal type of node to node with children and level information.
*/
treeFlattener: MatTreeFlattener<TreeviewNode, TreeviewFlatNode>;
/**
* Flat tree data source
*/
dataSource: MatTreeFlatDataSource<TreeviewNode, TreeviewFlatNode>;
/**
* 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<boolean>;
/**
* An event fired when a vocabulary entry is selected.
* Event's payload equals to {@link VocabularyEntryDetail} selected.
*/
@Output() select: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null);
/**
* A boolean representing if user is authenticated
*/
private isAuthenticated: Observable<boolean>;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
*/
private subs: Subscription[] = [];
/**
* Initialize instance variables
*
* @param {NgbActiveModal} activeModal
* @param {VocabularyTreeviewService} vocabularyTreeviewService
* @param {Store<CoreState>} store
* @param {TranslateService} translate
*/
constructor(
public activeModal: NgbActiveModal,
private vocabularyTreeviewService: VocabularyTreeviewService,
private store: Store<CoreState>,
private translate: TranslateService
) {
this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel,
this.isExpandable, this.getChildren);
this.treeControl = new FlatTreeControl<TreeviewFlatNode>(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<TreeviewNode[]> => 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<string, TreeviewFlatNode>();
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<string, TreeviewFlatNode>();
this.vocabularyTreeviewService.restoreNodes();
}
this.searchText = '';
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.vocabularyTreeviewService.cleanTree();
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -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<string, TreeviewNode>;
let nodeMapWithChildren: Map<string, TreeviewNode>;
let searchNodeMap: Map<string, TreeviewNode>;
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<string, TreeviewNode>([
[item.id, itemNode],
[item2.id, itemNode2],
[item3.id, itemNode3]
]);
nodeMapWithChildren = new Map<string, TreeviewNode>([
[item.id, itemNode],
[item2.id, itemNode2],
[item3.id, itemNode3],
[child.id, childNode],
]);
searchNodeMap = new Map<string, TreeviewNode>([
[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);
});
});
});

View File

@@ -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<string, TreeviewNode>();
/**
* A map containing all the node already created for building the tree
*/
storedNodeMap = new Map<string, TreeviewNode>();
/**
* 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<TreeviewNode[]>([]);
/**
* Array containing node'ids hierarchy
*/
private initValueHierarchy: string[] = [];
/**
* A boolean representing if a search operation is pending
*/
private searching = new BehaviorSubject<boolean>(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<string, TreeviewNode>();
this.storedNodeMap = new Map<string, TreeviewNode>();
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<TreeviewNode[]> {
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<VocabularyEntryDetail>) => {
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<boolean> {
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<string, TreeviewNode>();
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<string, TreeviewNode>();
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<string[]>
*/
private getNodeHierarchyById(id: string): Observable<string[]> {
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<PaginatedList<VocabularyEntryDetail>>
*/
private getChildrenNodesByParent(parentId: string, pageInfo: PageInfo): Observable<PaginatedList<VocabularyEntryDetail>> {
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<VocabularyEntryDetail> {
return this.vocabularyService.getEntryDetailParent(entryId, this.vocabularyName).pipe(
getFirstSucceededRemoteDataPayload()
);
}
/**
* Return the vocabulary entry by id
* @param entryId The entry id
* @return Observable<VocabularyEntryDetail>
*/
private getById(entryId: string): Observable<VocabularyEntryDetail> {
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<VocabularyEntryDetail>) => {
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<string[]>
*/
private getNodeHierarchy(item: VocabularyEntry, children?: TreeviewNode[], toStore = true): Observable<TreeviewNode> {
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;
}
}
}

View File

@@ -211,6 +211,13 @@
resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-8.2.14.tgz#e18b27a6841577ce489ad31540150da5a444ca37" resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-8.2.14.tgz#e18b27a6841577ce489ad31540150da5a444ca37"
integrity sha512-7EhN9JJbAJcH2xCa+rIOmekjiEuB0qwPdHuD5qn/wwMfRzMZo+Db4hHbR9KHrLH6H82PTwYKye/LLpDaZqoHOA== 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": "@angular/platform-browser-dynamic@~8.2.14":
version "8.2.14" version "8.2.14"
resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.14.tgz#4439a79fe10ec45170e6940a28835e9ff0918950" resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.14.tgz#4439a79fe10ec45170e6940a28835e9ff0918950"