From 261bc29468d94dfe84f33c5a553227ace318f52e Mon Sep 17 00:00:00 2001 From: Wilson Zeng Date: Sun, 18 Mar 2018 18:41:45 +0800 Subject: [PATCH] feat(module:modal): add afterOpen/afterAllClose/closeAll/openModals, adjust the boolean props and changeBodyOverflow, complete testing. close #1162 --- components/core/style/index.less | 2 +- components/modal/demo/basic.ts | 15 +- components/modal/demo/service.ts | 29 +++- components/modal/doc/index.en-US.md | 12 +- components/modal/doc/index.zh-CN.md | 22 ++- components/modal/nz-modal-control.service.ts | 72 +++++++++ components/modal/nz-modal-ref.class.ts | 4 +- components/modal/nz-modal.component.ts | 149 +++++++++++------ components/modal/nz-modal.module.ts | 3 +- components/modal/nz-modal.service.ts | 40 +++-- components/modal/nz-modal.spec.ts | 162 ++++++++++++++++++- components/modal/nz-modal.type.ts | 10 +- 12 files changed, 438 insertions(+), 82 deletions(-) create mode 100644 components/modal/nz-modal-control.service.ts diff --git a/components/core/style/index.less b/components/core/style/index.less index 7c26ea7b48f..27acc8f5bfc 100644 --- a/components/core/style/index.less +++ b/components/core/style/index.less @@ -26,7 +26,7 @@ .cdk-overlay-pane { position: absolute; pointer-events: auto; - z-index: 1000; + // z-index: 1000; // Give an opportunity to the content own to manage their z-index such as Modal } .box-shadow-left() { diff --git a/components/modal/demo/basic.ts b/components/modal/demo/basic.ts index e0a1be5a128..6762b6c1b6f 100644 --- a/components/modal/demo/basic.ts +++ b/components/modal/demo/basic.ts @@ -1,10 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, ViewChild, OnInit } from '@angular/core'; +import { NzModalComponent } from 'ng-zorro-antd'; @Component({ selector: 'nz-demo-modal-basic', template: ` - +

Content one

Content two

Content three

@@ -12,13 +13,21 @@ import { Component } from '@angular/core'; `, styles: [] }) -export class NzDemoModalBasicComponent { +export class NzDemoModalBasicComponent implements OnInit { isVisible = false; + modalValid = true; + + @ViewChild('modal') private modal: NzModalComponent; constructor() {} + ngOnInit(): void { + (window as any).modal = this.modal; // tslint:disable-line + } + showModal(): void { this.isVisible = true; + window.setTimeout(() => this.modalValid = false, 2000); } handleOk(): void { diff --git a/components/modal/demo/service.ts b/components/modal/demo/service.ts index e1997328464..c26d8d9bdf4 100644 --- a/components/modal/demo/service.ts +++ b/components/modal/demo/service.ts @@ -1,6 +1,6 @@ /* entryComponents: NzModalCustomComponent */ -import { Component, Input, TemplateRef } from '@angular/core'; +import { Component, Input, TemplateRef, ViewChild } from '@angular/core'; import { NzModalRef, NzModalService } from 'ng-zorro-antd'; @Component({ @@ -34,11 +34,17 @@ import { NzModalRef, NzModalService } from 'ng-zorro-antd'; + +

+ + + This is a non-service html modal ` }) export class NzDemoModalServiceComponent { tplModal: NzModalRef; tplModalButtonLoading = false; + htmlModalVisible = false; constructor(private modalService: NzModalService) { } @@ -86,8 +92,10 @@ export class NzDemoModalServiceComponent { }] }); + modal.afterOpen.subscribe(() => console.log('[afterOpen] emitted!')); + // Return a result when closed - modal.afterClose().subscribe((result) => console.log('[afterClose] The result is:', result)); + modal.afterClose.subscribe((result) => console.log('[afterClose] The result is:', result)); // delay until modal instance created window.setTimeout(() => { @@ -133,6 +141,23 @@ export class NzDemoModalServiceComponent { ] }); } + + openAndCloseAll(): void { + let pos = 0; + + [ 'create', 'info', 'success', 'error' ].forEach((method) => this.modalService[method]({ + nzMask: false, + nzTitle: `Test ${method} title`, + nzContent: `Test content: ${method}`, + nzStyle: { position: 'absolute', top: `${pos * 70}px`, left: `${(pos++) * 300}px` } + })); + + this.htmlModalVisible = true; + + this.modalService.afterAllClose.subscribe(() => console.log('afterAllClose emitted!')); + + window.setTimeout(() => this.modalService.closeAll(), 2000); + } } @Component({ diff --git a/components/modal/doc/index.en-US.md b/components/modal/doc/index.en-US.md index a5a3316c7fa..d70b2c6970f 100644 --- a/components/modal/doc/index.en-US.md +++ b/components/modal/doc/index.en-US.md @@ -23,7 +23,8 @@ The dialog is currently divided into 2 modes, `normal mode` and `confirm box mod | Property | Description | Type | Default | |----|----|----|----| -| nzAfterClose | Specify a EventEmitter that will be emitted when modal is closed completely. | EventEmitter | - | +| nzAfterOpen | Specify a EventEmitter that will be emitted when modal opened | EventEmitter | - | +| nzAfterClose | Specify a EventEmitter that will be emitted when modal is closed completely (Can listen for parameters passed in the close/destroy method) | EventEmitter | - | | nzBodyStyle | Body style for modal body element. Such as height, padding etc. | object | - | | nzCancelText | Text of the Cancel button. Set to null to show no cancel button (this value is invalid if the nzFooter parameter is used in normal mode) | string | Cancel | | nzClosable | Whether a close (x) button is visible on top right of the modal dialog or not. Invalid value in confirm box mode (default will be hidden) | boolean | true | @@ -81,22 +82,25 @@ All the `NzModalService.method`s will return a reference, and then we can close ```ts constructor(modal: NzModalService) { const ref: NzModalRef = modal.info(); - ref.destroy(); // Note: This dialog will be destroyed directly + ref.close(); // Or ref.destroy(); This dialog will be destroyed directly } ``` ### Related type definition -#### NzModalRef (used for control dialogs) +#### NzModalRef + +> NzModalRef object is used to control dialogs and communicate with inside content The dialog created by the service method `NzModalService.xxx()` will return a `NzModalRef` object that is used to manipulate the dialog (this object can also be obtained by dependency injection `NzModalRef` if `nzContent` is used as Component) , This object has the following methods: | Method | Description | |----|----| +| afterOpen | Same as nzAfterOpen but of type Observable<void> | +| afterClose | Same as nzAfterClose, but of type Observable<result:any> | | open() | Open (display) dialog box. Calling this function will fail if the dialog is already destroyed | | close() | Close (hide) the dialog. Note: When used for a dialog created as a service, this method will destroy the dialog directly (as with the destroy method) | | destroy() | Destroy the dialog. Note: Used only for dialogs created by the service (non-service created dialogs, this method only hides the dialog) | -| afterClose() | Returns an Observable object to get the result parameter passed in close/destroy (will fire after the dialog is closed) | | getContentComponent() | Gets the Component instance in the contents of the dialog for `nzContent`. Note: When the dialog is not initialized (`ngOnInit` is not executed), this function will return `undefined` | #### ModalButtonOptions (used to customize the bottom button) diff --git a/components/modal/doc/index.zh-CN.md b/components/modal/doc/index.zh-CN.md index 8eaf0b65694..a8068a701a6 100644 --- a/components/modal/doc/index.zh-CN.md +++ b/components/modal/doc/index.zh-CN.md @@ -23,7 +23,8 @@ title: Modal | 参数 | 说明 | 类型 | 默认值 | |----|----|----|----| -| nzAfterClose | Modal 完全关闭后的回调 | EventEmitter | 无 | +| nzAfterOpen | Modal 打开后的回调 | EventEmitter | 无 | +| nzAfterClose | Modal 完全关闭后的回调,可监听close/destroy方法传入的参数 | EventEmitter | 无 | | nzBodyStyle | Modal body 样式 | object | 无 | | nzCancelText | 取消按钮文字。设为 null 表示不显示取消按钮(若在普通模式下使用了 nzFooter 参数,则该值无效) | string | 取消 | | nzClosable | 是否显示右上角的关闭按钮。确认框模式下该值无效(默认会被隐藏) | boolean | true | @@ -80,22 +81,33 @@ title: Modal ```ts constructor(modal: NzModalService) { const ref: NzModalRef = modal.info(); - ref.destroy(); // 注:这里将直接销毁对话框 + ref.close(); // 或 ref.destroy(); 将直接销毁对话框 } ``` ### 相关类型定义 -#### NzModalRef(用于控制对话框) +#### NzModalService的其他方法/属性 + +| 方法/属性 | 说明 | 类型 | +|----|----| +| openModals | 当前打开的所有Modal引用列表 | NzModalRef[] | +| afterAllClose | 所有Modal完全关闭后的回调 | Observable<void> | +| closeAll() | 关闭所有模态框 | function | + +#### NzModalRef + +> NzModalRef 对象用于控制对话框以及进行内容间的通信 通过服务方式 `NzModalService.xxx()` 创建的对话框,都会返回一个 `NzModalRef` 对象,用于操控该对话框(若使用nzContent为Component时,也可通过依赖注入 `NzModalRef` 方式获得此对象),该对象具有以下方法: -| 方法 | 说明 | +| 方法/属性 | 说明 | |----|----| +| afterOpen | 同nzAfterOpen,但类型为Observable<void> | +| afterClose | 同nzAfterClose,但类型为Observable<result:any> | | open() | 打开(显示)对话框。若对话框已销毁,则调用此函数将失效 | | close(result: any) | 关闭(隐藏)对话框。注:当用于以服务方式创建的对话框,此方法将直接 销毁 对话框(同destroy方法) | | destroy(result: any) | 销毁对话框。注:仅用于服务方式创建的对话框(非服务方式创建的对话框,此方法只会隐藏对话框) | -| afterClose() | 返回一个Observable对象来获取close/destroy中传递的result参数(将在对话框关闭后触发) | | getContentComponent() | 获取对话框内容中`nzContent`的Component实例instance。注:当对话框还未初始化完毕(`ngOnInit`未执行)时,此函数将返回`undefined` | #### ModalButtonOptions(用于自定义底部按钮) diff --git a/components/modal/nz-modal-control.service.ts b/components/modal/nz-modal-control.service.ts new file mode 100644 index 00000000000..6bfeb3a44b6 --- /dev/null +++ b/components/modal/nz-modal-control.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Optional, SkipSelf } from '@angular/core'; +import { Subject } from 'rxjs/Subject'; +import { Subscription } from 'rxjs/Subscription'; + +import { NzModalRef } from './nz-modal-ref.class'; + +interface RegisteredMeta { + modalRef: NzModalRef; + afterOpenSubscription: Subscription; + afterCloseSubscription: Subscription; +} + +@Injectable() +export class NzModalControlService { + // Track singleton afterAllClose through over the injection tree + get afterAllClose(): Subject { + return this.parentService ? this.parentService.afterAllClose : this.rootAfterAllClose; + } + // Track singleton openModals array through over the injection tree + get openModals(): NzModalRef[] { + return this.parentService ? this.parentService.openModals : this.rootOpenModals; + } + + private rootOpenModals: NzModalRef[] = this.parentService ? null : []; + private rootAfterAllClose: Subject = this.parentService ? null : new Subject(); + + private rootRegisteredMetaMap: Map = this.parentService ? null : new Map(); + private get registeredMetaMap(): Map { // Registered modal for later usage + return this.parentService ? this.parentService.registeredMetaMap : this.rootRegisteredMetaMap; + } + + constructor( + @Optional() @SkipSelf() private parentService: NzModalControlService) {} + + // Register a modal to listen its open/close + registerModal(modalRef: NzModalRef): void { + if (!this.hasRegistered(modalRef)) { + const afterOpenSubscription = modalRef.afterOpen.subscribe(() => this.openModals.push(modalRef)); + const afterCloseSubscription = modalRef.afterClose.subscribe(() => this.removeOpenModal(modalRef)); + + this.registeredMetaMap.set(modalRef, { modalRef, afterOpenSubscription, afterCloseSubscription }); + } + } + + // TODO: allow deregister modals + // deregisterModal(modalRef: NzModalRef): void {} + + hasRegistered(modalRef: NzModalRef): boolean { + return this.registeredMetaMap.has(modalRef); + } + + // Close all registered opened modals + closeAll(): void { + let i = this.openModals.length; + + while (i--) { + this.openModals[i].close(); + } + } + + private removeOpenModal(modalRef: NzModalRef): void { + const index = this.openModals.indexOf(modalRef); + + if (index > -1) { + this.openModals.splice(index, 1); + + if (!this.openModals.length) { + this.afterAllClose.next(); + } + } + } +} diff --git a/components/modal/nz-modal-ref.class.ts b/components/modal/nz-modal-ref.class.ts index 632c124efed..fd5f0815e79 100644 --- a/components/modal/nz-modal-ref.class.ts +++ b/components/modal/nz-modal-ref.class.ts @@ -8,10 +8,12 @@ import { NzModalComponent } from './nz-modal.component'; * NzModalRef is aim to avoid accessing to the modal instance directly by users. */ export abstract class NzModalRef { // tslint:disable-line:no-any + abstract afterOpen: Observable; + abstract afterClose: Observable; + abstract open(): void; abstract close(result?: R): void; abstract destroy(result?: R): void; - abstract afterClose(): Observable; // /** // * Return the ComponentRef of nzContent when specify nzContent as a Component diff --git a/components/modal/nz-modal.component.ts b/components/modal/nz-modal.component.ts index 5a8f4eba4de..cb8b01ea381 100644 --- a/components/modal/nz-modal.component.ts +++ b/components/modal/nz-modal.component.ts @@ -1,4 +1,4 @@ -import { Overlay } from '@angular/cdk/overlay'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { DOCUMENT } from '@angular/common'; import { AfterViewInit, @@ -11,6 +11,7 @@ import { Injector, Input, OnChanges, + OnDestroy, OnInit, Output, Renderer2, @@ -22,10 +23,12 @@ import { } from '@angular/core'; import { Observable } from 'rxjs/Observable'; +import { toBoolean } from '../core/util/convert'; import { measureScrollbar } from '../core/util/mesure-scrollbar'; import { NzI18nService } from '../i18n/nz-i18n.service'; import ModalUtil from './modal-util'; +import { NzModalControlService } from './nz-modal-control.service'; import { NzModalRef } from './nz-modal-ref.class'; import { ModalButtonOptions, ModalOptions, ModalType, OnClickCallback } from './nz-modal.type'; @@ -38,20 +41,25 @@ interface ClassMap { type AnimationState = 'enter' | 'leave' | null; @Component({ - selector : 'nz-modal', + selector: 'nz-modal', templateUrl: './nz-modal.component.html' }) // tslint:disable-next-line:no-any -export class NzModalComponent extends NzModalRef implements OnInit, OnChanges, AfterViewInit, ModalOptions { +export class NzModalComponent extends NzModalRef implements OnInit, OnChanges, AfterViewInit, OnDestroy, ModalOptions { @Input() nzModalType: ModalType = 'default'; @Input() nzContent: string | TemplateRef<{}> | Type; // [STATIC] If not specified, will use @Input() nzComponentParams: object; // [STATIC] ONLY avaliable when nzContent is a component @Input() nzFooter: string | TemplateRef<{}> | Array>; // [STATIC] Default Modal ONLY - @Input() nzGetContainer: HTMLElement | (() => HTMLElement) = () => this.overlay.create().overlayElement; // [STATIC] + @Input() nzGetContainer: HTMLElement | OverlayRef | (() => HTMLElement | OverlayRef) = () => this.overlay.create(); // [STATIC] + + @Input() + get nzVisible(): boolean { return this._visible; } + set nzVisible(value: boolean) { this._visible = toBoolean(value); } + private _visible: boolean = false; - @Input() nzVisible = false; @Output() nzVisibleChange = new EventEmitter(); + @Input() nzZIndex: number = 1000; @Input() nzWidth: number | string = 520; @Input() nzWrapClassName: string; @@ -59,22 +67,53 @@ export class NzModalComponent extends NzModalRef impleme @Input() nzStyle: object; @Input() nzIconType: string = 'question-circle'; // Confirm Modal ONLY @Input() nzTitle: string | TemplateRef<{}>; - @Input() nzClosable = true; - @Input() nzMask = true; - @Input() nzMaskClosable = true; + + @Input() + get nzClosable(): boolean { return this._closable; } + set nzClosable(value: boolean) { this._closable = toBoolean(value); } + private _closable: boolean = true; + + @Input() + get nzMask(): boolean { return this._mask; } + set nzMask(value: boolean) { this._mask = toBoolean(value); } + private _mask: boolean = true; + + @Input() + get nzMaskClosable(): boolean { return this._maskClosable; } + set nzMaskClosable(value: boolean) { this._maskClosable = toBoolean(value); } + private _maskClosable: boolean = true; + @Input() nzMaskStyle: object; @Input() nzBodyStyle: object; - @Output() nzAfterClose = new EventEmitter(); // Trigger when modal is hidden + + @Output() nzAfterOpen = new EventEmitter(); // Trigger when modal open(visible) after animations + @Output() nzAfterClose = new EventEmitter(); // Trigger when modal leave-animation over + get afterOpen(): Observable { // Observable alias for nzAfterOpen + return this.nzAfterOpen.asObservable(); + } + get afterClose(): Observable { // Observable alias for nzAfterClose + return this.nzAfterClose.asObservable(); + } // --- Predefined OK & Cancel buttons @Input() nzOkText: string; @Input() nzOkType = 'primary'; - @Input() nzOkLoading = false; - @Input() @Output() nzOnOk: EventEmitter | OnClickCallback = new EventEmitter(); + + @Input() + get nzOkLoading(): boolean { return this._okLoading; } + set nzOkLoading(value: boolean) { this._okLoading = toBoolean(value); } + private _okLoading: boolean = false; + + @Input() @Output() nzOnOk: EventEmitter | OnClickCallback = new EventEmitter(); @ViewChild('autoFocusButtonOk', { read: ElementRef }) autoFocusButtonOk: ElementRef; // Only aim to focus the ok button that needs to be auto focused @Input() nzCancelText: string; - @Input() nzCancelLoading = false; - @Input() @Output() nzOnCancel: EventEmitter | OnClickCallback = new EventEmitter(); + + @Input() + get nzCancelLoading(): boolean { return this._cancelLoading; } + set nzCancelLoading(value: boolean) { this._cancelLoading = toBoolean(value); } + private _cancelLoading: boolean = false; + + @Input() @Output() nzOnCancel: EventEmitter | OnClickCallback = new EventEmitter(); @ViewChild('modalContainer') modalContainer: ElementRef; @ViewChild('bodyContainer', { read: ViewContainerRef }) bodyContainer: ViewContainerRef; @@ -88,15 +127,18 @@ export class NzModalComponent extends NzModalRef impleme private contentComponentRef: ComponentRef; // Handle the reference when using nzContent as Component private animationState: AnimationState; // Current animation state + private container: HTMLElement | OverlayRef; + + constructor( + private overlay: Overlay, + private locale: NzI18nService, + private renderer: Renderer2, + private cfr: ComponentFactoryResolver, + private elementRef: ElementRef, + private viewContainer: ViewContainerRef, + private modalControl: NzModalControlService, + @Inject(DOCUMENT) private document: any) { // tslint:disable-line:no-any - constructor(private overlay: Overlay, - private locale: NzI18nService, - private renderer: Renderer2, - private cfr: ComponentFactoryResolver, - private elementRef: ElementRef, - private viewContainer: ViewContainerRef, - @Inject(DOCUMENT) private document: any // tslint:disable-line:no-any - ) { super(); } @@ -109,10 +151,16 @@ export class NzModalComponent extends NzModalRef impleme this.nzFooter = this.formatModalButtons(this.nzFooter as Array>); } - const container = typeof this.nzGetContainer === 'function' ? this.nzGetContainer() : this.nzGetContainer; - if (container instanceof HTMLElement) { - container.appendChild(this.elementRef.nativeElement); + // Place the modal dom to elsewhere + this.container = typeof this.nzGetContainer === 'function' ? this.nzGetContainer() : this.nzGetContainer; + if (this.container instanceof HTMLElement) { + this.container.appendChild(this.elementRef.nativeElement); + } else if (this.container instanceof OverlayRef) { // NOTE: only attach the dom to overlay, the view container is not changed actually + this.container.overlayElement.appendChild(this.elementRef.nativeElement); } + + // Register modal when afterOpen/afterClose is stable + this.modalControl.registerModal(this); } // [NOTE] NOT available when using by service! @@ -121,10 +169,7 @@ export class NzModalComponent extends NzModalRef impleme // BUT: User also can change "nzContent" dynamically to trigger UI changes (provided you don't use Component that needs initializations) ngOnChanges(changes: SimpleChanges): void { if (changes.nzVisible) { - this.changeBodyOverflow(this.nzVisible); - if (!changes.nzVisible.firstChange) { // Do not trigger animation while initializing - this.animateTo(this.nzVisible); - } + this.handleVisibleStateChange(this.nzVisible, !changes.nzVisible.firstChange); // Do not trigger animation while initializing } } @@ -139,22 +184,24 @@ export class NzModalComponent extends NzModalRef impleme } } + ngOnDestroy(): void { + if (this.container instanceof OverlayRef) { + this.container.dispose(); + } + } + open(): void { this.changeVisibleFromInside(true); } close(result?: R): void { - this.changeVisibleFromInside(false).then(() => this.nzAfterClose.emit(result)); + this.changeVisibleFromInside(false, result); } destroy(result?: R): void { // Destroy equals Close this.close(result); } - afterClose(): Observable { - return this.nzAfterClose.asObservable(); - } - getInstance(): NzModalComponent { return this; } @@ -222,6 +269,20 @@ export class NzModalComponent extends NzModalRef impleme return Array.isArray(value) && value.length > 0; } + // Do rest things when visible state changed + private handleVisibleStateChange(visible: boolean, animation: boolean = true, closeResult?: R): Promise { + return Promise + .resolve(animation && this.animateTo(visible)) + .then(() => { // Emit open/close event after animations over + if (visible) { + this.nzAfterOpen.emit(); + } else { + this.nzAfterClose.emit(closeResult); + } + }) + .then(() => this.changeBodyOverflow()); + } + // Lookup a button's property, if the prop is a function, call & then return the result, otherwise, return itself. private getButtonCallableProp(options: ModalButtonOptions, prop: string): {} { const value = options[ prop ]; @@ -242,13 +303,12 @@ export class NzModalComponent extends NzModalRef impleme } // Change nzVisible from inside - private changeVisibleFromInside(visible: boolean): Promise { + private changeVisibleFromInside(visible: boolean, closeResult?: R): Promise { if (this.nzVisible !== visible) { // Change nzVisible value immediately this.nzVisible = visible; - this.changeBodyOverflow(this.nzVisible); this.nzVisibleChange.emit(visible); - return this.animateTo(visible); + return this.handleVisibleStateChange(visible, true, closeResult); } return Promise.resolve(); } @@ -331,23 +391,16 @@ export class NzModalComponent extends NzModalRef impleme // } } - private changeBodyOverflow(visible: boolean): void { - const countKey = 'data-modal-count'; - let countValue = parseInt(this.document.body.attributes.getNamedItem(countKey) && this.document.body.attributes.getNamedItem('data-modal-count').value || 0, 10); - if (visible) { - countValue += 1; - } else { - countValue = (countValue - 1 >= 0) ? (countValue - 1) : 0; - } - if (countValue) { - const scrollBarWidth = measureScrollbar(); - this.renderer.setStyle(this.document.body, 'padding-right', `${scrollBarWidth}px`); + private changeBodyOverflow(): void { + const openModals = this.modalControl.openModals; + + if (openModals.length) { + this.renderer.setStyle(this.document.body, 'padding-right', `${measureScrollbar()}px`); this.renderer.setStyle(this.document.body, 'overflow', 'hidden'); } else { this.renderer.removeStyle(this.document.body, 'padding-right'); this.renderer.removeStyle(this.document.body, 'overflow'); } - this.renderer.setAttribute(this.document.body, countKey, `${countValue}`); } } diff --git a/components/modal/nz-modal.module.ts b/components/modal/nz-modal.module.ts index d7164eb7497..03c990dd6c1 100644 --- a/components/modal/nz-modal.module.ts +++ b/components/modal/nz-modal.module.ts @@ -7,6 +7,7 @@ import { LoggerModule } from '../core/util/logger/logger.module'; import { NzI18nModule } from '../i18n/nz-i18n.module'; import { CssUnitPipe } from './css-unit.pipe'; +import { NzModalControlService } from './nz-modal-control.service'; import { NzModalComponent } from './nz-modal.component'; import { NzModalService } from './nz-modal.service'; @@ -15,6 +16,6 @@ import { NzModalService } from './nz-modal.service'; exports: [ NzModalComponent ], declarations: [ NzModalComponent, CssUnitPipe ], entryComponents: [ NzModalComponent ], - providers: [ NzModalService ] + providers: [ NzModalControlService, NzModalService ] }) export class NzModalModule { } diff --git a/components/modal/nz-modal.service.ts b/components/modal/nz-modal.service.ts index 7a57d56797a..1e54338578f 100644 --- a/components/modal/nz-modal.service.ts +++ b/components/modal/nz-modal.service.ts @@ -1,9 +1,11 @@ import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; -import { ApplicationRef, ComponentFactoryResolver, ComponentRef, EventEmitter, Injectable, Injector, TemplateRef, Type } from '@angular/core'; +import { ApplicationRef, ComponentFactoryResolver, ComponentRef, EventEmitter, Injectable, Injector, Optional, SkipSelf, TemplateRef, Type } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; import { LoggerService } from '../core/util/logger/logger.service'; +import { NzModalControlService } from './nz-modal-control.service'; import { NzModalRef } from './nz-modal-ref.class'; import { NzModalComponent } from './nz-modal.component'; import { ConfirmType, ModalOptions, ModalOptionsForService } from './nz-modal.type'; @@ -14,15 +16,15 @@ export class ModalBuilderForService { private overlayRef: OverlayRef; constructor(private overlay: Overlay, options: ModalOptionsForService = {}) { - this.createModal(); + this.createModal(); - if (!('nzGetContainer' in options)) { // As we use CDK to create modal in service by force, there is no need to use nzGetContainer - options.nzGetContainer = null; // Override nzGetContainer's default value to prevent creating another overlay - } + if (!('nzGetContainer' in options)) { // As we use CDK to create modal in service by force, there is no need to use nzGetContainer + options.nzGetContainer = null; // Override nzGetContainer's default value to prevent creating another overlay + } - this.changeProps(options); - this.modalRef.instance.open(); - this.modalRef.instance.nzAfterClose.subscribe(() => this.destroyModal()); // [NOTE] By default, close equals destroy when using as Service + this.changeProps(options); + this.modalRef.instance.open(); + this.modalRef.instance.nzAfterClose.subscribe(() => this.destroyModal()); // [NOTE] By default, close equals destroy when using as Service } getInstance(): NzModalComponent { @@ -51,15 +53,33 @@ export class ModalBuilderForService { @Injectable() export class NzModalService { + // Track of the current close modals (we assume invisible is close this time) + get openModals(): NzModalRef[] { + return this.modalControl.openModals; + } + + get afterAllClose(): Observable { + return this.modalControl.afterAllClose.asObservable(); + } - constructor(private overlay: Overlay, private logger: LoggerService) { } + constructor( + private overlay: Overlay, + private logger: LoggerService, + private modalControl: NzModalControlService) { } + + // Closes all of the currently-open dialogs + closeAll(): void { + this.modalControl.closeAll(); + } create(options: ModalOptionsForService = {}): NzModalRef { if (typeof options.nzOnCancel !== 'function') { options.nzOnCancel = () => {}; // Leave a empty function to close this modal by default } - return new ModalBuilderForService(this.overlay, options).getInstance(); + const modalRef = new ModalBuilderForService(this.overlay, options).getInstance(); // NOTE: use NzModalComponent as the NzModalRef by now, we may need archive the real NzModalRef object in the future + + return modalRef; } confirm(options: ModalOptionsForService = {}, confirmType: ConfirmType = 'confirm'): NzModalRef { diff --git a/components/modal/nz-modal.spec.ts b/components/modal/nz-modal.spec.ts index 9b7c5794cd9..0ede87ee160 100644 --- a/components/modal/nz-modal.spec.ts +++ b/components/modal/nz-modal.spec.ts @@ -1,12 +1,16 @@ -import { Component, DebugElement, ElementRef, Input, NgModule, OnInit } from '@angular/core'; -import { async, fakeAsync, flush, tick, ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; +/* TODO: Sort out and rewrite for more standardized */ + +import { Component, DebugElement, ElementRef, EventEmitter, Input, NgModule } from '@angular/core'; +import { async, fakeAsync, flush, flushMicrotasks, inject, tick, ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { OverlayContainer } from '@angular/cdk/overlay'; import { NzButtonComponent } from '../button/nz-button.component'; import { NzButtonModule } from '../button/nz-button.module'; import { CssUnitPipe } from './css-unit.pipe'; +import { NzModalControlService } from './nz-modal-control.service'; import { NzModalRef } from './nz-modal-ref.class'; import { MODAL_ANIMATE_DURATION, NzModalComponent } from './nz-modal.component'; import { NzModalModule } from './nz-modal.module'; @@ -14,7 +18,7 @@ import { NzModalService } from './nz-modal.service'; const WAIT_ANIMATE_TIME = MODAL_ANIMATE_DURATION + 50; -describe('modal', () => { +describe('modal testing (legacy)', () => { let instance; let fixture: ComponentFixture<{}>; @@ -194,6 +198,8 @@ describe('modal', () => { fixture.detectChanges(); // Initial change detecting const contentComponent = modalAgent.getContentComponent(); + const contentComponentRef = (modalAgent as any).getContentComponentRef(); // tslint:disable-line:no-any + expect(contentComponent).toBe(contentComponentRef.instance); const contentElement = contentComponent.elementRef.nativeElement as HTMLElement; // change title from outside const firstButton = modalElement.querySelector('.ant-modal-footer button:first-child') as HTMLButtonElement; @@ -285,6 +291,143 @@ describe('modal', () => { }); }); +describe('NzModal', () => { + let modalService: NzModalService; + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [ NzModalModule ], + declarations: [ + ModalByServiceComponent + ] + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([ NzModalService, OverlayContainer ], (ms: NzModalService, oc: OverlayContainer) => { + modalService = ms; + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + })); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + describe('created by service', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(ModalByServiceComponent); + }); + afterEach(fakeAsync(() => { // wait all openModals tobe closed to clean up the ModalManager as it is globally static + modalService.closeAll(); + fixture.detectChanges(); + tick(1000); + })); + + it('should trigger both afterOpen/nzAfterOpen and have the correct openModals length', fakeAsync(() => { + const spy = jasmine.createSpy('afterOpen spy'); + const nzAfterOpen = new EventEmitter(); + const modalRef = modalService.create({ nzAfterOpen }); + + modalRef.afterOpen.subscribe(spy); + nzAfterOpen.subscribe(spy); + + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + + tick(600); + expect(spy).toHaveBeenCalledTimes(2); + expect(modalService.openModals.indexOf(modalRef)).toBeGreaterThan(-1); + expect(modalService.openModals.length).toBe(1); + })); + + it('should trigger both afterClose/nzAfterClose and have the correct openModals length', fakeAsync(() => { + const spy = jasmine.createSpy('afterClose spy'); + const nzAfterClose = new EventEmitter(); + const modalRef = modalService.create({ nzAfterClose }); + + modalRef.afterClose.subscribe(spy); + nzAfterClose.subscribe(spy); + + fixture.detectChanges(); + tick(600); + modalRef.close(); + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + + tick(600); + expect(spy).toHaveBeenCalledTimes(2); + expect(modalService.openModals.indexOf(modalRef)).toBe(-1); + expect(modalService.openModals.length).toBe(0); + })); + + it('should return/receive with/without result data', fakeAsync(() => { + const spy = jasmine.createSpy('afterClose without result spy'); + const modalRef = modalService.success(); + + modalRef.afterClose.subscribe(spy); + fixture.detectChanges(); + tick(600); + modalRef.destroy(); + expect(spy).not.toHaveBeenCalled(); + tick(600); + expect(spy).toHaveBeenCalledWith(undefined); + })); + + it('should return/receive with result data', fakeAsync(() => { + const result = { data: 'Fake Error' }; + const spy = jasmine.createSpy('afterClose with result spy'); + const modalRef = modalService.error(); + + fixture.detectChanges(); + tick(600); + modalRef.destroy(result); + modalRef.afterClose.subscribe(spy); + expect(spy).not.toHaveBeenCalled(); + tick(600); + expect(spy).toHaveBeenCalledWith(result); + })); + + it('should close all opened modals (include non-service modals)', fakeAsync(() => { + const spy = jasmine.createSpy('afterAllClose spy'); + const modalMethods = [ 'create', 'info', 'success', 'error', 'confirm' ]; + const uniqueId = (name: string) => `__${name}_ID_SUFFIX__`; + const queryOverlayElement = (name: string) => overlayContainerElement.querySelector(`.${uniqueId(name)}`) as HTMLElement; + + modalService.afterAllClose.subscribe(spy); + + fixture.componentInstance.nonServiceModalVisible = true; // Show non-service modal + modalMethods.forEach(method => modalService[method]({ nzWrapClassName: uniqueId(method) })); // Service modals + + fixture.detectChanges(); + tick(600); + (modalMethods.concat('NON_SERVICE')).forEach(method => expect(queryOverlayElement(method).style.display).not.toBe('none')); // Cover non-service modal for later checking + expect(modalService.openModals.length).toBe(6); + + modalService.closeAll(); + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + tick(600); + expect(spy).toHaveBeenCalled(); + expect(modalService.openModals.length).toBe(0); + })); + + it('should modal not be registered twice', fakeAsync(() => { + const modalRef = modalService.create(); + + fixture.detectChanges(); + (modalService as any).modalControl.registerModal(modalRef); // tslint:disable-line:no-any + tick(600); + expect(modalService.openModals.length).toBe(1); + })); + }); +}); + // ------------------------------------------- // | Testing Components // ------------------------------------------- @@ -458,6 +601,19 @@ export class TestConfirmModalComponent { }) class TestCssUnitPipeComponent { } +@Component({ + selector: 'nz-modal-by-service', + template: ` + + `, + providers: [ NzModalControlService ] // Testing for service with parent service +}) +export class ModalByServiceComponent { + nonServiceModalVisible = false; + + constructor(modalControlService: NzModalControlService) {} +} + // ------------------------------------------- // | Local tool functions // ------------------------------------------- diff --git a/components/modal/nz-modal.type.ts b/components/modal/nz-modal.type.ts index 080ff0821e3..5d4c72b270c 100644 --- a/components/modal/nz-modal.type.ts +++ b/components/modal/nz-modal.type.ts @@ -1,3 +1,4 @@ +import { OverlayRef } from '@angular/cdk/overlay'; import { EventEmitter, TemplateRef, Type } from '@angular/core'; export type OnClickCallback = ((instance: T) => (false | void | {}) | Promise); @@ -25,17 +26,18 @@ export interface ModalOptions { // tslint:disable-line:no-any nzMaskStyle?: object; nzBodyStyle?: object; nzFooter?: string | TemplateRef<{}> | Array>; // Default Modal ONLY - nzGetContainer?: HTMLElement | (() => HTMLElement); // STATIC - nzAfterClose?: EventEmitter; + nzGetContainer?: HTMLElement | OverlayRef | (() => HTMLElement | OverlayRef); // STATIC + nzAfterOpen?: EventEmitter; + nzAfterClose?: EventEmitter; // --- Predefined OK & Cancel buttons nzOkText?: string; nzOkType?: string; nzOkLoading?: boolean; - nzOnOk?: EventEmitter | OnClickCallback; // Mixed using ng's Input/Output (Should care of "this" when using OnClickCallback) + nzOnOk?: EventEmitter | OnClickCallback; // Mixed using ng's Input/Output (Should care of "this" when using OnClickCallback) nzCancelText?: string; nzCancelLoading?: boolean; - nzOnCancel?: EventEmitter | OnClickCallback; // Mixed using ng's Input/Output (Should care of "this" when using OnClickCallback) + nzOnCancel?: EventEmitter | OnClickCallback; // Mixed using ng's Input/Output (Should care of "this" when using OnClickCallback) } // tslint:disable-next-line:no-any