diff --git a/projects/components/src/multi-select/multi-select.component.scss b/projects/components/src/multi-select/multi-select.component.scss index 1d5ec38f3..d576ee82f 100644 --- a/projects/components/src/multi-select/multi-select.component.scss +++ b/projects/components/src/multi-select/multi-select.component.scss @@ -63,6 +63,12 @@ .multi-select-content { @include dropdown(6px); min-width: 120px; + display: flex; + flex-direction: column; + + .divider { + padding: 0px 16px 4px 16px; + } .multi-select-option { display: flex; diff --git a/projects/components/src/multi-select/multi-select.component.test.ts b/projects/components/src/multi-select/multi-select.component.test.ts index 0c9aa3f00..7a4d30658 100644 --- a/projects/components/src/multi-select/multi-select.component.test.ts +++ b/projects/components/src/multi-select/multi-select.component.test.ts @@ -1,11 +1,16 @@ +import { CommonModule } from '@angular/common'; import { fakeAsync, flush } from '@angular/core/testing'; import { IconType } from '@hypertrace/assets-library'; -import { SearchBoxComponent } from '@hypertrace/components'; -import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { NavigationService } from '@hypertrace/common'; +import { createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest'; import { MockComponent } from 'ng-mocks'; +import { NEVER } from 'rxjs'; +import { ButtonComponent } from '../button/button.component'; import { DividerComponent } from '../divider/divider.component'; import { LabelComponent } from '../label/label.component'; -import { LetAsyncModule } from '../let-async/let-async.module'; +import { PopoverComponent } from '../popover/popover.component'; +import { PopoverModule } from '../popover/popover.module'; +import { SearchBoxComponent } from '../search-box/search-box.component'; import { SelectOptionComponent } from '../select/select-option.component'; import { MultiSelectJustify } from './multi-select-justify'; import { MultiSelectComponent, TriggerLabelDisplayMode } from './multi-select.component'; @@ -13,9 +18,19 @@ import { MultiSelectComponent, TriggerLabelDisplayMode } from './multi-select.co describe('Multi Select Component', () => { const hostFactory = createHostFactory>({ component: MultiSelectComponent, - imports: [LetAsyncModule], - entryComponents: [SelectOptionComponent], - declarations: [MockComponent(LabelComponent), MockComponent(DividerComponent), MockComponent(SearchBoxComponent)], + imports: [PopoverModule, CommonModule], + providers: [ + mockProvider(NavigationService, { + navigation$: NEVER + }) + ], + declarations: [ + SelectOptionComponent, + MockComponent(LabelComponent), + MockComponent(DividerComponent), + MockComponent(SearchBoxComponent), + MockComponent(ButtonComponent) + ], shallow: true }); @@ -24,27 +39,50 @@ describe('Multi Select Component', () => { const selectionOptions = [ { label: 'first', value: 'first-value' }, { label: 'second', value: 'second-value' }, - { label: 'third', value: 'third-value' } + { label: 'third', value: 'third-value' }, + { label: 'fourth', value: 'fourth-value' }, + { label: 'fifth', value: 'fifth-value' }, + { label: 'sixth', value: 'sixth-value' } ]; test('should display initial selections', fakeAsync(() => { spectator = hostFactory( ` - + `, { hostProps: { options: selectionOptions, - selected: [selectionOptions[1].value] + selected: [selectionOptions[1].value], + triggerLabelDisplayMode: TriggerLabelDisplayMode.Selection } } ); + spectator.tick(); + expect(spectator.component.triggerLabel).toEqual(selectionOptions[1].label); + expect(spectator.query('.trigger-content')).toExist(); + expect(spectator.query('.trigger-label-container')).toExist(); + expect(spectator.query('.trigger-label')).toExist(); + expect(spectator.query('.trigger-icon')).toExist(); + + const popoverComponent = spectator.query(PopoverComponent); + expect(popoverComponent?.closeOnClick).toEqual(false); + expect(popoverComponent?.closeOnNavigate).toEqual(true); + spectator.click('.trigger-content'); - expect(spectator.element).toHaveText(selectionOptions[1].label); + + expect(spectator.query('.multi-select-content', { root: true })).toExist(); + expect(spectator.query('.multi-select-content .search-bar', { root: true })).toExist(); + expect(spectator.query('.multi-select-content .multi-select-option', { root: true })).toExist(); + + expect(spectator.query('.multi-select-content', { root: true })).toExist(); + const optionElements = spectator.queryAll('.multi-select-option', { root: true }); + + expect(optionElements.length).toEqual(6); spectator.setHostInput({ selected: [selectionOptions[1].value, selectionOptions[2].value] @@ -79,7 +117,7 @@ describe('Multi Select Component', () => { spectator.click('.trigger-content'); const optionElements = spectator.queryAll('.multi-select-option:not(.all-options)', { root: true }); expect(spectator.query('.multi-select-content', { root: true })).toExist(); - expect(optionElements.length).toBe(3); + expect(optionElements.length).toBe(6); const selectedElements = spectator.queryAll('input:checked', { root: true }); expect(selectedElements.length).toBe(2); @@ -144,12 +182,12 @@ describe('Multi Select Component', () => { flush(); })); - test('should notify and update selection when all checkbox is selected', fakeAsync(() => { + test('should show select all and clear selected buttons', fakeAsync(() => { const onChange = jest.fn(); spectator = hostFactory( ` - + `, @@ -158,7 +196,7 @@ describe('Multi Select Component', () => { options: selectionOptions, selected: [selectionOptions[1].value], placeholder: 'Select options', - showAllOptionControl: true, + enableSearch: true, onChange: onChange } } @@ -167,19 +205,39 @@ describe('Multi Select Component', () => { spectator.tick(); spectator.click('.trigger-content'); - const allOptionElement = spectator.query('.all-options', { root: true }); + expect(spectator.query('.search-bar', { root: true })).toExist(); + expect(spectator.query('.divider', { root: true })).toExist(); + + expect(spectator.component.isAnyOptionSelected()).toEqual(true); + const clearSelectedButton = spectator.query('.clear-selected', { root: true }); + expect(clearSelectedButton).toExist(); + spectator.click(clearSelectedButton!); + + spectator.tick(); + expect(spectator.queryAll('input:checked', { root: true }).length).toBe(0); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith([]); + expect(spectator.query(LabelComponent)?.label).toEqual('Select options'); + + const allOptionElement = spectator.query('.select-all', { root: true }); expect(allOptionElement).toExist(); spectator.click(allOptionElement!); - expect(onChange).toHaveBeenCalledTimes(1); + spectator.tick(); + const selectedElements = spectator.queryAll('input:checked', { root: true }); + expect(selectedElements.length).toBe(6); + expect(onChange).toHaveBeenCalledWith(selectionOptions.map(option => option.value)); - expect(spectator.query(LabelComponent)?.label).toEqual('first and 2 more'); + expect(spectator.query(LabelComponent)?.label).toEqual('first and 5 more'); - // De select all - spectator.click(allOptionElement!); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenLastCalledWith([]); - expect(spectator.query(LabelComponent)?.label).toEqual('Select options'); + spectator.setHostInput({ + enableSearch: false + }); + + expect(spectator.query('.search-bar', { root: true })).not.toExist(); + expect(spectator.query('.divider', { root: true })).not.toExist(); + expect(spectator.query('.clear-selected', { root: true })).not.toExist(); + expect(spectator.query('.select-all', { root: true })).not.toExist(); flush(); })); @@ -234,21 +292,23 @@ describe('Multi Select Component', () => { ); spectator.tick(); - expect(spectator.element).toHaveText(selectionOptions[1].label); + expect(spectator.component.triggerLabel).toEqual(selectionOptions[1].label); + expect(spectator.query('.trigger-content')).toExist(); + expect(spectator.query('.trigger-label-container')).toExist(); + expect(spectator.query('.trigger-label')).toExist(); + expect(spectator.query('.trigger-icon')).toExist(); expect(spectator.query('.trigger-content')!.getAttribute('style')).toBe('justify-content: flex-start;'); spectator.setInput({ justify: MultiSelectJustify.Center }); - expect(spectator.element).toHaveText(selectionOptions[1].label); expect(spectator.query('.trigger-content')!.getAttribute('style')).toBe('justify-content: center;'); spectator.setInput({ justify: MultiSelectJustify.Right }); - expect(spectator.element).toHaveText(selectionOptions[1].label); expect(spectator.query('.trigger-content')!.getAttribute('style')).toBe('justify-content: flex-end;'); })); @@ -267,24 +327,39 @@ describe('Multi Select Component', () => { } ); - spectator.tick(); - expect(spectator.query('.search-bar')).toExist(); - spectator.click('.search-bar'); + spectator.click('.trigger-content'); + + const searchBar = spectator.query('.search-bar', { root: true }); + expect(searchBar).toExist(); - spectator.triggerEventHandler(SearchBoxComponent, 'valueChange', 'fi'); + spectator.component.searchOptions('fi'); spectator.tick(); let options = spectator.queryAll('.multi-select-option', { root: true }); - expect(options.length).toBe(1); + expect(options.length).toBe(2); expect(options[0]).toContainText('first'); - spectator.triggerEventHandler(SearchBoxComponent, 'valueChange', 'i'); + spectator.component.searchOptions('i'); spectator.tick(); options = spectator.queryAll('.multi-select-option', { root: true }); - expect(options.length).toBe(2); + expect(options.length).toBe(4); expect(options[0]).toContainText('first'); expect(options[1]).toContainText('third'); + + expect(spectator.query('.divider', { root: true })).toExist(); + expect(spectator.query('.clear-selected', { root: true })).not.toExist(); // Due to initial selection + expect(spectator.query('.select-all', { root: true })).toExist(); + + // Set selected options to less than 5 and search box and buttons should hide + spectator.setHostInput({ + options: selectionOptions.slice(0, 3) + }); + + expect(spectator.query('.search-bar', { root: true })).not.toExist(); + expect(spectator.query('.divider', { root: true })).not.toExist(); + expect(spectator.query('.clear-selected', { root: true })).not.toExist(); + expect(spectator.query('.select-all', { root: true })).not.toExist(); flush(); })); }); diff --git a/projects/components/src/multi-select/multi-select.component.ts b/projects/components/src/multi-select/multi-select.component.ts index b842283de..4b55900c7 100644 --- a/projects/components/src/multi-select/multi-select.component.ts +++ b/projects/components/src/multi-select/multi-select.component.ts @@ -10,12 +10,12 @@ import { QueryList } from '@angular/core'; import { IconType } from '@hypertrace/assets-library'; -import { LoggerService, queryListAndChanges$, TypedSimpleChanges } from '@hypertrace/common'; -import { EMPTY, merge, Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { queryListAndChanges$ } from '@hypertrace/common'; +import { BehaviorSubject, combineLatest, EMPTY, Observable, Subject } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ButtonRole, ButtonStyle } from '../button/button'; import { IconSize } from '../icon/icon-size'; import { SearchBoxDisplayMode } from '../search-box/search-box.component'; -import { SelectOption } from '../select/select-option'; import { SelectOptionComponent } from '../select/select-option.component'; import { SelectSize } from '../select/select-size'; import { MultiSelectJustify } from './multi-select-justify'; @@ -33,7 +33,6 @@ import { MultiSelectJustify } from './multi-select-justify'; this.disabled ? 'disabled' : '', this.popoverOpen ? 'open' : '' ]" - *htLetAsync="this.selected$ as selected" >
- + + + + + + + + + + - -
- - Select All -
-
- - -
+
implements AfterContentInit, OnChanges { @Input() public justify: MultiSelectJustify = MultiSelectJustify.Left; - @Input() - public showAllOptionControl?: boolean = false; - @Input() public triggerLabelDisplayMode: TriggerLabelDisplayMode = TriggerLabelDisplayMode.Selection; @@ -128,60 +144,64 @@ export class MultiSelectComponent implements AfterContentInit, OnChanges { public readonly selectedChange: EventEmitter = new EventEmitter(); @ContentChildren(SelectOptionComponent) - public items?: QueryList>; + private readonly allOptionsList?: QueryList>; + public allOptions$!: Observable>>; + + public filteredOptions$!: Observable[]>; + private readonly searchSubject: Subject = new BehaviorSubject(''); public popoverOpen: boolean = false; - public selected$?: Observable[]>; public triggerLabel?: string; - public filteredItems?: SelectOptionComponent[]; - - public constructor(private readonly loggerService: LoggerService) {} public ngAfterContentInit(): void { - this.selected$ = this.buildObservableOfSelected(); + this.allOptions$ = this.allOptionsList !== undefined ? queryListAndChanges$(this.allOptionsList) : EMPTY; + this.filteredOptions$ = combineLatest([this.allOptions$, this.searchSubject]).pipe( + map(([options, searchText]) => + options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase())) + ) + ); this.setTriggerLabel(); - this.filteredItems = this.items?.toArray(); } - public ngOnChanges(changes: TypedSimpleChanges): void { - if (this.items !== undefined && changes.selected !== undefined) { - this.selected$ = this.buildObservableOfSelected(); - } + public ngOnChanges(): void { this.setTriggerLabel(); } public searchOptions(searchText: string): void { - this.filteredItems = this.items?.filter(item => item.label.toLowerCase().includes(searchText.toLowerCase())); + this.searchSubject.next(searchText); } - public onAllSelectionChange(): void { - this.selected = this.areAllOptionsSelected() ? [] : this.items!.map(item => item.value); // Select All or none - this.setSelection(); + public onSelectAll(): void { + this.setSelection(this.allOptionsList!.map(item => item.value)); + } + + public onClearSelected(): void { + this.setSelection([]); } public isIconOnlyMode(): boolean { return this.triggerLabelDisplayMode === TriggerLabelDisplayMode.Icon; } - public areAllOptionsSelected(): boolean { - return this.selected !== undefined && this.items !== undefined && this.selected.length === this.items.length; + public isAnyOptionSelected(): boolean { + return this.selected !== undefined && this.allOptionsList !== undefined && this.selected.length > 0; } public onSelectionChange(item: SelectOptionComponent): void { - this.selected = this.isSelectedItem(item) + const selected = this.isSelectedItem(item) ? this.selected?.filter(value => value !== item.value) : (this.selected ?? []).concat(item.value); - this.setSelection(); + this.setSelection(selected ?? []); } public isSelectedItem(item: SelectOptionComponent): boolean { return this.selected !== undefined && this.selected.filter(value => value === item.value).length > 0; } - private setSelection(): void { + private setSelection(selected: V[]): void { + this.selected = selected; this.setTriggerLabel(); - this.selected$ = this.buildObservableOfSelected(); this.selectedChange.emit(this.selected); } @@ -192,7 +212,9 @@ export class MultiSelectComponent implements AfterContentInit, OnChanges { return; } - const selectedItems: SelectOptionComponent[] | undefined = this.items?.filter(item => this.isSelectedItem(item)); + const selectedItems: SelectOptionComponent[] | undefined = this.allOptionsList?.filter(item => + this.isSelectedItem(item) + ); if (selectedItems === undefined || selectedItems.length === 0) { this.triggerLabel = this.placeholder; } else if (selectedItems.length === 1) { @@ -201,28 +223,6 @@ export class MultiSelectComponent implements AfterContentInit, OnChanges { this.triggerLabel = `${selectedItems[0].label} and ${selectedItems.length - 1} more`; } } - - private buildObservableOfSelected(): Observable[]> { - if (!this.items) { - return EMPTY; - } - - return queryListAndChanges$(this.items).pipe( - switchMap(items => merge(of(undefined), ...items.map(option => option.optionChange$))), - map(() => this.findItems(this.selected) ?? []) - ); - } - - // Find the select option object for a value - private findItems(value: V[] | undefined): SelectOption[] | undefined { - if (this.items === undefined) { - this.loggerService.warn(`Invalid items for select option '${String(value)}'`); - - return undefined; - } - - return this.items.filter(item => this.isSelectedItem(item)); - } } export const enum TriggerLabelDisplayMode { diff --git a/projects/components/src/multi-select/multi-select.module.ts b/projects/components/src/multi-select/multi-select.module.ts index 25786a4ed..ea1edf790 100644 --- a/projects/components/src/multi-select/multi-select.module.ts +++ b/projects/components/src/multi-select/multi-select.module.ts @@ -1,25 +1,15 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ButtonModule } from '../button/button.module'; import { DividerModule } from '../divider/divider.module'; import { IconModule } from '../icon/icon.module'; import { LabelModule } from '../label/label.module'; -import { LetAsyncModule } from '../let-async/let-async.module'; import { PopoverModule } from '../popover/popover.module'; import { TraceSearchBoxModule } from '../search-box/search-box.module'; import { MultiSelectComponent } from './multi-select.component'; @NgModule({ - imports: [ - FormsModule, - CommonModule, - IconModule, - LabelModule, - LetAsyncModule, - PopoverModule, - DividerModule, - TraceSearchBoxModule - ], + imports: [CommonModule, IconModule, LabelModule, PopoverModule, DividerModule, TraceSearchBoxModule, ButtonModule], declarations: [MultiSelectComponent], exports: [MultiSelectComponent] })