Merge pull request #2738 from 4Science/main-restore-hierarchical-tree-original-behaviour

Restore hierarchical tree original behaviour
This commit is contained in:
Tim Donohue
2024-05-21 12:07:53 -05:00
committed by GitHub
12 changed files with 132 additions and 81 deletions

View File

@@ -7,8 +7,10 @@
} }}
</h1>
<div class="mb-3">
<ds-vocabulary-treeview [vocabularyOptions]=vocabularyOptions
<ds-vocabulary-treeview [description]="description"
[vocabularyOptions]=vocabularyOptions
[multiSelect]="true"
[showAdd]="false"
(select)="onSelect($event)"
(deselect)="onDeselect($event)">
</ds-vocabulary-treeview>

View File

@@ -14,7 +14,10 @@ import {
Params,
RouterLink,
} from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
BehaviorSubject,
Observable,
@@ -124,6 +127,11 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy {
*/
browseDefinition$: Observable<BrowseDefinition>;
/**
* Browse description
*/
description: string;
/**
* Subscriptions to track
*/
@@ -131,6 +139,7 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy {
public constructor(
protected route: ActivatedRoute,
protected translate: TranslateService,
) {
}
@@ -141,9 +150,11 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy {
}),
);
this.subs.push(this.browseDefinition$.subscribe((browseDefinition: HierarchicalBrowseDefinition) => {
this.selectedItems = [];
this.facetType = browseDefinition.facetType;
this.vocabularyName = browseDefinition.vocabulary;
this.vocabularyOptions = { name: this.vocabularyName, closed: true };
this.description = this.translate.instant(`browse.metadata.${this.vocabularyName}.tree.descrption`);
}));
this.subs.push(this.scope$.subscribe(() => {
this.updateQueryParams();

View File

@@ -293,7 +293,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewModalComponent, { size: 'lg', windowClass: 'treeview' });
modalRef.componentInstance.vocabularyOptions = this.model.vocabularyOptions;
modalRef.componentInstance.preloadLevel = preloadLevel;
modalRef.componentInstance.selectedItems = this.currentValue ? [this.currentValue.value] : [];
modalRef.componentInstance.selectedItems = this.currentValue ? [this.currentValue] : [];
modalRef.result.then((result: VocabularyEntryDetail) => {
if (result) {
this.currentValue = result;

View File

@@ -7,9 +7,11 @@
<div class="modal-body">
<div class="p-3">
<ds-vocabulary-treeview [vocabularyOptions]="vocabularyOptions"
[description]="description"
[preloadLevel]="preloadLevel"
[selectedItems]="selectedItems"
[multiSelect]="multiSelect"
[showAdd]="showAdd"
(select)="onSelect($event)">
</ds-vocabulary-treeview>
</div>

View File

@@ -5,6 +5,7 @@ import {
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
import { VocabularyTreeviewComponent } from '../vocabulary-treeview/vocabulary-treeview.component';
import { VocabularyTreeviewModalComponent } from './vocabulary-treeview-modal.component';
@@ -13,6 +14,7 @@ describe('VocabularyTreeviewModalComponent', () => {
let fixture: ComponentFixture<VocabularyTreeviewModalComponent>;
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const vocabularyOptions = new VocabularyOptions('vocabularyTest', false);
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -32,10 +34,16 @@ describe('VocabularyTreeviewModalComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(VocabularyTreeviewModalComponent);
component = fixture.componentInstance;
component.vocabularyOptions = vocabularyOptions;
spyOn(component as any, 'setDescription').and.callThrough();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should init descrption message', () => {
expect((component as any).setDescription).toHaveBeenCalled();
});
});

View File

@@ -2,10 +2,14 @@ import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
@@ -24,7 +28,7 @@ import { VocabularyTreeviewComponent } from '../vocabulary-treeview/vocabulary-t
/**
* Component that contains a modal to display a VocabularyTreeviewComponent
*/
export class VocabularyTreeviewModalComponent {
export class VocabularyTreeviewModalComponent implements OnInit {
/**
* The {@link VocabularyOptions} object
@@ -39,13 +43,23 @@ export class VocabularyTreeviewModalComponent {
/**
* The vocabulary entries already selected, if any
*/
@Input() selectedItems: string[] = [];
@Input() selectedItems: VocabularyEntryDetail[] = [];
/**
* Whether to allow selecting multiple values with checkboxes
*/
@Input() multiSelect = false;
/**
* A boolean representing if to show the add button or not
*/
@Input() showAdd = true;
/**
* Contain a descriptive message for this vocabulary retrieved from i18n files
*/
description: string;
/**
* An event fired when a vocabulary entry is selected.
* Event's payload equals to {@link VocabularyEntryDetail} selected.
@@ -56,11 +70,17 @@ export class VocabularyTreeviewModalComponent {
* Initialize instance variables
*
* @param {NgbActiveModal} activeModal
* @param {TranslateService} translate
*/
constructor(
public activeModal: NgbActiveModal,
protected translate: TranslateService,
) { }
ngOnInit(): void {
this.setDescription();
}
/**
* Method called on entry select
*/
@@ -68,4 +88,13 @@ export class VocabularyTreeviewModalComponent {
this.select.emit(item);
this.activeModal.close(item);
}
/**
* Set the description message related to the given vocabulary
*/
private setDescription() {
const descriptionLabel = 'vocabulary-treeview.tree.description.' + this.vocabularyOptions.name;
this.description = this.translate.instant(descriptionLabel);
}
}

View File

@@ -1,4 +1,4 @@
<ds-alert [content]="'vocabulary-treeview.info' | translate" [type]="AlertType.Info"></ds-alert>
<ds-alert *ngIf="description" [content]="description" [type]="AlertType.Info"></ds-alert>
<div class="treeview-header row mb-1">
<div class="col-12">
<div class="input-group">
@@ -11,7 +11,7 @@
<button class="btn btn-outline-secondary" type="button" (click)="reset()">
{{'vocabulary-treeview.search.form.reset' | translate}}
</button>
<button class="btn btn-outline-primary" type="button" (click)="add()" [disabled]="this.vocabularyOptions.closed">
<button *ngIf="showAdd && this.vocabularyOptions.closed" class="btn btn-outline-primary" type="button" (click)="add()">
{{'vocabulary-treeview.search.form.add' | translate}}
</button>
<button class="btn btn-outline-primary" type="button" (click)="add()" [disabled]="this.vocabularyOptions.closed">

View File

@@ -13,14 +13,9 @@ import {
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { StoreModule } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { storeModuleConfig } from '../../../app.reducer';
import { authReducer } from '../../../core/auth/auth.reducer';
import { AuthTokenInfo } from '../../../core/auth/models/auth-token-info.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model';
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
@@ -40,7 +35,6 @@ describe('VocabularyTreeviewComponent test suite', () => {
let comp: VocabularyTreeviewComponent;
let compAsAny: any;
let fixture: ComponentFixture<VocabularyTreeviewComponent>;
let initialState;
let de;
const item = new VocabularyEntryDetail();
@@ -71,25 +65,10 @@ describe('VocabularyTreeviewComponent test suite', () => {
clearSearchTopRequests: jasmine.createSpy('clearSearchTopRequests'),
});
initialState = {
core: {
auth: {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
authToken: new AuthTokenInfo('test_token'),
userId: 'testid',
authMethods: [],
},
},
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
CdkTreeModule,
StoreModule.forRoot({ auth: authReducer }, storeModuleConfig),
TranslateModule.forRoot(),
VocabularyTreeviewComponent,
TestComponent,
@@ -99,7 +78,6 @@ describe('VocabularyTreeviewComponent test suite', () => {
{ provide: VocabularyTreeviewService, useValue: vocabularyTreeviewServiceStub },
{ provide: VocabularyService, useValue: vocabularyServiceStub },
{ provide: NgbActiveModal, useValue: modalStub },
provideMockStore({ initialState }),
ChangeDetectorRef,
VocabularyTreeviewComponent,
],
@@ -155,10 +133,10 @@ describe('VocabularyTreeviewComponent test suite', () => {
currentValue.otherInformation = {
id: 'entryID',
};
comp.selectedItems = [currentValue.value];
comp.selectedItems = [currentValue];
fixture.detectChanges();
expect(comp.dataSource.data).toEqual([]);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['testValue'], null);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['entryID'], 'entryID');
});
it('should should init component properly with init value as VocabularyEntry', () => {
@@ -167,10 +145,20 @@ describe('VocabularyTreeviewComponent test suite', () => {
currentValue.otherInformation = {
id: 'entryID',
};
comp.selectedItems = [currentValue.value];
comp.selectedItems = [currentValue];
fixture.detectChanges();
expect(comp.dataSource.data).toEqual([]);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['testValue'], null);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['entryID'], 'entryID');
});
it('should should init component properly with init value as VocabularyEntryDetail', () => {
const currentValue = new VocabularyEntryDetail();
currentValue.value = 'testValue';
currentValue.id = 'entryID';
comp.selectedItems = [currentValue];
fixture.detectChanges();
expect(comp.dataSource.data).toEqual([]);
expect(vocabularyTreeviewServiceStub.initialize).toHaveBeenCalledWith(comp.vocabularyOptions, new PageInfo(), ['entryID'], 'entryID');
});
it('should call loadMore function', () => {

View File

@@ -18,23 +18,16 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { Store } from '@ngrx/store';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import {
Observable,
Subscription,
} from 'rxjs';
import { CoreState } from '../../../core/core-state.model';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { PageInfo } from '../../../core/shared/page-info.model';
import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model';
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service';
import { AlertComponent } from '../../alert/alert.component';
import { AlertType } from '../../alert/alert-type';
import {
@@ -43,6 +36,7 @@ import {
isNotEmpty,
} from '../../empty.util';
import { ThemedLoadingComponent } from '../../loading/themed-loading.component';
import { FormFieldMetadataValueObject } from '../builder/models/form-field-metadata-value.model';
import { VocabularyTreeFlatDataSource } from './vocabulary-tree-flat-data-source';
import { VocabularyTreeFlattener } from './vocabulary-tree-flattener';
import { VocabularyTreeviewService } from './vocabulary-treeview.service';
@@ -53,6 +47,8 @@ import {
TreeviewNode,
} from './vocabulary-treeview-node.model';
export type VocabularyTreeItemType = FormFieldMetadataValueObject | VocabularyEntry | VocabularyEntryDetail;
/**
* Component that shows a hierarchical vocabulary in a tree view
*/
@@ -85,15 +81,25 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
@Input() preloadLevel = 2;
/**
* The vocabulary entries already selected, if any
* Contain a descriptive message for the tree
*/
@Input() selectedItems: string[] = [];
@Input() description = '';
/**
* Whether to allow selecting multiple values with checkboxes
*/
@Input() multiSelect = false;
/**
* A boolean representing if to show the add button or not
*/
@Input() showAdd = true;
/**
* The vocabulary entries already selected, if any
*/
@Input() selectedItems: VocabularyTreeItemType[] = [];
/**
* A map containing the current node showed by the tree
*/
@@ -131,20 +137,15 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
/**
* An event fired when a vocabulary entry is selected.
* Event's payload equals to {@link VocabularyEntryDetail} selected.
* Event's payload equals to {@link VocabularyTreeItemType} selected.
*/
@Output() select: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null);
@Output() select: EventEmitter<VocabularyTreeItemType> = new EventEmitter<VocabularyTreeItemType>(null);
/**
* An event fired when a vocabulary entry is deselected.
* Event's payload equals to {@link VocabularyEntryDetail} deselected.
* Event's payload equals to {@link VocabularyTreeItemType} deselected.
*/
@Output() deselect: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null);
/**
* A boolean representing if user is authenticated
*/
private isAuthenticated: Observable<boolean>;
@Output() deselect: EventEmitter<VocabularyTreeItemType> = new EventEmitter<VocabularyTreeItemType>(null);
/**
* Array to track all subscriptions and unsubscribe them onDestroy
@@ -157,15 +158,9 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
* Initialize instance variables
*
* @param {VocabularyTreeviewService} vocabularyTreeviewService
* @param {vocabularyService} vocabularyService
* @param {Store<CoreState>} store
* @param {TranslateService} translate
*/
constructor(
private vocabularyTreeviewService: VocabularyTreeviewService,
private vocabularyService: VocabularyService,
private store: Store<CoreState>,
private translate: TranslateService,
) {
this.treeFlattener = new VocabularyTreeFlattener(this.transformer, this.getLevel,
this.isExpandable, this.getChildren);
@@ -187,7 +182,8 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
* @param level The node level information
*/
transformer = (node: TreeviewNode, level: number) => {
const existingNode = this.nodeMap.get(node.item.id);
const entryId = this.getEntryId(node.item);
const existingNode = this.nodeMap.get(entryId);
if (existingNode && existingNode.item.id !== LOAD_MORE && existingNode.item.id !== LOAD_MORE_ROOT) {
return existingNode;
@@ -204,7 +200,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
node.isInInitValueHierarchy,
node.isSelected,
);
this.nodeMap.set(node.item.id, newNode);
this.nodeMap.set(entryId, newNode);
if ((((level + 1) < this.preloadLevel) && newNode.childrenLoaded)
|| (newNode.isSearchNode && newNode.childrenLoaded)
@@ -259,7 +255,8 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
this.loading = this.vocabularyTreeviewService.isLoading();
this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), this.selectedItems, null);
const entryId: string = (this.selectedItems?.length > 0) ? this.getEntryId(this.selectedItems[0]) : null;
this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), this.getSelectedEntryIds(), entryId);
}
/**
@@ -267,7 +264,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
* @param item The VocabularyEntryDetail for which to load more nodes
*/
loadMore(item: VocabularyEntryDetail) {
this.vocabularyTreeviewService.loadMore(item, this.selectedItems);
this.vocabularyTreeviewService.loadMore(item, this.getSelectedEntryIds());
}
/**
@@ -275,7 +272,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
* @param node The TreeviewFlatNode for which to load more nodes
*/
loadMoreRoot(node: TreeviewFlatNode) {
this.vocabularyTreeviewService.loadMoreRoot(node, this.selectedItems);
this.vocabularyTreeviewService.loadMoreRoot(node, this.getSelectedEntryIds());
}
/**
@@ -283,18 +280,18 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
* @param node The TreeviewFlatNode for which to load children nodes
*/
loadChildren(node: TreeviewFlatNode) {
this.vocabularyTreeviewService.loadMore(node.item, this.selectedItems, true);
this.vocabularyTreeviewService.loadMore(node.item, this.getSelectedEntryIds(), true);
}
/**
* Method called on entry select/deselect
*/
onSelect(item: VocabularyEntryDetail) {
if (!this.selectedItems.includes(item.id)) {
this.selectedItems.push(item.id);
if (!this.getSelectedEntryIds().includes(this.getEntryId(item))) {
this.selectedItems.push(item);
this.select.emit(item);
} else {
this.selectedItems = this.selectedItems.filter((detail: string) => { return detail !== item.id; });
this.selectedItems = this.selectedItems.filter((detail: VocabularyTreeItemType) => this.getEntryId(detail) !== this.getEntryId(item));
this.deselect.emit(item);
}
}
@@ -308,7 +305,7 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
this.storedNodeMap = this.nodeMap;
}
this.nodeMap = new Map<string, TreeviewFlatNode>();
this.vocabularyTreeviewService.searchByQuery(this.searchText, this.selectedItems);
this.vocabularyTreeviewService.searchByQuery(this.searchText, this.getSelectedEntryIds());
}
}
@@ -325,12 +322,8 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
reset() {
this.searchText = '';
for (const item of this.selectedItems) {
this.subs.push(this.vocabularyService.findEntryDetailById(item, this.vocabularyOptions.name, true, true, false).pipe(
getFirstSucceededRemoteDataPayload(),
).subscribe((detail: VocabularyEntryDetail) => {
this.deselect.emit(detail);
}));
this.nodeMap.get(item).isSelected = false;
this.deselect.emit(item);
this.nodeMap.get(this.getEntryId(item)).isSelected = false;
}
this.selectedItems = [];
@@ -361,14 +354,27 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges
}
/**
* Return an id for a given {@link VocabularyEntry}
* Return an id for a given {@link VocabularyTreeItemType}
*/
private getEntryId(entry: VocabularyEntry): string {
return entry.authority || entry.otherInformation.id || undefined;
private getEntryId(entry: VocabularyTreeItemType): string {
return entry?.authority || entry?.otherInformation?.id || (entry as any)?.id || undefined;
}
/**
* Return an ids for all selected entries
*/
private getSelectedEntryIds(): string[] {
return this.selectedItems
.map((entry: VocabularyTreeItemType) => this.getEntryId(entry))
.filter((value) => isNotEmpty(value));
}
ngOnChanges(changes: SimpleChanges): void {
this.reset();
this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), this.selectedItems, null);
if (!changes.vocabularyOptions.isFirstChange() && changes.vocabularyOptions.currentValue !== changes.vocabularyOptions.previousValue) {
this.selectedItems = [];
this.searchText = '';
this.vocabularyTreeviewService.cleanTree();
this.vocabularyTreeviewService.initialize(this.vocabularyOptions, new PageInfo(), this.getSelectedEntryIds(), null);
}
}
}

View File

@@ -260,7 +260,7 @@ export class VocabularyTreeviewService {
const hasChildren = entry.hasOtherInformation() && (entry.otherInformation as any)!.hasChildren === 'true';
const pageInfo: PageInfo = this.pageInfo;
const isInInitValueHierarchy = this.initValueHierarchy.includes(entryId);
const isSelected: boolean = selectedItems.some(() => selectedItems.includes(entry.id));
const isSelected: boolean = selectedItems.some(() => selectedItems.includes(entryId));
const result = new TreeviewNode(
entry,
hasChildren,

View File

@@ -126,6 +126,7 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
name: this.getVocabularyEntry(),
closed: true,
};
modalRef.componentInstance.showAdd = false;
this.subs.push(from(modalRef.result).pipe(
switchMap((detail: VocabularyEntryDetail) => this.searchConfigService.selectNewAppliedFilterParams(this.filterConfig.name, detail.value, 'equals')),
take(1),

View File

@@ -1034,8 +1034,12 @@
"browse.metadata.srsc.breadcrumbs": "Browse by Subject Category",
"browse.metadata.srsc.tree.descrption": "Select a subject to add as search filter",
"browse.metadata.nsi.breadcrumbs": "Browse by Norwegian Science Index",
"browse.metadata.nsi.tree.descrption": "Select an index to add as search filter",
"browse.metadata.title.breadcrumbs": "Browse by Title",
"pagination.next.button": "Next",