From 6d979745a7f8ef864d4e6680c484e41cbf30e9ef Mon Sep 17 00:00:00 2001
From: Nancy <68706811+nancy-dassana@users.noreply.github.com>
Date: Wed, 6 Jan 2021 14:41:31 -0800
Subject: [PATCH] chore #179 - Modal and Wizard components (#180)
Closes #179
---
package-lock.json | 8 +-
package.json | 2 +-
src/__snapshots__/storybook.test.ts.snap | 18 ++
src/components/Modal/Modal.stories.tsx | 34 ++++
src/components/Modal/Modal.tsx | 96 ++++++++++
src/components/Modal/ModalContext.ts | 14 ++
src/components/Modal/__tests__/Modal.test.tsx | 119 ++++++++++++
.../Modal/__tests__/ModalProvider.test.tsx | 82 ++++++++
src/components/Modal/__tests__/utils.test.tsx | 39 ++++
src/components/Modal/index.tsx | 35 ++++
src/components/Modal/utils.ts | 24 +++
.../NotificationV2/__tests__/utils.test.ts | 60 +-----
src/components/NotificationV2/index.tsx | 8 +-
src/components/NotificationV2/utils.ts | 26 +--
src/components/Wizard/WizardContext.ts | 19 ++
.../Wizard/__tests__/Wizard.test.tsx | 63 ++++++
src/components/Wizard/__tests__/utils.test.ts | 181 ++++++++++++++++++
src/components/Wizard/index.tsx | 35 ++++
src/components/Wizard/utils.ts | 83 ++++++++
src/components/index.ts | 3 +
src/components/utils.test.ts | 57 +++++-
src/components/utils.ts | 26 +++
22 files changed, 940 insertions(+), 92 deletions(-)
create mode 100644 src/components/Modal/Modal.stories.tsx
create mode 100644 src/components/Modal/Modal.tsx
create mode 100644 src/components/Modal/ModalContext.ts
create mode 100644 src/components/Modal/__tests__/Modal.test.tsx
create mode 100644 src/components/Modal/__tests__/ModalProvider.test.tsx
create mode 100644 src/components/Modal/__tests__/utils.test.tsx
create mode 100644 src/components/Modal/index.tsx
create mode 100644 src/components/Modal/utils.ts
create mode 100644 src/components/Wizard/WizardContext.ts
create mode 100644 src/components/Wizard/__tests__/Wizard.test.tsx
create mode 100644 src/components/Wizard/__tests__/utils.test.ts
create mode 100644 src/components/Wizard/index.tsx
create mode 100644 src/components/Wizard/utils.ts
diff --git a/package-lock.json b/package-lock.json
index 54e6ae29..56c6fd75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "@dassana-io/web-components",
- "version": "0.7.7",
+ "version": "0.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -16079,9 +16079,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
- "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"inline-style-parser": {
"version": "0.1.1",
diff --git a/package.json b/package.json
index 16b0cbf9..c4ed6e0a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@dassana-io/web-components",
- "version": "0.7.7",
+ "version": "0.8.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com/dassana-io"
},
diff --git a/src/__snapshots__/storybook.test.ts.snap b/src/__snapshots__/storybook.test.ts.snap
index 55cc3d0b..4fcfc3fd 100644
--- a/src/__snapshots__/storybook.test.ts.snap
+++ b/src/__snapshots__/storybook.test.ts.snap
@@ -398,6 +398,24 @@ exports[`Storyshots Link Href 1`] = `
`;
+exports[`Storyshots Modal Default 1`] = `
+
+
+
+`;
+
exports[`Storyshots Notification Error 1`] = `
(
+
+
+
+ )
+ ],
+ title: 'Modal'
+} as Meta
+
+const Template: Story
= args => {
+ const { setModalConfig } = useModal()
+
+ return
+}
+
+export const Default = Template.bind({})
+Default.args = {
+ content: Modal Content
,
+ options: {
+ disableKeyboardShortcut: false,
+ hideCloseButton: false
+ }
+}
diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx
new file mode 100644
index 00000000..601c9dab
--- /dev/null
+++ b/src/components/Modal/Modal.tsx
@@ -0,0 +1,96 @@
+import cn from 'classnames'
+import { createUseStyles } from 'react-jss'
+import { ModalConfig } from './utils'
+import noop from 'lodash/noop'
+import {
+ Emitter,
+ EmitterEventTypes,
+ useShortcut,
+ useTheme
+} from '@dassana-io/web-utils'
+import { IconButton, IconSizes } from 'components/IconButton'
+import React, { FC, useEffect } from 'react'
+import { styleguide, themedStyles, ThemeType } from 'components/assets/styles'
+
+const { flexCenter, spacing } = styleguide
+
+const useStyles = createUseStyles({
+ closeButton: {
+ position: 'absolute',
+ right: spacing.l,
+ top: spacing.m,
+ zIndex: 10000
+ },
+ container: ({
+ overlay,
+ theme
+ }: {
+ overlay: boolean
+ theme: ThemeType
+ }) => ({
+ ...flexCenter,
+ background: themedStyles[theme].base.backgroundColor,
+ bottom: 0,
+ color: themedStyles[theme].base.color,
+ height: '100%',
+ left: 0,
+ opacity: overlay ? 0.6 : 1,
+ position: 'fixed',
+ right: 0,
+ top: 0,
+ width: '100%',
+ zIndex: 9999
+ })
+})
+
+interface ModalProps {
+ emitter: Emitter
+ modalConfig: ModalConfig
+ unsetModal: () => void
+}
+
+const Modal: FC = ({
+ emitter,
+ modalConfig,
+ unsetModal
+}: ModalProps) => {
+ const { content, options = {} } = modalConfig
+ const {
+ classes = [],
+ disableKeyboardShortcut = false,
+ hideCloseButton = false,
+ onClose,
+ overlay = false
+ } = options
+ const theme = useTheme(emitter)
+ const modalClasses = useStyles({ overlay, theme })
+
+ const onModalClose = () => (onClose ? onClose() : unsetModal())
+
+ useShortcut({
+ callback: disableKeyboardShortcut ? noop : onModalClose,
+ key: 'Escape',
+ keyEvent: 'keydown'
+ })
+
+ useEffect(() => {
+ emitter.on(EmitterEventTypes.loggedOut, unsetModal)
+
+ return () => emitter.off(EmitterEventTypes.loggedOut, unsetModal)
+ })
+
+ return (
+
+ {!hideCloseButton && (
+
+ )}
+ {content}
+
+ )
+}
+
+export default Modal
diff --git a/src/components/Modal/ModalContext.ts b/src/components/Modal/ModalContext.ts
new file mode 100644
index 00000000..19514b3d
--- /dev/null
+++ b/src/components/Modal/ModalContext.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from 'react'
+import { ModalConfig, ModalOptions } from './utils'
+
+export interface ModalContextProps {
+ options?: ModalOptions
+ setModalConfig: (config: ModalConfig) => void
+ unsetModal: () => void
+}
+
+const ModalCtx = createContext({} as ModalContextProps)
+
+const useModal = (): ModalContextProps => useContext(ModalCtx)
+
+export { ModalCtx, useModal }
diff --git a/src/components/Modal/__tests__/Modal.test.tsx b/src/components/Modal/__tests__/Modal.test.tsx
new file mode 100644
index 00000000..55753721
--- /dev/null
+++ b/src/components/Modal/__tests__/Modal.test.tsx
@@ -0,0 +1,119 @@
+import { act } from 'react-dom/test-utils'
+import { IconButton } from '../../IconButton'
+import Modal from '../Modal'
+import { ModalOptions } from '../utils'
+import { ModalProvider } from 'components/Modal'
+import React from 'react'
+import { mount, ReactWrapper } from 'enzyme'
+
+let wrapper: ReactWrapper
+
+const mockDassanaEmitter = {
+ emit: jest.fn(),
+ emitNotificationEvent: jest.fn(),
+ off: jest.fn(),
+ on: jest.fn()
+} as jest.Mocked
+
+const mockMessage = 'Hello World'
+const mockModalContent = {mockMessage}
+
+const unsetModalSpy = jest.fn()
+
+const getWrapper = (options: ModalOptions = {}, mountOptions = {}) =>
+ mount(
+
+
+ ,
+ mountOptions
+ )
+
+beforeEach(() => {
+ wrapper = getWrapper()
+})
+
+afterEach(() => {
+ wrapper.unmount()
+ jest.clearAllMocks()
+})
+
+it('renders', () => {
+ expect(wrapper).toHaveLength(1)
+})
+
+it('should render its content', () => {
+ expect(wrapper.text()).toContain(mockMessage)
+})
+
+it('should not call unsetModal if disableKeyboardShortcut is set to true', () => {
+ wrapper.unmount()
+ wrapper = getWrapper({ disableKeyboardShortcut: true })
+
+ act(() => {
+ dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code: 'Escape',
+ key: 'Escape'
+ })
+ )
+ })
+
+ expect(unsetModalSpy).not.toHaveBeenCalled()
+})
+
+it('should call the onClose function if one is provided', () => {
+ const mockOnClose = jest.fn()
+
+ wrapper = getWrapper({
+ onClose: mockOnClose
+ })
+
+ act(() => {
+ dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code: 'Escape',
+ key: 'Escape'
+ })
+ )
+ })
+
+ expect(mockOnClose).toHaveBeenCalled()
+})
+
+it('should close the modal if Esc is pressed', () => {
+ act(() => {
+ dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code: 'Escape',
+ key: 'Escape'
+ })
+ )
+ })
+
+ expect(unsetModalSpy).toHaveBeenCalled()
+})
+
+it('should close modal if the close button is clicked', () => {
+ wrapper.find(IconButton).props().onClick!()
+
+ expect(unsetModalSpy).toHaveBeenCalled()
+})
+
+it('should have an opaque background if overlay is set to true in options', () => {
+ const div = document.createElement('div')
+ div.setAttribute('id', 'container')
+ document.body.appendChild(div)
+
+ wrapper = getWrapper(
+ { overlay: true },
+ { attachTo: document.getElementById('container') }
+ )
+
+ const style = window.getComputedStyle(wrapper.find(Modal).getDOMNode())
+
+ expect(style.opacity).not.toEqual(1)
+})
diff --git a/src/components/Modal/__tests__/ModalProvider.test.tsx b/src/components/Modal/__tests__/ModalProvider.test.tsx
new file mode 100644
index 00000000..d60fa095
--- /dev/null
+++ b/src/components/Modal/__tests__/ModalProvider.test.tsx
@@ -0,0 +1,82 @@
+import * as hooks from '../utils'
+import Modal from '../Modal'
+import { mount } from 'enzyme'
+import React from 'react'
+import { act, renderHook } from '@testing-library/react-hooks'
+import { ModalProvider, Props as ModalProviderProps, useModal } from '../index'
+
+const setModalConfigSpy = jest.fn()
+const unsetModalSpy = jest.fn()
+const mockModalContent = Hello World
+
+// @ts-ignore
+jest.spyOn(hooks, 'useModalCmp').mockImplementation(() => ({
+ modalConfig: { content: mockModalContent },
+ setModalConfig: setModalConfigSpy,
+ unsetModal: unsetModalSpy
+}))
+
+jest.mock('react-dom', () => {
+ const original = jest.requireActual('react-dom')
+
+ return {
+ ...original,
+ createPortal: (node: any) => node
+ }
+})
+
+const mockDassanaEmitter = {
+ emit: jest.fn(),
+ emitNotificationEvent: jest.fn(),
+ off: jest.fn(),
+ on: jest.fn()
+} as jest.Mocked
+
+let wrapper: React.FC
+
+afterEach(() => {
+ jest.clearAllMocks()
+})
+
+it('should provide an setModal function via context', () => {
+ wrapper = ({ children }: ModalProviderProps) => (
+ {children}
+ )
+
+ const { result } = renderHook(() => useModal(), { wrapper })
+
+ act(() => {
+ result.current.setModalConfig({ content: mockModalContent })
+ })
+
+ expect(setModalConfigSpy).toHaveBeenCalledWith({
+ content: mockModalContent
+ })
+})
+
+it('should provide an unsetModal function via context', () => {
+ wrapper = ({ children }: ModalProviderProps) => (
+ {children}
+ )
+
+ const { result } = renderHook(() => useModal(), { wrapper })
+
+ act(() => {
+ result.current.unsetModal()
+ })
+
+ expect(unsetModalSpy).toHaveBeenCalled()
+})
+
+it('should render a Modal with the modal content in context', () => {
+ const component = mount(
+
+ Test
+
+ )
+
+ const modal = component.find(Modal)
+
+ expect(modal).toHaveLength(1)
+ expect(modal.props().modalConfig.content).toEqual(mockModalContent)
+})
diff --git a/src/components/Modal/__tests__/utils.test.tsx b/src/components/Modal/__tests__/utils.test.tsx
new file mode 100644
index 00000000..9d1d39e3
--- /dev/null
+++ b/src/components/Modal/__tests__/utils.test.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import { useModalCmp } from '../utils'
+import { act, renderHook } from '@testing-library/react-hooks'
+
+describe('useModalCmp', () => {
+ const mockCmp = Hello World
+
+ it('should not have modal content when initialized', () => {
+ const { result } = renderHook(() => useModalCmp())
+
+ expect(result.current.modalConfig).toBeUndefined()
+ })
+
+ it('should have a setModalContent function that sets modal content', () => {
+ const { result } = renderHook(() => useModalCmp())
+
+ act(() => {
+ result.current.setModalConfig({ content: mockCmp })
+ })
+
+ expect(result.current.modalConfig!.content).toBe(mockCmp)
+ })
+
+ it('should have an unsetModal function that clears the modal content', () => {
+ const { result } = renderHook(() => useModalCmp())
+
+ act(() => {
+ result.current.setModalConfig({ content: mockCmp })
+ })
+
+ expect(result.current.modalConfig!.content).toBe(mockCmp)
+
+ act(() => {
+ result.current.unsetModal()
+ })
+
+ expect(result.current.modalConfig).toBeUndefined()
+ })
+})
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
new file mode 100644
index 00000000..8f1c163e
--- /dev/null
+++ b/src/components/Modal/index.tsx
@@ -0,0 +1,35 @@
+import { createPortal } from 'react-dom'
+import { Emitter } from '@dassana-io/web-utils'
+import Modal from './Modal'
+import { useCreateDomElement } from 'components/utils'
+import { MODAL_CONTAINER_ID, useModalCmp } from './utils'
+import { ModalCtx, useModal } from './ModalContext'
+import React, { FC, ReactNode } from 'react'
+
+export interface Props {
+ children: ReactNode
+ emitter: Emitter
+}
+
+const ModalProvider: FC = ({ children, emitter }: Props) => {
+ const { modalConfig, setModalConfig, unsetModal } = useModalCmp()
+ const rootElement = useCreateDomElement(MODAL_CONTAINER_ID)
+
+ return (
+
+ {children}
+ {modalConfig &&
+ rootElement &&
+ createPortal(
+ ,
+ rootElement
+ )}
+
+ )
+}
+
+export { ModalProvider, useModal }
diff --git a/src/components/Modal/utils.ts b/src/components/Modal/utils.ts
new file mode 100644
index 00000000..a3baa938
--- /dev/null
+++ b/src/components/Modal/utils.ts
@@ -0,0 +1,24 @@
+import { ReactNode, useCallback, useState } from 'react'
+
+export const MODAL_CONTAINER_ID = 'modal-root'
+
+export interface ModalOptions {
+ classes?: string[]
+ disableKeyboardShortcut?: boolean
+ hideCloseButton?: boolean
+ onClose?: () => void
+ overlay?: boolean
+}
+
+export interface ModalConfig {
+ content: ReactNode
+ options?: ModalOptions
+}
+
+export const useModalCmp = () => {
+ const [modalConfig, setModalConfig] = useState()
+
+ const unsetModal = useCallback(() => setModalConfig(undefined), [])
+
+ return { modalConfig, setModalConfig, unsetModal }
+}
diff --git a/src/components/NotificationV2/__tests__/utils.test.ts b/src/components/NotificationV2/__tests__/utils.test.ts
index 3d81bcaa..104a780a 100644
--- a/src/components/NotificationV2/__tests__/utils.test.ts
+++ b/src/components/NotificationV2/__tests__/utils.test.ts
@@ -1,63 +1,5 @@
-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()
- })
-})
+import { NotificationTypes, useNotifications } from '../utils'
describe('useNotifications', () => {
it('should return a notification array', () => {
diff --git a/src/components/NotificationV2/index.tsx b/src/components/NotificationV2/index.tsx
index f0c7637a..671195a0 100644
--- a/src/components/NotificationV2/index.tsx
+++ b/src/components/NotificationV2/index.tsx
@@ -3,11 +3,12 @@ import { createPortal } from 'react-dom'
import { createUseStyles } from 'react-jss'
import { Notification } from './Notification'
import { styleguide } from 'components/assets/styles'
+import { useCreateDomElement } from 'components/utils'
import {
+ NOTIFICATION_CONTAINER_ID,
NotificationConfig as NotificationConfigInterface,
NotificationTypes,
ProcessedNotification,
- useCreateDomElement,
useNotifications
} from './utils'
import { NotificationCtxProvider, useNotification } from './NotificationContext'
@@ -34,7 +35,10 @@ const NotificationProvider: FC = ({
children,
getPopupContainer
}: NotificationProviderProps) => {
- const rootElement = useCreateDomElement(getPopupContainer)
+ const rootElement = useCreateDomElement(
+ NOTIFICATION_CONTAINER_ID,
+ getPopupContainer
+ )
const { generateNotification, notifications } = useNotifications()
diff --git a/src/components/NotificationV2/utils.ts b/src/components/NotificationV2/utils.ts
index e74068fd..c52acc61 100644
--- a/src/components/NotificationV2/utils.ts
+++ b/src/components/NotificationV2/utils.ts
@@ -9,36 +9,12 @@ import {
faTimesCircle
} from '@fortawesome/free-solid-svg-icons'
import { themedStyles, themes, ThemeType } from '../assets/styles/themes'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useState } from 'react'
const { borderRadius, flexSpaceBetween, 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 interface NotificationConfig {
duration?: number
message: string
diff --git a/src/components/Wizard/WizardContext.ts b/src/components/Wizard/WizardContext.ts
new file mode 100644
index 00000000..2831c720
--- /dev/null
+++ b/src/components/Wizard/WizardContext.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext } from 'react'
+
+export interface WizardContextProps {
+ data: Record
+ goToStep: (step: number) => void
+ maxActiveStep: number
+ nextStep: () => void
+ prevStep: () => void
+ resetData: () => void
+ resetWizard: (step?: number) => void
+ storeData: (data: Record) => void
+ step: number
+}
+
+const WizardCtx = createContext({} as WizardContextProps)
+
+const useWizard = (): WizardContextProps => useContext(WizardCtx)
+
+export { WizardCtx, useWizard }
diff --git a/src/components/Wizard/__tests__/Wizard.test.tsx b/src/components/Wizard/__tests__/Wizard.test.tsx
new file mode 100644
index 00000000..89596a4c
--- /dev/null
+++ b/src/components/Wizard/__tests__/Wizard.test.tsx
@@ -0,0 +1,63 @@
+import * as hooks from '../utils'
+import React from 'react'
+import { WizardCtx } from '../WizardContext'
+import { act, renderHook } from '@testing-library/react-hooks'
+import { mount, ReactWrapper } from 'enzyme'
+import { Step, useWizard, Wizard } from '../index'
+
+let wrapper: ReactWrapper
+
+const nextStepSpy = jest.fn()
+
+// @ts-ignore
+jest.spyOn(hooks, 'useWizardCmp').mockImplementation(() => ({
+ goToStep: jest.fn(),
+ nextStep: nextStepSpy,
+ step: 1
+}))
+
+const mockSteps: Step[] = [
+ {
+ render: () => Step 1
,
+ title: 'Step 1'
+ },
+ {
+ render: () => Step 2
,
+ title: 'Step 2'
+ }
+]
+
+beforeEach(() => {
+ wrapper = mount()
+})
+
+afterEach(() => {
+ jest.clearAllMocks()
+})
+
+it('renders', () => {
+ expect(wrapper).toHaveLength(1)
+})
+
+it('should render the correct component in the steps config', () => {
+ expect(wrapper.text()).toContain('Step 1')
+})
+
+it('should provide a nextStep function via context', () => {
+ // @ts-ignore
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ )
+
+ const { result } = renderHook(() => useWizard(), {
+ wrapper
+ })
+
+ act(() => {
+ result.current.nextStep()
+ })
+
+ expect(nextStepSpy).toHaveBeenCalled()
+})
diff --git a/src/components/Wizard/__tests__/utils.test.ts b/src/components/Wizard/__tests__/utils.test.ts
new file mode 100644
index 00000000..cb7920ad
--- /dev/null
+++ b/src/components/Wizard/__tests__/utils.test.ts
@@ -0,0 +1,181 @@
+import { useWizardCmp } from '../utils'
+import { act, renderHook } from '@testing-library/react-hooks'
+
+const initializeWizard = (
+ totalSteps = 3,
+ defaultStep?: number,
+ enableAllSteps = false
+) => renderHook(() => useWizardCmp(totalSteps, defaultStep, enableAllSteps))
+
+const nextStep = (result: any) => {
+ act(() => {
+ result.current.nextStep()
+ })
+}
+
+describe('useWizardCmp', () => {
+ it('should have default active step to 1 when initialized', () => {
+ const { result } = initializeWizard()
+
+ expect(result.current.step).toBe(1)
+ })
+
+ it('should have the max number of activated steps if all steps are enabled', () => {
+ const { result } = initializeWizard(3, 1, true)
+
+ expect(result.current.maxActiveStep).toBe(3)
+ })
+
+ describe('prevStep', () => {
+ it('should go back to the previous step when invoked', () => {
+ const { result } = initializeWizard(2, 2)
+
+ act(() => {
+ result.current.prevStep()
+ })
+
+ expect(result.current.step).toBe(1)
+ })
+
+ it('should throw an error if invoked on the first step', () => {
+ const { result } = initializeWizard()
+
+ const prevStep = () => {
+ act(() => {
+ result.current.prevStep()
+ })
+ }
+
+ expect(prevStep).toThrowError('Invalid use of prevStep')
+ })
+ })
+
+ describe('nextStep', () => {
+ it('should advance to the next step when invoked', () => {
+ const { result } = initializeWizard()
+
+ nextStep(result)
+
+ expect(result.current.step).toBe(2)
+ })
+
+ it('should throw an error if nextStep is invoked on the final step', () => {
+ const { result } = initializeWizard(2, 2)
+
+ const advanceStep = () => {
+ act(() => {
+ result.current.nextStep()
+ })
+ }
+
+ expect(advanceStep).toThrowError('Invalid use of nextStep')
+ })
+
+ it('should correctly update maxActiveStep', () => {
+ const { result } = initializeWizard(3, 2)
+
+ expect(result.current.step).toBe(2)
+ expect(result.current.maxActiveStep).toBe(2)
+
+ act(() => {
+ result.current.prevStep()
+ })
+
+ expect(result.current.step).toBe(1)
+ expect(result.current.maxActiveStep).toBe(2)
+
+ nextStep(result)
+
+ expect(result.current.step).toBe(2)
+ expect(result.current.maxActiveStep).toBe(2)
+
+ nextStep(result)
+
+ expect(result.current.step).toBe(3)
+ expect(result.current.maxActiveStep).toBe(3)
+ })
+ })
+
+ describe('resetWizard', () => {
+ it('should reset the stepper to the defaultActiveStep', () => {
+ const { result } = initializeWizard(3, 2)
+
+ nextStep(result)
+
+ expect(result.current.step).toBe(3)
+
+ act(() => {
+ result.current.resetWizard()
+ })
+
+ expect(result.current.maxActiveStep).toBe(2)
+ expect(result.current.step).toBe(2)
+ })
+
+ it('should not reset maxActiveStep if enableAllSteps is passed as true', () => {
+ const { result } = initializeWizard(3, 2, true)
+
+ expect(result.current.maxActiveStep).toBe(3)
+
+ nextStep(result)
+
+ expect(result.current.step).toBe(3)
+
+ act(() => {
+ result.current.resetWizard()
+ })
+
+ expect(result.current.maxActiveStep).toBe(3)
+ expect(result.current.step).toBe(2)
+ })
+ })
+
+ describe('goToStep', () => {
+ it('should set the active step to the step that is passed in', () => {
+ const { result } = initializeWizard(3)
+
+ nextStep(result)
+ nextStep(result)
+
+ expect(result.current.step).toBe(3)
+
+ act(() => {
+ result.current.goToStep(2)
+ })
+
+ expect(result.current.step).toBe(2)
+ })
+
+ it('should throw an error if the step has never been visited', () => {
+ const { result } = initializeWizard(3)
+
+ nextStep(result)
+
+ expect(result.current.step).toBe(2)
+
+ const advanceStep = () => {
+ act(() => {
+ result.current.goToStep(3)
+ })
+ }
+
+ expect(advanceStep).toThrowError(
+ 'Step must be less than or equal to maxActiveStep'
+ )
+ })
+ })
+
+ describe('storeData', () => {
+ it('should provide a data store that persists until the wizard is unmounted', () => {
+ const { result } = initializeWizard()
+
+ const mockData = { foo: 'bar' }
+
+ act(() => {
+ result.current.storeData(mockData)
+ })
+
+ expect(result.current.data).toMatchObject(mockData)
+ })
+ })
+})
diff --git a/src/components/Wizard/index.tsx b/src/components/Wizard/index.tsx
new file mode 100644
index 00000000..1bb34f0d
--- /dev/null
+++ b/src/components/Wizard/index.tsx
@@ -0,0 +1,35 @@
+import { useWizardCmp } from './utils'
+import React, { FC, ReactNode } from 'react'
+import { useWizard, WizardCtx } from './WizardContext'
+
+export interface Step {
+ render: () => ReactNode
+ title?: string
+}
+
+export interface WizardProps {
+ defaultActiveStep?: number
+ enableAllSteps?: boolean
+ steps: Step[]
+}
+
+const Wizard: FC = ({
+ defaultActiveStep,
+ enableAllSteps = false,
+ steps
+}: WizardProps) => {
+ const { step, ...rest } = useWizardCmp(
+ steps.length,
+ defaultActiveStep,
+ enableAllSteps
+ )
+
+ return (
+
+ {/* Steps array is index-based — if step is 1, steps[0] returns the correct step */}
+ {steps[step - 1].render()}
+
+ )
+}
+
+export { useWizard, Wizard }
diff --git a/src/components/Wizard/utils.ts b/src/components/Wizard/utils.ts
new file mode 100644
index 00000000..49401af9
--- /dev/null
+++ b/src/components/Wizard/utils.ts
@@ -0,0 +1,83 @@
+import { useCallback, useState } from 'react'
+
+export const useWizardCmp = (
+ totalSteps: number,
+ defaultActiveStep = 1,
+ enableAllSteps: boolean
+) => {
+ const [activeStep, setActiveStep] = useState(defaultActiveStep)
+ const [maxActiveStep, setMaxActiveStep] = useState(
+ enableAllSteps ? totalSteps : defaultActiveStep
+ )
+ const [data, setData] = useState({})
+
+ const goToStep = useCallback(
+ (step: number) => {
+ if (step > maxActiveStep) {
+ throw new Error(
+ `Step must be less than or equal to maxActiveStep. Step: ${step}, MaxActiveStep: ${maxActiveStep}`
+ )
+ }
+
+ setActiveStep(step)
+ },
+ [maxActiveStep]
+ )
+
+ const nextStep = useCallback(() => {
+ const nextStep = activeStep + 1
+
+ if (nextStep > totalSteps) {
+ throw new Error(
+ 'Invalid use of nextStep. Current step is the final step'
+ )
+ }
+
+ if (nextStep > maxActiveStep) {
+ setMaxActiveStep(nextStep)
+ }
+
+ setActiveStep(nextStep)
+ }, [activeStep, maxActiveStep, totalSteps])
+
+ const prevStep = useCallback(() => {
+ if (activeStep === 1) {
+ throw new Error(
+ 'Invalid use of prevStep. Current step is the first step'
+ )
+ }
+
+ setActiveStep(activeStep - 1)
+ }, [activeStep])
+
+ const resetData = useCallback(() => setData({}), [])
+
+ const resetWizard = useCallback(
+ (step: number = defaultActiveStep) => {
+ resetData()
+ setActiveStep(step)
+
+ if (!enableAllSteps) setMaxActiveStep(step)
+ },
+ [defaultActiveStep, enableAllSteps, resetData]
+ )
+
+ const storeData = useCallback(
+ (dataToStore: Record) => {
+ setData({ ...data, ...dataToStore })
+ },
+ [data]
+ )
+
+ return {
+ data,
+ goToStep,
+ maxActiveStep,
+ nextStep,
+ prevStep,
+ resetData,
+ resetWizard,
+ step: activeStep,
+ storeData
+ }
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index a2d323fc..26056f88 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -5,7 +5,9 @@ export * from './ColoredDot'
export * from './Form'
export * from './Input'
export * from './Icon'
+export * from './IconButton'
export * from './Link'
+export * from './Modal'
export * from './Notification'
export * from './NotificationV2'
export * from './Popover'
@@ -18,3 +20,4 @@ export * from './Toggle'
export * from './Tooltip'
export * from './Tree'
export * from './types'
+export * from './Wizard'
diff --git a/src/components/utils.test.ts b/src/components/utils.test.ts
index fdaae9d1..62241514 100644
--- a/src/components/utils.test.ts
+++ b/src/components/utils.test.ts
@@ -1,13 +1,19 @@
+import { renderHook } from '@testing-library/react-hooks'
import {
ColorManipulationTypes,
generatePopupSelector,
getDataTestAttributeProp,
manipulateColor,
- TAG
+ TAG,
+ useCreateDomElement
} from './utils'
const mockCmpName = 'input'
const mockDataTag = 'foo'
+const MOCK_DIV_ID = 'mockDivId'
+
+jest.spyOn(document.body, 'appendChild')
+jest.spyOn(document.body, 'removeChild')
describe('getDataTestAttributeProp', () => {
it('should return a data attribute with the component name by default', () => {
@@ -85,3 +91,52 @@ describe('Color utils', () => {
})
})
})
+
+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(MOCK_DIV_ID))
+
+ expect(document.body.appendChild).toHaveBeenCalled()
+
+ unmount()
+
+ expect(document.body.removeChild).toHaveBeenCalled()
+ })
+
+ it('should append the div 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(
+ MOCK_DIV_ID,
+ generatePopupSelector(`#${popupContainerId}`)
+ )
+ )
+
+ expect(
+ document.querySelector('#popup-container')?.firstElementChild?.id
+ ).toBe(MOCK_DIV_ID)
+ })
+
+ it('should default the root to document.body if getPopupContainer returns null', () => {
+ const { unmount } = renderHook(() =>
+ useCreateDomElement(
+ MOCK_DIV_ID,
+ generatePopupSelector('#non-existent-selector')
+ )
+ )
+
+ expect(document.body.firstElementChild?.id).toBe(MOCK_DIV_ID)
+
+ unmount()
+ })
+})
diff --git a/src/components/utils.ts b/src/components/utils.ts
index e33adedd..1b9d4034 100644
--- a/src/components/utils.ts
+++ b/src/components/utils.ts
@@ -1,6 +1,7 @@
import Color from 'color'
import mapValues from 'lodash/mapValues'
import { TooltipPlacement } from 'antd/es/tooltip'
+import { useEffect, useState } from 'react'
export const TAG = 'data-test'
@@ -90,3 +91,28 @@ export const manipulateColor = (
}
}
}
+
+// 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 = (
+ divId: string,
+ getPopupContainer: () => HTMLElement = () => document.body
+) => {
+ const [domElement, setDomElement] = useState(null)
+
+ const root = getPopupContainer() || document.body
+
+ useEffect(() => {
+ const element = document.createElement('div')
+ element.setAttribute('id', divId)
+
+ root.appendChild(element)
+ setDomElement(element)
+
+ return () => {
+ root.removeChild(element)
+ }
+ }, [divId, root])
+
+ return domElement
+}