diff options
Diffstat (limited to 'sdc-workflow-designer-ui/src/app/paletx/plx-modal')
11 files changed, 1234 insertions, 0 deletions
diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts new file mode 100644 index 00000000..887b66e4 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; +import {PlxModalBackdrop} from './modal-backdrop'; + +describe('plx-modal-backdrop', () => { + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [PlxModalBackdrop]}); + }); + + it('should render backdrop with required CSS classes', () => { + const fixture = TestBed.createComponent(PlxModalBackdrop); + + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveCssClass('modal-backdrop'); + }); +}); diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts new file mode 100644 index 00000000..07e2ff84 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts @@ -0,0 +1,9 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'plx-modal-backdrop', + template: '', + host: {'class': 'modal-backdrop fade show'} +}) +export class PlxModalBackdrop { +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts new file mode 100644 index 00000000..08395852 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts @@ -0,0 +1,4 @@ +export enum ModalDismissReasons { + BACKDROP_CLICK, + ESC +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts new file mode 100644 index 00000000..061dc70e --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts @@ -0,0 +1,109 @@ +import {Injectable, ComponentRef} from '@angular/core'; +import {PlxModalBackdrop} from './modal-backdrop'; +import {PlxModalWindow} from './modal-window'; +import {ContentRef} from '../util/popup'; + +/** + * A reference to an active (currently opened) modal. Instances of this class + * can be injected into components passed as modal content. + */ +@Injectable() +export class PlxActiveModal { + /** + * Can be used to close a modal, passing an optional result. + */ + public close(result?: any): void { + // TO DO + } + + /** + * Can be used to dismiss a modal, passing an optional reason. + */ + public dismiss(reason?: any): void { + // TO DO + } +} + +/** + * A reference to a newly opened modal. + */ +@Injectable() +export class PlxModalRef { + private _resolve: (result?: any) => void; + private _reject: (reason?: any) => void; + + /** + * The instance of component used as modal's content. + * Undefined when a TemplateRef is used as modal's content. + */ + get componentInstance(): any { + if (this._contentRef.componentRef) { + return this._contentRef.componentRef.instance; + } + } + + // only needed to keep TS1.8 compatibility + set componentInstance(instance: any) { + // TO DO + } + + /** + * A promise that is resolved when a modal is closed and rejected when a modal is dismissed. + */ + public result: Promise<any>; + + constructor(private _windowCmptRef: ComponentRef<PlxModalWindow>, private _contentRef: ContentRef, + private _backdropCmptRef?: ComponentRef<PlxModalBackdrop>) { + _windowCmptRef.instance.dismissEvent.subscribe((reason: any) => { + this.dismiss(reason); + }); + + this.result = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + this.result.then(null, () => { + // TO DO + }); + } + + /** + * Can be used to close a modal, passing an optional result. + */ + public close(result?: any): void { + if (this._windowCmptRef) { + this._resolve(result); + this._removeModalElements(); + } + } + + /** + * Can be used to dismiss a modal, passing an optional reason. + */ + public dismiss(reason?: any): void { + if (this._windowCmptRef) { + this._reject(reason); + this._removeModalElements(); + } + } + + private _removeModalElements() { + const windowNativeEl = this._windowCmptRef.location.nativeElement; + windowNativeEl.parentNode.removeChild(windowNativeEl); + this._windowCmptRef.destroy(); + + if (this._backdropCmptRef) { + const backdropNativeEl = this._backdropCmptRef.location.nativeElement; + backdropNativeEl.parentNode.removeChild(backdropNativeEl); + this._backdropCmptRef.destroy(); + } + + if (this._contentRef && this._contentRef.viewRef) { + this._contentRef.viewRef.destroy(); + } + + this._windowCmptRef = null; + this._backdropCmptRef = null; + this._contentRef = null; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts new file mode 100644 index 00000000..37f5b171 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts @@ -0,0 +1,103 @@ +import { + ApplicationRef, + Injectable, + Injector, + ReflectiveInjector, + ComponentFactory, + ComponentFactoryResolver, + ComponentRef, + TemplateRef +} from '@angular/core'; + +import {ContentRef} from '../util/popup'; +import {isDefined, isString} from '../util/util'; + +import {PlxModalBackdrop} from './modal-backdrop'; +import {PlxModalWindow} from './modal-window'; +import {PlxActiveModal, PlxModalRef} from './modal-ref'; + +@Injectable() +export class PlxModalStack { + private _backdropFactory: ComponentFactory<PlxModalBackdrop>; + private _windowFactory: ComponentFactory<PlxModalWindow>; + + constructor(private _applicationRef: ApplicationRef, private _injector: Injector, + private _componentFactoryResolver: ComponentFactoryResolver) { + this._backdropFactory = _componentFactoryResolver.resolveComponentFactory(PlxModalBackdrop); + this._windowFactory = _componentFactoryResolver.resolveComponentFactory(PlxModalWindow); + } + + public open(moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, options): PlxModalRef { + const containerSelector = options.container || 'body'; + const containerEl = document.querySelector(containerSelector);// 默认获取到body的DOM + + if (!containerEl) { + throw new Error(`The specified modal container "${containerSelector}" was not found in the DOM.`); + } + + const activeModal = new PlxActiveModal(); + const contentRef = this._getContentRef(moduleCFR, contentInjector, content, activeModal); + + let windowCmptRef: ComponentRef<PlxModalWindow>; + let backdropCmptRef: ComponentRef<PlxModalBackdrop>; + let ngbModalRef: PlxModalRef; + + + if (options.backdrop !== false) { + backdropCmptRef = this._backdropFactory.create(this._injector); + this._applicationRef.attachView(backdropCmptRef.hostView); + containerEl.appendChild(backdropCmptRef.location.nativeElement); + } + windowCmptRef = this._windowFactory.create(this._injector, contentRef.nodes); + + /** + * Attaches a view so that it will be dirty checked. + * The view will be automatically detached when it is destroyed. + * This will throw if the view is already attached to a ViewContainer. + */ + this._applicationRef.attachView(windowCmptRef.hostView); + + containerEl.appendChild(windowCmptRef.location.nativeElement); + + ngbModalRef = new PlxModalRef(windowCmptRef, contentRef, backdropCmptRef); + + activeModal.close = (result: any) => { + ngbModalRef.close(result); + }; + activeModal.dismiss = (reason: any) => { + ngbModalRef.dismiss(reason); + }; + + this._applyWindowOptions(windowCmptRef.instance, options); + + return ngbModalRef; + } + + private _applyWindowOptions(windowInstance: PlxModalWindow, options: Object): void { + ['backdrop', 'keyboard', 'size', 'windowClass'].forEach((optionName: string) => { + if (isDefined(options[optionName])) { + windowInstance[optionName] = options[optionName]; + } + }); + } + + private _getContentRef(moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, + context: PlxActiveModal): ContentRef { + if (!content) { + return new ContentRef([]); + } else if (content instanceof TemplateRef) { + const viewRef = content.createEmbeddedView(context); + this._applicationRef.attachView(viewRef); + return new ContentRef([viewRef.rootNodes], viewRef); + } else if (isString(content)) { + return new ContentRef([[document.createTextNode(`${content}`)]]); + } else { + const contentCmptFactory = moduleCFR.resolveComponentFactory(content); + const modalContentInjector = + ReflectiveInjector.resolveAndCreate([{provide: PlxActiveModal, useValue: context}], contentInjector); + const componentRef = contentCmptFactory.create(modalContentInjector); + this._applicationRef.attachView(componentRef.hostView); + return new ContentRef([[componentRef.location.nativeElement]], componentRef.hostView, componentRef); + } + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts new file mode 100644 index 00000000..5767bfee --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts @@ -0,0 +1,114 @@ +import {TestBed, ComponentFixture} from '@angular/core/testing'; + +import {PlxModalWindow} from './modal-window'; +import {ModalDismissReasons} from './modal-dismiss-reasons'; + +describe('plx-modal-dialog', () => { + + let fixture: ComponentFixture<PlxModalWindow>; + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [PlxModalWindow]}); + fixture = TestBed.createComponent(PlxModalWindow); + }); + + describe('basic rendering functionality', () => { + + it('should render default modal window', () => { + fixture.detectChanges(); + + const modalEl: Element = fixture.nativeElement; + const dialogEl: Element = fixture.nativeElement.querySelector('.modal-dialog'); + + expect(modalEl).toHaveCssClass('modal'); + expect(dialogEl).toHaveCssClass('modal-dialog'); + }); + + it('should render default modal window with a specified size', () => { + fixture.componentInstance.size = 'sm'; + fixture.detectChanges(); + + const dialogEl: Element = fixture.nativeElement.querySelector('.modal-dialog'); + expect(dialogEl).toHaveCssClass('modal-dialog'); + expect(dialogEl).toHaveCssClass('modal-sm'); + }); + + it('should render default modal window with a specified class', () => { + fixture.componentInstance.windowClass = 'custom-class'; + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveCssClass('custom-class'); + }); + + it('aria attributes', () => { + fixture.detectChanges(); + const dialogEl: Element = fixture.nativeElement.querySelector('.modal-dialog'); + + expect(fixture.nativeElement.getAttribute('role')).toBe('dialog'); + expect(dialogEl.getAttribute('role')).toBe('document'); + }); + }); + + describe('dismiss', () => { + + it('should dismiss on backdrop click by default', (done) => { + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.BACKDROP_CLICK); + done(); + }); + + fixture.nativeElement.click(); + }); + + it('should not dismiss on modal content click when there is active backdrop', (done) => { + fixture.detectChanges(); + fixture.componentInstance.dismissEvent.subscribe( + () => { + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.nativeElement.querySelector('.modal-content').click(); + setTimeout(done, 200); + }); + + it('should ignore backdrop clicks when there is no backdrop', (done) => { + fixture.componentInstance.backdrop = false; + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.BACKDROP_CLICK); + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.nativeElement.querySelector('.modal-dialog').click(); + setTimeout(done, 200); + }); + + it('should ignore backdrop clicks when backdrop is "static"', (done) => { + fixture.componentInstance.backdrop = 'static'; + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.BACKDROP_CLICK); + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.nativeElement.querySelector('.modal-dialog').click(); + setTimeout(done, 200); + }); + + it('should dismiss on esc press by default', (done) => { + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.ESC); + done(); + }); + + fixture.debugElement.triggerEventHandler('keyup.esc', {}); + }); + }); + +}); diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts new file mode 100644 index 00000000..eda5b39f --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts @@ -0,0 +1,82 @@ +import { + Component, + Output, + EventEmitter, + Input, + ElementRef, + Renderer, + OnInit, + AfterViewInit, + OnDestroy, ViewEncapsulation +} from '@angular/core'; + +import {ModalDismissReasons} from './modal-dismiss-reasons'; + +@Component({ + selector: 'plx-modal-window', + host: { + '[class]': '"modal plx-modal fade show" + (windowClass ? " " + windowClass : "")', + 'role': 'dialog', + 'tabindex': '-1', + 'style': 'display: block;', + '(keyup.esc)': 'escKey($event)', + '(click)': 'backdropClick($event)' + }, + template: ` + <div [class]="'modal-dialog' + (size ? ' modal-' + size : '')" role="document"> + <div class="modal-content"><ng-content></ng-content></div> + </div> + `, + styleUrls: ['modal.less'], + encapsulation: ViewEncapsulation.None +}) +export class PlxModalWindow implements OnInit, AfterViewInit, OnDestroy { + private _elWithFocus: Element; // element that is focused prior to modal opening + + @Input() public backdrop: boolean | string = true; + @Input() public keyboard = true; + @Input() public size: string; + @Input() public windowClass: string; + + @Output('dismiss') public dismissEvent = new EventEmitter(); + + constructor(private _elRef: ElementRef, private _renderer: Renderer) { + } + + public backdropClick($event): void { + if (this.backdrop === true && this._elRef.nativeElement === $event.target) { + this.dismiss(ModalDismissReasons.BACKDROP_CLICK); + } + } + + public escKey($event): void { + if (this.keyboard && !$event.defaultPrevented) { + this.dismiss(ModalDismissReasons.ESC); + } + } + + public dismiss(reason): void { + this.dismissEvent.emit(reason); + } + + public ngOnInit() { + this._elWithFocus = document.activeElement; + this._renderer.setElementClass(document.body, 'modal-open', true); + } + + public ngAfterViewInit() { + if (!this._elRef.nativeElement.contains(document.activeElement)) { + this._renderer.invokeElementMethod(this._elRef.nativeElement, 'focus', []); + } + } + + public ngOnDestroy() { + if (this._elWithFocus && document.body.contains(this._elWithFocus)) { + this._renderer.invokeElementMethod(this._elWithFocus, 'focus', []); + } else { + this._renderer.invokeElementMethod(document.body, 'focus', []); + } + this._elWithFocus = null; + this._renderer.setElementClass(document.body, 'modal-open', false); + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less new file mode 100644 index 00000000..c17a7fd1 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less @@ -0,0 +1,125 @@ +@import "../../assets/components/themes/default/theme.less";
+
+plx-modal-window {
+ .modal {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ display: none;
+ outline: 0;
+ z-index: 10000;
+ }
+ .modal-dialog {
+ position: relative;
+ max-width: 600px;
+ margin: 30px auto;
+ &.modal-sm {
+ max-width: 600px;
+ }
+ &.modal-lg {
+ max-width: 1000px;
+ }
+ }
+ .modal-content {
+ position: relative;
+ display: flex;
+ -webkit-box-orient: vertical;
+ -webkit-box-direction: normal;
+ -ms-flex-direction: column;
+ flex-direction: column;
+ background-color: @component-bg;
+ background-clip: padding-box;
+ border-radius: @radius;
+ box-shadow: 0 5px 15px @shadow-color;
+ outline: 0;
+ .modal-header {
+ border-bottom: 0;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: justify;
+ -ms-flex-pack: justify;
+ justify-content: space-between;
+ padding: 15px;
+ }
+ .modal-body {
+ .form-group:last-child, form:last-child {
+ margin-bottom: 0;
+ }
+ }
+ .modal-footer {
+ display: block;
+ border-top: 0;
+ margin-top: 0;
+ padding: 0 15px 15px 15px;
+ }
+ .modal-title {
+ font-size: @font-size-title-level1;
+ margin-bottom: 0;
+ line-height: 1.5;
+ }
+ .modal-btn {
+ text-align: center;
+ font-size: 0;
+ }
+ }
+ .close {
+ color: @fonticon-color;
+ font-size: @font-size-title-level2;
+ text-shadow: none;
+ width: 24px;
+ height: 24px;
+ background: @scene-textcolor;
+ border-radius: 20px;
+ padding-bottom: 2px;
+ outline: none;
+ &:hover {
+ color: @fonticon-color;
+ background: @fonticon-bg-color-hover;
+ }
+ }
+ .alert-modal {
+ &.row {
+ margin-left: 100px;
+ margin-bottom: 30px;
+ text-align: left;
+ .tip-img {
+ display: inline-block;
+ width: 52px;
+ height: 52px;
+ border-radius: 50px;
+ font-size: 45px;
+ text-align: center;
+ line-height: 1;
+ margin-top: -5px;
+ margin-right: 15px;
+ &::before {
+ content: "!";
+ }
+ }
+ .tip-info {
+ width: 300px;
+ .alert-title {
+ font-size: @font-size-title-level2;
+ color: @title-text-color;
+ }
+ .alert-result {
+ margin-top: 5px;
+ font-size: @font-size;
+ color: @unselected-text-color;
+ }
+ }
+ .warning {
+ border: 3px solid @warning-color;
+ color: @warning-color;
+ }
+ .error {
+ border: 3px solid @error-color;
+ color: @error-color;
+ }
+ }
+ }
+}
\ No newline at end of file diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts new file mode 100644 index 00000000..67fdb478 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts @@ -0,0 +1,21 @@ +import {NgModule, ModuleWithProviders} from '@angular/core'; + +import {PlxModalBackdrop} from './modal-backdrop'; +import {PlxModalWindow} from './modal-window'; +import {PlxModalStack} from './modal-stack'; +import {PlxModal} from './modal'; + +export {PlxModal, PlxModalOptions} from './modal'; +export {PlxModalRef, PlxActiveModal} from './modal-ref'; +export {ModalDismissReasons} from './modal-dismiss-reasons'; + +@NgModule({ + declarations: [PlxModalBackdrop, PlxModalWindow], + entryComponents: [PlxModalBackdrop, PlxModalWindow], + providers: [PlxModal] +}) +export class PlxModalModule { + public static forRoot(): ModuleWithProviders { + return {ngModule: PlxModalModule, providers: [PlxModal, PlxModalStack]}; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts new file mode 100644 index 00000000..a99c86b1 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts @@ -0,0 +1,597 @@ +import {Component, Injectable, ViewChild, OnDestroy, NgModule, getDebugNode, DebugElement} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TestBed, ComponentFixture} from '@angular/core/testing'; + +import {PlxModalModule, PlxModal, PlxActiveModal, PlxModalRef} from './modal.module'; + +const NOOP = () => { +}; + +@Injectable() +class SpyService { + called = false; +} + +describe('plx-modal', () => { + + let fixture: ComponentFixture<TestComponent>; + + beforeEach(() => { + jasmine.addMatchers({ + toHaveModal: function (util, customEqualityTests) { + return { + compare: function (actual, content?, selector?) { + const allModalsContent = document.querySelector(selector || 'body').querySelectorAll('.modal-content'); + let pass = true; + let errMsg; + + if (!content) { + pass = allModalsContent.length > 0; + errMsg = 'at least one modal open but found none'; + } else if (Array.isArray(content)) { + pass = allModalsContent.length === content.length; + errMsg = `${content.length} modals open but found ${allModalsContent.length}`; + } else { + pass = allModalsContent.length === 1 && allModalsContent[0].textContent.trim() === content; + errMsg = `exactly one modal open but found ${allModalsContent.length}`; + } + + return {pass: pass, message: `Expected ${actual.outerHTML} to have ${errMsg}`}; + }, + negativeCompare: function (actual) { + const allOpenModals = actual.querySelectorAll('plx-modal-window'); + + return { + pass: allOpenModals.length === 0, + message: `Expected ${actual.outerHTML} not to have any modals open but found ${allOpenModals.length}` + }; + } + }; + } + }); + + jasmine.addMatchers({ + toHaveBackdrop: function (util, customEqualityTests) { + return { + compare: function (actual) { + return { + pass: document.querySelectorAll('plx-modal-backdrop').length === 1, + message: `Expected ${actual.outerHTML} to have exactly one backdrop element` + }; + }, + negativeCompare: function (actual) { + const allOpenModals = document.querySelectorAll('plx-modal-backdrop'); + + return { + pass: allOpenModals.length === 0, + message: `Expected ${actual.outerHTML} not to have any backdrop elements` + }; + } + }; + } + }); + }); + + beforeEach(() => { + TestBed.configureTestingModule({imports: [OesModalTestModule]}); + fixture = TestBed.createComponent(TestComponent); + }); + + afterEach(() => { + // detect left-over modals and close them or report errors when can't + + const remainingModalWindows = document.querySelectorAll('plx-modal-window'); + if (remainingModalWindows.length) { + fail(`${remainingModalWindows.length} modal windows were left in the DOM.`); + } + + const remainingModalBackdrops = document.querySelectorAll('plx-modal-backdrop'); + if (remainingModalBackdrops.length) { + fail(`${remainingModalBackdrops.length} modal backdrops were left in the DOM.`); + } + }); + + describe('basic functionality', () => { + + it('should open and close modal with default options', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should open and close modal from a TemplateRef content', () => { + const modalInstance = fixture.componentInstance.openTpl(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Hello, World!'); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should properly destroy TemplateRef content', () => { + const spyService = fixture.debugElement.injector.get(SpyService); + const modalInstance = fixture.componentInstance.openDestroyableTpl(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Some content'); + expect(spyService.called).toBeFalsy(); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(spyService.called).toBeTruthy(); + }); + + it('should open and close modal from a component type', () => { + const spyService = fixture.debugElement.injector.get(SpyService); + const modalInstance = fixture.componentInstance.openCmpt(DestroyableCmpt); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Some content'); + expect(spyService.called).toBeFalsy(); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(spyService.called).toBeTruthy(); + }); + + it('should inject active modal ref when component is used as content', () => { + fixture.componentInstance.openCmpt(WithActiveModalCmpt); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Close'); + + (<HTMLElement>document.querySelector('button.closeFromInside')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should expose component used as modal content', () => { + const modalInstance = fixture.componentInstance.openCmpt(WithActiveModalCmpt); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Close'); + expect(modalInstance.componentInstance instanceof WithActiveModalCmpt).toBeTruthy(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should open and close modal from inside', () => { + fixture.componentInstance.openTplClose(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (<HTMLElement>document.querySelector('button#close')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should open and dismiss modal from inside', () => { + fixture.componentInstance.openTplDismiss().result.catch(NOOP); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (<HTMLElement>document.querySelector('button#dismiss')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should resolve result promise on close', () => { + let resolvedResult; + fixture.componentInstance.openTplClose().result.then((result) => resolvedResult = result); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (<HTMLElement>document.querySelector('button#close')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + fixture.whenStable().then(() => { + expect(resolvedResult).toBe('myResult'); + }); + }); + + it('should reject result promise on dismiss', () => { + let rejectReason; + fixture.componentInstance.openTplDismiss().result.catch((reason) => rejectReason = reason); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (<HTMLElement>document.querySelector('button#dismiss')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + fixture.whenStable().then(() => { + expect(rejectReason).toBe('myReason'); + }); + }); + + it('should add / remove "modal-open" class to body when modal is open', () => { + const modalRef = fixture.componentInstance.open('bar'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + expect(document.body).toHaveCssClass('modal-open'); + + modalRef.close('bar result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(document.body).not.toHaveCssClass('modal-open'); + }); + + it('should not throw when close called multiple times', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not throw when dismiss called multiple times', () => { + const modalRef = fixture.componentInstance.open('foo'); + modalRef.result.catch(NOOP); + + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + modalRef.dismiss('some reason'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + modalRef.dismiss('some reason'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + }); + + describe('backdrop options', () => { + + it('should have backdrop by default', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).toHaveBackdrop(); + + modalInstance.close('some reason'); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should open and close modal without backdrop', () => { + const modalInstance = fixture.componentInstance.open('foo', {backdrop: false}); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).not.toHaveBackdrop(); + + modalInstance.close('some reason'); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should open and close modal without backdrop from template content', () => { + const modalInstance = fixture.componentInstance.openTpl({backdrop: false}); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('Hello, World!'); + expect(fixture.nativeElement).not.toHaveBackdrop(); + + modalInstance.close('some reason'); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should dismiss on backdrop click', () => { + fixture.componentInstance.open('foo').result.catch(NOOP); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).toHaveBackdrop(); + + (<HTMLElement>document.querySelector('plx-modal-window')).click(); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should not dismiss on "static" backdrop click', () => { + const modalInstance = fixture.componentInstance.open('foo', {backdrop: 'static'}); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).toHaveBackdrop(); + + (<HTMLElement>document.querySelector('plx-modal-window')).click(); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal(); + expect(fixture.nativeElement).toHaveBackdrop(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss on clicks outside content where there is no backdrop', () => { + const modalInstance = fixture.componentInstance.open('foo', {backdrop: false}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (<HTMLElement>document.querySelector('plx-modal-window')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss on clicks that result in detached elements', () => { + const modalInstance = fixture.componentInstance.openTplIf({}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (<HTMLElement>document.querySelector('button#if')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + }); + + describe('container options', () => { + + it('should attach window and backdrop elements to the specified container', () => { + const modalInstance = fixture.componentInstance.open('foo', {container: '#testContainer'}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo', '#testContainer'); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should throw when the specified container element doesnt exist', () => { + const brokenSelector = '#notInTheDOM'; + expect(() => { + fixture.componentInstance.open('foo', {container: brokenSelector}); + }).toThrowError(`The specified modal container "${brokenSelector}" was not found in the DOM.`); + }); + }); + + describe('keyboard options', () => { + + it('should dismiss modals on ESC by default', () => { + fixture.componentInstance.open('foo').result.catch(NOOP); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (<DebugElement>getDebugNode(document.querySelector('plx-modal-window'))).triggerEventHandler('keyup.esc', {}); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss modals on ESC when keyboard option is false', () => { + const modalInstance = fixture.componentInstance.open('foo', {keyboard: false}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (<DebugElement>getDebugNode(document.querySelector('plx-modal-window'))).triggerEventHandler('keyup.esc', {}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss modals on ESC when default is prevented', () => { + const modalInstance = fixture.componentInstance.open('foo', {keyboard: true}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (<DebugElement>getDebugNode(document.querySelector('plx-modal-window'))).triggerEventHandler('keyup.esc', { + defaultPrevented: true + }); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + }); + + describe('size options', () => { + + it('should render modals with specified size', () => { + const modalInstance = fixture.componentInstance.open('foo', {size: 'sm'}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + expect(document.querySelector('.modal-dialog')).toHaveCssClass('modal-sm'); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + }); + + describe('custom class options', () => { + + it('should render modals with the correct custom classes', () => { + const modalInstance = fixture.componentInstance.open('foo', {windowClass: 'bar'}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + expect(document.querySelector('plx-modal-window')).toHaveCssClass('bar'); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + }); + + describe('focus management', () => { + + it('should focus modal window and return focus to previously focused element', () => { + fixture.detectChanges(); + const openButtonEl = fixture.nativeElement.querySelector('button#open'); + + openButtonEl.focus(); + openButtonEl.click(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('from button'); + expect(document.activeElement).toBe(document.querySelector('plx-modal-window')); + + fixture.componentInstance.close(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(document.activeElement).toBe(openButtonEl); + }); + + + it('should return focus to body if no element focused prior to modal opening', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + expect(document.activeElement).toBe(document.querySelector('plx-modal-window')); + + modalInstance.close('ok!'); + expect(document.activeElement).toBe(document.body); + }); + }); + + describe('window element ordering', () => { + it('should place newer windows on top of older ones', () => { + const modalInstance1 = fixture.componentInstance.open('foo', {windowClass: 'window-1'}); + fixture.detectChanges(); + + const modalInstance2 = fixture.componentInstance.open('bar', {windowClass: 'window-2'}); + fixture.detectChanges(); + + let windows = document.querySelectorAll('plx-modal-window'); + expect(windows.length).toBe(2); + expect(windows[0]).toHaveCssClass('window-1'); + expect(windows[1]).toHaveCssClass('window-2'); + + modalInstance2.close(); + modalInstance1.close(); + fixture.detectChanges(); + }); + }); +}); + +@Component({selector: 'destroyable-cmpt', template: 'Some content'}) +export class DestroyableCmpt implements OnDestroy { + constructor(private _spyService: SpyService) { + } + + ngOnDestroy(): void { + this._spyService.called = true; + } +} + +@Component( + {selector: 'modal-content-cmpt', template: '<button class="closeFromInside" (click)="close()">Close</button>'}) +export class WithActiveModalCmpt { + constructor(public activeModal: PlxActiveModal) { + } + + close() { + this.activeModal.close('from inside'); + } +} + +@Component({ + selector: 'test-cmpt', + template: ` + <div id="testContainer"></div> + <template #content>Hello, {{name}}!</template> + <template #destroyableContent><destroyable-cmpt></destroyable-cmpt></template> + <template #contentWithClose let-close="close"><button id="close" (click)="close('myResult')">Close me</button></template> + <template #contentWithDismiss let-dismiss="dismiss"><button id="dismiss" (click)="dismiss('myReason')">Dismiss me</button></template> + <template #contentWithIf> + <template [ngIf]="show"> + <button id="if" (click)="show = false">Click me</button> + </template> + </template> + <button id="open" (click)="open('from button')">Open</button> + ` +}) +class TestComponent { + name = 'World'; + openedModal: PlxModalRef; + show = true; + @ViewChild('content') tplContent; + @ViewChild('destroyableContent') tplDestroyableContent; + @ViewChild('contentWithClose') tplContentWithClose; + @ViewChild('contentWithDismiss') tplContentWithDismiss; + @ViewChild('contentWithIf') tplContentWithIf; + + constructor(private modalService: PlxModal) { + } + + open(content: string, options?: Object) { + this.openedModal = this.modalService.open(content, options); + return this.openedModal; + } + + close() { + if (this.openedModal) { + this.openedModal.close('ok'); + } + } + + openTpl(options?: Object) { + return this.modalService.open(this.tplContent, options); + } + + openCmpt(cmptType: any, options?: Object) { + return this.modalService.open(cmptType, options); + } + + openDestroyableTpl(options?: Object) { + return this.modalService.open(this.tplDestroyableContent, options); + } + + openTplClose(options?: Object) { + return this.modalService.open(this.tplContentWithClose, options); + } + + openTplDismiss(options?: Object) { + return this.modalService.open(this.tplContentWithDismiss, options); + } + + openTplIf(options?: Object) { + return this.modalService.open(this.tplContentWithIf, options); + } +} + +@NgModule({ + declarations: [TestComponent, DestroyableCmpt, WithActiveModalCmpt], + exports: [TestComponent, DestroyableCmpt], + imports: [CommonModule, PlxModalModule.forRoot()], + entryComponents: [DestroyableCmpt, WithActiveModalCmpt], + providers: [SpyService] +}) +class OesModalTestModule { +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts new file mode 100644 index 00000000..5935eee6 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts @@ -0,0 +1,54 @@ +import {Injectable, Injector, ComponentFactoryResolver} from '@angular/core'; +import {PlxModalStack} from './modal-stack'; +import {PlxModalRef} from './modal-ref'; + +/** + * Represent options available when opening new modal windows. + */ +export interface PlxModalOptions { + /** + * Whether a backdrop element should be created for a given modal (true by default). + * Alternatively, specify 'static' for a backdrop which doesn't close the modal on click. + */ + backdrop?: boolean | 'static'; + + /** + * An element to which to attach newly opened modal windows. + */ + container?: string; + + /** + * Whether to close the modal when escape key is pressed (true by default). + */ + keyboard?: boolean; + + /** + * Size of a new modal window. + */ + size?: 'sm' | 'lg'; + + /** + * Custom class to append to the modal window + */ + windowClass?: string; +} + +/** + * A service to open modal windows. Creating a modal is straightforward: create a template and pass it as an argument to + * the "open" method! + */ +@Injectable() +export class PlxModal { + constructor(private _moduleCFR: ComponentFactoryResolver, private _injector: Injector, private _modalStack: PlxModalStack) { + } + + /** + * Opens a new modal window with the specified content and using supplied options. Content can be provided + * as a TemplateRef or a component type. If you pass a component type as content than instances of those + * components can be injected with an instance of the PlxActiveModal class. You can use methods on the + * PlxActiveModal class to close / dismiss modals from "inside" of a component. + */ + public open(content: any, options: PlxModalOptions = {}): PlxModalRef { + return this._modalStack.open(this._moduleCFR, this._injector, content, options); + } +} |