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 +}