src/app/modules/search/search.component.ts
selector | app-search |
styleUrls | ./search.component.scss |
templateUrl | ./search.component.html |
Properties |
|
Methods |
Inputs |
HostListeners |
constructor(bms: BimodalService, store: Store, ga: GoogleAnalyticsService, router: Router, elementRef: ElementRef)
|
||||||||||||||||||
Parameters :
|
disabled | |
Type : boolean
|
|
Default value : false
|
|
document:click |
Arguments : '$event'
|
document:click(event: MouseEvent)
|
clearSearchField |
clearSearchField()
|
Returns :
void
|
clickOutsideSearchList | ||||||
clickOutsideSearchList(event: MouseEvent)
|
||||||
Decorators :
@HostListener('document:click', ['$event'])
|
||||||
Parameters :
Returns :
void
|
closeSearchList |
closeSearchList()
|
Returns :
void
|
deselectAllOptions |
deselectAllOptions()
|
Returns :
void
|
Public filterStructuresOnSearch |
filterStructuresOnSearch()
|
Returns :
void
|
filterToggleChange | ||||||
filterToggleChange(value: string[])
|
||||||
Parameters :
Returns :
void
|
hideStructure | ||||||
hideStructure(structure: SearchStructure)
|
||||||
Parameters :
Returns :
boolean
|
isSelected | ||||||
isSelected(structure: SearchStructure)
|
||||||
Parameters :
Returns :
any
|
openSearchList |
openSearchList()
|
Returns :
void
|
selectAllOptions |
selectAllOptions()
|
Returns :
void
|
selectFirstOption |
selectFirstOption()
|
Returns :
void
|
selectOption |
selectOption()
|
Returns :
void
|
Public bms |
Type : BimodalService
|
Public ga |
Type : GoogleAnalyticsService
|
Public groupFilteredStructures |
Type : SearchStructure[]
|
Default value : []
|
nodes |
Type : BMNode[]
|
Default value : []
|
Public router |
Type : Router
|
searchFieldContent |
Type : ElementRef
|
Decorators :
@ViewChild('searchField', {static: false})
|
Public searchFilteredStructures |
Type : SearchStructure[]
|
Default value : []
|
searchOpen |
Default value : false
|
searchState$ |
Type : Observable<boolean>
|
Decorators :
@Select(UIState.getSearchState)
|
searchValue |
Type : string
|
Default value : ''
|
selectedOptions |
Type : SearchStructure[]
|
Default value : []
|
selectedValues |
Type : string
|
Default value : ''
|
selectionCompareFunction |
Default value : () => {...}
|
selectionMemory |
Type : SearchStructure[]
|
Default value : []
|
sheetConfig |
Type : SheetConfig
|
sheetConfig$ |
Type : Observable<SheetConfig>
|
Decorators :
@Select(SheetState.getSheetConfig)
|
Public store |
Type : Store
|
Public structures |
Type : SearchStructure[]
|
Default value : []
|
tree$ |
Type : Observable<TreeStateModel>
|
Decorators :
@Select(TreeState)
|
treeData |
Type : TNode[]
|
Default value : []
|
ui$ |
Type : Observable<UIStateModel>
|
Decorators :
@Select(UIState)
|
import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Select, Store } from '@ngxs/store';
import { GoogleAnalyticsService } from 'ngx-google-analytics';
import { Observable } from 'rxjs';
import { UpdateConfig } from '../../actions/sheet.actions';
import { DiscrepencyId, DiscrepencyLabel, DoSearch, DuplicateId } from '../../actions/tree.actions';
import { CloseSearch, OpenSearch } from '../../actions/ui.actions';
import { BMNode } from '../../models/bimodal.model';
import { GaAction, GaCategory } from '../../models/ga.model';
import { SheetConfig } from '../../models/sheet.model';
import { SearchStructure, TNode } from '../../models/tree.model';
import { BimodalService } from '../../modules/tree/bimodal.service';
import { SheetState } from '../../store/sheet.state';
import { TreeState, TreeStateModel } from '../../store/tree.state';
import { UIState, UIStateModel } from '../../store/ui.state';
@Component({
selector: 'app-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss'],
})
export class SearchComponent {
@Input() disabled = false;
// Structures contains the full list of structures to render for the search
public structures: SearchStructure[] = [];
// Contains the subset matching the search term, to hide filtered out
// elements without removing them from the DOM completely
public searchFilteredStructures: SearchStructure[] = [];
// Contains the subset of structures matching the group name button toggle
public groupFilteredStructures: SearchStructure[] = [];
@ViewChild('searchField', { static: false }) searchFieldContent!: ElementRef;
@Select(TreeState) tree$!: Observable<TreeStateModel>;
@Select(UIState) ui$!: Observable<UIStateModel>;
@Select(UIState.getSearchState) searchState$!: Observable<boolean>;
@Select(SheetState.getSheetConfig) sheetConfig$!: Observable<SheetConfig>;
treeData: TNode[] = [];
nodes: BMNode[] = [];
searchValue = '';
selectedValues = '';
selectedOptions: SearchStructure[] = [];
selectionMemory: SearchStructure[] = [];
sheetConfig!: SheetConfig;
searchOpen = false;
selectionCompareFunction = (o1: SearchStructure, o2: SearchStructure) => o1.id === o2.id;
constructor(
public bms: BimodalService,
public store: Store,
public ga: GoogleAnalyticsService,
public router: Router,
private readonly elementRef: ElementRef,
) {
this.tree$.subscribe((tree) => {
this.selectedOptions = tree.search;
this.treeData = tree.treeData;
this.nodes = tree.bimodal.nodes;
});
// On tree selection, reset the selected options and structures array
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.structures = [];
this.selectedValues = '';
this.selectedOptions = [];
this.selectionMemory = [];
}
});
this.sheetConfig$.subscribe((config) => {
this.sheetConfig = config;
});
}
selectOption() {
// Find the latest option clicked
const newSelections = this.selectedOptions.filter((item) => this.selectionMemory.indexOf(item) < 0);
let lastClickedOption = null;
if (newSelections.length > 0) {
lastClickedOption = newSelections[0];
}
console.log(lastClickedOption);
// Toggling the Discrepency fields to off
this.sheetConfig.discrepencyId = false;
this.sheetConfig.discrepencyLabel = false;
this.sheetConfig.duplicateId = false;
this.store.dispatch(new UpdateConfig(this.sheetConfig));
// Dispatch the search data to the tree store
this.store.dispatch(new DoSearch(this.selectedOptions, lastClickedOption as SearchStructure));
// Clearing Discrepency fields so that searched options can appear
this.store.dispatch(new DiscrepencyLabel([]));
this.store.dispatch(new DiscrepencyId([]));
this.store.dispatch(new DuplicateId([]));
// Update the memory
this.selectionMemory = this.selectedOptions.slice();
// Build values for search bar UI text
this.selectedValues = this.selectedOptions.map((obj) => obj.name).join(', ');
this.ga.event(GaAction.CLICK, GaCategory.NAVBAR, 'Select/Deselect Search Filters');
}
selectFirstOption() {
this.selectedOptions.push(this.searchFilteredStructures[0]);
this.selectOption();
}
isSelected(structure: SearchStructure) {
return this.selectedOptions.includes(structure);
}
deselectAllOptions() {
this.selectedOptions = [];
this.selectionMemory = [];
this.selectedValues = '';
this.store.dispatch(new DoSearch(this.selectedOptions, null as unknown as SearchStructure));
this.ga.event(GaAction.CLICK, GaCategory.NAVBAR, 'Deselect All Search Filters');
}
selectAllOptions() {
this.selectedOptions = this.groupFilteredStructures.filter((s) => this.searchFilteredStructures.indexOf(s) >= 0);
this.selectionMemory = this.selectedOptions.slice();
this.selectedValues = this.selectedOptions.map((obj) => obj.name).join(', ');
this.store.dispatch(new DoSearch(this.selectedOptions, this.selectedOptions[0]));
this.ga.event(GaAction.CLICK, GaCategory.NAVBAR, 'Select All Searched Options');
}
openSearchList() {
if (this.structures.length === 0) {
const searchSet = new Set<SearchStructure>();
for (const node of this.treeData) {
if (node.children !== 0) {
searchSet.add({
id: node.id,
name: node.name,
groupName: 'Anatomical Structures',
x: node.x,
y: node.y,
});
}
}
for (const node of this.nodes) {
searchSet.add({
id: node.id,
name: node.name,
groupName: node.groupName,
x: node.x,
y: node.y,
});
}
this.structures = [...searchSet];
this.searchFilteredStructures = this.structures.slice();
this.groupFilteredStructures = this.structures.slice();
}
// Show search dropdown
this.store.dispatch(new OpenSearch());
this.searchOpen = true;
this.searchFieldContent.nativeElement.focus();
this.selectedOptions = this.selectionMemory.slice();
}
closeSearchList() {
if (this.searchOpen) {
this.store.dispatch(new CloseSearch());
this.searchOpen = false;
}
}
clearSearchField() {
this.searchValue = '';
this.filterStructuresOnSearch();
}
@HostListener('document:click', ['$event'])
clickOutsideSearchList(event: MouseEvent) {
const targetElement = event.target as HTMLElement;
// Check if the click was outside the element
if (targetElement && !this.elementRef.nativeElement.contains(targetElement)) {
this.closeSearchList();
}
}
// This method filters the structures on every letter typed
public filterStructuresOnSearch() {
if (!this.structures) {
return;
}
if (!this.searchValue) {
this.searchFilteredStructures = this.structures.slice();
return;
}
// filter the structures
this.searchFilteredStructures = this.structures.filter((structures) =>
structures.name.toLowerCase().includes(this.searchValue.toLowerCase()),
);
// This event fires for every letter typed
this.ga.event(GaAction.INPUT, GaCategory.NAVBAR, `Search term typed in: ${this.searchValue}`);
}
filterToggleChange(value: string[]) {
this.ga.event(GaAction.TOGGLE, GaCategory.NAVBAR, `Structure Group Name Toggle: ${this.searchValue}`);
if (value.length === 0) {
this.groupFilteredStructures = this.structures.slice();
return;
}
this.groupFilteredStructures = this.structures.filter((structure) => value.includes(structure.groupName));
}
// Hide a structure if it is absent from the filtered group list, otherwise hide when absent from the
// filtered search list
hideStructure(structure: SearchStructure) {
return (
this.groupFilteredStructures.indexOf(structure) <= -1 || this.searchFilteredStructures.indexOf(structure) <= -1
);
}
}
<div class="h-100 w-100 search-container">
<div class="pl-2 br-left search-icon-container">
<mat-icon class="mt-2">search</mat-icon>
</div>
<button
mat-flat-button
id="searchBtn"
[disabled]="disabled"
(click)="openSearchList()"
class="w-100 secondary ch text-start"
#tooltip="matTooltip"
matTooltip="Search Structures"
matTooltipPosition="below"
>
<span>{{ selectedValues }}</span>
<mat-icon class="droprown-arrow-icon">arrow_drop_down</mat-icon>
</button>
<div class="search-modal mat-elevation-z4" [hidden]="(searchState$ | async) === false">
<div class="search-controls br-top">
<mat-form-field class="br-top" appearance="fill" floatLabel="auto">
<input
matInput
cdkFocusInitial
#searchField
(keyup.enter)="selectFirstOption()"
[(ngModel)]="searchValue"
placeholder="Search Structures"
(input)="filterStructuresOnSearch()"
/>
<button
matSuffix
mat-icon-button
#clearBtn
[hidden]="!searchValue"
aria-label="Clear Search"
matTooltip="Clear Search"
(click)="clearSearchField()"
>
<mat-icon>close</mat-icon>
</button>
<button matSuffix mat-icon-button aria-label="Close" matTooltip="Close" (click)="closeSearchList()">
<mat-icon>arrow_drop_up</mat-icon>
</button>
</mat-form-field>
<br />
<p>
<span id="label">Show<br />only:</span>
<mat-button-toggle-group
multiple
#group="matButtonToggleGroup"
(change)="filterToggleChange(group.value)"
name="searchFilterButtons"
aria-label="searchFilterButtons"
>
<mat-button-toggle value="Anatomical Structures" matTooltip="Anatomical Structures">AS</mat-button-toggle>
<mat-button-toggle value="Cell Types" matTooltip="Cell Types">CT</mat-button-toggle>
<mat-button-toggle value="Biomarkers" matTooltip="Biomarkers">B</mat-button-toggle>
</mat-button-toggle-group>
<button class="selectBtn" mat-stroked-button aria-label="Select All" (click)="selectAllOptions()">
Select All
</button>
<button class="selectBtn" mat-stroked-button aria-label="Deselect All" (click)="deselectAllOptions()">
Deselect All
</button>
</p>
</div>
<mat-selection-list
#multiSelect
[disabled]="disabled"
[multiple]="true"
(selectionChange)="selectOption()"
[(ngModel)]="selectedOptions"
[compareWith]="selectionCompareFunction"
>
<mat-list-item *ngIf="searchFilteredStructures.length === 0 && groupFilteredStructures.length === 0">
<div>No entries found.</div>
</mat-list-item>
<mat-list-option
*ngFor="let structure of structures"
[value]="structure"
color="primary"
[hidden]="hideStructure(structure)"
[selected]="isSelected(structure)"
checkboxPosition="before"
>
<div class="structure-name">
<div>
{{ structure.name }}
</div>
<div class="structure-group-name">
<em>{{ structure.groupName }}</em>
</div>
</div>
</mat-list-option>
</mat-selection-list>
</div>
</div>
./search.component.scss
@use '@angular/material' as mat;
.mat-select-arrow {
visibility: hidden;
}
.selection-icon {
position: relative;
top: 0.375rem;
margin-right: 1rem;
}
.ch {
height: 2.5812rem !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.br-left {
border-top-left-radius: 0.5rem !important;
border-bottom-left-radius: 0.5rem !important;
}
.br-top {
border-top-left-radius: 0.25rem !important;
border-top-right-radius: 0.25rem !important;
}
.search-container {
display: flex;
align-items: center;
}
.search-icon-container {
display: inline-block;
background: #f5f5f5;
padding-left: 0.6rem;
}
.structure-name {
display: flex;
justify-content: space-between;
font-size: 11pt;
}
.structure-group-name {
font-size: 8pt;
}
::ng-deep .search-container .mat-form-field-underline {
display: none;
}
#searchBtn {
mat-icon {
float: right;
position: absolute;
top: 9px;
right: 10px;
}
::ng-deep .mat-button-wrapper {
text-overflow: ellipsis;
display: block;
overflow: hidden;
white-space: nowrap;
padding-right: 10px;
}
}
.search-modal {
background: white;
position: absolute;
width: 375px;
top: 10px;
z-index: 20;
border-bottom: 0.1875rem solid rgba(68, 74, 101, 0.063);
border-radius: 0.25rem;
.search-controls {
position: fixed;
z-index: 30;
border-bottom: 1px lightgray solid;
background: white;
width: 375px;
p {
padding: 0px 15px;
font-size: 10pt;
display: flex;
justify-content: center;
align-content: center;
margin-bottom: 10px;
}
#label {
width: 35px;
line-height: 14px;
font-size: 8pt;
display: inline-block;
padding-top: 4px;
}
::ng-deep .mat-button-toggle-appearance-standard .mat-button-toggle-label-content {
line-height: 32px !important;
}
button.selectBtn {
font-size: 13px;
margin-left: 12px;
padding: 0px 7px;
}
}
mat-form-field {
padding: 10px 15px;
width: 100%;
height: 55px;
font-size: 12pt;
//border-bottom: 1px rgb(238, 238, 238) solid;
color: gray;
button {
font-size: 18pt;
color: black;
}
input {
color: rgba(0, 0, 0, 0.87);
}
}
.mat-form-field-flex {
padding-top: 0px;
}
mat-selection-list {
margin-top: 95px;
overflow-y: scroll;
height: 300px;
}
mat-list-option {
height: 40px;
font-size: 11pt;
}
}
.droprown-arrow-icon {
transform: scale(1.4);
}
//@include mat.checkbox-theme($mat-reporter-primary);