From 54da61027b588603780908bea10be1e26ce457e2 Mon Sep 17 00:00:00 2001 From: Sergey Andrievskiy Date: Wed, 24 Apr 2019 17:13:53 +0200 Subject: [PATCH] fix(option group): propagate disabled state to child options (#1416) --- scripts/ci/browserstack/stop-tunnel.sh | 4 +- .../select/option-group.component.spec.ts | 148 ++++++++++++++++++ .../select/option-group.component.ts | 56 ++++++- .../components/select/option.component.ts | 16 +- .../theme/components/select/select.spec.ts | 83 ++++++++++ src/framework/theme/index.ts | 3 + 6 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 src/framework/theme/components/select/option-group.component.spec.ts diff --git a/scripts/ci/browserstack/stop-tunnel.sh b/scripts/ci/browserstack/stop-tunnel.sh index f95970de9f..854a8d7e68 100755 --- a/scripts/ci/browserstack/stop-tunnel.sh +++ b/scripts/ci/browserstack/stop-tunnel.sh @@ -5,7 +5,7 @@ set -e -o pipefail echo "Shutting down Browserstack tunnel" -killall BrowserStackLocal +pkill BrowserStack while [[ -n `ps -ef | grep "BrowserStackLocal" | grep -v "grep"` ]]; do printf "." @@ -13,4 +13,4 @@ while [[ -n `ps -ef | grep "BrowserStackLocal" | grep -v "grep"` ]]; do done echo "" -echo "Browserstack tunnel has been shut down" \ No newline at end of file +echo "Browserstack tunnel has been shut down" diff --git a/src/framework/theme/components/select/option-group.component.spec.ts b/src/framework/theme/components/select/option-group.component.spec.ts new file mode 100644 index 0000000000..f240d724e3 --- /dev/null +++ b/src/framework/theme/components/select/option-group.component.spec.ts @@ -0,0 +1,148 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + NbLayoutModule, + NbOptionComponent, + NbOptionGroupComponent, + NbSelectModule, + NbSelectComponent, + NbThemeModule, +} from '@nebular/theme'; + +@Component({ + template: ` + + + + + + 1 + + + + + + `, +}) +export class NbOptionGroupTestComponent { + selectDisabled = false; + optionGroupDisabled = false; + optionDisabled = false; + showOption = true; + optionGroupTitle = ''; + + @ViewChild(NbSelectComponent) selectComponent: NbSelectComponent; + @ViewChild(NbOptionGroupComponent) optionGroupComponent: NbOptionGroupComponent; + @ViewChild(NbOptionComponent) optionComponent: NbOptionComponent; +} + +describe('NbOptionGroupComponent', () => { + let fixture: ComponentFixture; + let testComponent: NbOptionGroupTestComponent; + let selectComponent: NbSelectComponent; + let optionGroupComponent: NbOptionGroupComponent; + let optionComponent: NbOptionComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + NbThemeModule.forRoot(), + NbLayoutModule, + NbSelectModule, + ], + declarations: [ NbOptionGroupTestComponent ], + }); + + fixture = TestBed.createComponent(NbOptionGroupTestComponent); + testComponent = fixture.componentInstance; + fixture.detectChanges(); + flush(); + + selectComponent = testComponent.selectComponent; + optionGroupComponent = testComponent.optionGroupComponent; + optionComponent = testComponent.optionComponent; + })); + + it('should contain passed title', () => { + const title = 'random option group title'; + selectComponent.show(); + testComponent.optionGroupTitle = title; + fixture.detectChanges(); + + const groupTitle = fixture.debugElement.query(By.directive(NbOptionGroupComponent)) + .query(By.css('.option-group-title')); + + expect(groupTitle.nativeElement.textContent).toEqual(title); + }); + + it('should have disabled attribute if disabled', () => { + selectComponent.show(); + testComponent.optionGroupDisabled = true; + fixture.detectChanges(); + + const optionGroup = fixture.debugElement.query(By.directive(NbOptionGroupComponent)); + expect(optionGroup.attributes.disabled).toEqual(''); + }); + + it('should remove disabled attribute if disabled set to false', () => { + selectComponent.show(); + testComponent.optionGroupDisabled = true; + fixture.detectChanges(); + + testComponent.optionGroupDisabled = false; + fixture.detectChanges(); + + const optionGroup = fixture.debugElement.query(By.directive(NbOptionGroupComponent)); + expect(optionGroup.attributes.disabled).toEqual(null); + }); + + it('should disable group options if group disabled', () => { + const setDisabledSpy = spyOn(optionComponent, 'setDisabledByGroupState'); + + optionGroupComponent.disabled = true; + fixture.detectChanges(); + + expect(setDisabledSpy).toHaveBeenCalledTimes(1); + expect(setDisabledSpy).toHaveBeenCalledWith(true); + }); + + it('should enable group options if group enabled', () => { + testComponent.optionDisabled = true; + fixture.detectChanges(); + + expect(optionComponent.disabled).toEqual(true); + + const setDisabledSpy = spyOn(optionComponent, 'setDisabledByGroupState'); + optionGroupComponent.disabled = false; + + expect(setDisabledSpy).toHaveBeenCalledTimes(1); + expect(setDisabledSpy).toHaveBeenCalledWith(false); + }); + + it('should update options state when options change', fakeAsync(() => { + testComponent.optionGroupDisabled = true; + testComponent.showOption = false; + fixture.detectChanges(); + flush(); + + testComponent.showOption = true; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(optionComponent.disabledAttribute).toEqual(''); + })); + + it('should update options state after content initialisation', fakeAsync(() => { + fixture = TestBed.createComponent(NbOptionGroupTestComponent); + testComponent = fixture.componentInstance; + testComponent.optionDisabled = true; + fixture.detectChanges(); + flush(); + + expect(testComponent.optionComponent.disabledAttribute).toEqual(''); + })); +}); diff --git a/src/framework/theme/components/select/option-group.component.ts b/src/framework/theme/components/select/option-group.component.ts index 6dea67e246..d3248e5a37 100644 --- a/src/framework/theme/components/select/option-group.component.ts +++ b/src/framework/theme/components/select/option-group.component.ts @@ -4,9 +4,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ -import { ChangeDetectionStrategy, Component, HostBinding, Input } from '@angular/core'; -import { convertToBoolProperty } from '../helpers'; +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + HostBinding, + Input, + OnDestroy, + QueryList, +} from '@angular/core'; +import { takeWhile } from 'rxjs/operators'; +import { convertToBoolProperty } from '../helpers'; +import { NbOptionComponent } from './option.component'; @Component({ selector: 'nb-option-group', @@ -17,7 +28,10 @@ import { convertToBoolProperty } from '../helpers'; `, }) -export class NbOptionGroupComponent { +export class NbOptionGroupComponent implements AfterContentInit, OnDestroy { + + protected alive = true; + @Input() title: string; @Input() @@ -26,6 +40,10 @@ export class NbOptionGroupComponent { } set disabled(value: boolean) { this._disabled = convertToBoolProperty(value); + + if (this.options) { + this.updateOptionsDisabledState(); + } } protected _disabled: boolean = false; @@ -33,6 +51,38 @@ export class NbOptionGroupComponent { get disabledAttribute(): '' | null { return this.disabled ? '' : null; } + + @ContentChildren(NbOptionComponent, { descendants: true }) options: QueryList>; + + ngAfterContentInit() { + if (this.options.length) { + this.asyncUpdateOptionsDisabledState(); + } + + this.options.changes + .pipe(takeWhile(() => this.alive)) + .subscribe(() => this.asyncUpdateOptionsDisabledState()); + } + + ngOnDestroy() { + this.alive = false; + } + + /** + * Sets disabled state for each option to current group disabled state. + */ + protected updateOptionsDisabledState(): void { + this.options.forEach((option: NbOptionComponent) => option.setDisabledByGroupState(this.disabled)); + } + + /** + * Updates options disabled state after promise resolution. + * This way change detection will be triggered after options state updated. + * Use this method when updating options during change detection run (e.g. QueryList.changes, lifecycle hooks). + */ + protected asyncUpdateOptionsDisabledState(): void { + Promise.resolve().then(() => this.updateOptionsDisabledState()); + } } diff --git a/src/framework/theme/components/select/option.component.ts b/src/framework/theme/components/select/option.component.ts index 39d96d8026..c318fd8428 100644 --- a/src/framework/theme/components/select/option.component.ts +++ b/src/framework/theme/components/select/option.component.ts @@ -42,6 +42,9 @@ import { NbSelectComponent } from './select.component'; `, }) export class NbOptionComponent implements OnDestroy { + + protected disabledByGroup = false; + /** * Option value that will be fired on selection. * */ @@ -103,7 +106,8 @@ export class NbOptionComponent implements OnDestroy { @HostBinding('attr.disabled') get disabledAttribute(): '' | null { - return this.disabled ? '' : null; + const disabled = this.disabledByGroup || this.disabled; + return disabled ? '' : null; } @HostListener('click') @@ -119,6 +123,16 @@ export class NbOptionComponent implements OnDestroy { this.setSelection(false); } + /** + * Sets disabled by group state and marks component for check. + */ + setDisabledByGroupState(disabled: boolean): void { + if (this.disabledByGroup !== disabled) { + this.disabledByGroup = disabled; + this.cd.markForCheck(); + } + } + protected setSelection(selected: boolean): void { /** * In case of changing options in runtime the reference to the selected option will be kept in select component. diff --git a/src/framework/theme/components/select/select.spec.ts b/src/framework/theme/components/select/select.spec.ts index f912f238b2..123bb8a13c 100644 --- a/src/framework/theme/components/select/select.spec.ts +++ b/src/framework/theme/components/select/select.spec.ts @@ -19,6 +19,7 @@ import { NB_DOCUMENT } from '../../theme.options'; import { NbSelectComponent } from './select.component'; import { NbLayoutModule } from '../layout/layout.module'; import { NbOptionComponent } from './option.component'; +import { NbOptionGroupComponent } from './option-group.component'; const TEST_GROUPS = [ @@ -248,6 +249,30 @@ export class NbSelectWithFalsyOptionValuesComponent { }) export class NbMultipleSelectWithFalsyOptionValuesComponent extends NbSelectWithFalsyOptionValuesComponent {} +@Component({ + template: ` + + + + + + 1 + + + + + + `, +}) +export class NbOptionDisabledTestComponent { + optionGroupDisabled = false; + optionDisabled = false; + + @ViewChild(NbSelectComponent) selectComponent: NbSelectComponent; + @ViewChild(NbOptionGroupComponent) optionGroupComponent: NbOptionGroupComponent; + @ViewChild(NbOptionComponent) optionComponent: NbOptionComponent; +} + describe('Component: NbSelectComponent', () => { let fixture: ComponentFixture; let overlayContainerService: NbOverlayContainerAdapter; @@ -729,3 +754,61 @@ describe('NbOptionComponent', () => { expect(selectionChangeSpy).toHaveBeenCalledTimes(1); })); }); + +describe('NbOptionComponent disabled', () => { + let fixture: ComponentFixture; + let testComponent: NbOptionDisabledTestComponent; + let selectComponent: NbSelectComponent; + let optionGroupComponent: NbOptionGroupComponent; + let optionComponent: NbOptionComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + NbThemeModule.forRoot(), + NbLayoutModule, + NbSelectModule, + ], + declarations: [ NbOptionDisabledTestComponent ], + }); + + fixture = TestBed.createComponent(NbOptionDisabledTestComponent); + testComponent = fixture.componentInstance; + fixture.detectChanges(); + flush(); + + selectComponent = testComponent.selectComponent; + optionGroupComponent = testComponent.optionGroupComponent; + optionComponent = testComponent.optionComponent; + })); + + it('should has disabled attribute if disabled set to true', () => { + selectComponent.show(); + testComponent.optionDisabled = true; + fixture.detectChanges(); + + const option = fixture.debugElement.query(By.directive(NbOptionComponent)); + expect(option.attributes.disabled).toEqual(''); + }); + + it('should has disabled attribute if group disabled set to true', fakeAsync(() => { + selectComponent.show(); + testComponent.optionGroupDisabled = true; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const option = fixture.debugElement.query(By.directive(NbOptionComponent)); + expect(option.attributes.disabled).toEqual(''); + })); + + it('should not change disabled property if group disabled changes', fakeAsync(() => { + testComponent.optionGroupDisabled = true; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(optionComponent.disabled).toEqual(false); + })); +}); diff --git a/src/framework/theme/index.ts b/src/framework/theme/index.ts index 51eccb6b37..067884210f 100644 --- a/src/framework/theme/index.ts +++ b/src/framework/theme/index.ts @@ -91,6 +91,9 @@ export * from './components/toastr/toastr.service'; export * from './components/tooltip/tooltip.module'; export * from './components/tooltip/tooltip.directive'; export * from './components/select/select.module'; +export * from './components/select/select.component'; +export * from './components/select/option.component'; +export * from './components/select/option-group.component'; export * from './components/window'; export * from './components/datepicker/datepicker.module'; export * from './components/datepicker/datepicker.directive';