From b190439608cfdecb5279ac901da1c9c5795c5237 Mon Sep 17 00:00:00 2001 From: Nancy <68706811+nancy-dassana@users.noreply.github.com> Date: Mon, 19 Oct 2020 17:37:02 -0700 Subject: [PATCH] feat #119 - Notifications V2 (#120) * feat #114 - Update input border radius, refac * feat #116 - Render stories inside themed containers * feat #116 - Update FieldLabel styles * feat #113 - Button theming (#117) * v0.4.0 -> v0.5.0 (#105) * feat #99 - Avatar component (#103) * feat #100 - Notification component should take in configuration options (#103) * feat #101 - Icon component should render svgs (#103) * feat #92 - Theming (#97) * fix #104 - Fix exported types for table and form (#97) * feat #113 - Revamp button styles * feat #113 - Update button styles for primary * feat #113 - Fix broken snapshots * feat #113 - Fix bad rebase * feat #113 - Update story snapshot * feat #113 - Address PR comments Co-authored-by: github-actions * v0.4.0 -> v0.5.0 (#105) * feat #99 - Avatar component (#103) * feat #100 - Notification component should take in configuration options (#103) * feat #101 - Icon component should render svgs (#103) * feat #92 - Theming (#97) * fix #104 - Fix exported types for table and form (#97) * feat #113 - Revamp button styles * feat #113 - Update button styles for primary * v0.4.0 -> v0.5.0 (#105) * feat #99 - Avatar component (#103) * feat #100 - Notification component should take in configuration options (#103) * feat #101 - Icon component should render svgs (#103) * feat #92 - Theming (#97) * fix #104 - Fix exported types for table and form (#97) * feat #113 - Revamp button styles * feat #119 - Notifications V2 Closes #119 * feat #119 - Set up dark/light theme for notifications and update styles * feat #119 - Fix weird rebase * feat #119 - Fix build errors * feat #119 - Address PR comments * feat #119 - Fix bad rebase * feat #119 - Import styleguide nits Co-authored-by: sam-m-m Co-authored-by: github-actions --- package-lock.json | 112 +++++++++++++++++- package.json | 6 +- rollup.config.ts | 2 +- src/__snapshots__/storybook.test.ts.snap | 68 +++++++---- src/components/Button/index.tsx | 3 +- src/components/Button/utils.ts | 6 +- .../NotificationV2/Notification.tsx | 70 +++++++++++ .../NotificationV2/NotificationContext.ts | 15 +++ .../NotificationV2/NotificationV2.stories.tsx | 50 ++++++++ .../__tests__/Notification.test.tsx | 27 +++++ .../__tests__/NotificationProvider.test.tsx | 81 +++++++++++++ .../NotificationV2/__tests__/utils.test.ts | 81 +++++++++++++ src/components/NotificationV2/index.tsx | 68 +++++++++++ src/components/NotificationV2/utils.ts | 104 ++++++++++++++++ src/components/Popover/utils.ts | 4 +- src/components/Tooltip/utils.ts | 4 +- src/components/assets/styles/styleguide.ts | 13 ++ src/components/index.ts | 1 + src/components/utils.test.ts | 2 +- 19 files changed, 676 insertions(+), 41 deletions(-) create mode 100644 src/components/NotificationV2/Notification.tsx create mode 100644 src/components/NotificationV2/NotificationContext.ts create mode 100644 src/components/NotificationV2/NotificationV2.stories.tsx create mode 100644 src/components/NotificationV2/__tests__/Notification.test.tsx create mode 100644 src/components/NotificationV2/__tests__/NotificationProvider.test.tsx create mode 100644 src/components/NotificationV2/__tests__/utils.test.ts create mode 100644 src/components/NotificationV2/index.tsx create mode 100644 src/components/NotificationV2/utils.ts diff --git a/package-lock.json b/package-lock.json index f476b85b..68ddce36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5631,6 +5631,15 @@ "@types/testing-library__react": "^9.1.2" } }, + "@testing-library/react-hooks": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz", + "integrity": "sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A==", + "requires": { + "@babel/runtime": "^7.5.4", + "@types/testing-library__react-hooks": "^3.4.0" + } + }, "@testing-library/user-event": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-7.2.1.tgz", @@ -6031,6 +6040,14 @@ "@types/react": "*" } }, + "@types/react-test-renderer": { + "version": "16.9.3", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.3.tgz", + "integrity": "sha512-wJ7IlN5NI82XMLOyHSa+cNN4Z0I+8/YaLl04uDgcZ+W+ExWCmCiVTLT/7fRNqzy4OhStZcUwIqLNF7q+AdW43Q==", + "requires": { + "@types/react": "*" + } + }, "@types/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.3.tgz", @@ -6159,6 +6176,14 @@ } } }, + "@types/testing-library__react-hooks": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz", + "integrity": "sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==", + "requires": { + "@types/react-test-renderer": "*" + } + }, "@types/uglify-js": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.9.3.tgz", @@ -6182,6 +6207,12 @@ "integrity": "sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==", "dev": true }, + "@types/uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", + "dev": true + }, "@types/webpack": { "version": "4.41.21", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz", @@ -6587,6 +6618,14 @@ "log-symbols": "^2.1.0", "loglevelnext": "^1.0.1", "uuid": "^3.1.0" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } } } } @@ -13712,6 +13751,27 @@ "map-cache": "^0.2.2" } }, + "framer-motion": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.9.1.tgz", + "integrity": "sha512-NjEF5u1FkCTS+zRsDWOPPCxWBUWk255WXy4d1FDCC3j6ETJzDXx0V/NUPvwhzyATHbahfX5JdsHWdRe1whNHJg==", + "requires": { + "@emotion/is-prop-valid": "^0.8.2", + "framesync": "^4.1.0", + "hey-listen": "^1.0.8", + "popmotion": "9.0.0-rc.20", + "style-value-types": "^3.1.9", + "tslib": "^1.10.0" + } + }, + "framesync": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-4.1.0.tgz", + "integrity": "sha512-MmgZ4wCoeVxNbx2xp5hN/zPDCbLSKiDt4BbbslK7j/pM2lg5S0vhTNv1v8BCVb99JPIo6hXBFdwzU7Q4qcAaoQ==", + "requires": { + "hey-listen": "^1.0.5" + } + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -14361,6 +14421,11 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "highlight.js": { "version": "9.15.10", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz", @@ -18823,6 +18888,17 @@ "@babel/runtime": "^7.9.2" } }, + "popmotion": { + "version": "9.0.0-rc.20", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.0.0-rc.20.tgz", + "integrity": "sha512-f98sny03WuA+c8ckBjNNXotJD4G2utG/I3Q23NU69OEafrXtxxSukAaJBxzbtxwDvz3vtZK69pu9ojdkMoBNTg==", + "requires": { + "framesync": "^4.1.0", + "hey-listen": "^1.0.8", + "style-value-types": "^3.1.9", + "tslib": "^1.10.0" + } + }, "popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -21990,6 +22066,13 @@ "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "request-promise-core": { @@ -23041,6 +23124,13 @@ "faye-websocket": "^0.10.0", "uuid": "^3.4.0", "websocket-driver": "0.6.5" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "sockjs-client": { @@ -23597,6 +23687,15 @@ "inline-style-parser": "0.1.1" } }, + "style-value-types": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-3.1.9.tgz", + "integrity": "sha512-050uqgB7WdvtgacoQKm+4EgKzJExVq0sieKBQQtJiU3Muh6MYcCp4T3M8+dfl6VOF2LR0NNwXBP1QYEed8DfIw==", + "requires": { + "hey-listen": "^1.0.8", + "tslib": "^1.10.0" + } + }, "stylehacks": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz", @@ -24725,9 +24824,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==" }, "v8-compile-cache": { "version": "2.1.1", @@ -25350,6 +25449,13 @@ "requires": { "ansi-colors": "^3.0.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "webpack-manifest-plugin": { diff --git a/package.json b/package.json index 605d7980..cb21c4ec 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@storybook/addon-cssresources": "^6.0.22", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.5.0", + "@testing-library/react-hooks": "^3.4.2", "@testing-library/user-event": "^7.2.1", "@types/color": "^3.0.1", "@types/jest": "^24.9.1", @@ -24,6 +25,7 @@ "bytes": "^3.1.0", "classnames": "^2.2.6", "color": "^3.1.2", + "framer-motion": "^2.9.1", "fuse.js": "^6.4.1", "lodash": "^4.17.20", "moment": "^2.27.0", @@ -32,7 +34,8 @@ "react-hook-form": "^6.5.0", "react-jss": "^10.4.0", "react-scripts": "^3.4.3", - "typescript": "^3.9.7" + "typescript": "^3.9.7", + "uuid": "^8.3.1" }, "scripts": { "start": "npm run storybook", @@ -88,6 +91,7 @@ "@types/enzyme": "^3.10.5", "@types/enzyme-adapter-react-16": "^1.0.6", "@types/lodash": "^4.14.161", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^3.9.0", "@typescript-eslint/parser": "^3.9.0", "chromatic": "^5.1.0", diff --git a/rollup.config.ts b/rollup.config.ts index b1975ea3..047009e7 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -28,7 +28,7 @@ const rootImport = options => ({ }) export default { - external: ['antd', 'react'], + external: ['antd', 'react', 'uuid'], input: 'src/components/index.ts', output: [ { diff --git a/src/__snapshots__/storybook.test.ts.snap b/src/__snapshots__/storybook.test.ts.snap index 212c6544..414bb62e 100644 --- a/src/__snapshots__/storybook.test.ts.snap +++ b/src/__snapshots__/storybook.test.ts.snap @@ -363,6 +363,24 @@ exports[`Storyshots Link Href 1`] = ` `; +exports[`Storyshots Notification Default 1`] = ` +
+ +
+`; + exports[`Storyshots Notifications Error 1`] = `
 
 
  @@ -712,10 +730,10 @@ exports[`Storyshots Select Default 1`] = ` className="light storyWrapper-0-2-2" >
Lorem @@ -909,10 +927,10 @@ exports[`Storyshots Select Icon 1`] = ` className="light storyWrapper-0-2-2" >
  @@ -1129,27 +1147,27 @@ exports[`Storyshots Skeleton Count 1`] = ` className="light storyWrapper-0-2-2" >           @@ -1161,7 +1179,7 @@ exports[`Storyshots Skeleton Default 1`] = ` className="light storyWrapper-0-2-2" >   diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 20f4cef1..7162fc2b 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -10,7 +10,7 @@ import { LoadingOutlined } from '@ant-design/icons' import { Skeleton } from '../Skeleton' import { Button as AntDButton, Spin } from 'antd' import React, { FC, ReactNode } from 'react' -import { styleguide, ThemeType } from '../assets/styles' +import { styleguide, ThemeType } from 'components/assets/styles' const { colors: { blacks } @@ -24,7 +24,6 @@ const useStyles = createUseStyles({ button: generateButtonStyles(light) } }) - export interface ButtonProps extends CommonComponentProps { /** * Required click handler. diff --git a/src/components/Button/utils.ts b/src/components/Button/utils.ts index ddf68834..60c38d47 100644 --- a/src/components/Button/utils.ts +++ b/src/components/Button/utils.ts @@ -1,7 +1,9 @@ import { styleguide, themedStyles, ThemeType } from 'components/assets/styles' -const { borderRadius, colors } = styleguide -const { blacks } = colors +const { + borderRadius, + colors: { blacks } +} = styleguide const { dark, light } = ThemeType diff --git a/src/components/NotificationV2/Notification.tsx b/src/components/NotificationV2/Notification.tsx new file mode 100644 index 00000000..78ac435a --- /dev/null +++ b/src/components/NotificationV2/Notification.tsx @@ -0,0 +1,70 @@ +import { createUseStyles } from 'react-jss' +import { motion } from 'framer-motion' +import { generateNotificationStyles, ProcessedNotification } from './utils' +import React, { FC } from 'react' +import { styleguide, ThemeType } from 'components/assets/styles' + +const { + colors: { blacks }, + font, + spacing +} = styleguide + +const { dark, light } = ThemeType + +const useStyles = createUseStyles({ + closeButton: { + ...font.label, + '&:hover': { + color: blacks['lighten-30'] + }, + alignSelf: 'flex-end', + color: blacks['lighten-70'], + cursor: 'pointer', + lineHeight: 1, + position: 'absolute', + right: spacing.s, + top: spacing.s + }, + container: generateNotificationStyles(light), + // eslint-disable-next-line sort-keys + '@global': { + [`.${dark}`]: { + '& $closeButton': { + '&:hover': { + color: blacks['lighten-40'] + }, + color: blacks['lighten-20'] + }, + '& $container': generateNotificationStyles(dark) + } + } +}) + +export type NotificationProps = ProcessedNotification + +export const Notification: FC = ( + props: NotificationProps +) => { + const { message, onClose } = props + const classes = useStyles(props) + + return ( + + {message} +
+ X +
+
+ ) +} diff --git a/src/components/NotificationV2/NotificationContext.ts b/src/components/NotificationV2/NotificationContext.ts new file mode 100644 index 00000000..7d7bf83e --- /dev/null +++ b/src/components/NotificationV2/NotificationContext.ts @@ -0,0 +1,15 @@ +import { NotificationConfig } from './utils' +import { createContext, useContext } from 'react' + +export interface NotificationContextProps { + generateNotification: (notification: NotificationConfig) => void +} + +const NotificationCtx = createContext( + {} as NotificationContextProps +) + +const useNotification = (): NotificationContextProps => + useContext(NotificationCtx) + +export { NotificationCtx, useNotification } diff --git a/src/components/NotificationV2/NotificationV2.stories.tsx b/src/components/NotificationV2/NotificationV2.stories.tsx new file mode 100644 index 00000000..12003caa --- /dev/null +++ b/src/components/NotificationV2/NotificationV2.stories.tsx @@ -0,0 +1,50 @@ +import { Button } from '../Button' +import { generatePopupSelector } from '../utils' +import React from 'react' +import { SbTheme } from '../../../.storybook/preview' +import { useTheme } from 'react-jss' +import { Meta, Story } from '@storybook/react/types-6-0' +import { + NotificationProvider, + NotificationTypes, + useNotification +} from './index' + +export default { + argTypes: { + children: { control: 'text' } + }, + decorators: [ + Story => { + const theme: SbTheme = useTheme() + + return ( + + + + ) + } + ], + title: 'Notification' +} as Meta + +const Template: Story = () => { + const { generateNotification } = useNotification() + + return ( + + ) +} + +export const Default = Template.bind({}) diff --git a/src/components/NotificationV2/__tests__/Notification.test.tsx b/src/components/NotificationV2/__tests__/Notification.test.tsx new file mode 100644 index 00000000..53edabde --- /dev/null +++ b/src/components/NotificationV2/__tests__/Notification.test.tsx @@ -0,0 +1,27 @@ +import { Notification } from '../Notification' +import { NotificationTypes } from '../utils' +import React from 'react' +import { shallow, ShallowWrapper } from 'enzyme' + +let wrapper: ShallowWrapper + +const mockMessage = 'foo' + +const onCloseSpy = jest.fn() + +beforeEach(() => { + wrapper = shallow( + + ) +}) + +describe('Notification', () => { + it('renders', () => { + expect(wrapper).toHaveLength(1) + }) +}) diff --git a/src/components/NotificationV2/__tests__/NotificationProvider.test.tsx b/src/components/NotificationV2/__tests__/NotificationProvider.test.tsx new file mode 100644 index 00000000..76695e72 --- /dev/null +++ b/src/components/NotificationV2/__tests__/NotificationProvider.test.tsx @@ -0,0 +1,81 @@ +import * as hooks from '../utils' +import { Button } from 'components/Button' +import { mount } from 'enzyme' +import { Notification } from '../Notification' +import React from 'react' +import { act, renderHook } from '@testing-library/react-hooks' +import { + NotificationProvider, + NotificationProviderProps, + useNotification +} from '../index' + +const generateNotificationSpy = jest.fn() +const mockMessage = 'foo' + +// @ts-ignore +jest.spyOn(hooks, 'useNotifications').mockImplementation(() => ({ + generateNotification: generateNotificationSpy, + notifications: [ + { + id: 'foo', + message: mockMessage, + type: hooks.NotificationTypes.info + } + ] +})) + +jest.mock('react-dom', () => { + const original = jest.requireActual('react-dom') + return { + ...original, + createPortal: (node: any) => node + } +}) +jest.mock('framer-motion', () => { + const AnimatePresence = jest.fn(({ children }) => children) + const motion = { + div: jest.fn(({ children }) => children) + } + + return { + AnimatePresence, + motion + } +}) + +let wrapper: React.FC + +afterEach(() => { + jest.clearAllMocks() +}) + +it('should provide a generateNotification function via context', () => { + wrapper = ({ children }: NotificationProviderProps) => ( + {children} + ) + + const { result } = renderHook(() => useNotification(), { wrapper }) + + act(() => { + result.current.generateNotification({ + message: 'foo', + type: hooks.NotificationTypes.info + }) + }) + + expect(generateNotificationSpy).toHaveBeenCalled() +}) + +it('should render a Notification for every notification config in the notifications array', () => { + const component = mount( + + + + ) + + const notification = component.find(Notification) + + expect(notification).toHaveLength(1) + expect(notification.props().message).toEqual(mockMessage) +}) diff --git a/src/components/NotificationV2/__tests__/utils.test.ts b/src/components/NotificationV2/__tests__/utils.test.ts new file mode 100644 index 00000000..3d81bcaa --- /dev/null +++ b/src/components/NotificationV2/__tests__/utils.test.ts @@ -0,0 +1,81 @@ +import { generatePopupSelector } from '../../utils' +import { act, renderHook } from '@testing-library/react-hooks' +import { + NOTIFICATION_CONTAINER_ID, + NotificationTypes, + useCreateDomElement, + useNotifications +} from '../utils' + +jest.spyOn(document.body, 'appendChild') +jest.spyOn(document.body, 'removeChild') + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('useCreateDomElement', () => { + afterEach(() => { + // Jest only clears the DOM after ALL tests in the file are run, so the reset must be done manually since the DOM + // is being manipulated in the following tests + document.body.innerHTML = '' + }) + + it('should append a div to the document body', () => { + const { unmount } = renderHook(() => useCreateDomElement()) + + expect(document.body.appendChild).toHaveBeenCalled() + + unmount() + + expect(document.body.removeChild).toHaveBeenCalled() + }) + + it('should append the notification to correct popup container if one is provided', () => { + const popupContainerElement = document.createElement('div') + const popupContainerId = 'popup-container' + popupContainerElement.setAttribute('id', popupContainerId) + document.body.appendChild(popupContainerElement) + + renderHook(() => + useCreateDomElement(generatePopupSelector(`#${popupContainerId}`)) + ) + + expect( + document.querySelector('#popup-container')?.firstElementChild?.id + ).toBe(NOTIFICATION_CONTAINER_ID) + }) + + it('should default the root to document.body if getPopupContainer returns null', () => { + const { unmount } = renderHook(() => + useCreateDomElement(generatePopupSelector('#non-existent-selector')) + ) + + expect(document.body.firstElementChild?.id).toBe( + NOTIFICATION_CONTAINER_ID + ) + + unmount() + }) +}) + +describe('useNotifications', () => { + it('should return a notification array', () => { + const { result } = renderHook(() => useNotifications()) + + act(() => { + result.current.generateNotification({ + message: 'foo', + type: NotificationTypes.info + }) + }) + + expect(result.current.notifications).toHaveLength(1) + + act(() => { + result.current.notifications[0].onClose() + }) + + expect(result.current.notifications).toHaveLength(0) + }) +}) diff --git a/src/components/NotificationV2/index.tsx b/src/components/NotificationV2/index.tsx new file mode 100644 index 00000000..ebe0abcc --- /dev/null +++ b/src/components/NotificationV2/index.tsx @@ -0,0 +1,68 @@ +import { AnimatePresence } from 'framer-motion' +import { createPortal } from 'react-dom' +import { createUseStyles } from 'react-jss' +import { Notification } from './Notification' +import { styleguide } from 'components/assets/styles' +import { + NotificationConfig as NotificationConfigInterface, + NotificationTypes, + ProcessedNotification, + useCreateDomElement, + useNotifications +} from './utils' +import { NotificationCtx, useNotification } from './NotificationContext' +import React, { FC, ReactNode } from 'react' + +const { spacing } = styleguide + +const useStyles = createUseStyles({ + container: { + position: 'fixed', + right: spacing.m, + top: 64 + } +}) + +export type NotificationConfig = NotificationConfigInterface + +export interface NotificationProviderProps { + children: ReactNode + getPopupContainer?: () => HTMLElement +} + +const NotificationProvider: FC = ({ + children, + getPopupContainer +}: NotificationProviderProps) => { + const rootElement = useCreateDomElement(getPopupContainer) + + const { generateNotification, notifications } = useNotifications() + + const classes = useStyles() + + return ( + <> + + {children} + + {rootElement && + createPortal( +
+ + {notifications.map( + (notification: ProcessedNotification) => ( + + ) + )} + +
, + rootElement + )} + + ) +} + +export { NotificationProvider, NotificationTypes, useNotification } diff --git a/src/components/NotificationV2/utils.ts b/src/components/NotificationV2/utils.ts new file mode 100644 index 00000000..d6d26d4d --- /dev/null +++ b/src/components/NotificationV2/utils.ts @@ -0,0 +1,104 @@ +import omit from 'lodash/omit' +import { styleguide } from 'components/assets/styles' +import { v4 as uuidV4 } from 'uuid' +import { themedStyles, themes, ThemeType } from '../assets/styles/themes' +import { useCallback, useEffect, useState } from 'react' + +const { spacing } = styleguide + +export const NOTIFICATION_CONTAINER_ID = 'notification-root' + +// Appends a div to the document, usually for use with React portals +// Optional popup container function can be provided as an argument. Otherwise, it defaults to appending the div to document.body +export const useCreateDomElement = ( + getPopupContainer: () => HTMLElement = () => document.body +) => { + const [domElement, setDomElement] = useState(null) + + const root = getPopupContainer() || document.body + + useEffect(() => { + const element = document.createElement('div') + element.setAttribute('id', NOTIFICATION_CONTAINER_ID) + + root.appendChild(element) + setDomElement(element) + + return () => { + root.removeChild(element) + } + }, [root]) + + return domElement +} + +export enum NotificationTypes { + error = 'error', + info = 'info', + success = 'success', + warning = 'warning' +} + +export interface NotificationConfig { + duration?: number + message: string + type: NotificationTypes +} + +export interface ProcessedNotification + extends Omit { + id: string + onClose: () => void +} + +export const useNotifications = () => { + const [notifications, setNotifications] = useState( + [] + ) + + const generateNotification = useCallback( + (notificationConfig: NotificationConfig) => { + const { duration = 3000 } = notificationConfig + const processedConfig = omit(notificationConfig, 'duration') + const id = uuidV4() + + const removeNotification = () => { + setNotifications((notifications: ProcessedNotification[]) => + notifications.filter(notification => notification.id !== id) + ) + } + + setNotifications((notifications: ProcessedNotification[]) => [ + ...notifications, + { + ...processedConfig, + id, + onClose: removeNotification + } + ]) + + setTimeout(removeNotification, duration) + }, + [] + ) + + return { generateNotification, notifications } +} + +export const generateNotificationStyles = (themeType: ThemeType) => { + const { base } = themedStyles[themeType] + const palette = themes[themeType] + + return { + background: palette.background.primary, + border: `1px solid ${base.borderColor}`, + borderRadius: 4, + color: base.color, + display: 'flex', + justifyContent: 'space-between', + marginBottom: spacing.m, + padding: spacing.m, + position: 'relative', + width: 384 + } +} diff --git a/src/components/Popover/utils.ts b/src/components/Popover/utils.ts index 33a5e380..55956d65 100644 --- a/src/components/Popover/utils.ts +++ b/src/components/Popover/utils.ts @@ -1,12 +1,10 @@ -import colors from 'components/assets/styles/colors' import { styleguide } from 'components/assets/styles/styleguide' import { ColorManipulationTypes, manipulateColor } from '../utils' import { themedStyles, ThemeType } from 'components/assets/styles/themes' +const { borderRadius, colors } = styleguide const { blacks, whites } = colors -const { borderRadius } = styleguide - const { dark, light } = ThemeType const { fade } = ColorManipulationTypes diff --git a/src/components/Tooltip/utils.ts b/src/components/Tooltip/utils.ts index 4fd7edcf..19f56877 100644 --- a/src/components/Tooltip/utils.ts +++ b/src/components/Tooltip/utils.ts @@ -1,11 +1,9 @@ -import colors from 'components/assets/styles/colors' import { styleguide } from 'components/assets/styles/styleguide' import { ThemeType } from 'components/assets/styles/themes' +const { borderRadius, colors } = styleguide const { blacks, whites } = colors -const { borderRadius } = styleguide - const { dark, light } = ThemeType const tooltipPalette = { diff --git a/src/components/assets/styles/styleguide.ts b/src/components/assets/styles/styleguide.ts index 111c8d99..6eed2590 100644 --- a/src/components/assets/styles/styleguide.ts +++ b/src/components/assets/styles/styleguide.ts @@ -17,6 +17,19 @@ export const fieldErrorStyles = { export const styleguide = { borderRadius: 4, colors, + flexCenter: { + alignItems: 'center', + display: 'flex', + justifyContent: 'center' + }, + flexDown: { + display: 'flex', + flexDirection: 'column' + }, + flexSpaceBetween: { + display: 'flex', + justifyContent: 'space-between' + }, font: { body: { fontSize: 14, lineHeight: 22 }, bodyLarge: { fontSize: 16, lineHeight: 24 }, diff --git a/src/components/index.ts b/src/components/index.ts index f226ac31..23b108ed 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export * from './Input' export * from './Icon' export * from './Link' export * from './Notification' +export * from './NotificationV2' export * from './Popover' export * from './RadioGroup' export * from './Select' diff --git a/src/components/utils.test.ts b/src/components/utils.test.ts index ba9e1325..fdaae9d1 100644 --- a/src/components/utils.test.ts +++ b/src/components/utils.test.ts @@ -1,7 +1,7 @@ import { ColorManipulationTypes, - getDataTestAttributeProp, generatePopupSelector, + getDataTestAttributeProp, manipulateColor, TAG } from './utils'