diff --git a/packages/elements/package.json b/packages/elements/package.json index b2bd04e76c..bb0fdf5b30 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -48,6 +48,7 @@ "papaparse": "^5.1.1", "pell": "^1.0.6", "prop-types": "^15.7.2", + "rc-notification": "^4.0.0", "react-datasheet": "^1.4.0", "react-datepicker": "^2.9.6", "react-google-map": "^3.1.1", @@ -83,4 +84,3 @@ "react-router-dom": "^5.1.2" } } - diff --git a/packages/elements/src/components/Notification/__tests__/index.tsx b/packages/elements/src/components/Notification/__tests__/index.tsx new file mode 100644 index 0000000000..46999308fc --- /dev/null +++ b/packages/elements/src/components/Notification/__tests__/index.tsx @@ -0,0 +1,149 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import NotificationApi, { getPlacementStyle } from '../' + +describe('Notification', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + afterEach(() => { + NotificationApi.destroy() + }) + + describe('NotificationApi', () => { + it('not duplicate create holder', () => { + const originRender = ReactDOM.render + const argsList = [] + const spyRender = jest.spyOn(ReactDOM, 'render').mockImplementation((...args) => { + argsList.push(args as never) + }) + for (let i = 0; i < 5; i += 1) { + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + prefixCls: 'additional-holder', + }) + } + + argsList.forEach(args => { + originRender(args[0], args[1], args[2]) + }) + + const count = document.querySelectorAll('.additional-holder').length + expect(count).toEqual(1) + + spyRender.mockRestore() + }) + }) + + it('should be able to hide manually', async () => { + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + key: '1', + }) + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + key: '2', + }) + + await Promise.resolve() + expect(document.querySelectorAll('.reapit-notification-notice').length).toBe(2) + NotificationApi.close('1') + await Promise.resolve() + jest.runAllTimers() + expect(document.querySelectorAll('.reapit-notification-notice').length).toBe(1) + NotificationApi.close('2') + await Promise.resolve() + jest.runAllTimers() + expect(document.querySelectorAll('.reapit-notification-notice').length).toBe(0) + }) + + it('should be able to destroy globally', async () => { + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + }) + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + }) + await Promise.resolve() + expect(document.querySelectorAll('.reapit-notification').length).toBe(1) + expect(document.querySelectorAll('.reapit-notification-notice').length).toBe(2) + NotificationApi.destroy() + await Promise.resolve() + expect(document.querySelectorAll('.reapit-notification').length).toBe(0) + expect(document.querySelectorAll('.reapit-notification-notice').length).toBe(0) + }) + + it('should be able to destroy after config', () => { + NotificationApi.config({ + bottom: 100, + }) + NotificationApi.destroy() + }) + + it('trigger onClick', () => { + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + }) + expect(document.querySelectorAll('.reapit-notification').length).toBe(1) + }) + + it('support closeIcon', () => { + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + closeIcon: , + }) + expect(document.querySelectorAll('.test-customize-icon').length).toBe(1) + }) + + it('support config closeIcon', () => { + NotificationApi.config({ + closeIcon: , + }) + NotificationApi.open({ + message: 'Notification Title', + duration: 0, + closeIcon: , + }) + expect(document.querySelectorAll('.test-customize-icon').length).toBe(1) + }) + + describe('getPlacementStyle', () => { + it('should run correctly', () => { + expect(getPlacementStyle('topLeft', 10, 20)).toEqual({ + left: 0, + top: 10, + bottom: 'auto', + }) + + expect(getPlacementStyle('topRight', 10, 20)).toEqual({ + right: 0, + top: 10, + bottom: 'auto', + }) + + expect(getPlacementStyle('bottomLeft', 10, 20)).toEqual({ + left: 0, + bottom: 20, + top: 'auto', + }) + + expect(getPlacementStyle('bottomRight', 10, 20)).toEqual({ + right: 0, + bottom: 20, + top: 'auto', + }) + }) + }) +}) diff --git a/packages/elements/src/components/Notification/__tests__/useNotification.tsx b/packages/elements/src/components/Notification/__tests__/useNotification.tsx new file mode 100644 index 0000000000..0d4d7424dd --- /dev/null +++ b/packages/elements/src/components/Notification/__tests__/useNotification.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { mount } from 'enzyme' +import NotificationApi from '../' + +describe('notification.hooks', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + afterEach(() => { + NotificationApi.destroy() + }) + + it('should run correctly', () => { + const Context = React.createContext('light') + + const Demo = () => { + const [api, holder] = NotificationApi.useNotification() + + return ( + + + + + + + + ) +} + +const Placement = () => { + return ( +
+ + + + +
+ ) +} + +const Context = React.createContext({ name: 'Default' }) + +const Hooks = () => { + const [instance, contextHolder] = NotificationApi.useNotification() + + const openNotification = placement => { + instance.info({ + message: {({ name }) => `Hello, ${name}! - ${placement}`}, + placement, + }) + } + + return ( +
+ + {contextHolder} + + + + + +
+ ) +} + +stories.add('Api', () => ) +stories.add('Placement', () => ) +stories.add('Hooks', () => ) diff --git a/packages/elements/src/components/Notification/useNotification.tsx b/packages/elements/src/components/Notification/useNotification.tsx new file mode 100644 index 0000000000..ac8f0f96bd --- /dev/null +++ b/packages/elements/src/components/Notification/useNotification.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import useRCNotification from 'rc-notification/lib/useNotification' +import { + NotificationInstance as RCNotificationInstance, + NoticeContent as RCNoticeContent, + HolderReadyCallback as RCHolderReadyCallback, +} from 'rc-notification/lib/Notification' +import { NotificationInstance, ArgsProps } from '.' + +export default function createUseNotification( + getNotificationInstance: ( + args: ArgsProps, + callback: (info: { prefixCls: string; instance: RCNotificationInstance }) => void, + ) => void, + getRCNoticeProps: (args: ArgsProps, prefixCls: string) => RCNoticeContent, +) { + const useNotification = (): [NotificationInstance, React.ReactElement] => { + // We create a proxy to handle delay created instance + let innerInstance: RCNotificationInstance | null = null + const proxy = { + add: (noticeProps: RCNoticeContent, holderCallback?: RCHolderReadyCallback) => { + innerInstance?.component.add(noticeProps, holderCallback) + }, + } as any + + const [hookNotify, holder] = useRCNotification(proxy) + + function notify(args: ArgsProps) { + getNotificationInstance( + { + ...args, + prefixCls: 'reapit-notification', + }, + ({ prefixCls, instance }) => { + innerInstance = instance + hookNotify(getRCNoticeProps(args, prefixCls)) + }, + ) + } + + // Fill functions + const hookAPI: any = { + open: notify, + } + ;['success', 'info', 'warn', 'error'].forEach(type => { + hookAPI[type] = (args: ArgsProps) => + hookAPI.open({ + ...args, + type, + }) + }) + + return [hookAPI, holder] + } + + return useNotification +} diff --git a/packages/elements/src/index.tsx b/packages/elements/src/index.tsx index 0a28b8e801..7ba33e8e41 100644 --- a/packages/elements/src/index.tsx +++ b/packages/elements/src/index.tsx @@ -44,6 +44,7 @@ export * from './components/Helper' export * from './components/Spreadsheet' export * from './components/HelpGuide' export * from './components/HelpGuide/context' +export * from './components/Notification' // Utils export * from './utils/validators' diff --git a/packages/elements/src/styles/components/notification.scss b/packages/elements/src/styles/components/notification.scss new file mode 100644 index 0000000000..4a2c608604 --- /dev/null +++ b/packages/elements/src/styles/components/notification.scss @@ -0,0 +1,131 @@ +$notification-width: 400px; +$notification-padding-vertical: 16px; +$notification-padding-horizontal: 24px; +$notification-padding: $notification-padding-vertical $notification-padding-horizontal; +$notification-margin-bottom: 16px; + +.reapit-notification { + box-sizing: border-box; + margin: 0; + padding: 0; + position: fixed; + z-index: 99; + width: $notification-width; + margin-right: 24px; + margin-left: 24px; + + &-topLeft, + &-bottomLeft { + .reapit-notification-fade-enter.reapit-notification-fade-enter-active, + .reapit-notification-fade-appear.reapit-notification-fade-appear-active { + animation-name: NotificationLeftFadeIn; + } + } + + &-hook-holder, + &-notice { + position: relative; + margin-bottom: $notification-margin-bottom; + overflow: hidden; + background: white; + } + + &-hook-holder > &-notice { + margin-bottom: 0; + box-shadow: none; + } + + &-notice { + &-close { + position: absolute; + top: 12px; + right: 12px; + color: rgba(0, 0, 0, 0.45); + outline: none; + } + + &-btn { + float: right; + margin-top: 16px; + } + } + + %notification-fade-effect { + animation-duration: 0.24s; + animation-timing-function: ease-in-out; + animation-fill-mode: both; + } + + .notification-fade-effect { + @extend %notification-fade-effect; + } + + &-fade-enter, + &-fade-appear { + opacity: 0; + @extend %notification-fade-effect; + } + + &-fade-leave { + @extend %notification-fade-effect; + animation-duration: 0.2s; + animation-play-state: paused; + } + + &-fade-enter, + &-fade-enter-active, + &-fade-appear, + &-fade-appear-active { + animation-name: NotificationFadeIn; + animation-play-state: running; + } + + &-fade-leave, + &-fade-leave-active { + animation-name: NotificationFadeOut; + animation-play-state: running; + } + + &-content { + padding: 24px; + } +} + +@keyframes NotificationFadeIn { + 0% { + left: $notification-width; + opacity: 0; + } + 100% { + left: 0; + opacity: 1; + } +} + +@keyframes NotificationLeftFadeIn { + 0% { + right: $notification-width; + opacity: 0; + } + 100% { + right: 0; + opacity: 1; + } +} + +@keyframes NotificationFadeOut { + 0% { + max-height: 150px; + margin-bottom: $notification-margin-bottom; + padding-top: $notification-padding; + padding-bottom: $notification-padding; + opacity: 1; + } + 100% { + max-height: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + } +} diff --git a/packages/elements/src/styles/index.scss b/packages/elements/src/styles/index.scss index 56bc89c8dc..9c80b35e89 100644 --- a/packages/elements/src/styles/index.scss +++ b/packages/elements/src/styles/index.scss @@ -29,3 +29,4 @@ @import './components/helper.scss'; @import './components/spreadsheet.scss'; @import './components/help-guide.scss'; +@import './components/notification.scss'; diff --git a/yarn.lock b/yarn.lock index a5da6eaeb7..23b24003d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5512,6 +5512,13 @@ acorn@^3.1.0, acorn@^4.0.4, acorn@^5.5.3, acorn@^6.0.1, acorn@^6.2.1, acorn@^6.4 resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== +add-dom-event-listener@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz#6a92db3a0dd0abc254e095c0f1dc14acbbaae310" + integrity sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw== + dependencies: + object-assign "4.x" + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -6974,7 +6981,7 @@ babel-preset-jest@^25.1.0: babel-plugin-transform-undefined-to-void "^6.9.4" lodash "^4.17.11" -babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0: +babel-runtime@6.x, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= @@ -8113,7 +8120,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5, classnames@^2.2.6: +classnames@2.x, classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -8560,11 +8567,23 @@ compare-versions@^3.5.1: resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== +component-classes@^1.2.5: + version "1.2.6" + resolved "https://registry.yarnpkg.com/component-classes/-/component-classes-1.2.6.tgz#c642394c3618a4d8b0b8919efccbbd930e5cd691" + integrity sha1-xkI5TDYYpNiwuJGe/Mu9kw5c1pE= + dependencies: + component-indexof "0.0.3" + component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== +component-indexof@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/component-indexof/-/component-indexof-0.0.3.tgz#11d091312239eb8f32c8f25ae9cb002ffe8d3c24" + integrity sha1-EdCRMSI5648yyPJa6csAL/6NPCQ= + compress-commons@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-1.2.2.tgz#524a9f10903f3a813389b0225d27c48bb751890f" @@ -9188,6 +9207,14 @@ crypto-random-string@^1.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= +css-animation@^1.3.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/css-animation/-/css-animation-1.6.1.tgz#162064a3b0d51f958b7ff37b3d6d4de18e17039e" + integrity sha512-/48+/BaEaHRY6kNQ2OIPzKf9A6g8WjZYjhiNDNuIVbsm5tXCGIAsHDjB4Xu1C4vXJtUWZo26O68OQkDpNBaPog== + dependencies: + babel-runtime "6.x" + component-classes "^1.2.5" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -18048,7 +18075,7 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== -object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@4.x, object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -19504,7 +19531,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -19826,7 +19853,7 @@ quick-lru@^1.0.0: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= -raf@^3.1.0, raf@^3.4.1: +raf@^3.1.0, raf@^3.4.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== @@ -19920,6 +19947,40 @@ raw-loader@^3.1.0: loader-utils "^1.1.0" schema-utils "^2.0.1" +rc-animate@2.x: + version "2.10.3" + resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-2.10.3.tgz#163d5e29281a4ff82d53ee7918eeeac856b756f9" + integrity sha512-A9qQ5Y8BLlM7EhuCO3fWb/dChndlbWtY/P5QvPqBU7h4r5Q2QsvsbpTGgdYZATRDZbTRnJXXfVk9UtlyS7MBLg== + dependencies: + babel-runtime "6.x" + classnames "^2.2.6" + css-animation "^1.3.2" + prop-types "15.x" + raf "^3.4.0" + rc-util "^4.15.3" + react-lifecycles-compat "^3.0.4" + +rc-notification@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/rc-notification/-/rc-notification-4.0.0.tgz#ffe59783d6738003972dde8b9658f1acd469cd2c" + integrity sha512-In9FimkJY+JSIq3/eopPfBpQQr2Zugq5i9Aw9vdiNCGCsAsSO9bGq2dPsn8bamOydNrhc3djljGfmxUUMbcZnA== + dependencies: + classnames "2.x" + rc-animate "2.x" + rc-util "^4.0.4" + +rc-util@^4.0.4, rc-util@^4.15.3: + version "4.20.1" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-4.20.1.tgz#a5976eabfc3198ed9b8e79ffb8c53c231db36e77" + integrity sha512-EGlDg9KPN0POzmAR2hk9ZyFc3DmJIrXwlC8NoDxJguX2LTINnVqwadLIVauLfYgYISMiFYFrSHiFW+cqUhZ5dA== + dependencies: + add-dom-event-listener "^1.1.0" + babel-runtime "6.x" + prop-types "^15.5.10" + react-is "^16.12.0" + react-lifecycles-compat "^3.0.4" + shallowequal "^1.1.0" + rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"