From 7cb2d0e336955091b1ab6f5e18fc5a3bdf694e28 Mon Sep 17 00:00:00 2001 From: Kairui Guo Date: Sat, 18 Nov 2017 13:18:54 +0800 Subject: [PATCH] Notification (#112) * [Notification] add component * Notification docs enhance. * enhance notification tests --- docs/pages/components/Notification.md | 191 +++++++++++++ docs/router/routes.js | 5 + src/services/index.js | 1 + src/services/notification/Notification.vue | 123 +++++++++ src/services/notification/constants.js | 13 + src/services/notification/notification.js | 66 +++++ test/unit/specs/Notification.spec.js | 300 +++++++++++++++++++++ 7 files changed, 699 insertions(+) create mode 100644 docs/pages/components/Notification.md create mode 100644 src/services/notification/Notification.vue create mode 100644 src/services/notification/constants.js create mode 100644 src/services/notification/notification.js create mode 100644 test/unit/specs/Notification.spec.js diff --git a/docs/pages/components/Notification.md b/docs/pages/components/Notification.md new file mode 100644 index 000000000..738c68907 --- /dev/null +++ b/docs/pages/components/Notification.md @@ -0,0 +1,191 @@ +# Notification + +> Displays a global notification message at a corner of the page. + +## Example + +Click on the button below to show a notification. By default, it is dismissible with a close button, and will dismiss automatically after 5000ms (both are configurable). + +```html + + + +``` + +## Types + +There're 4 optional types of notification: `info` / `success` / `warning` / `danger`. + +Notification with specific type will has a default icon on the left, you can also change or remove the icon by `icon` option. + +```html + + + +``` + +## Placements + +Notifications can be placed on any corner on a page. + +The `position` prop defines which corner a notification will slide in. It can be `top-right` (default), `top-left`, `bottom-right` or `bottom-left`. + +```html + + + +``` + +## Dismissible + +By default a notification is dismissible with a close button, you can hide it by setting `dismissible` to `false`. + +```html + + + +``` + +## Global Method + +`$notify(options, callback)` global method for `Vue.prototype` will be added **if uiv is installed**. + +Note that the dismissed callback is optional. + +The method will return a `Promise` object that resolve while the notification is dismissed (if supported by browser or with es6 promise polyfill). + +## Import Individually + +If you prefer importing `Notification` individually: + +```javascript +import { Notification } from 'uiv' +``` + +The corresponding method is `Notification.notify`, with same parameters as above. + +# API Reference + +## [Notification.vue](https://github.com/wxsms/uiv/tree/master/src/services/notification/Notification.vue) + +These props are used as `options` in the methods above. + +### Props + +Name | Type | Default | Required | Description +---------- | ---------- | -------- | -------- | ----------------------- +`title` | String | | | The notification title. +`content` | String | | | The notification content. +`type` | String | | | Support: `info` / `success` / `warning` / `danger`. +`duration` | Number | 5000 | | Dismiss after milliseconds, use 0 to prevent self-closing. +`dismissible` | Boolean | true | | Show dismiss button. +`placement` | String | top-right | | Support: `top-right` / `top-left` / `bottom-right` / `bottom-left`. +`icon` | String | | | Custom icon class, use an empty string to disable icon. + +## [notification.js](https://github.com/wxsms/uiv/tree/master/src/services/notification/notification.js) + +This file has no props. diff --git a/docs/router/routes.js b/docs/router/routes.js index 6821379fd..17ba9c85d 100644 --- a/docs/router/routes.js +++ b/docs/router/routes.js @@ -71,6 +71,11 @@ const routes = [ meta: {type: 'component', label: 'Modal'}, component: () => import('./../pages/components/Modal.md') }, + { + path: '/notification', + meta: {type: 'component', label: 'Notification'}, + component: () => import('./../pages/components/Notification.md') + }, { path: '/pagination', meta: {type: 'component', label: 'Pagination'}, diff --git a/src/services/index.js b/src/services/index.js index 35f83776b..4edec0403 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -1 +1,2 @@ export {default as MessageBox} from './messagebox/messageBox.js' +export {default as Notification} from './notification/notification.js' diff --git a/src/services/notification/Notification.vue b/src/services/notification/Notification.vue new file mode 100644 index 000000000..ea6b49ab8 --- /dev/null +++ b/src/services/notification/Notification.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/services/notification/constants.js b/src/services/notification/constants.js new file mode 100644 index 000000000..f01568b66 --- /dev/null +++ b/src/services/notification/constants.js @@ -0,0 +1,13 @@ +export const TYPES = { + SUCCESS: 'success', + INFO: 'info', + DANGER: 'danger', + WARNING: 'warning' +} + +export const PLACEMENTS = { + TOP_LEFT: 'top-left', + TOP_RIGHT: 'top-right', + BOTTOM_LEFT: 'bottom-left', + BOTTOM_RIGHT: 'bottom-right' +} diff --git a/src/services/notification/notification.js b/src/services/notification/notification.js new file mode 100644 index 000000000..3c8012690 --- /dev/null +++ b/src/services/notification/notification.js @@ -0,0 +1,66 @@ +import {removeFromDom} from '@src/utils/domUtils' +import {spliceIfExist} from '@src/utils/arrayUtils' +import {isFunction, isExist} from '@src/utils/objectUtils' +import Notification from './Notification.vue' +import {PLACEMENTS} from './constants' +import Vue from 'vue' + +const queues = { + [PLACEMENTS.TOP_LEFT]: [], + [PLACEMENTS.TOP_RIGHT]: [], + [PLACEMENTS.BOTTOM_LEFT]: [], + [PLACEMENTS.BOTTOM_RIGHT]: [] +} + +const body = document.body + +const destroy = (queue, instance) => { + // console.log('destroyNotification') + removeFromDom(instance.$el) + instance.$destroy() + spliceIfExist(queue, instance) +} + +const init = (options, cb, resolve = null, reject = null) => { + const placement = options.placement + const queue = queues[placement] + // check if placement is valid + if (!isExist(queue)) { + return + } + const Constructor = Vue.extend(Notification) + let instance = new Constructor({ + propsData: { + queue, + placement, + ...options, + cb (msg) { + destroy(queue, instance) + if (isFunction(cb)) { + cb(msg) + } else if (resolve && reject) { + resolve(msg) + } + } + } + }) + instance.$mount() + body.appendChild(instance.$el) + queue.push(instance) +} + +const notify = (options = {}, cb) => { + // set default placement as top-right + if (!isExist(options.placement)) { + options.placement = PLACEMENTS.TOP_RIGHT + } + if (isExist(window.Promise)) { + return new Promise((resolve, reject) => { + init(options, cb, resolve, reject) + }) + } else { + init(options, cb) + } +} + +export default {notify} diff --git a/test/unit/specs/Notification.spec.js b/test/unit/specs/Notification.spec.js new file mode 100644 index 000000000..26f4b1428 --- /dev/null +++ b/test/unit/specs/Notification.spec.js @@ -0,0 +1,300 @@ +import Vue from 'vue' +import $ from 'jquery' +import Notification from '@src/services/notification/notification' +import NotificationDoc from '@docs/pages/components/Notification.md' +import utils from '../utils' + +const OFFSET = '15px' + +describe('Notification', () => { + let vm + let $el + let spy + let savedLog + + beforeEach(() => { + savedLog = console.log + console.log = function () { + return true + } + const Constructor = Vue.extend(NotificationDoc) + vm = new Constructor().$mount() + $el = $(vm.$el) + spy = sinon.spy(console, 'log') + }) + + afterEach(() => { + console.log.restore() + console.log = savedLog + vm.$destroy() + $el.remove() + $('.alert').remove() + }) + + it('should be able to use notification', async () => { + const _vm = vm.$refs['notification-example'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[0] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.className).to.contain('alert-info') + expect(alert.className).to.contain('alert-dismissible') + expect(alert.className).to.contain('fade') + expect(alert.className).to.contain('in') + expect(alert.querySelector('.media-heading').textContent).to.equal('Title') + expect(alert.querySelector('.media-body > div').textContent).to.equal('This is a notify message.') + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + sinon.assert.calledWith(spy, 'dismissed') + }) + + it('should be able to use no auto-dismiss notification', async () => { + const _vm = vm.$refs['notification-example'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[1] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.className).to.contain('alert-info') + expect(alert.className).to.contain('alert-dismissible') + expect(alert.className).to.contain('fade') + expect(alert.className).to.contain('in') + expect(alert.querySelector('.media-heading').textContent).to.equal('Title') + expect(alert.querySelector('.media-body > div').textContent).to.equal('This notification will not dismiss automatically.') + await utils.sleep(5200) + expect(document.querySelector('.alert')).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + sinon.assert.calledWith(spy, 'dismissed') + }).timeout(5000 + 2000) + + it('should be able to use `type=info` notification', async () => { + const _vm = vm.$refs['notification-types'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[0] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.className).to.contain('alert-info') + expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1) + expect(alert.querySelectorAll('.media-left > .glyphicon-info-sign')).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `type=success` notification', async () => { + const _vm = vm.$refs['notification-types'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[1] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.className).to.contain('alert-success') + expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1) + expect(alert.querySelectorAll('.media-left > .glyphicon-ok-sign')).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `type=warning` notification', async () => { + const _vm = vm.$refs['notification-types'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[2] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.className).to.contain('alert-warning') + expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1) + expect(alert.querySelectorAll('.media-left > .glyphicon-info-sign')).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `type=danger` notification', async () => { + const _vm = vm.$refs['notification-types'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[3] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.className).to.contain('alert-danger') + expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1) + expect(alert.querySelectorAll('.media-left > .glyphicon-remove-sign')).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `placement=top-right` notification', async () => { + const _vm = vm.$refs['notification-placements'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[0] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.style.top).to.equal(OFFSET) + expect(alert.style.right).to.equal(OFFSET) + expect(alert.style.bottom).to.equal('') + expect(alert.style.left).to.equal('') + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `placement=bottom-right` notification', async () => { + const _vm = vm.$refs['notification-placements'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[1] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.style.top).to.equal('') + expect(alert.style.right).to.equal(OFFSET) + expect(alert.style.bottom).to.equal(OFFSET) + expect(alert.style.left).to.equal('') + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `placement=bottom-left` notification', async () => { + const _vm = vm.$refs['notification-placements'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[2] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.style.top).to.equal('') + expect(alert.style.right).to.equal('') + expect(alert.style.bottom).to.equal(OFFSET) + expect(alert.style.left).to.equal(OFFSET) + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `placement=top-left` notification', async () => { + const _vm = vm.$refs['notification-placements'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[3] + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.style.top).to.equal(OFFSET) + expect(alert.style.right).to.equal('') + expect(alert.style.bottom).to.equal('') + expect(alert.style.left).to.equal(OFFSET) + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use `dismissible=false` notification', async () => { + const _vm = vm.$refs['notification-without-dismiss-btn'] + await vm.$nextTick() + const trigger = _vm.$el.querySelectorAll('.btn')[0] + trigger.click() + await vm.$nextTick() + trigger.click() + await utils.sleep(utils.transitionDuration) + await vm.$nextTick() + const alert = document.querySelectorAll('.alert') + expect(alert.length).to.equal(2) + expect(alert[0].querySelector('button.close')).not.exist + expect(alert[1].querySelector('button.close')).not.exist + await utils.sleep(5200) + await vm.$nextTick() + expect(document.querySelector('.alert')).not.exist + }).timeout(5000 + 2000) + + it('should be able to use without options and callback', async () => { + Notification.notify(undefined) + await utils.sleep(utils.transitionDuration) + const alert = document.querySelector('.alert') + expect(alert).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use without Promise', async () => { + // mute Promise + const savedPromise = window.Promise + window.Promise = null + // alert + Notification.notify({title: 'test'}) + // restore Promise + window.Promise = savedPromise + await utils.sleep(utils.transitionDuration) + const alert = document.querySelector('.alert') + expect(alert).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to avoid invalid placement', async () => { + Notification.notify({placement: 'top-bottom'}) // invalid + await utils.sleep(utils.transitionDuration) + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to use custom icon', async () => { + Notification.notify({title: 'test', icon: 'fa fa-check'}) + await utils.sleep(utils.transitionDuration) + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.querySelectorAll('.media-left > .fa').length).to.equal(1) + expect(alert.querySelectorAll('.media-left > .fa-check')).to.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + expect(document.querySelector('.alert')).not.exist + }) + + it('should be able to disable icon with types', async () => { + Notification.notify({title: 'test', icon: '', type: 'danger'}) + await utils.sleep(utils.transitionDuration) + const alert = document.querySelector('.alert') + expect(alert).to.exist + expect(alert.querySelector('.media-left')).not.exist + alert.querySelector('button.close').click() + await utils.sleep(utils.transitionDuration) + expect(document.querySelector('.alert')).not.exist + }) +})