diff --git a/projects/ion/src/lib/core/types/notification.ts b/projects/ion/src/lib/core/types/notification.ts index 1c9363e1c..6b8a0ae21 100644 --- a/projects/ion/src/lib/core/types/notification.ts +++ b/projects/ion/src/lib/core/types/notification.ts @@ -3,10 +3,13 @@ import { fadeInDirection, fadeOutDirection } from '../../utils/animationsTypes'; import { IconType } from './icon'; import { StatusType } from './status'; -export interface NotificationProps { +export interface NotificationProps extends NotificationConfigOptions { title: string; message: string; type?: StatusType; +} + +export interface NotificationConfigOptions { icon?: IconType; fixed?: boolean; fadeIn?: fadeInDirection; diff --git a/projects/ion/src/lib/icon/mock/list-icons.module.ts b/projects/ion/src/lib/icon/mock/list-icons.module.ts index 73e8b3d1e..67238337e 100644 --- a/projects/ion/src/lib/icon/mock/list-icons.module.ts +++ b/projects/ion/src/lib/icon/mock/list-icons.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { IonIconComponent } from '../icon.component'; import { IonInputComponent } from '../../input/input.component'; import { FormsModule } from '@angular/forms'; -import { IonNotificationComponent } from '../../notification/notification.component'; +import { IonNotificationComponent } from '../../notification/component/notification.component'; @NgModule({ declarations: [IonInputComponent, IonIconComponent, IonNotificationComponent], diff --git a/projects/ion/src/lib/notification/notification.component.html b/projects/ion/src/lib/notification/component/notification.component.html similarity index 100% rename from projects/ion/src/lib/notification/notification.component.html rename to projects/ion/src/lib/notification/component/notification.component.html diff --git a/projects/ion/src/lib/notification/notification.component.scss b/projects/ion/src/lib/notification/component/notification.component.scss similarity index 85% rename from projects/ion/src/lib/notification/notification.component.scss rename to projects/ion/src/lib/notification/component/notification.component.scss index 6a1166a3b..29b660be4 100644 --- a/projects/ion/src/lib/notification/notification.component.scss +++ b/projects/ion/src/lib/notification/component/notification.component.scss @@ -1,5 +1,5 @@ -@import '../../styles/index.scss'; -@import '../utils/fadeAnimations.scss'; +@import '../../../styles/index.scss'; +@import '../../utils/fadeAnimations.scss'; @mixin icon-color($color) { ::ng-deep svg { @@ -13,9 +13,11 @@ align-items: flex-start; justify-content: space-between; padding: spacing(1.5) spacing(2); + position: relative; z-index: $zIndexMax; max-width: 500px; + min-width: 250px; background: rgba(255, 255, 255, 0.9); box-shadow: 0px 8px 6px -4px rgba(0, 0, 0, 0.15), @@ -75,3 +77,11 @@ .negative-icon { @include icon-color($negative-color); } + +.notification-container:nth-child(2) { + top: 30px; +} + +.notification-container:nth-child(3) { + top: 30px; +} diff --git a/projects/ion/src/lib/notification/notification.component.spec.ts b/projects/ion/src/lib/notification/component/notification.component.spec.ts similarity index 96% rename from projects/ion/src/lib/notification/notification.component.spec.ts rename to projects/ion/src/lib/notification/component/notification.component.spec.ts index 2acb8b57a..8ced43bbe 100644 --- a/projects/ion/src/lib/notification/notification.component.spec.ts +++ b/projects/ion/src/lib/notification/component/notification.component.spec.ts @@ -1,8 +1,8 @@ import { EventEmitter } from '@angular/core'; import { fireEvent, render, screen } from '@testing-library/angular'; -import { StatusType } from '../core/types'; -import { NotificationProps } from '../core/types/notification'; -import { IonIconModule } from '../icon/icon.module'; +import { StatusType } from '../../core/types'; +import { NotificationProps } from '../../core/types/notification'; +import { IonIconModule } from '../../icon/icon.module'; import { IonNotificationComponent } from './notification.component'; const defaultNotification = { diff --git a/projects/ion/src/lib/notification/notification.component.ts b/projects/ion/src/lib/notification/component/notification.component.ts similarity index 93% rename from projects/ion/src/lib/notification/notification.component.ts rename to projects/ion/src/lib/notification/component/notification.component.ts index 812f3ff8a..c5569042c 100644 --- a/projects/ion/src/lib/notification/notification.component.ts +++ b/projects/ion/src/lib/notification/component/notification.component.ts @@ -8,9 +8,9 @@ import { ViewChild, } from '@angular/core'; import { Subscription } from 'rxjs'; -import { IconType } from '../core/types/icon'; -import { NotificationProps } from '../core/types/notification'; -import { setTimer } from '../utils/setTimer'; +import { IconType } from '../../core/types/icon'; +import { NotificationProps } from '../../core/types/notification'; +import { setTimer } from '../../utils/setTimer'; @Component({ selector: 'ion-notification', diff --git a/projects/ion/src/lib/notification/mock/open-notification-mock.component.ts b/projects/ion/src/lib/notification/mock/open-notification-mock.component.ts new file mode 100644 index 000000000..0c86be2f2 --- /dev/null +++ b/projects/ion/src/lib/notification/mock/open-notification-mock.component.ts @@ -0,0 +1,81 @@ +import { IonNotificationService } from './../service/notification.service'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'open-notification-button', + template: ` +
+ + + + + + + +
+
+ `, + styles: [ + ` + .button { + /deep/ button { + width: 150px !important; + } + } + `, + ], +}) +export class OpenNotificationButtonComponent { + constructor(private ionNotificationService: IonNotificationService) {} + + notificationSuccess(): void { + this.ionNotificationService.success( + 'Title...', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...' + ); + } + + notificationWarning(): void { + this.ionNotificationService.warning( + 'Title...', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...' + ); + } + + notificationInfo(): void { + this.ionNotificationService.info( + 'Title...', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...' + ); + } + + notificationError(): void { + this.ionNotificationService.error( + 'Title...', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...' + ); + } +} diff --git a/projects/ion/src/lib/notification/notification.module.ts b/projects/ion/src/lib/notification/notification.module.ts index aa8ef4faf..a9d66eae1 100644 --- a/projects/ion/src/lib/notification/notification.module.ts +++ b/projects/ion/src/lib/notification/notification.module.ts @@ -1,11 +1,18 @@ +import { IonNotificationContainerComponent } from './service/notification.container.component'; +import { IonNotificationService } from './service/notification.service'; import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { IonIconModule } from '../icon/icon.module'; -import { IonNotificationComponent } from './notification.component'; +import { IonNotificationComponent } from './component/notification.component'; @NgModule({ - declarations: [IonNotificationComponent], + declarations: [IonNotificationComponent, IonNotificationContainerComponent], imports: [CommonModule, IonIconModule], - exports: [IonNotificationComponent], + providers: [IonNotificationService], + exports: [IonNotificationComponent, IonNotificationContainerComponent], + entryComponents: [ + IonNotificationComponent, + IonNotificationContainerComponent, + ], }) export class IonNotificationModule {} diff --git a/projects/ion/src/lib/notification/service/notification.container.component.ts b/projects/ion/src/lib/notification/service/notification.container.component.ts new file mode 100644 index 000000000..98503bbae --- /dev/null +++ b/projects/ion/src/lib/notification/service/notification.container.component.ts @@ -0,0 +1,26 @@ +import { IonNotificationComponent } from '../component/notification.component'; +import { Component, ComponentRef, Renderer2, ElementRef } from '@angular/core'; + +@Component({ + selector: 'notification-container', + template: '', + styleUrls: ['notification.container.scss'], +}) +export class IonNotificationContainerComponent { + constructor(private renderer: Renderer2, private element: ElementRef) {} + + addNotification(notification: ComponentRef): void { + notification.instance.ionOnClose.subscribe(() => { + this.removeNotification(notification.location.nativeElement); + }); + + this.renderer.appendChild( + this.element.nativeElement, + notification.location.nativeElement + ); + } + + removeNotification(notification: ElementRef): void { + this.renderer.removeChild(this.element.nativeElement, notification); + } +} diff --git a/projects/ion/src/lib/notification/service/notification.container.scss b/projects/ion/src/lib/notification/service/notification.container.scss new file mode 100644 index 000000000..17b9e63ed --- /dev/null +++ b/projects/ion/src/lib/notification/service/notification.container.scss @@ -0,0 +1,8 @@ +:host { + position: absolute; + display: flex; + flex-direction: column; + top: 0; + right: 0; + gap: 1rem; +} diff --git a/projects/ion/src/lib/notification/service/notification.service.spec.ts b/projects/ion/src/lib/notification/service/notification.service.spec.ts new file mode 100644 index 000000000..c0c460b8f --- /dev/null +++ b/projects/ion/src/lib/notification/service/notification.service.spec.ts @@ -0,0 +1,164 @@ +import { IonSharedModule } from './../../shared.module'; +import { IonNotificationContainerComponent } from './notification.container.component'; +import { IonNotificationComponent } from '../component/notification.component'; +import { IonNotificationService } from './notification.service'; +import { TestBed } from '@angular/core/testing'; +import { Component, NgModule } from '@angular/core'; +import { fireEvent, screen } from '@testing-library/angular'; + +const NOTIFICATION_ICONS = { + success: 'success-icon', + info: 'info-icon', + warning: 'warning-icon', + negative: 'negative-icon', +}; + +const NOTIFICATION_TYPES = Object.keys(NOTIFICATION_ICONS); + +const DEFAULT_NOTIFICATION_OPTIONS = { + title: 'Titulo Padrão', + message: 'Mensagem Padrão', +}; + +@Component({ + template: '
', +}) +class ContainerRefTestComponent {} + +@NgModule({ + declarations: [ + ContainerRefTestComponent, + IonNotificationContainerComponent, + IonNotificationComponent, + ], + imports: [IonSharedModule], + entryComponents: [ + ContainerRefTestComponent, + IonNotificationContainerComponent, + IonNotificationComponent, + ], +}) +class TestModule {} + +jest.setTimeout(1000); + +describe('NotificationService', () => { + let notificationService: IonNotificationService; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestModule], + }).compileComponents(); + + notificationService = TestBed.get(IonNotificationService); + }); + + it('should remove a notification', () => { + notificationService.success( + DEFAULT_NOTIFICATION_OPTIONS.title, + DEFAULT_NOTIFICATION_OPTIONS.message, + { fixed: true } + ); + const removeNotification = screen.getByTestId('btn-remove'); + fireEvent.click(removeNotification); + const elements = document.getElementsByTagName('ion-notification'); + expect(elements).toHaveLength(0); + }); + + it('should emit event when a notification is closed', () => { + const closeEvent = jest.fn(); + notificationService.success( + DEFAULT_NOTIFICATION_OPTIONS.title, + DEFAULT_NOTIFICATION_OPTIONS.message, + { fixed: true }, + closeEvent + ); + const removeNotification = screen.getByTestId('btn-remove'); + fireEvent.click(removeNotification); + expect(closeEvent).toHaveBeenCalledTimes(1); + }); + + it('should create a notification', () => { + notificationService.success( + DEFAULT_NOTIFICATION_OPTIONS.title, + DEFAULT_NOTIFICATION_OPTIONS.message + ); + expect(screen.getByTestId('ion-notification')).toBeTruthy(); + }); + + it.each(['title', 'message'])( + 'should render a notification with default %s', + (key) => { + expect( + screen.getByText(DEFAULT_NOTIFICATION_OPTIONS[key]) + ).toBeInTheDocument(); + } + ); +}); + +describe('NotificationService -> notification types', () => { + let notificationService: IonNotificationService; + + let currentIndex = 1; + + let notificationsOnScreen = 5; + + const indexToRemove = [1, 2, 0]; + + const NOTIFICATIONS_CALLS = { + success: (): void => { + notificationService.success('teste', 'teste', {}, () => { + return true; + }); + }, + info: (): void => { + notificationService.info('teste', 'teste', {}, () => { + return true; + }); + }, + warning: (): void => { + notificationService.warning('teste', 'teste', {}, () => { + return true; + }); + }, + negative: (): void => { + notificationService.error('teste', 'teste', {}, () => { + return true; + }); + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestModule], + }).compileComponents(); + + notificationService = TestBed.get(IonNotificationService); + }); + + it.each(NOTIFICATION_TYPES)('should create %s notification', async (type) => { + NOTIFICATIONS_CALLS[type](); + const iconType = screen.getAllByTestId('notification-icon'); + expect(iconType[currentIndex]).toHaveClass(NOTIFICATION_ICONS[type]); + currentIndex += 1; + }); + + it.each(indexToRemove)( + 'should remove multiple notifications', + async (index) => { + const elements = document.getElementsByTagName('ion-notification'); + const removeNotification = screen.getAllByTestId('btn-remove'); + fireEvent.click(removeNotification[index]); + notificationsOnScreen -= 1; + expect(elements).toHaveLength(notificationsOnScreen); + } + ); + + it.each(NOTIFICATION_TYPES)( + 'should add ionOnClose subscription when %s notification is created', + async (type) => { + notificationService.addCloseEventEmitter = jest.fn(); + NOTIFICATIONS_CALLS[type](); + expect(notificationService.addCloseEventEmitter).toHaveBeenCalledTimes(1); + } + ); +}); diff --git a/projects/ion/src/lib/notification/service/notification.service.ts b/projects/ion/src/lib/notification/service/notification.service.ts new file mode 100644 index 000000000..259f00bf9 --- /dev/null +++ b/projects/ion/src/lib/notification/service/notification.service.ts @@ -0,0 +1,193 @@ +import { StatusType } from './../../core/types/status'; +import { NotificationConfigOptions } from './../../core/types/notification'; +import { IonNotificationComponent } from './../component/notification.component'; +import { + Injectable, + ComponentRef, + Inject, + ComponentFactoryResolver, + ApplicationRef, + Injector, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { Subject } from 'rxjs'; +import { SafeAny } from './../../utils/safe-any'; +import { IonNotificationContainerComponent } from './notification.container.component'; + +enum NOTIFICATION_TYPES { + success = 'success', + info = 'info', + warning = 'warning', + negative = 'negative', +} + +@Injectable({ + providedIn: 'root', +}) +export class IonNotificationService { + private notificationContainerComponentRef: ComponentRef; + private componentSubscriber!: Subject; + + constructor( + @Inject(DOCUMENT) private document: SafeAny, + private componentFactoryResolver: ComponentFactoryResolver, + private appRef: ApplicationRef, + private injector: Injector + ) {} + + public success( + title: string, + message: string, + options?: NotificationConfigOptions, + closeEventCall?: () => void + ): void { + this.showNotification( + title, + message, + options, + NOTIFICATION_TYPES.success, + closeEventCall ? closeEventCall : undefined + ); + } + + public info( + title: string, + message: string, + options?: NotificationConfigOptions, + closeEventCall?: () => void + ): void { + this.showNotification( + title, + message, + options, + NOTIFICATION_TYPES.info, + closeEventCall ? closeEventCall : undefined + ); + } + + public warning( + title: string, + message: string, + options?: NotificationConfigOptions, + closeEventCall?: () => void + ): void { + this.showNotification( + title, + message, + options, + NOTIFICATION_TYPES.warning, + closeEventCall ? closeEventCall : undefined + ); + } + + public error( + title: string, + message: string, + options?: NotificationConfigOptions, + closeEventCall?: () => void + ): void { + this.showNotification( + title, + message, + options, + NOTIFICATION_TYPES.negative, + closeEventCall ? closeEventCall : undefined + ); + } + + addCloseEventEmitter( + notification: ComponentRef, + closeEvent: () => void + ): void { + notification.instance.ionOnClose.subscribe(() => { + closeEvent(); + }); + } + + private createComponentView( + viewRef: ComponentRef + ): void { + this.appRef.attachView(viewRef.hostView); + viewRef.changeDetectorRef.detectChanges(); + + const notificationElement = viewRef.location.nativeElement; + this.document.body.appendChild(notificationElement); + + this.componentSubscriber = new Subject(); + this.componentSubscriber.asObservable(); + } + + private createNotificationContainer(): void { + const containerRef = this.componentFactoryResolver + .resolveComponentFactory(IonNotificationContainerComponent) + .create(this.injector); + + this.notificationContainerComponentRef = containerRef; + + this.createComponentView(this.notificationContainerComponentRef); + } + + private createNotificationInstance(): ComponentRef { + return this.componentFactoryResolver + .resolveComponentFactory(IonNotificationComponent) + .create(this.injector); + } + + private showNotification( + title: string, + message: string, + options: NotificationConfigOptions, + type: StatusType = 'success', + closeEventCall?: () => void + ): void { + if (!this.notificationContainerComponentRef) + this.createNotificationContainer(); + + const notification = this.createNotificationInstance(); + + this.configNotification( + notification.instance, + title, + message, + options, + type + ); + + this.instanceNotification(notification); + + if (closeEventCall) { + this.addCloseEventEmitter(notification, closeEventCall); + } + } + + private configNotification( + notification: IonNotificationComponent, + title: string, + message: string, + options: NotificationConfigOptions, + type: StatusType = 'success' + ): void { + notification.title = title; + notification.message = message; + notification.type = type; + + if (options) { + Object.assign(notification, options); + } + } + + private instanceNotification( + notification: ComponentRef + ): void { + notification.hostView.detectChanges(); + notification.changeDetectorRef.detectChanges(); + + this.notificationContainerComponentRef.instance.addNotification( + notification + ); + + this.notificationContainerComponentRef.changeDetectorRef.detectChanges(); + + return this.componentSubscriber.next(); + } +} diff --git a/projects/ion/src/public-api.ts b/projects/ion/src/public-api.ts index 14e04f12c..f7cd9a051 100644 --- a/projects/ion/src/public-api.ts +++ b/projects/ion/src/public-api.ts @@ -28,6 +28,7 @@ export * from './lib/modal/modal.module'; export * from './lib/modal/modal.service'; export * from './lib/no-data/no-data.module'; export * from './lib/notification/notification.module'; +export * from './lib/notification/service/notification.service'; export * from './lib/pagination/pagination.module'; export * from './lib/picker/date-picker/date-picker.module'; export * from './lib/popconfirm/popconfirm.module'; diff --git a/stories/Notification.stories.ts b/stories/Notification.stories.ts index bd3e00e2c..ae45a1981 100644 --- a/stories/Notification.stories.ts +++ b/stories/Notification.stories.ts @@ -1,7 +1,40 @@ import { Story, Meta } from '@storybook/angular/types-6-0'; -import { IonNotificationComponent } from '../projects/ion/src/lib/notification/notification.component'; +import { IonNotificationComponent } from '../projects/ion/src/lib/notification/component/notification.component'; +import { + IonIconModule, + IonNotificationModule, + IonSharedModule, +} from '../projects/ion/src/public-api'; +import { OpenNotificationButtonComponent } from '../projects/ion/src/lib/notification/mock/open-notification-mock.component'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { moduleMetadata } from '@storybook/angular'; -import { IonIconModule } from '../projects/ion/src/public-api'; + +const Template: Story = ( + args: IonNotificationComponent +) => ({ + component: IonNotificationComponent, + props: args, +}); + +const basicTemplate: Story = ( + args: IonNotificationComponent +) => ({ + component: OpenNotificationButtonComponent, + props: { + ...args, + }, + moduleMetadata: { + declarations: [OpenNotificationButtonComponent], + imports: [ + CommonModule, + FormsModule, + IonSharedModule, + IonNotificationModule, + ], + entryComponents: [OpenNotificationButtonComponent], + }, +}); export default { title: 'Ion/Feedback/Notification', @@ -11,14 +44,12 @@ export default { imports: [IonIconModule], }), ], -} as Meta; +} as Meta; -const Template: Story = ( - args: IonNotificationComponent -) => ({ - component: IonNotificationComponent, - props: args, -}); +export const Service = basicTemplate.bind({}); +Service.args = { + componentToBody: OpenNotificationButtonComponent, +}; export const Basic = Template.bind({}); Basic.args = {