From 92242401ae85a15c418adc791e2f681a69e28a1a Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 14 Apr 2020 12:46:05 +0800 Subject: [PATCH] feat(module:notification): add onClick observable (#4989) close #4986 BREAKING CHANGE: - NzMessageDataFilled is replaced by NzMessageRef - NzNotificationDataFilled is replaced by NzNotificationRef --- components/message/base.ts | 228 ++++++++++++++++++ components/message/doc/index.en-US.md | 5 +- components/message/doc/index.zh-CN.md | 5 +- .../message/message-container.component.ts | 99 ++------ components/message/message.component.ts | 107 +------- components/message/message.service.ts | 85 ++----- components/message/message.spec.ts | 4 +- components/message/public-api.ts | 1 + components/message/typings.ts | 23 +- components/notification/demo/basic.ts | 16 +- components/notification/doc/index.en-US.md | 6 +- components/notification/doc/index.zh-CN.md | 6 +- .../notification-container.component.ts | 184 ++++++-------- .../notification/notification.component.ts | 139 +++-------- .../notification/notification.service.ts | 105 +++----- components/notification/notification.spec.ts | 8 +- components/notification/typings.ts | 38 ++- scripts/site/_site/doc/app/app.module.ts | 4 +- 18 files changed, 481 insertions(+), 582 deletions(-) create mode 100644 components/message/base.ts diff --git a/components/message/base.ts b/components/message/base.ts new file mode 100644 index 00000000000..ee8c9e448a0 --- /dev/null +++ b/components/message/base.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright Alibaba.com All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE + */ + +import { ComponentType, Overlay } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { ChangeDetectorRef, EventEmitter, Injector, OnDestroy, OnInit } from '@angular/core'; +import { MessageConfig, NzConfigService } from 'ng-zorro-antd/core/config'; +import { NzSingletonService } from 'ng-zorro-antd/core/services'; +import { Subject } from 'rxjs'; + +import { NzMessageData, NzMessageDataOptions } from './typings'; + +let globalCounter = 0; + +export abstract class NzMNService { + protected abstract componentPrefix: string; + protected container: NzMNContainerComponent; + + constructor(protected nzSingletonService: NzSingletonService, protected overlay: Overlay, private injector: Injector) {} + + remove(id?: string): void { + if (id) { + this.container.remove(id); + } else { + this.container.removeAll(); + } + } + + protected getInstanceId(): string { + return `${this.componentPrefix}-${globalCounter++}`; + } + + protected withContainer(ctor: ComponentType): T { + let containerInstance = this.nzSingletonService.getSingletonWithKey(this.componentPrefix); + if (containerInstance) { + return containerInstance as T; + } + + const overlayRef = this.overlay.create({ + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.noop(), + positionStrategy: this.overlay.position().global() + }); + const componentPortal = new ComponentPortal(ctor, null, this.injector); + const componentRef = overlayRef.attach(componentPortal); + const overlayPane = overlayRef.overlayElement; + overlayPane.style.zIndex = '1010'; + + if (!containerInstance) { + this.container = containerInstance = componentRef.instance; + this.nzSingletonService.registerSingletonWithKey(this.componentPrefix, containerInstance); + } + + return containerInstance as T; + } +} + +export abstract class NzMNContainerComponent implements OnInit, OnDestroy { + config: Required; + instances: Array> = []; + + protected readonly destroy$ = new Subject(); + + constructor(protected cdr: ChangeDetectorRef, protected nzConfigService: NzConfigService) { + this.updateConfig(); + } + + ngOnInit(): void { + this.subscribeConfigChange(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + create(data: NzMessageData): Required { + const instance = this.onCreate(data); + + if (this.instances.length >= this.config.nzMaxStack) { + this.instances = this.instances.slice(1); + } + + this.instances = [...this.instances, instance]; + + this.readyInstances(); + + return instance; + } + + remove(id: string, userAction: boolean = false): void { + this.instances.some((instance, index) => { + if (instance.messageId === id) { + this.instances.splice(index, 1); + this.instances = [...this.instances]; + this.onRemove(instance, userAction); + this.readyInstances(); + return true; + } + return false; + }); + } + + removeAll(): void { + this.instances.forEach(i => this.onRemove(i, false)); + this.instances = []; + + this.readyInstances(); + } + + protected onCreate(instance: NzMessageData): Required { + instance.options = this.mergeOptions(instance.options); + instance.onClose = new Subject(); + return instance as Required; + } + + protected onRemove(instance: Required, userAction: boolean): void { + instance.onClose.next(userAction); + instance.onClose.complete(); + } + + protected readyInstances(): void { + this.cdr.detectChanges(); + } + + protected abstract updateConfig(): void; + + protected abstract subscribeConfigChange(): void; + + protected mergeOptions(options?: NzMessageDataOptions): NzMessageDataOptions { + const { nzDuration, nzAnimate, nzPauseOnHover } = this.config; + return { nzDuration, nzAnimate, nzPauseOnHover, ...options }; + } +} + +export abstract class NzMNComponent implements OnInit, OnDestroy { + instance: Required; + index: number; + + readonly destroyed = new EventEmitter<{ id: string; userAction: boolean }>(); + + protected options: Required; + protected autoClose: boolean; + protected eraseTimer: number | null = null; + protected eraseTimingStart: number; + protected eraseTTL: number; + + constructor(protected cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + this.options = this.instance.options as Required; + + if (this.options.nzAnimate) { + this.instance.state = 'enter'; + } + + this.autoClose = this.options.nzDuration > 0; + + if (this.autoClose) { + this.initErase(); + this.startEraseTimeout(); + } + } + + ngOnDestroy(): void { + if (this.autoClose) { + this.clearEraseTimeout(); + } + } + + onEnter(): void { + if (this.autoClose && this.options.nzPauseOnHover) { + this.clearEraseTimeout(); + this.updateTTL(); + } + } + + onLeave(): void { + if (this.autoClose && this.options.nzPauseOnHover) { + this.startEraseTimeout(); + } + } + + protected destroy(userAction: boolean = false): void { + if (this.options.nzAnimate) { + this.instance.state = 'leave'; + this.cdr.detectChanges(); + setTimeout(() => { + this.destroyed.next({ id: this.instance.messageId, userAction: userAction }); + }, 200); + } else { + this.destroyed.next({ id: this.instance.messageId, userAction: userAction }); + } + } + + private initErase(): void { + this.eraseTTL = this.options.nzDuration; + this.eraseTimingStart = Date.now(); + } + + private updateTTL(): void { + if (this.autoClose) { + this.eraseTTL -= Date.now() - this.eraseTimingStart; + } + } + + private startEraseTimeout(): void { + if (this.eraseTTL > 0) { + this.clearEraseTimeout(); + this.eraseTimer = setTimeout(() => this.destroy(), this.eraseTTL); + this.eraseTimingStart = Date.now(); + } else { + this.destroy(); + } + } + + private clearEraseTimeout(): void { + if (this.eraseTimer !== null) { + clearTimeout(this.eraseTimer); + this.eraseTimer = null; + } + } +} diff --git a/components/message/doc/index.en-US.md b/components/message/doc/index.en-US.md index 6157a87c111..8aa88c0c890 100644 --- a/components/message/doc/index.en-US.md +++ b/components/message/doc/index.en-US.md @@ -57,12 +57,13 @@ You can use `NzConfigService` to configure this component globally. Please check | nzAnimate | Whether to turn on animation | `boolean` | `true` | | nzTop | Distance from top | `number \| string` | `24` | -### NzMessageDataFilled +### NzMessageRef It's the object that returned when you call `NzMessageService.success` and others. ```ts -export interface NzMessageDataFilled { +export interface NzMessageRef { + messageId: string; onClose: Subject; // It would emit an event when the message is closed } ``` diff --git a/components/message/doc/index.zh-CN.md b/components/message/doc/index.zh-CN.md index d90f90faba9..07e255ff63e 100644 --- a/components/message/doc/index.zh-CN.md +++ b/components/message/doc/index.zh-CN.md @@ -58,12 +58,13 @@ import { NzMessageModule } from 'ng-zorro-antd/message'; | nzAnimate | 开关动画效果 | `boolean` | `true` | | nzTop | 消息距离顶部的位置 | `number \| string` | `24` | -### NzMessageDataFilled +### NzMessageRef 当你调用 `NzMessageService.success` 或其他方法时会返回该对象。 ```ts -export interface NzMessageDataFilled { +export interface NzMessageRef { + messageId: string; onClose: Subject; // 当 message 关闭时它会派发一个事件 } ``` \ No newline at end of file diff --git a/components/message/message-container.component.ts b/components/message/message-container.component.ts index bb4a055f160..e85dc3e22af 100644 --- a/components/message/message-container.component.ts +++ b/components/message/message-container.component.ts @@ -6,15 +6,17 @@ * found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE */ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; - +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewEncapsulation } from '@angular/core'; import { MessageConfig, NzConfigService } from 'ng-zorro-antd/core/config'; import { toCssPixel } from 'ng-zorro-antd/core/util'; import { Subject } from 'rxjs'; + import { takeUntil } from 'rxjs/operators'; -import { NzMessageDataFilled, NzMessageDataOptions } from './typings'; +import { NzMNContainerComponent } from './base'; +import { NzMessageData } from './typings'; const NZ_CONFIG_COMPONENT_NAME = 'message'; + const NZ_MESSAGE_DEFAULT_CONFIG: Required = { nzAnimate: true, nzDuration: 3000, @@ -31,78 +33,17 @@ const NZ_MESSAGE_DEFAULT_CONFIG: Required = { preserveWhitespaces: false, template: `
- +
` }) -export class NzMessageContainerComponent implements OnInit, OnDestroy { - destroy$ = new Subject(); - messages: NzMessageDataFilled[] = []; - config: Required; +export class NzMessageContainerComponent extends NzMNContainerComponent { + readonly destroy$ = new Subject(); + instances: Array> = []; top: string | null; - constructor(protected cdr: ChangeDetectorRef, protected nzConfigService: NzConfigService) { - this.updateConfig(); - } - - ngOnInit(): void { - this.subscribeConfigChange(); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - /** - * Create a new message. - * @param message Parsed message configuration. - */ - createMessage(message: NzMessageDataFilled): void { - if (this.messages.length >= this.config.nzMaxStack) { - this.messages = this.messages.slice(1); - } - message.options = this.mergeMessageOptions(message.options); - message.onClose = new Subject(); - this.messages = [...this.messages, message]; - this.cdr.detectChanges(); - } - - /** - * Remove a message by `messageId`. - * @param messageId Id of the message to be removed. - * @param userAction Whether this is closed by user interaction. - */ - removeMessage(messageId: string, userAction: boolean = false): void { - this.messages.some((message, index) => { - if (message.messageId === messageId) { - this.messages.splice(index, 1); - this.messages = [...this.messages]; - this.cdr.detectChanges(); - message.onClose!.next(userAction); - message.onClose!.complete(); - return true; - } - return false; - }); - } - - /** - * Remove all messages. - */ - removeMessageAll(): void { - this.messages = []; - this.cdr.detectChanges(); - } - - protected updateConfig(): void { - this.config = this.updateConfigFromConfigService(); - this.top = toCssPixel(this.config.nzTop); - this.cdr.markForCheck(); + constructor(cdr: ChangeDetectorRef, nzConfigService: NzConfigService) { + super(cdr, nzConfigService); } protected subscribeConfigChange(): void { @@ -112,24 +53,14 @@ export class NzMessageContainerComponent implements OnInit, OnDestroy { .subscribe(() => this.updateConfig()); } - protected updateConfigFromConfigService(): Required { - return { + protected updateConfig(): void { + this.config = { ...NZ_MESSAGE_DEFAULT_CONFIG, ...this.config, ...this.nzConfigService.getConfigForComponent(NZ_CONFIG_COMPONENT_NAME) }; - } - /** - * Merge default options and custom message options - * @param options - */ - protected mergeMessageOptions(options?: NzMessageDataOptions): NzMessageDataOptions { - const defaultOptions: NzMessageDataOptions = { - nzDuration: this.config.nzDuration, - nzAnimate: this.config.nzAnimate, - nzPauseOnHover: this.config.nzPauseOnHover - }; - return { ...defaultOptions, ...options }; + this.top = toCssPixel(this.config.nzTop); + this.cdr.markForCheck(); } } diff --git a/components/message/message.component.ts b/components/message/message.component.ts index 150ac4b8927..6c952282a2d 100644 --- a/components/message/message.component.ts +++ b/components/message/message.component.ts @@ -19,7 +19,8 @@ import { } from '@angular/core'; import { moveUpMotion } from 'ng-zorro-antd/core/animation'; -import { NzMessageDataFilled, NzMessageDataOptions } from './typings'; +import { NzMNComponent } from './base'; +import { NzMessageData } from './typings'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -29,113 +30,29 @@ import { NzMessageDataFilled, NzMessageDataOptions } from './typings'; preserveWhitespaces: false, animations: [moveUpMotion], template: ` -
+
-
- +
+ - - + +
` }) -export class NzMessageComponent implements OnInit, OnDestroy { - @Input() nzMessage: NzMessageDataFilled; - @Input() nzIndex: number; - @Output() readonly messageDestroy = new EventEmitter<{ id: string; userAction: boolean }>(); +export class NzMessageComponent extends NzMNComponent implements OnInit, OnDestroy { + @Input() instance: Required; + @Output() readonly destroyed = new EventEmitter<{ id: string; userAction: boolean }>(); - protected options: Required; - - // Whether to set a timeout to destroy itself. - private autoClose: boolean; - - private eraseTimer: number | null = null; - private eraseTimingStart: number; - private eraseTTL: number; // Time to live. - - constructor(protected cdr: ChangeDetectorRef) {} - - ngOnInit(): void { - // `NzMessageContainer` does its job so all properties cannot be undefined. - this.options = this.nzMessage.options as Required; - - if (this.options.nzAnimate) { - this.nzMessage.state = 'enter'; - } - - this.autoClose = this.options.nzDuration > 0; - - if (this.autoClose) { - this.initErase(); - this.startEraseTimeout(); - } - } - - ngOnDestroy(): void { - if (this.autoClose) { - this.clearEraseTimeout(); - } - } - - onEnter(): void { - if (this.autoClose && this.options.nzPauseOnHover) { - this.clearEraseTimeout(); - this.updateTTL(); - } - } - - onLeave(): void { - if (this.autoClose && this.options.nzPauseOnHover) { - this.startEraseTimeout(); - } - } - - // Remove self - protected destroy(userAction: boolean = false): void { - if (this.options.nzAnimate) { - this.nzMessage.state = 'leave'; - this.cdr.detectChanges(); - setTimeout(() => { - this.messageDestroy.next({ id: this.nzMessage.messageId, userAction: userAction }); - }, 200); - } else { - this.messageDestroy.next({ id: this.nzMessage.messageId, userAction: userAction }); - } - } - - private initErase(): void { - this.eraseTTL = this.options.nzDuration; - this.eraseTimingStart = Date.now(); - } - - private updateTTL(): void { - if (this.autoClose) { - this.eraseTTL -= Date.now() - this.eraseTimingStart; - } - } - - private startEraseTimeout(): void { - if (this.eraseTTL > 0) { - this.clearEraseTimeout(); - this.eraseTimer = setTimeout(() => this.destroy(), this.eraseTTL); - this.eraseTimingStart = Date.now(); - } else { - this.destroy(); - } - } - - private clearEraseTimeout(): void { - if (this.eraseTimer !== null) { - clearTimeout(this.eraseTimer); - this.eraseTimer = null; - } + constructor(cdr: ChangeDetectorRef) { + super(cdr); } } diff --git a/components/message/message.service.ts b/components/message/message.service.ts index b47d793f22d..b4b5322965b 100644 --- a/components/message/message.service.ts +++ b/components/message/message.service.ts @@ -7,98 +7,63 @@ */ import { Overlay } from '@angular/cdk/overlay'; -import { ComponentPortal } from '@angular/cdk/portal'; import { Injectable, Injector, TemplateRef } from '@angular/core'; import { NzSingletonService } from 'ng-zorro-antd/core/services'; + +import { NzMNService } from './base'; import { NzMessageContainerComponent } from './message-container.component'; import { NzMessageServiceModule } from './message.service.module'; -import { NzMessageData, NzMessageDataFilled, NzMessageDataOptions } from './typings'; - -let globalCounter = 0; +import { NzMessageData, NzMessageDataOptions, NzMessageRef } from './typings'; @Injectable({ providedIn: NzMessageServiceModule }) -export class NzMessageService { - private name = 'message-'; +export class NzMessageService extends NzMNService { protected container: NzMessageContainerComponent; + protected componentPrefix = 'message-'; - constructor(private nzSingletonService: NzSingletonService, private overlay: Overlay, private injector: Injector) {} + constructor(nzSingletonService: NzSingletonService, overlay: Overlay, injector: Injector) { + super(nzSingletonService, overlay, injector); + } - // Shortcut methods - success(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageDataFilled { - return this.createMessage({ type: 'success', content }, options); + success(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageRef { + return this.createInstance({ type: 'success', content }, options); } - error(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageDataFilled { - return this.createMessage({ type: 'error', content }, options); + error(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageRef { + return this.createInstance({ type: 'error', content }, options); } - info(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageDataFilled { - return this.createMessage({ type: 'info', content }, options); + info(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageRef { + return this.createInstance({ type: 'info', content }, options); } - warning(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageDataFilled { - return this.createMessage({ type: 'warning', content }, options); + warning(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageRef { + return this.createInstance({ type: 'warning', content }, options); } - loading(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageDataFilled { - return this.createMessage({ type: 'loading', content }, options); + loading(content: string | TemplateRef, options?: NzMessageDataOptions): NzMessageRef { + return this.createInstance({ type: 'loading', content }, options); } create( type: 'success' | 'info' | 'warning' | 'error' | 'loading' | string, content: string | TemplateRef, options?: NzMessageDataOptions - ): NzMessageDataFilled { - return this.createMessage({ type, content }, options); + ): NzMessageRef { + return this.createInstance({ type, content }, options); } - remove(messageId?: string): void { - if (messageId) { - this.container.removeMessage(messageId); - } else { - this.container.removeMessageAll(); - } - } + private createInstance(message: NzMessageData, options?: NzMessageDataOptions): NzMessageRef { + this.container = this.withContainer(NzMessageContainerComponent); - createMessage(message: NzMessageData, options?: NzMessageDataOptions): NzMessageDataFilled { - this.container = this.withContainer(); - this.nzSingletonService.registerSingletonWithKey(this.name, this.container); - const resultMessage: NzMessageDataFilled = { - ...(message as NzMessageData), + return this.container.create({ + ...message, ...{ createdAt: new Date(), - messageId: this.generateMessageId(), + messageId: this.getInstanceId(), options } - }; - this.container.createMessage(resultMessage); - - return resultMessage; - } - - protected generateMessageId(): string { - return `${this.name}-${globalCounter++}`; - } - - // Manually creating container for overlay to avoid multi-checking error, see: https://github.com/NG-ZORRO/ng-zorro-antd/issues/391 - // NOTE: we never clean up the container component and it's overlay resources, if we should, we need to do it by our own codes. - private withContainer(): NzMessageContainerComponent { - const containerInstance = this.nzSingletonService.getSingletonWithKey(this.name); - - if (containerInstance) { - return containerInstance as NzMessageContainerComponent; - } - const overlayRef = this.overlay.create({ - hasBackdrop: false, - scrollStrategy: this.overlay.scrollStrategies.noop(), - positionStrategy: this.overlay.position().global() }); - const componentPortal = new ComponentPortal(NzMessageContainerComponent, null, this.injector); - const componentRef = overlayRef.attach(componentPortal); - const overlayPane = overlayRef.overlayElement; - overlayPane.style.zIndex = '1010'; // Patching: assign the same zIndex of ant-message to it's parent overlay panel, to the ant-message's zindex work. - return componentRef.instance; } } diff --git a/components/message/message.spec.ts b/components/message/message.spec.ts index 5ef0476bdef..b0a923e32af 100644 --- a/components/message/message.spec.ts +++ b/components/message/message.spec.ts @@ -152,14 +152,14 @@ describe('message', () => { expect(overlayContainerElement.textContent).toContain(content); if (id === 3) { expect(overlayContainerElement.textContent).not.toContain('SUCCESS-1'); - expect((messageService as any).container.messages.length).toBe(2); // tslint:disable-line:no-any + expect((messageService as any).container.instances.length).toBe(2); // tslint:disable-line:no-any } }); messageService.remove(); fixture.detectChanges(); expect(overlayContainerElement.textContent).not.toContain('SUCCESS-3'); - expect((messageService as any).container.messages.length).toBe(0); // tslint:disable-line:no-any + expect((messageService as any).container.instances.length).toBe(0); // tslint:disable-line:no-any })); it('should destroy without animation', fakeAsync(() => { diff --git a/components/message/public-api.ts b/components/message/public-api.ts index 71ddb7b1601..ffea0e18de1 100644 --- a/components/message/public-api.ts +++ b/components/message/public-api.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE */ +export * from './base'; export * from './message.service'; export * from './message.service.module'; export * from './message.module'; diff --git a/components/message/typings.ts b/components/message/typings.ts index 5fe004263a7..7261b2f5e03 100644 --- a/components/message/typings.ts +++ b/components/message/typings.ts @@ -17,22 +17,21 @@ export interface NzMessageDataOptions { nzPauseOnHover?: boolean; } -/** - * Message data for terminal users. - */ export interface NzMessageData { type?: NzMessageType | string; content?: string | TemplateRef; -} - -/** - * Filled version of NzMessageData (includes more private properties). - */ -export interface NzMessageDataFilled extends NzMessageData { - messageId: string; - createdAt: Date; - + messageId?: string; + createdAt?: Date; options?: NzMessageDataOptions; state?: 'enter' | 'leave'; + onClose?: Subject; } + +export type NzMessageRef = Pick, 'onClose' | 'messageId'>; + +/** + * @deprecated use `NzMessageRef` instead + * @breaking-change 10.0.0 + */ +export type NzMessageDataFilled = NzMessageRef; diff --git a/components/notification/demo/basic.ts b/components/notification/demo/basic.ts index 39e3a6df633..7a40c746b6a 100644 --- a/components/notification/demo/basic.ts +++ b/components/notification/demo/basic.ts @@ -3,17 +3,19 @@ import { NzNotificationService } from 'ng-zorro-antd/notification'; @Component({ selector: 'nz-demo-notification-basic', - template: ` - - ` + template: ` ` }) export class NzDemoNotificationBasicComponent { constructor(private notification: NzNotificationService) {} createBasicNotification(): void { - this.notification.blank( - 'Notification Title', - 'This is the content of the notification. This is the content of the notification. This is the content of the notification.' - ); + this.notification + .blank( + 'Notification Title', + 'This is the content of the notification. This is the content of the notification. This is the content of the notification.' + ) + .onClick.subscribe(() => { + console.log('notification clicked!'); + }); } } diff --git a/components/notification/doc/index.en-US.md b/components/notification/doc/index.en-US.md index 49fc03339a7..7d32f453124 100644 --- a/components/notification/doc/index.en-US.md +++ b/components/notification/doc/index.en-US.md @@ -70,12 +70,14 @@ You can use `NzConfigService` to configure this component globally. | nzBottom | The bottom of the notification when it pops up from the bottom. | `string` | 24px | | nzPlacement | Popup position, optional `topLeft` `topRight` `bottomLeft` `bottomRight` | `string` | `topRight` | -### NzNotificationDataFilled +### NzNotificationRef It's the object that returned when you call `NzNotificationService.success` and others. ```ts -export interface NzNotificationDataFilled { +export interface NzNotificationRef { + messageId: string; onClose: Subject; // It would emit an event when the notification is closed, and emit a `true` if it's closed by user + onClick: Subject; } ``` diff --git a/components/notification/doc/index.zh-CN.md b/components/notification/doc/index.zh-CN.md index 9b30ce3bb22..15aa356a84b 100644 --- a/components/notification/doc/index.zh-CN.md +++ b/components/notification/doc/index.zh-CN.md @@ -70,12 +70,14 @@ import { NzNotificationModule } from 'ng-zorro-antd/notification'; | nzBottom | 消息从底部弹出时,距离底部的位置。 | `string` | 24px | | nzPlacement | 弹出位置,可选 `topLeft` `topRight` `bottomLeft` `bottomRight` | `string` | `topRight` | -### NzNotificationDataFilled +### NzNotificationRef 当你调用 `NzNotificationService.success` 或其他方法时会返回该对象。 ```ts -export interface NzNotificationDataFilled { +export interface NzNotificationDataRef { + messageId: string; onClose: Subject; // 当 notification 关闭时它会派发一个事件,如果为用户手动关闭会派发 `true` + onClick: Subject; } ``` diff --git a/components/notification/notification-container.component.ts b/components/notification/notification-container.component.ts index 8431c64fe0a..7247de52153 100644 --- a/components/notification/notification-container.component.ts +++ b/components/notification/notification-container.component.ts @@ -6,14 +6,18 @@ * found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE */ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewEncapsulation } from '@angular/core'; import { NotificationConfig, NzConfigService } from 'ng-zorro-antd/core/config'; import { toCssPixel } from 'ng-zorro-antd/core/util'; +import { NzMNContainerComponent } from 'ng-zorro-antd/message'; import { Subject } from 'rxjs'; -import { NzNotificationDataFilled, NzNotificationDataOptions } from './typings'; +import { takeUntil } from 'rxjs/operators'; + +import { NzNotificationData, NzNotificationDataOptions } from './typings'; const NZ_CONFIG_COMPONENT_NAME = 'notification'; + const NZ_NOTIFICATION_DEFAULT_CONFIG: Required = { nzTop: '24px', nzBottom: '24px', @@ -33,162 +37,112 @@ const NZ_NOTIFICATION_DEFAULT_CONFIG: Required = { template: `
` }) -export class NzNotificationContainerComponent implements OnInit, OnDestroy { - config: Required; +export class NzNotificationContainerComponent extends NzMNContainerComponent { bottom: string | null; - messages: Array> = []; - - constructor(protected cdr: ChangeDetectorRef, protected nzConfigService: NzConfigService) { - this.updateConfig(); - } - - get topLeftMessages(): Array> { - return this.messages.filter(m => m.options.nzPosition === 'topLeft'); - } - - get topRightMessages(): Array> { - return this.messages.filter(m => m.options.nzPosition === 'topRight' || !m.options.nzPosition); - } - - get bottomLeftMessages(): Array> { - return this.messages.filter(m => m.options.nzPosition === 'bottomLeft'); - } - - get bottomRightMessages(): Array> { - return this.messages.filter(m => m.options.nzPosition === 'bottomRight'); + top: string | null; + config: Required; + instances: Array> = []; + topLeftInstances: Array> = []; + topRightInstances: Array> = []; + bottomLeftInstances: Array> = []; + bottomRightInstances: Array> = []; + + constructor(cdr: ChangeDetectorRef, nzConfigService: NzConfigService) { + super(cdr, nzConfigService); } - /** - * Create a new notification. - * If there's a notification whose `nzKey` is same with `nzKey` in `NzNotificationDataFilled`, - * replace its content instead of create a new one. - * @override - * @param notification - */ - createMessage(notification: NzNotificationDataFilled): void { - notification.options = this.mergeMessageOptions(notification.options); - notification.onClose = new Subject(); - const key = notification.options.nzKey; - const notificationWithSameKey = this.messages.find( + create(notification: NzNotificationData): Required { + const noti = this.onCreate(notification); + const key = noti.options.nzKey; + const notificationWithSameKey = this.instances.find( msg => msg.options.nzKey === (notification.options as Required).nzKey ); - if (key && notificationWithSameKey) { - this.replaceNotification(notificationWithSameKey, notification); + this.replaceNotification(notificationWithSameKey, noti); } else { - if (this.messages.length >= this.config.nzMaxStack) { - this.messages.splice(0, 1); + if (this.instances.length >= this.config.nzMaxStack) { + this.instances = this.instances.slice(1); } - this.messages.push(notification as Required); + this.instances = [...this.instances, noti]; } - this.cdr.detectChanges(); + + this.readyInstances(); + + return noti; + } + + protected onCreate(instance: NzNotificationData): Required { + instance.options = this.mergeOptions(instance.options); + instance.onClose = new Subject(); + instance.onClick = new Subject(); + return instance as Required; + } + + protected subscribeConfigChange(): void { + this.nzConfigService + .getConfigChangeEventForComponent(NZ_CONFIG_COMPONENT_NAME) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.updateConfig()); } protected updateConfig(): void { - const newConfig = (this.config = { + this.config = { ...NZ_NOTIFICATION_DEFAULT_CONFIG, ...this.config, ...this.nzConfigService.getConfigForComponent(NZ_CONFIG_COMPONENT_NAME) - }) as NotificationConfig; + }; - this.top = toCssPixel(newConfig.nzTop!); - this.bottom = toCssPixel(newConfig.nzBottom!); + this.top = toCssPixel(this.config.nzTop!); + this.bottom = toCssPixel(this.config.nzBottom!); this.cdr.markForCheck(); } - protected subscribeConfigChange(): void { - this.nzConfigService.getConfigChangeEventForComponent(NZ_CONFIG_COMPONENT_NAME).subscribe(() => this.updateConfig()); - } - - private replaceNotification(old: NzNotificationDataFilled, _new: NzNotificationDataFilled): void { + private replaceNotification(old: NzNotificationData, _new: NzNotificationData): void { old.title = _new.title; old.content = _new.content; old.template = _new.template; old.type = _new.type; } - destroy$ = new Subject(); - top: string | null; + protected readyInstances(): void { + this.topLeftInstances = this.instances.filter(m => m.options.nzPosition === 'topLeft'); + this.topRightInstances = this.instances.filter(m => m.options.nzPosition === 'topRight' || !m.options.nzPosition); + this.bottomLeftInstances = this.instances.filter(m => m.options.nzPosition === 'bottomLeft'); + this.bottomRightInstances = this.instances.filter(m => m.options.nzPosition === 'bottomRight'); - ngOnInit(): void { - this.subscribeConfigChange(); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - /** - * Remove a message by `messageId`. - * @param messageId Id of the message to be removed. - * @param userAction Whether this is closed by user interaction. - */ - removeMessage(messageId: string, userAction: boolean = false): void { - this.messages.some((message, index) => { - if (message.messageId === messageId) { - this.messages.splice(index, 1); - this.messages = [...this.messages]; - this.cdr.detectChanges(); - message.onClose!.next(userAction); - message.onClose!.complete(); - return true; - } - return false; - }); - } - - /** - * Remove all messages. - */ - removeMessageAll(): void { - this.messages = []; this.cdr.detectChanges(); } - - /** - * Merge default options and custom message options - * @param options - */ - protected mergeMessageOptions(options?: NzNotificationDataOptions): NzNotificationDataOptions { - const defaultOptions: NzNotificationDataOptions = { - nzDuration: this.config.nzDuration, - nzAnimate: this.config.nzAnimate, - nzPauseOnHover: this.config.nzPauseOnHover - }; - return { ...defaultOptions, ...options }; - } } diff --git a/components/notification/notification.component.ts b/components/notification/notification.component.ts index eaa11fa20ed..050889ceabf 100644 --- a/components/notification/notification.component.ts +++ b/components/notification/notification.component.ts @@ -6,9 +6,11 @@ * found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE */ -import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, Output, ViewEncapsulation } from '@angular/core'; import { notificationMotion } from 'ng-zorro-antd/core/animation'; -import { NzNotificationDataFilled, NzNotificationDataOptions } from './typings'; +import { NzMNComponent } from 'ng-zorro-antd/message'; + +import { NzNotificationData } from './typings'; @Component({ encapsulation: ViewEncapsulation.None, @@ -19,16 +21,17 @@ import { NzNotificationDataFilled, NzNotificationDataOptions } from './typings'; template: `
-
-
- ` }) -export class NzNotificationComponent implements OnInit, OnDestroy { - @Input() nzMessage: NzNotificationDataFilled; - @Input() nzIndex: number; - @Input() nzPlacement: string; - @Output() readonly messageDestroy = new EventEmitter<{ id: string; userAction: boolean }>(); - - protected options: Required; - - // Whether to set a timeout to destroy itself. - private autoClose: boolean; - - private eraseTimer: number | null = null; - private eraseTimingStart: number; - private eraseTTL: number; // Time to live. - - constructor(protected cdr: ChangeDetectorRef) {} - - ngOnInit(): void { - // `NzMessageContainer` does its job so all properties cannot be undefined. - this.options = this.nzMessage.options as Required; - - if (this.options.nzAnimate) { - this.nzMessage.state = 'enter'; - } - - this.autoClose = this.options.nzDuration > 0; - - if (this.autoClose) { - this.initErase(); - this.startEraseTimeout(); - } +export class NzNotificationComponent extends NzMNComponent implements OnDestroy { + @Input() instance: Required; + @Input() placement: string; + @Input() index: number; + @Output() readonly destroyed = new EventEmitter<{ id: string; userAction: boolean }>(); + + constructor(cdr: ChangeDetectorRef) { + super(cdr); } ngOnDestroy(): void { - if (this.autoClose) { - this.clearEraseTimeout(); - } - } - - onEnter(): void { - if (this.autoClose && this.options.nzPauseOnHover) { - this.clearEraseTimeout(); - this.updateTTL(); - } - } - - onLeave(): void { - if (this.autoClose && this.options.nzPauseOnHover) { - this.startEraseTimeout(); - } - } - - // Remove self - protected destroy(userAction: boolean = false): void { - if (this.options.nzAnimate) { - this.nzMessage.state = 'leave'; - this.cdr.detectChanges(); - setTimeout(() => { - this.messageDestroy.next({ id: this.nzMessage.messageId, userAction: userAction }); - }, 200); - } else { - this.messageDestroy.next({ id: this.nzMessage.messageId, userAction: userAction }); - } - } - - private initErase(): void { - this.eraseTTL = this.options.nzDuration; - this.eraseTimingStart = Date.now(); + super.ngOnDestroy(); + this.instance.onClick.complete(); } - private updateTTL(): void { - if (this.autoClose) { - this.eraseTTL -= Date.now() - this.eraseTimingStart; - } - } - - private startEraseTimeout(): void { - if (this.eraseTTL > 0) { - this.clearEraseTimeout(); - this.eraseTimer = setTimeout(() => this.destroy(), this.eraseTTL); - this.eraseTimingStart = Date.now(); - } else { - this.destroy(); - } - } - - private clearEraseTimeout(): void { - if (this.eraseTimer !== null) { - clearTimeout(this.eraseTimer); - this.eraseTimer = null; - } + onClick(event: MouseEvent): void { + this.instance.onClick.next(event); } close(): void { @@ -178,14 +107,14 @@ export class NzNotificationComponent implements OnInit, OnDestroy { } get state(): string | undefined { - if (this.nzMessage.state === 'enter') { - if (this.nzPlacement === 'topLeft' || this.nzPlacement === 'bottomLeft') { + if (this.instance.state === 'enter') { + if (this.placement === 'topLeft' || this.placement === 'bottomLeft') { return 'enterLeft'; } else { return 'enterRight'; } } else { - return this.nzMessage.state; + return this.instance.state; } } } diff --git a/components/notification/notification.service.ts b/components/notification/notification.service.ts index 06a4e69da94..ab1ab4e9079 100644 --- a/components/notification/notification.service.ts +++ b/components/notification/notification.service.ts @@ -7,90 +7,45 @@ */ import { Overlay } from '@angular/cdk/overlay'; -import { ComponentPortal } from '@angular/cdk/portal'; import { Injectable, Injector, TemplateRef } from '@angular/core'; import { NzSingletonService } from 'ng-zorro-antd/core/services'; +import { NzMNService } from 'ng-zorro-antd/message'; import { NzNotificationContainerComponent } from './notification-container.component'; import { NzNotificationServiceModule } from './notification.service.module'; -import { NzNotificationData, NzNotificationDataFilled, NzNotificationDataOptions } from './typings'; +import { NzNotificationData, NzNotificationDataOptions, NzNotificationRef } from './typings'; -let globalCounter = 0; +let notificationId = 0; @Injectable({ providedIn: NzNotificationServiceModule }) -export class NzNotificationService { - private name = 'notification-'; +export class NzNotificationService extends NzMNService { protected container: NzNotificationContainerComponent; - remove(messageId?: string): void { - if (messageId) { - this.container.removeMessage(messageId); - } else { - this.container.removeMessageAll(); - } - } - - createMessage(message: NzNotificationData, options?: NzNotificationDataOptions): NzNotificationDataFilled { - this.container = this.withContainer(); - this.nzSingletonService.registerSingletonWithKey(this.name, this.container); - const resultMessage: NzNotificationDataFilled = { - ...(message as NzNotificationData), - ...{ - createdAt: new Date(), - messageId: this.generateMessageId(), - options - } - }; - this.container.createMessage(resultMessage); - - return resultMessage; - } - - protected generateMessageId(): string { - return `${this.name}-${globalCounter++}`; - } - - // Manually creating container for overlay to avoid multi-checking error, see: https://github.com/NG-ZORRO/ng-zorro-antd/issues/391 - // NOTE: we never clean up the container component and it's overlay resources, if we should, we need to do it by our own codes. - private withContainer(): NzNotificationContainerComponent { - const containerInstance = this.nzSingletonService.getSingletonWithKey(this.name); + protected componentPrefix = 'notification-'; - if (containerInstance) { - return containerInstance as NzNotificationContainerComponent; - } - const overlayRef = this.overlay.create({ - hasBackdrop: false, - scrollStrategy: this.overlay.scrollStrategies.noop(), - positionStrategy: this.overlay.position().global() - }); - const componentPortal = new ComponentPortal(NzNotificationContainerComponent, null, this.injector); - const componentRef = overlayRef.attach(componentPortal); - const overlayPane = overlayRef.overlayElement; - overlayPane.style.zIndex = '1010'; // Patching: assign the same zIndex of ant-message to it's parent overlay panel, to the ant-message's zindex work. - return componentRef.instance; + constructor(nzSingletonService: NzSingletonService, overlay: Overlay, injector: Injector) { + super(nzSingletonService, overlay, injector); } - constructor(private nzSingletonService: NzSingletonService, private overlay: Overlay, private injector: Injector) {} - // Shortcut methods - success(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationDataFilled { - return this.createMessage({ type: 'success', title, content }, options) as NzNotificationDataFilled; + success(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationRef { + return this.createInstance({ type: 'success', title, content }, options); } - error(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationDataFilled { - return this.createMessage({ type: 'error', title, content }, options) as NzNotificationDataFilled; + error(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationRef { + return this.createInstance({ type: 'error', title, content }, options); } - info(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationDataFilled { - return this.createMessage({ type: 'info', title, content }, options) as NzNotificationDataFilled; + info(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationRef { + return this.createInstance({ type: 'info', title, content }, options); } - warning(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationDataFilled { - return this.createMessage({ type: 'warning', title, content }, options) as NzNotificationDataFilled; + warning(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationRef { + return this.createInstance({ type: 'warning', title, content }, options); } - blank(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationDataFilled { - return this.createMessage({ type: 'blank', title, content }, options) as NzNotificationDataFilled; + blank(title: string, content: string, options?: NzNotificationDataOptions): NzNotificationRef { + return this.createInstance({ type: 'blank', title, content }, options); } create( @@ -98,12 +53,28 @@ export class NzNotificationService { title: string, content: string, options?: NzNotificationDataOptions - ): NzNotificationDataFilled { - return this.createMessage({ type, title, content }, options) as NzNotificationDataFilled; + ): NzNotificationRef { + return this.createInstance({ type, title, content }, options); + } + + template(template: TemplateRef<{}>, options?: NzNotificationDataOptions): NzNotificationRef { + return this.createInstance({ template }, options); } - // For content with template - template(template: TemplateRef<{}>, options?: NzNotificationDataOptions): NzNotificationDataFilled { - return this.createMessage({ template }, options) as NzNotificationDataFilled; + protected generateMessageId(): string { + return `${this.componentPrefix}-${notificationId++}`; + } + + private createInstance(message: NzNotificationData, options?: NzNotificationDataOptions): NzNotificationRef { + this.container = this.withContainer(NzNotificationContainerComponent); + + return this.container.create({ + ...message, + ...{ + createdAt: new Date(), + messageId: this.generateMessageId(), + options + } + }); } } diff --git a/components/notification/notification.spec.ts b/components/notification/notification.spec.ts index 1de322776e3..be50958ad62 100644 --- a/components/notification/notification.spec.ts +++ b/components/notification/notification.spec.ts @@ -13,9 +13,7 @@ import { NzNotificationModule } from './notification.module'; import { NzNotificationService } from './notification.service'; @Component({ - template: ` - {{ 'test template content' }}{{ data }} - ` + template: ` {{ 'test template content' }}{{ data }} ` }) export class NzTestNotificationComponent { @ViewChild(TemplateRef, { static: true }) demoTemplateRef: TemplateRef<{}>; @@ -159,14 +157,14 @@ describe('NzNotification', () => { expect(overlayContainerElement.textContent).toContain(content); if (id === 3) { expect(overlayContainerElement.textContent).not.toContain('SUCCESS-1'); - expect((notificationService as any).container.messages.length).toBe(2); // tslint:disable-line:no-any + expect((notificationService as any).container.instances.length).toBe(2); // tslint:disable-line:no-any } }); notificationService.remove(); fixture.detectChanges(); expect(overlayContainerElement.textContent).not.toContain('SUCCESS-3'); - expect((notificationService as any).container.messages.length).toBe(0); // tslint:disable-line:no-any + expect((notificationService as any).container.instances.length).toBe(0); // tslint:disable-line:no-any })); it('should destroy without animation', fakeAsync(() => { diff --git a/components/notification/typings.ts b/components/notification/typings.ts index 013a55abbf8..a3bf01a027b 100644 --- a/components/notification/typings.ts +++ b/components/notification/typings.ts @@ -11,14 +11,6 @@ import { TemplateRef } from '@angular/core'; import { NgClassInterface, NgStyleInterface } from 'ng-zorro-antd/core/types'; import { Subject } from 'rxjs'; -export interface NzNotificationData { - template?: TemplateRef<{}>; - - type?: 'success' | 'info' | 'warning' | 'error' | 'blank' | string; - title?: string; - content?: string | TemplateRef; -} - export type NzNotificationPosition = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; export interface NzNotificationDataOptions { @@ -27,25 +19,31 @@ export interface NzNotificationDataOptions { nzClass?: NgClassInterface | string; nzCloseIcon?: TemplateRef | string; nzPosition?: NzNotificationPosition; - - /** Anything user wants renderer into a template. */ nzData?: T; nzDuration?: number; nzAnimate?: boolean; nzPauseOnHover?: boolean; } -// Filled version of NzMessageData (includes more private properties) -export interface NzNotificationDataFilled { - messageId: string; // Service-wide unique id, auto generated - createdAt: Date; // Auto created - - state?: 'enter' | 'leave'; +export interface NzNotificationData { + content?: string | TemplateRef; + createdAt?: Date; + messageId?: string; options?: NzNotificationDataOptions; - onClose?: Subject; + state?: 'enter' | 'leave'; template?: TemplateRef<{}>; - - type?: 'success' | 'info' | 'warning' | 'error' | 'blank' | string; title?: string; - content?: string | TemplateRef; + type?: 'success' | 'info' | 'warning' | 'error' | 'blank' | string; + + // observables exposed to users + onClose?: Subject; + onClick?: Subject; } + +export type NzNotificationRef = Pick, 'onClose' | 'onClick' | 'messageId'>; + +/** + * @deprecated use `NzNotificationRef` instead + * @breaking-change 10.0.0 + */ +export type NzNotificationDataFilled = NzNotificationRef; diff --git a/scripts/site/_site/doc/app/app.module.ts b/scripts/site/_site/doc/app/app.module.ts index e68e20b576e..8a74dba4d1e 100644 --- a/scripts/site/_site/doc/app/app.module.ts +++ b/scripts/site/_site/doc/app/app.module.ts @@ -8,10 +8,10 @@ import { ServiceWorkerModule } from '@angular/service-worker'; import { IconDefinition } from '@ant-design/icons-angular'; import { LeftOutline, RightOutline } from '@ant-design/icons-angular/icons'; import { NzAffixModule } from 'ng-zorro-antd/affix'; -import { NzGridModule } from 'ng-zorro-antd/grid'; import { NzBadgeModule } from 'ng-zorro-antd/badge'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NZ_CONFIG } from 'ng-zorro-antd/core/config'; +import { NzGridModule } from 'ng-zorro-antd/grid'; import { NzI18nModule } from 'ng-zorro-antd/i18n'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzInputModule } from 'ng-zorro-antd/input'; @@ -19,7 +19,7 @@ import { NzMenuModule } from 'ng-zorro-antd/menu'; import { NzMessageModule } from 'ng-zorro-antd/message'; import { NzPopoverModule } from 'ng-zorro-antd/popover'; import { NzSelectModule } from 'ng-zorro-antd/select'; -import { ColorSketchModule } from "ngx-color/sketch"; +import { ColorSketchModule } from 'ngx-color/sketch'; import { environment } from '../environments/environment'; import { DEMOComponent } from './_demo/demo.component';