mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
[CST-3088] Added component to show hierarchical vocabulary as a tree
This commit is contained in:
@@ -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",
|
||||
|
@@ -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,
|
||||
|
@@ -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 = [
|
||||
|
@@ -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 */
|
@@ -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">×</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>
|
@@ -0,0 +1,7 @@
|
||||
::ng-deep .tooltip-inner {
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
cdk-tree .btn:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
@@ -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;
|
||||
|
||||
}
|
@@ -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());
|
||||
}
|
||||
}
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user