diff --git a/packages/react-core/src/next/components/Wizard/Wizard.tsx b/packages/react-core/src/next/components/Wizard/Wizard.tsx index ceae05460d3..8034ab498bb 100644 --- a/packages/react-core/src/next/components/Wizard/Wizard.tsx +++ b/packages/react-core/src/next/components/Wizard/Wizard.tsx @@ -1,19 +1,22 @@ import React from 'react'; +import findLastIndex from 'lodash/findLastIndex'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; import { - DefaultWizardFooterProps, - DefaultWizardNavProps, isWizardParentStep, WizardNavStepFunction, - CustomWizardNavFunction + WizardControlStep, + isCustomWizardNav, + WizardFooterType, + WizardNavType } from './types'; import { buildSteps, normalizeNavStep } from './utils'; -import { WizardContextProvider } from './WizardContext'; +import { useWizardContext, WizardContextProvider } from './WizardContext'; import { WizardStepProps } from './WizardStep'; -import { WizardToggleInternal } from './WizardToggle'; +import { WizardToggle } from './WizardToggle'; +import { WizardNavInternal } from './WizardNavInternal'; /** * Wrapper for all steps and hosts state, including navigation helpers, within context. @@ -26,9 +29,9 @@ export interface WizardProps extends React.HTMLProps { /** Wizard header */ header?: React.ReactNode; /** Wizard footer */ - footer?: DefaultWizardFooterProps | React.ReactElement; - /** Wizard nav */ - nav?: DefaultWizardNavProps | CustomWizardNavFunction; + footer?: WizardFooterType; + /** Wizard navigation */ + nav?: WizardNavType; /** The initial index the wizard is to start on (1 or higher). Defaults to 1. */ startIndex?: number; /** Additional classes spread to the wizard */ @@ -37,9 +40,11 @@ export interface WizardProps extends React.HTMLProps { width?: number | string; /** Custom height of the wizard */ height?: number | string; + /** Disables navigation items that haven't been visited. Defaults to false */ + isStepVisitRequired?: boolean; /** Flag to unmount inactive steps instead of hiding. Defaults to true */ - unmountInactiveSteps?: boolean; - /** Callback function when a step in the nav is clicked */ + hasUnmountedSteps?: boolean; + /** Callback function when a step in the navigation is clicked */ onNavByIndex?: WizardNavStepFunction; /** Callback function after next button is clicked */ onNext?: WizardNavStepFunction; @@ -52,7 +57,6 @@ export interface WizardProps extends React.HTMLProps { } export const Wizard = ({ - startIndex = 1, children, footer, height, @@ -60,7 +64,9 @@ export const Wizard = ({ className, header, nav, - unmountInactiveSteps, + startIndex = 1, + isStepVisitRequired = false, + hasUnmountedSteps = true, onNavByIndex, onNext, onBack, @@ -69,83 +75,91 @@ export const Wizard = ({ ...wrapperProps }: WizardProps) => { const [currentStepIndex, setCurrentStepIndex] = React.useState(startIndex); - const steps = buildSteps(children); + const initialSteps = buildSteps(children); - const goToStepByIndex = (index: number) => { - const lastStepIndex = steps.length; + const goToNextStep = (steps: WizardControlStep[] = initialSteps) => { + const newStepIndex = + steps.findIndex((step, index) => index + 1 > currentStepIndex && !step.isHidden && !isWizardParentStep(step)) + 1; - if (index < 1) { - index = 1; - } else if (index > lastStepIndex) { - index = lastStepIndex; + if (currentStepIndex >= steps.length || !newStepIndex) { + return onSave ? onSave() : onClose?.(); } - const currStep = steps[index - 1]; + const currStep = isWizardParentStep(steps[currentStepIndex]) + ? steps[currentStepIndex + 1] + : steps[currentStepIndex]; const prevStep = steps[currentStepIndex - 1]; - setCurrentStepIndex(index); - return onNavByIndex?.(normalizeNavStep(currStep), normalizeNavStep(prevStep)); + setCurrentStepIndex(newStepIndex); + + return onNext?.(normalizeNavStep(currStep, steps), normalizeNavStep(prevStep, steps)); }; - const goToNextStep = () => { - // Save when on the last step, otherwise close - if (currentStepIndex >= steps.length) { - if (onSave) { - return onSave(); - } + const goToPrevStep = (steps: WizardControlStep[] = initialSteps) => { + const newStepIndex = + findLastIndex( + steps, + (step: WizardControlStep, index: number) => + index + 1 < currentStepIndex && !step.isHidden && !isWizardParentStep(step) + ) + 1; + const currStep = isWizardParentStep(steps[currentStepIndex - 2]) + ? steps[currentStepIndex - 3] + : steps[currentStepIndex - 2]; + const prevStep = steps[currentStepIndex - 1]; - return onClose?.(); - } + setCurrentStepIndex(newStepIndex); - let currStep = steps[currentStepIndex]; - let newStepIndex = currentStepIndex + 1; - const prevStep = steps[currentStepIndex - 1]; + return onBack?.(normalizeNavStep(currStep, steps), normalizeNavStep(prevStep, steps)); + }; + + const goToStepByIndex = (steps: WizardControlStep[] = initialSteps, index: number) => { + const lastStepIndex = steps.length + 1; - // Skip parent step and focus on the first sub-step if they exist - if (isWizardParentStep(currStep)) { - newStepIndex += 1; - currStep = steps[currentStepIndex + 1]; + // Handle index when out of bounds or hidden + if (index < 1) { + index = 1; + } else if (index > lastStepIndex) { + index = lastStepIndex; + } else if (steps[index - 1].isHidden) { + // eslint-disable-next-line no-console + console.error('Wizard: Unable to navigate to hidden step.'); } - setCurrentStepIndex(newStepIndex); - return onNext?.(normalizeNavStep(currStep), normalizeNavStep(prevStep)); + const currStep = steps[index - 1]; + const prevStep = steps[currentStepIndex - 1]; + setCurrentStepIndex(index); + + return onNavByIndex?.(normalizeNavStep(currStep, steps), normalizeNavStep(prevStep, steps)); }; - const goToPrevStep = () => { - if (steps.length < currentStepIndex) { - // Previous step was removed, just update the currentStep state - setCurrentStepIndex(steps.length); + const goToStepById = (steps: WizardControlStep[] = initialSteps, id: number | string) => { + const stepIndex = steps.findIndex(step => step.id === id) + 1; + + if (stepIndex > 0 && stepIndex < steps.length + 1 && !steps[stepIndex].isHidden) { + setCurrentStepIndex(stepIndex); } else { - let currStep = steps[currentStepIndex - 2]; - let newStepIndex = currentStepIndex - 1; - const prevStep = steps[currentStepIndex - 1]; - - // // Skip parent step and focus on the step prior - if (isWizardParentStep(currStep)) { - newStepIndex -= 1; - currStep = steps[currentStepIndex - 3]; - } - - setCurrentStepIndex(newStepIndex); - return onBack?.(normalizeNavStep(currStep), normalizeNavStep(prevStep)); + // eslint-disable-next-line no-console + console.error(`Wizard: Unable to navigate to step with id: ${id}.`); } }; - const goToStepById = (id: number | string) => { - const stepIndex = steps.findIndex(step => step.id === id) + 1; - stepIndex > 0 && setCurrentStepIndex(stepIndex); - }; + const goToStepByName = (steps: WizardControlStep[] = initialSteps, name: string) => { + const stepIndex = initialSteps.findIndex(step => step.name === name) + 1; - const goToStepByName = (name: string) => { - const stepIndex = steps.findIndex(step => step.name === name) + 1; - stepIndex > 0 && setCurrentStepIndex(stepIndex); + if (stepIndex > 0 && stepIndex < steps.length + 1 && !steps[stepIndex].isHidden) { + setCurrentStepIndex(stepIndex); + } else { + // eslint-disable-next-line no-console + console.error(`Wizard: Unable to navigate to step with name: ${name}.`); + } }; return ( {header} - + ); }; +const WizardInternal = ({ + nav, + hasUnmountedSteps, + isStepVisitRequired +}: Pick) => { + const { currentStep, steps, footer, goToStepByIndex } = useWizardContext(); + const [isNavExpanded, setIsNavExpanded] = React.useState(false); + + const wizardNav = React.useMemo(() => { + if (isCustomWizardNav(nav)) { + return typeof nav === 'function' ? nav(isNavExpanded, steps, currentStep, goToStepByIndex) : nav; + } + + return ; + }, [currentStep, isStepVisitRequired, goToStepByIndex, isNavExpanded, nav, steps]); + + return ( + setIsNavExpanded(prevIsExpanded => !prevIsExpanded)} + hasUnmountedSteps={hasUnmountedSteps} + /> + ); +}; + Wizard.displayName = 'Wizard'; diff --git a/packages/react-core/src/next/components/Wizard/WizardBody.tsx b/packages/react-core/src/next/components/Wizard/WizardBody.tsx index c9d4e3c40bb..872851d1c46 100644 --- a/packages/react-core/src/next/components/Wizard/WizardBody.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardBody.tsx @@ -16,7 +16,7 @@ export interface WizardBodyProps { /** Sets the aria-labelledby attribute for the wrapper element */ 'aria-labelledby'?: string; /** Component used as the wrapping content container */ - wrapperElement?: React.ElementType; + component?: React.ElementType; } export const WizardBody = ({ @@ -24,11 +24,11 @@ export const WizardBody = ({ hasNoBodyPadding = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, - wrapperElement: Wrapper = 'div' + component: WrapperComponent = 'div' }: WizardBodyProps) => ( - +
{children}
-
+ ); WizardBody.displayName = 'WizardBody'; diff --git a/packages/react-core/src/next/components/Wizard/WizardContext.tsx b/packages/react-core/src/next/components/Wizard/WizardContext.tsx index d9a934203de..8e9c25de173 100644 --- a/packages/react-core/src/next/components/Wizard/WizardContext.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardContext.tsx @@ -1,17 +1,16 @@ import React from 'react'; -import { css } from '@patternfly/react-styles'; -import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; - -import { DefaultWizardFooterProps, isCustomWizardFooter, WizardControlStep } from './types'; -import { getActiveStep } from './utils'; -import { WizardFooter } from './WizardFooter'; +import { isCustomWizardFooter, isWizardParentStep, WizardControlStep, WizardFooterType } from './types'; +import { getCurrentStep } from './utils'; +import { WizardFooter, WizardFooterProps } from './WizardFooter'; export interface WizardContextProps { /** List of steps */ steps: WizardControlStep[]; - /** Active step */ - activeStep: WizardControlStep; + /** Current step */ + currentStep: WizardControlStep; + /** Current step index */ + currentStepIndex: number; /** Footer element */ footer: React.ReactElement; /** Navigate to the next step */ @@ -27,38 +26,36 @@ export interface WizardContextProps { /** Navigate to step by index */ goToStepByIndex: (index: number) => void; /** Update the footer with any react element */ - setFooter: (footer: DefaultWizardFooterProps | React.ReactElement) => void; + setFooter: (footer: React.ReactElement | Partial) => void; + /** Get step by ID */ + getStep: (stepId: number | string) => WizardControlStep; + /** Set step by ID */ + setStep: (step: Pick & Partial) => void; + /** Toggle step visibility by ID */ + toggleStep: (stepId: number | string, isHidden: boolean) => void; } export const WizardContext = React.createContext({} as WizardContextProps); -interface WizardContextRenderProps { - steps: WizardControlStep[]; - activeStep: WizardControlStep; - footer: React.ReactElement; - onNext(): void; - onBack(): void; - onClose(): void; -} - export interface WizardContextProviderProps { steps: WizardControlStep[]; currentStepIndex: number; - footer: DefaultWizardFooterProps | React.ReactElement; - children: React.ReactElement | ((props: WizardContextRenderProps) => React.ReactElement); - onNext(): void; - onBack(): void; + footer: WizardFooterType; + isStepVisitRequired: boolean; + children: React.ReactElement; + onNext(steps: WizardControlStep[]): void; + onBack(steps: WizardControlStep[]): void; onClose(): void; - goToStepById(id: number | string): void; - goToStepByName(name: string): void; - goToStepByIndex(index: number): void; + goToStepById(steps: WizardControlStep[], id: number | string): void; + goToStepByName(steps: WizardControlStep[], name: string): void; + goToStepByIndex(steps: WizardControlStep[], index: number): void; } -// eslint-disable-next-line patternfly-react/no-anonymous-functions export const WizardContextProvider: React.FunctionComponent = ({ steps: initialSteps, footer: initialFooter, currentStepIndex, + isStepVisitRequired, children, onNext, onBack, @@ -68,61 +65,116 @@ export const WizardContextProvider: React.FunctionComponent { const [steps, setSteps] = React.useState(initialSteps); - const [footer, setFooter] = React.useState(initialFooter); - const activeStep = getActiveStep(steps, currentStepIndex); - - const wizardFooter = React.useMemo( - () => - isCustomWizardFooter(footer) ? ( -
{footer}
- ) : ( - - ), - [activeStep, footer, onBack, onClose, onNext, steps] + const [currentFooter, setCurrentFooter] = React.useState( + typeof initialFooter !== 'function' ? initialFooter : undefined ); + const currentStep = getCurrentStep(steps, currentStepIndex); + + const goToNextStep = React.useCallback(() => onNext(steps), [onNext, steps]); + const goToPrevStep = React.useCallback(() => onBack(steps), [onBack, steps]); + + const footer = React.useMemo(() => { + const wizardFooter = currentFooter || initialFooter; + + if (isCustomWizardFooter(wizardFooter)) { + const customFooter = wizardFooter; + + return typeof customFooter === 'function' + ? customFooter(currentStep, goToNextStep, goToPrevStep, onClose) + : customFooter; + } + + return ( + + ); + }, [currentFooter, initialFooter, currentStep, goToNextStep, goToPrevStep, onClose, steps]); - // When the active step changes and the newly active step isn't visited, set the visited flag to true. - React.useEffect(() => { - if (activeStep && !activeStep?.visited) { + const getStep = React.useCallback((stepId: string | number) => steps.find(step => step.id === stepId), [steps]); + + const setStep = React.useCallback( + (step: Pick & Partial) => setSteps(prevSteps => - prevSteps.map(step => { - if (step.id === activeStep.id) { - return { ...step, visited: true }; + prevSteps.map(prevStep => { + if (prevStep.id === step.id) { + return { ...prevStep, ...step }; } - return step; + return prevStep; }) - ); - } - }, [activeStep]); + ), + [] + ); + + const toggleStep = React.useCallback( + (stepId: string | number, isHidden: boolean) => + setSteps(prevSteps => { + let stepToHide: WizardControlStep; + + return prevSteps.map(prevStep => { + if (prevStep.id === stepId) { + // Don't hide the currently active step or its parent (if a sub-step). + if ( + isHidden && + (currentStep.id === prevStep.id || + (isWizardParentStep(prevStep) && prevStep.subStepIds.includes(currentStep.id))) + ) { + // eslint-disable-next-line no-console + console.error('Wizard: Unable to hide the current step or its parent.'); + return prevStep; + } + + stepToHide = { ...prevStep, isHidden }; + return stepToHide; + } + + // When isStepVisitRequired is enabled, if the step was previously hidden and not visited yet, + // when it is shown, all steps beyond it should be disabled to ensure it is visited. + if ( + isStepVisitRequired && + stepToHide?.isHidden === false && + !stepToHide?.isVisited && + prevSteps.indexOf(stepToHide) < prevSteps.indexOf(prevStep) + ) { + return { ...prevStep, isVisited: false }; + } + + return prevStep; + }); + }), + [currentStep.id, isStepVisitRequired] + ); return ( goToStepById(steps, id), [goToStepById, steps]), + goToStepByName: React.useCallback(name => goToStepByName(steps, name), [goToStepByName, steps]), + goToStepByIndex: React.useCallback(index => goToStepByIndex(steps, index), [goToStepByIndex, steps]) }} > - {typeof children === 'function' - ? children({ activeStep, steps, footer: wizardFooter, onNext, onBack, onClose }) - : children} + {children} ); }; +WizardContextProvider.displayName = 'WizardContextProvider'; + export const useWizardContext = () => React.useContext(WizardContext); diff --git a/packages/react-core/src/next/components/Wizard/WizardFooter.tsx b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx index e99371969d2..bab9cb79c43 100644 --- a/packages/react-core/src/next/components/Wizard/WizardFooter.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx @@ -4,60 +4,78 @@ import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; import { Button, ButtonVariant } from '../../../components/Button'; -import { WizardControlStep, WizardNavStepFunction } from './types'; +import { isCustomWizardFooter, WizardControlStep, WizardNavStepFunction } from './types'; /** * Hosts the standard structure of a footer with ties to the active step so that text for buttons can vary from step to step. */ export interface WizardFooterProps { - /** The currently active WizardStep */ - activeStep: WizardControlStep; + /** The current step */ + currentStep: WizardControlStep; /** Next button callback */ onNext: () => WizardNavStepFunction | void; /** Back button callback */ onBack: () => WizardNavStepFunction | void; /** Cancel link callback */ onClose: () => void; - /** Custom text for the Next button. The activeStep's nextButtonText takes precedence. */ + /** Custom text for the Next button. The current step's nextButtonText takes precedence. */ nextButtonText?: React.ReactNode; /** Custom text for the Back button */ backButtonText?: React.ReactNode; /** Custom text for the Cancel link */ cancelButtonText?: React.ReactNode; - /** Optional flag to disable the first step's back button */ - disableBackButton?: boolean; + /** Flag to disable the next button */ + isNextDisabled?: boolean; + /** Flag to disable the back button */ + isBackDisabled?: boolean; + /** True to hide the Back button */ + isBackHidden?: boolean; + /** True to hide the Cancel button */ + isCancelHidden?: boolean; } -export const WizardFooter = ({ +export const WizardFooterWrapper: React.FunctionComponent = ({ children }) => ( +
{children}
+); + +export const WizardFooter = ({ currentStep, ...internalProps }: WizardFooterProps) => { + const currentStepFooter = !isCustomWizardFooter(currentStep.footer) && currentStep.footer; + return ; +}; + +const InternalWizardFooter = ({ onNext, onBack, onClose, - activeStep, - disableBackButton, + isNextDisabled, + isBackDisabled, + isBackHidden, + isCancelHidden, nextButtonText = 'Next', backButtonText = 'Back', cancelButtonText = 'Cancel' -}: WizardFooterProps) => ( -
- - {!activeStep?.hideBackButton && ( - )} - {!activeStep?.hideCancelButton && ( + {!isCancelHidden && (
)} -
+ ); +WizardFooterWrapper.displayName = 'WizardFooterWrapper'; WizardFooter.displayName = 'WizardFooter'; diff --git a/packages/react-core/src/next/components/Wizard/WizardHeader.tsx b/packages/react-core/src/next/components/Wizard/WizardHeader.tsx index a41307fcb4f..c459efc2b53 100644 --- a/packages/react-core/src/next/components/Wizard/WizardHeader.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardHeader.tsx @@ -15,7 +15,7 @@ export interface WizardHeaderProps { /** Component type of the description */ descriptionComponent?: 'div' | 'p'; /** Flag indicating whether the close button should be in the header */ - hideClose?: boolean; + isCloseHidden?: boolean; /** Aria-label applied to the X (Close) button */ closeButtonAriaLabel?: string; /** id for the title */ @@ -28,21 +28,23 @@ export const WizardHeader: React.FunctionComponent = ({ onClose = () => undefined, title, description, - hideClose, + isCloseHidden, closeButtonAriaLabel, titleId, descriptionComponent: Component = 'p', descriptionId }: WizardHeaderProps) => (
- {!hideClose && ( + {!isCloseHidden && ( )} + {title || <> </>} + {description && ( {description} @@ -50,4 +52,5 @@ export const WizardHeader: React.FunctionComponent = ({ )}
); + WizardHeader.displayName = 'WizardHeader'; diff --git a/packages/react-core/src/next/components/Wizard/WizardNav.tsx b/packages/react-core/src/next/components/Wizard/WizardNav.tsx index 76e8f6cd58b..a1b31df2f81 100644 --- a/packages/react-core/src/next/components/Wizard/WizardNav.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardNav.tsx @@ -5,14 +5,14 @@ import { css } from '@patternfly/react-styles'; export interface WizardNavProps { /** children should be WizardNavItem components */ children?: any; - /** Aria-label applied to the nav element */ + /** Aria-label applied to the navigation element */ 'aria-label'?: string; - /** Sets the aria-labelledby attribute on the nav element */ + /** Sets the aria-labelledby attribute on the navigation element */ 'aria-labelledby'?: string; - /** Whether the nav is expanded */ + /** Whether the navigation is expanded */ isExpanded?: boolean; - /** True to return the inner list without the wrapping nav element */ - returnList?: boolean; + /** True to return the inner list without the wrapping navigation element */ + isInnerList?: boolean; } export const WizardNav: React.FunctionComponent = ({ @@ -20,12 +20,10 @@ export const WizardNav: React.FunctionComponent = ({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, isExpanded = false, - returnList = false + isInnerList = false }: WizardNavProps) => { - const innerList =
    {children}
; - - if (returnList) { - return innerList; + if (isInnerList) { + return
    {children}
; } return ( @@ -38,4 +36,5 @@ export const WizardNav: React.FunctionComponent = ({ ); }; + WizardNav.displayName = 'WizardNav'; diff --git a/packages/react-core/src/next/components/Wizard/WizardNavInternal.tsx b/packages/react-core/src/next/components/Wizard/WizardNavInternal.tsx new file mode 100644 index 00000000000..10085b03179 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardNavInternal.tsx @@ -0,0 +1,137 @@ +import React from 'react'; + +import { isCustomWizardNavItem, isWizardBasicStep, isWizardParentStep } from './types'; +import { WizardProps } from './Wizard'; +import { useWizardContext } from './WizardContext'; +import { WizardNav, WizardNavProps } from './WizardNav'; +import { WizardNavItem } from './WizardNavItem'; + +/** + * Hosts deafult wizard navigation logic by utilizing the wizard's context and WizardNav/WizardNavItem. + * This component is not exposed to consumers. + */ + +interface WizardNavInternalProps extends Pick { + nav: Partial; + isNavExpanded: boolean; +} + +export const WizardNavInternal = ({ nav, isStepVisitRequired, isNavExpanded }: WizardNavInternalProps) => { + const { currentStep, steps, goToStepByIndex } = useWizardContext(); + + const wizardNavProps: WizardNavProps = { + isExpanded: isNavExpanded, + 'aria-label': nav?.['aria-label'] || 'Wizard steps', + ...(nav?.['aria-labelledby'] && { + 'aria-labelledby': nav['aria-labelledby'] + }) + }; + + return ( + + {steps.map((step, index) => { + const stepIndex = index + 1; + const customStepNavItem = isCustomWizardNavItem(step.navItem) && ( + + {typeof step.navItem === 'function' + ? step.navItem(step, currentStep, steps, goToStepByIndex) + : step.navItem} + + ); + + if (isWizardParentStep(step) && !step.isHidden) { + let firstSubStepIndex; + let hasActiveChild = false; + + const subNavItems = step.subStepIds?.map((subStepId, subStepIndex) => { + const subStep = steps.find(step => step.id === subStepId); + const subStepOrderIndex = steps.indexOf(subStep) + 1; + const customSubStepNavItem = isCustomWizardNavItem(subStep.navItem) && ( + + {typeof subStep.navItem === 'function' + ? subStep.navItem(subStep, currentStep, steps, goToStepByIndex) + : subStep.navItem} + + ); + + // Don't render the sub-step navigation item if the hidden property is enabled + if (subStep.isHidden) { + return; + } + + // Store the first sub-step index so that when its parent is clicked, the first sub-step is focused + if (subStepIndex === 0) { + firstSubStepIndex = subStepOrderIndex; + } + + // When a sub-step is active, use this flag to set the parent step as active (isCurrent) + if (currentStep?.id === subStep.id) { + hasActiveChild = true; + } + + return ( + customSubStepNavItem || ( + + ) + ); + }); + const hasEnabledChildren = React.Children.toArray(subNavItems).some( + child => React.isValidElement(child) && !child.props.isDisabled + ); + + return ( + customStepNavItem || ( + + + {subNavItems} + + + ) + ); + } + + if (isWizardBasicStep(step) && !step.isHidden) { + return ( + customStepNavItem || ( + + ) + ); + } + })} + + ); +}; diff --git a/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx b/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx index 5d6648bced6..499c3e3f9b5 100644 --- a/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx @@ -1,29 +1,37 @@ import * as React from 'react'; + import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; +import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; + +import { WizardNavItemStatus } from './types'; export interface WizardNavItemProps { /** Can nest a WizardNav component for substeps */ children?: React.ReactNode; - /** The content to display in the nav item */ + /** The content to display in the navigation item */ content?: React.ReactNode; - /** Whether the nav item is the currently active item */ + /** Whether the navigation item is the currently active item */ isCurrent?: boolean; - /** Whether the nav item is disabled */ + /** Whether the navigation item is disabled */ isDisabled?: boolean; - /** The step passed into the onNavItemClick callback */ - step: number; - /** Callback for when the nav item is clicked */ - onNavItemClick?: (step: number) => any; + /** Whether the navigation item has been visited */ + isVisited?: boolean; + /** The step index passed into the onNavItemClick callback */ + stepIndex: number; + /** Callback for when the navigation item is clicked */ + onNavItemClick?: (stepIndex: number) => any; /** Component used to render WizardNavItem */ navItemComponent?: 'button' | 'a'; /** An optional url to use for when using an anchor component */ href?: string; /** Flag indicating that this NavItem has child steps and is expandable */ isExpandable?: boolean; - /** The id for the nav item */ + /** The id for the navigation item */ id?: string | number; + /** Used to determine the icon displayed next to content. Default has no icon. */ + status?: 'default' | 'error'; } export const WizardNavItem: React.FunctionComponent = ({ @@ -31,16 +39,17 @@ export const WizardNavItem: React.FunctionComponent = ({ content = '', isCurrent = false, isDisabled = false, - step, + isVisited = false, + stepIndex, onNavItemClick = () => undefined, navItemComponent = 'button', href = null, isExpandable = false, id, + status = 'default', ...rest }: WizardNavItemProps) => { const NavItemComponent = navItemComponent; - const [isExpanded, setIsExpanded] = React.useState(false); React.useEffect(() => { @@ -52,14 +61,22 @@ export const WizardNavItem: React.FunctionComponent = ({ console.error('WizardNavItem: When using an anchor, please provide an href'); } - const btnProps = { - disabled: isDisabled - }; + const ariaLabel = React.useMemo(() => { + if (status === WizardNavItemStatus.Error || (isVisited && !isCurrent)) { + let label = content.toString(); + + if (status === WizardNavItemStatus.Error) { + label += `, ${status}`; + } + + // No need to signify step is visited if current + if (isVisited && !isCurrent) { + label += ', visited'; + } - const linkProps = { - tabIndex: isDisabled ? -1 : undefined, - href - }; + return label; + } + }, [content, isCurrent, isVisited, status]); return (
  • = ({ > { e.preventDefault(); - isExpandable ? setIsExpanded(!isExpanded || isCurrent) : onNavItemClick(step); + isExpandable ? setIsExpanded(!isExpanded || isCurrent) : onNavItemClick(stepIndex); }} className={css( styles.wizardNavLink, @@ -85,6 +102,7 @@ export const WizardNavItem: React.FunctionComponent = ({ aria-disabled={isDisabled ? true : null} aria-current={isCurrent && !children ? 'step' : false} {...(isExpandable && { 'aria-expanded': isExpanded })} + {...(ariaLabel && { 'aria-label': ariaLabel })} > {isExpandable ? ( <> @@ -96,11 +114,20 @@ export const WizardNavItem: React.FunctionComponent = ({ ) : ( - content + <> + {content} + {/* TODO, patternfly/patternfly#5142 */} + {status === WizardNavItemStatus.Error && ( + + + + )} + )} {children}
  • ); }; + WizardNavItem.displayName = 'WizardNavItem'; diff --git a/packages/react-core/src/next/components/Wizard/WizardStep.tsx b/packages/react-core/src/next/components/Wizard/WizardStep.tsx index 64b32cde34f..052cf31ccd3 100644 --- a/packages/react-core/src/next/components/Wizard/WizardStep.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardStep.tsx @@ -1,23 +1,68 @@ import React from 'react'; -import { WizardBasicStep } from './types'; +import { WizardNavItemType } from './types'; import { WizardBody, WizardBodyProps } from './WizardBody'; +import { useWizardFooter } from './hooks/useWizardFooter'; +import { useWizardContext } from './WizardContext'; +import { WizardFooterProps } from './WizardFooter'; /** - * Used as a passthrough of step properties for Wizard and all supporting child components. - * Also acts as a wrapper for content, with an optional inclusion of WizardBody. + * The primary child component for Wizard. Step props are used for the list of steps managed in context. */ -export interface WizardStepProps extends Omit { +export interface WizardStepProps { + /** Name of the step's navigation item */ + name: React.ReactNode; + /** Unique identifier */ + id: string | number; /** Optional for when the step is used as a parent to sub-steps */ children?: React.ReactNode; /** Props for WizardBody that wraps content by default. Can be set to null for exclusion of WizardBody. */ - body?: Omit | null; + body?: Omit, 'children'> | null; /** Optional list of sub-steps */ steps?: React.ReactElement[]; + /** Flag to disable the step's navigation item */ + isDisabled?: boolean; + /** Flag to represent whether the step has been visited (navigated to) */ + isVisited?: boolean; + /** Flag to determine whether the step is hidden */ + isHidden?: boolean; + /** Replaces the step's navigation item or its properties. */ + navItem?: WizardNavItemType; + /** Replaces the step's footer. The step's footer takes precedance over the wizard's footer. */ + footer?: React.ReactElement | Partial; + /** Used to determine icon next to the step's navigation item */ + status?: 'default' | 'error'; } -export const WizardStep = ({ body, children }: WizardStepProps) => - body || body === undefined ? {children} : <>{children}; +export const WizardStep = ({ children, body, id, footer, isHidden, isDisabled, navItem, status }: WizardStepProps) => { + const { currentStep, setStep, toggleStep } = useWizardContext(); + + useWizardFooter(footer, id); + + // Update the controlled step when a change is detected with select properties + React.useEffect(() => { + if (currentStep?.id === id && (isDisabled || navItem || status || !currentStep?.isVisited)) { + setStep({ + id, + ...(isDisabled && { isDisabled }), + ...(navItem && { navItem }), + ...(status && { status }), + // When the current step is active and isn't visited, set as isVisited + ...(!currentStep?.isVisited && { isVisited: true }) + }); + } + }, [id, setStep, currentStep?.id, isDisabled, navItem, status, currentStep?.isVisited]); + + // Toggle visibility when the isHidden flag updates. + // Wizard's hasUnmountedSteps prop must be set to false for visibility changes to take effect. + React.useEffect(() => { + if (isHidden !== undefined) { + toggleStep(id, isHidden); + } + }, [toggleStep, id, isHidden]); + + return body || body === undefined ? {children} : <>{children}; +}; WizardStep.displayName = 'WizardStep'; diff --git a/packages/react-core/src/next/components/Wizard/WizardToggle.tsx b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx index 69503a48333..341845e29b2 100644 --- a/packages/react-core/src/next/components/Wizard/WizardToggle.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx @@ -6,52 +6,48 @@ import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-i import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; import { KeyTypes } from '../../../helpers/constants'; -import { WizardNav, WizardNavItem, WizardNavProps } from '../Wizard'; -import { - WizardControlStep, - isWizardBasicStep, - isWizardParentStep, - isWizardSubStep, - isCustomWizardNav, - DefaultWizardNavProps, - CustomWizardNavFunction -} from './types'; -import { useWizardContext } from './WizardContext'; +import { WizardNavProps } from '../Wizard'; +import { WizardControlStep, isWizardSubStep } from './types'; /** - * Used to toggle between step content, including the body and footer. This is also where the nav and its expandability is controlled. + * Used to toggle between step content, including the body and footer. This is also where the navigation and its expandability is controlled. */ export interface WizardToggleProps { /** List of steps and/or sub-steps */ steps: WizardControlStep[]; - /** The currently active WizardStep */ - activeStep: WizardControlStep; + /** The current step */ + currentStep: WizardControlStep; /** Wizard footer */ footer: React.ReactElement; - /** Wizard nav */ + /** Wizard navigation */ nav: React.ReactElement; /** The expandable dropdown button's aria-label */ 'aria-label'?: string; /** Flag to unmount inactive steps instead of hiding. Defaults to true */ - unmountInactiveSteps?: boolean; - /** Flag to determine whether the dropdown nav is expanded */ + hasUnmountedSteps?: boolean; + /** Flag to determine whether the dropdown navigation is expanded */ isNavExpanded?: boolean; - /** Callback to expand or collapse the dropdown nav */ + /** Callback to expand or collapse the dropdown navigation */ toggleNavExpanded?: () => void; } export const WizardToggle = ({ steps, - activeStep, + currentStep, footer, nav, isNavExpanded, toggleNavExpanded, - unmountInactiveSteps = true, + hasUnmountedSteps = true, 'aria-label': ariaLabel = 'Wizard toggle' }: WizardToggleProps) => { - const isActiveSubStep = isWizardSubStep(activeStep); + const isActiveSubStep = isWizardSubStep(currentStep); + const nonSubSteps = steps.filter(step => !isWizardSubStep(step)); + const wizardToggleIndex = + nonSubSteps.indexOf( + isWizardSubStep(currentStep) ? steps.find(step => step.id === currentStep.parentId) : currentStep + ) + 1; const handleKeyClicks = React.useCallback( (event: KeyboardEvent): void => { @@ -62,7 +58,7 @@ export const WizardToggle = ({ [isNavExpanded, toggleNavExpanded] ); - // Open/close collapsable nav on keydown event + // Open/close collapsable navigation on keydown event React.useEffect(() => { const target = typeof document !== 'undefined' ? document.body : null; target?.addEventListener('keydown', handleKeyClicks, false); @@ -72,11 +68,11 @@ export const WizardToggle = ({ }; }, [handleKeyClicks]); - // Only render the active step when unmountInactiveSteps is true - const bodyContent = unmountInactiveSteps - ? activeStep?.component + // Only render the current step when hasUnmountedSteps is true + const bodyContent = hasUnmountedSteps + ? currentStep?.component : steps.map(step => { - if (activeStep?.name === step.name) { + if (currentStep?.name === step.name) { return step.component; } @@ -97,10 +93,10 @@ export const WizardToggle = ({ > - {activeStep?.name} + {wizardToggleIndex} {currentStep?.name} {isActiveSubStep && - {isActiveSubStep && {activeStep?.name}} + {isActiveSubStep && {currentStep?.name}} @@ -119,118 +115,4 @@ export const WizardToggle = ({ ); }; -interface WizardToggleInternalProps extends Pick { - /** Custom WizardNav or callback used to create a default WizardNav */ - nav?: DefaultWizardNavProps | CustomWizardNavFunction; -} - -export const WizardToggleInternal = ({ nav, unmountInactiveSteps }: WizardToggleInternalProps) => { - const [isNavExpanded, setIsNavExpanded] = React.useState(false); - const { activeStep, steps, footer, goToStepByIndex } = useWizardContext(); - - const wizardNav = React.useMemo(() => { - if (isCustomWizardNav(nav)) { - return nav(isNavExpanded, steps, activeStep, goToStepByIndex); - } - - const navProps = { - isExpanded: isNavExpanded, - 'aria-label': nav?.ariaLabel || 'Wizard nav', - ...(nav?.ariaLabelledBy && { 'aria-labelledby': nav?.ariaLabelledBy }) - }; - - return ( - - {steps.map((step, index) => { - const stepIndex = index + 1; - const stepNavItem = step.navItem && {step.navItem}; - - if (isWizardParentStep(step)) { - let firstSubStepIndex; - let hasActiveChild = false; - - const subNavItems = step.subStepIds?.map((subStepId, index) => { - const subStep = steps.find(step => step.id === subStepId); - const subStepIndex = steps.indexOf(subStep) + 1; - - if (index === 0) { - firstSubStepIndex = subStepIndex; - } - - if (activeStep?.id === subStep.id) { - hasActiveChild = true; - } - - return subStep.navItem ? ( - {subStep.navItem} - ) : ( - - ); - }); - - const hasEnabledChildren = React.Children.toArray(subNavItems).some( - child => React.isValidElement(child) && !child.props.isDisabled - ); - - return ( - stepNavItem || ( - - - {subNavItems} - - - ) - ); - } - - if (isWizardBasicStep(step)) { - return ( - stepNavItem || ( - - ) - ); - } - })} - - ); - }, [activeStep, goToStepByIndex, isNavExpanded, nav, steps]); - - return ( - setIsNavExpanded(prevIsExpanded => !prevIsExpanded)} - unmountInactiveSteps={unmountInactiveSteps} - /> - ); -}; - WizardToggle.displayName = 'WizardToggle'; diff --git a/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx index 1f23e98213e..8b7f5157af0 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx @@ -3,8 +3,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { DefaultWizardNavProps, DefaultWizardFooterProps } from '../types'; -import { Wizard, WizardStep } from '../'; +import { Wizard, WizardFooterProps, WizardStep, WizardNavProps } from '../'; test('renders step when child is of type WizardStep', () => { render( @@ -57,7 +56,7 @@ test('renders default footer without specifying the footer prop', () => { }); test('renders default footer with custom props', () => { - const footer: DefaultWizardFooterProps = { + const footer: Partial = { nextButtonText: <>Proceed with caution, backButtonText: 'Turn back!', cancelButtonText: 'Leave now!' @@ -96,15 +95,13 @@ test('renders default nav without specifying the nav prop', () => { }); test('renders default nav with custom props', () => { - const nav: DefaultWizardNavProps = { - isExpandable: true, - ariaLabel: 'Some nav label', - ariaLabelledBy: 'wizard-id', - forceStepVisit: true + const nav: Partial = { + 'aria-label': 'Some nav label', + 'aria-labelledby': 'wizard-id' }; render( - + ]} /> @@ -119,7 +116,7 @@ test('renders default nav with custom props', () => { }); test('renders custom nav', () => { - const customNav = ; + const customNav = () => ; render( @@ -130,7 +127,7 @@ test('renders custom nav', () => { expect(screen.getByRole('navigation')).toBeVisible(); }); -test('starts at the first step as the active one by default', () => { +test('starts with the first step as the current one by default', () => { render( @@ -301,11 +298,11 @@ test('unmounts inactive steps by default', async () => { expect(screen.getByText('Step 2 content')).toBeVisible(); }); -test('keeps inactive steps mounted when unmountInactiveSteps is enabled', async () => { +test('keeps inactive steps mounted when hasUnmountedSteps is enabled', async () => { const user = userEvent.setup(); render( - + Step 1 content diff --git a/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx index bbb7329a951..61b1cdfd1e7 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/WizardBody.test.tsx @@ -30,12 +30,12 @@ test('has aria-labelledby when one is specified', () => { expect(container.firstElementChild).toHaveAttribute('aria-labelledby', 'some-id'); }); -test('wrapper element is of type div when wrapperElement is not specified', () => { +test('wrapper element is of type div when component is not specified', () => { const { container } = render(content); expect(container.firstElementChild?.tagName).toEqual('DIV'); }); -test('renders with custom wrapperElement', () => { - const { container } = render(content); +test('renders with custom component', () => { + const { container } = render(content); expect(container.firstElementChild?.tagName).toEqual('MAIN'); }); diff --git a/packages/react-core/src/next/components/Wizard/__tests__/WizardFooter.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/WizardFooter.test.tsx index 988e91498bc..d5f9b819006 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/WizardFooter.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/WizardFooter.test.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { WizardFooter } from '../WizardFooter'; +import { WizardFooter, WizardFooterProps } from '../WizardFooter'; -const defaultProps = { - activeStep: { name: 'Step name', id: 'some-id' }, +const defaultProps: WizardFooterProps = { + currentStep: { name: 'Step name', id: 'some-id' }, onNext: jest.fn(), onBack: jest.fn(), onClose: jest.fn() @@ -65,27 +65,24 @@ test('can have custom button names', () => { expect(screen.getByRole('button', { name: 'Get out!' })).toBeVisible(); }); -test('has disabled back button when disableBackButton is enabled', () => { - render(); +test('has disabled back button when isBackDisabled is enabled', () => { + render(); expect(screen.getByRole('button', { name: 'Back' })).toHaveAttribute('disabled'); }); -test('has no back button when activeStep has hideBackButton enabled', () => { - render(); +test('uses currentStep footer properties when specified', () => { + render( + + ); expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); }); -test('has no cancel button when activeStep has hideCancelButton enabled', () => { - render(); - expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); -}); - -test(`uses activeStep's nextButtonText when specified instead of nextButtonText of WizardFooter`, () => { +test(`currentStep footer properties take precendence over WizardFooter's`, () => { render( ); expect(screen.queryByRole('button', { name: 'Footer next' })).toBeNull(); diff --git a/packages/react-core/src/next/components/Wizard/__tests__/WizardStep.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/WizardStep.test.tsx index b71bdeaab9d..c99eb78ac48 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/WizardStep.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/WizardStep.test.tsx @@ -1,6 +1,35 @@ import React from 'react'; + import { render, screen } from '@testing-library/react'; + import { WizardStep } from '../WizardStep'; +import * as useWizardFooter from '../hooks/useWizardFooter'; +import * as WizardContext from '../WizardContext'; +import { WizardStepProps } from '..'; + +const testStep: WizardStepProps = { + id: 'test-step', + name: 'Test step', + footer: <>Step footer +}; +const wizardContext: WizardContext.WizardContextProps = { + steps: [testStep], + currentStep: testStep, + currentStepIndex: 1, + footer: <>Wizard footer, + onNext: jest.fn(), + onBack: jest.fn(), + onClose: jest.fn(), + goToStepById: jest.fn(), + goToStepByName: jest.fn(), + goToStepByIndex: jest.fn(), + setFooter: jest.fn(), + getStep: jest.fn(), + setStep: jest.fn(), + toggleStep: jest.fn() +}; +const useWizardContextSpy = jest.spyOn(WizardContext, 'useWizardContext'); +const useWizardFooterSpy = jest.spyOn(useWizardFooter, 'useWizardFooter'); test('renders without children', () => { const { container } = render(); @@ -31,3 +60,74 @@ test('uses body props for WizardBody when passed', () => { expect(screen.getByLabelText('Some label')).toBeVisible(); }); + +test('calls useWizardFooter', () => { + useWizardContextSpy.mockReturnValueOnce(wizardContext); + + render(content); + expect(useWizardFooterSpy).toHaveBeenCalledWith(testStep.footer, 'test-step'); +}); + +test('updates "isDisabled" in context when the value changes', () => { + const setStep = jest.fn(); + useWizardContextSpy.mockReturnValueOnce({ ...wizardContext, setStep }); + + render(); + + expect(setStep).toHaveBeenCalledWith({ id: testStep.id, isDisabled: true, isVisited: true }); +}); + +test('updates "navItem" in context when the value changes', () => { + const setStep = jest.fn(); + const testNavItem = <>Some nav item; + useWizardContextSpy.mockReturnValueOnce({ ...wizardContext, setStep }); + + render(); + + expect(setStep).toHaveBeenCalledWith({ id: testStep.id, navItem: testNavItem, isVisited: true }); +}); + +test('updates "status" in context when the value changes', () => { + const setStep = jest.fn(); + useWizardContextSpy.mockReturnValueOnce({ ...wizardContext, setStep }); + + render(); + + expect(setStep).toHaveBeenCalledWith({ id: testStep.id, status: 'error', isVisited: true }); +}); + +test('calls "toggleStep" when isHidden value changes and the previous value was not undefined', () => { + const toggleStep = jest.fn(); + useWizardContextSpy.mockReturnValueOnce({ ...wizardContext, toggleStep }); + + render(); + + expect(toggleStep).toHaveBeenCalledWith(testStep.id, true); +}); + +test('does not call "toggleStep" when isHidden value changes and was previously undefined', () => { + const toggleStep = jest.fn(); + useWizardContextSpy.mockReturnValueOnce({ + ...wizardContext, + toggleStep + }); + + render(); + + expect(toggleStep).not.toHaveBeenCalled(); +}); + +test('does not update "isVisited" value in context when the step was previously visited', () => { + const setStep = jest.fn(); + const visitedTestStep = { ...testStep, isVisited: true }; + useWizardContextSpy.mockReturnValueOnce({ + ...wizardContext, + currentStep: visitedTestStep, + steps: [visitedTestStep], + setStep + }); + + render(); + + expect(setStep).not.toHaveBeenCalled(); +}); diff --git a/packages/react-core/src/next/components/Wizard/__tests__/WizardToggle.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/WizardToggle.test.tsx index ff1fd72d221..10169c3b9a5 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/WizardToggle.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/WizardToggle.test.tsx @@ -13,13 +13,13 @@ const steps = [ const defaultProps: WizardToggleProps = { steps, - activeStep: steps[0], + currentStep: steps[0], footer:
    Some footer
    , nav:
    Some nav
    , 'aria-label': 'Some label' }; -test('renders provided footer, nav, and active step content', () => { +test('renders provided footer, nav, and current step content', () => { render(); expect(screen.getByText('Some footer')).toBeVisible(); @@ -58,8 +58,8 @@ test('renders only the active step content by default', async () => { expect(screen.queryByText('Second step content')).toBeNull; }); -test('renders all step content when unmountInactiveSteps is false', async () => { - render(); +test('renders all step content when hasUnmountedSteps is false', async () => { + render(); expect(screen.getByText('First step content')).toBeInTheDocument(); expect(screen.getByText('Second step content')).toBeInTheDocument(); diff --git a/packages/react-core/src/next/components/Wizard/examples/Wizard.md b/packages/react-core/src/next/components/Wizard/examples/Wizard.md index 71de9f50f01..4ea9cccb064 100644 --- a/packages/react-core/src/next/components/Wizard/examples/Wizard.md +++ b/packages/react-core/src/next/components/Wizard/examples/Wizard.md @@ -16,8 +16,7 @@ propComponents: 'WizardBasicStep', 'WizardParentStep', 'WizardSubStep', - 'DefaultWizardNavProps', - 'DefaultWizardFooterProps', + 'WizardNavStepData', ] beta: true --- @@ -28,7 +27,9 @@ TextInput, Drawer, DrawerContent, Button, +Title, Flex, +Checkbox, DrawerPanelContent, DrawerColorVariant, DrawerHead, @@ -38,6 +39,7 @@ DrawerCloseButton import { Wizard, WizardFooter, +WizardFooterWrapper, WizardToggle, WizardStep, WizardBody, @@ -66,7 +68,7 @@ The `Wizard`'s `nav` property can be used to build your own navigation. export type CustomWizardNavFunction = ( isExpanded: boolean, steps: WizardControlStep[], - activeStep: WizardControlStep, + currentStep: WizardControlStep, goToStepByIndex: (index: number) => void ) => React.ReactElement; @@ -79,9 +81,18 @@ type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubStep; ### Kitchen sink -Includes a header, custom footer, sub-steps, step content with a drawer, custom nav item, and nav prevention until step visitation. +Includes the following: -Custom operations when navigating between steps can be achieved by utilizing `onNext`, `onBack` or `onNavByIndex` properties whose callback functions return the 'id' and 'name' of the currently focused step (currentStep), and the previously focused step (previousStep). +- Header +- Custom footer +- Sub-steps +- Step content with a drawer +- Custom navigation item +- Disabled navigation items until visited +- Action to toggle visibility of a step +- Action to toggle navigation item error status + +Custom operations when navigating between steps can be achieved by utilizing `onNext`, `onBack`, or `onNavByIndex` properties whose callback functions return the 'id' and 'name' of the currently focused step (currentStep), and the previously focused step (previousStep). ```noLive /** Callback for the Wizard's 'onNext', 'onBack', and 'onNavByIndex' properties */ @@ -91,33 +102,47 @@ type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: Wiza type WizardNavStepData = Pick; ``` +# + +The `WizardStep`'s `navItem` property can be used to build your own nav item for that step. + +```noLive +/** Callback for the Wizard's 'navItem' property. Returns element which replaces the WizardStep's default navItem. */ +export type CustomWizardNavItemFunction = ( + step: WizardControlStep, + currentStep: WizardControlStep, + steps: WizardControlStep[], + goToStepByIndex: (index: number) => void +) => React.ReactElement; +``` + ```ts file="./WizardKitchenSink.tsx" ``` ## Hooks -### useWizardFooter +### useWizardContext -Used to set a unique footer for the wizard on any given step. See step 3 of [Kitchen sink](#kitchen-sink) for a live example. +Used to access any property of [WizardContext](#wizardcontextprops): ```noLive -import { useWizardFooter } from '@patternfly/react-core/next'; +import { useWizardContext } from '@patternfly/react-core/next'; const StepContent = () => { - useWizardFooter(<>Some footer); - return <>Step content; + const { currentStep } = useWizardContext(); + return <>This is the current step: {currentStep}; } ``` -### useWizardContext +### useWizardFooter -Used to access any property of [WizardContext](#wizardcontextprops): +Used to set a unique footer for the wizard on any given step. See step 3 of [Kitchen sink](#kitchen-sink) for a live example. ```noLive -import { useWizardContext } from '@patternfly/react-core/next'; +import { useWizardFooter } from '@patternfly/react-core/next'; const StepContent = () => { - const { activeStep } = useWizardContext(); - return <>This is the active step: {activeStep}; + useWizardFooter(<>Some footer); + return <>Step content; } ``` diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx index 0739a06eb35..4d1823dbfae 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx @@ -13,9 +13,9 @@ export const WizardBasic: React.FunctionComponent = () => (

    Step 3 content

    -

    Step 4 content

    {' '} +

    Step 4 content

    - +

    Review step content

    diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx index 2d09c545b88..06f5e4db61a 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx @@ -12,7 +12,7 @@ export const WizardCustomNav: React.FunctionComponent = () => { const nav: CustomWizardNavFunction = ( isExpanded: boolean, steps: WizardControlStep[], - activeStep: WizardControlStep, + currentStep: WizardControlStep, goToStepByIndex: (index: number) => void ) => ( @@ -21,9 +21,9 @@ export const WizardCustomNav: React.FunctionComponent = () => { key={step.id} id={step.id} content={step.name} - isCurrent={activeStep.id === step.id} + isCurrent={currentStep.id === step.id} isDisabled={step.isDisabled} - step={index + 1} + stepIndex={index + 1} onNavItemClick={goToStepByIndex} /> ))} @@ -38,7 +38,7 @@ export const WizardCustomNav: React.FunctionComponent = () => {

    Step 2 content

    - +

    Review step content

    diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx index a54781be81f..356b892a823 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx @@ -11,7 +11,8 @@ import { DrawerColorVariant, DrawerHead, DrawerActions, - DrawerCloseButton + DrawerCloseButton, + Checkbox } from '@patternfly/react-core'; import { Wizard, @@ -19,32 +20,55 @@ import { WizardBody, WizardFooter, WizardStepProps, - WizardControlStep, - useWizardFooter, useWizardContext, WizardNavStepFunction, WizardNavStepData, WizardNavItem, - WizardHeader + WizardHeader, + WizardFooterWrapper, + WizardNavItemType } from '@patternfly/react-core/next'; -const CustomWizardFooter = () => { - const { activeStep, onNext, onBack, onClose } = useWizardContext(); - return ; +const StepId = { + StepOne: 'sink-step-1', + StepTwo: 'sink-step-2', + StepTwoSubOne: 'sink-step-2-1', + StepTwoSubTwo: 'sink-step-2-2', + StepThree: 'sink-step-3', + StepFour: 'sink-step-4', + ReviewStep: 'sink-review-step' +}; + +interface SomeContextProps { + isToggleStepChecked: boolean; + errorMessage: string | undefined; + setIsToggleStepChecked(isHidden: boolean): void; + setErrorMessage(error: string | undefined): void; +} + +const SomeContext = React.createContext({} as SomeContextProps); + +const SomeContextProvider = ({ children }) => { + const [isToggleStepChecked, setIsToggleStepChecked] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(); + + return ( + + {children} + + ); }; -const CustomNavItem = () => { - const { steps, activeStep, goToStepByIndex } = useWizardContext(); - const step = (steps.find(step => step.id === 'third-step') || {}) as WizardControlStep; +const CustomWizardFooter = () => { + const { currentStep, currentStepIndex, onNext, onBack, onClose } = useWizardContext(); return ( - Custom item
    } - isCurrent={activeStep.id === step.id} - step={steps.indexOf(step) + 1} - isDisabled={step.isDisabled || !step.visited} - onNavItemClick={goToStepByIndex} + ); }; @@ -84,7 +108,7 @@ const StepContentWithDrawer = () => { ); }; -const StepWithCustomFooter = () => { +const StepWithCustomFooter = (props: WizardStepProps) => { const { onNext: goToNextStep, onBack, onClose } = useWizardContext(); const [isLoading, setIsLoading] = React.useState(false); @@ -98,7 +122,7 @@ const StepWithCustomFooter = () => { const footer = React.useMemo( () => ( - <> + @@ -108,55 +132,97 @@ const StepWithCustomFooter = () => { - + ), - [isLoading, onBack, onClose, goToNextStep] + [goToNextStep, isLoading, onBack, onClose] ); - useWizardFooter(footer); - return <>Step 3 content w/ custom async footer; + return ( + + Step 3 content w/ custom async footer + + ); }; -const CustomStepFour = (props: WizardStepProps) =>
    ; +const StepWithContextActions = (props: WizardStepProps) => { + const { isToggleStepChecked, errorMessage, setIsToggleStepChecked, setErrorMessage } = React.useContext(SomeContext); -export const WizardKitchenSink: React.FunctionComponent = () => { - const onNext: WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => { - // eslint-disable-next-line no-console - console.log('currentStep: ', currentStep, '\n previousStep: ', previousStep); - }; + const navItem: WizardNavItemType = React.useCallback( + (step, currentStep, steps, goToStepByIndex) => ( + + ), + [errorMessage] + ); return ( - } - footer={} - nav={{ forceStepVisit: true, isExpandable: true }} - onNext={onNext} - > - - - - - Substep 1 content - , - - Substep 2 content - - ]} + + setErrorMessage(checked ? 'Some error message' : undefined)} + id="toggle-error-checkbox" + name="Toggle Error Checkbox" + /> + setIsToggleStepChecked(checked)} + id="toggle-hide-step-checkbox" + name="Toggle Hide Step Checkbox" /> - }> - - - - Step 4 content - - - Review step content - - + + ); +}; + +const StepToHide = (props: WizardStepProps) => { + const { isToggleStepChecked } = React.useContext(SomeContext); + return ; +}; + +export const WizardKitchenSink: React.FunctionComponent = () => { + const onNext: WizardNavStepFunction = (_currentStep: WizardNavStepData, _previousStep: WizardNavStepData) => {}; + + return ( + + } + footer={} + isStepVisitRequired + onNext={onNext} + hasUnmountedSteps={false} + > + + + + , + + Substep 2 content + + ]} + /> + Custom item }} + /> + + Review step content + + + ); }; diff --git a/packages/react-core/src/next/components/Wizard/hooks/__tests__/useWizardFooter.test.tsx b/packages/react-core/src/next/components/Wizard/hooks/__tests__/useWizardFooter.test.tsx index ba7121e68f6..cba661c66d9 100644 --- a/packages/react-core/src/next/components/Wizard/hooks/__tests__/useWizardFooter.test.tsx +++ b/packages/react-core/src/next/components/Wizard/hooks/__tests__/useWizardFooter.test.tsx @@ -5,47 +5,36 @@ import { renderHook } from '@testing-library/react-hooks'; import { useWizardFooter } from '../useWizardFooter'; import * as WizardContext from '../../WizardContext'; -describe('useWizardFooter', () => { - const customFooter = <>My custom footer; - const useWizardContextSpy = jest.spyOn(WizardContext, 'useWizardContext'); - const setFooter = jest.fn(); +const customFooter = <>My custom footer; +const useWizardContextSpy = jest.spyOn(WizardContext, 'useWizardContext'); +const setFooter = jest.fn(); - it('sets the footer when one is provided without a stepId', () => { - useWizardContextSpy.mockReturnValueOnce({ setFooter } as any); +test('sets the footer when one is provided without a stepId', () => { + useWizardContextSpy.mockReturnValueOnce({ setFooter } as any); - renderHook(() => useWizardFooter(customFooter)); - expect(setFooter).toHaveBeenCalledWith(customFooter); - }); - - it(`sets the footer when the provided stepId matches the activeStep's id`, () => { - useWizardContextSpy.mockReturnValueOnce({ setFooter, activeStep: { id: 'active-step-id' } } as any); - - renderHook(() => useWizardFooter(customFooter, 'active-step-id')); - expect(setFooter).toHaveBeenCalledWith(customFooter); - }); - - it(`does not set the footer when the provided stepId does not match the activeStep's id`, () => { - useWizardContextSpy.mockReturnValueOnce({ setFooter, activeStep: { id: 'active-step-id' } } as any); + renderHook(() => useWizardFooter(customFooter)); + expect(setFooter).toHaveBeenCalledWith(customFooter); +}); - renderHook(() => useWizardFooter(customFooter, 'some-other-step-id')); - expect(setFooter).not.toHaveBeenCalled(); - }); +test(`sets the footer when the provided stepId matches the currentStep's id`, () => { + useWizardContextSpy.mockReturnValueOnce({ setFooter, currentStep: { id: 'curr-step-id' } } as any); - it('sets the footer to null on unmount', () => { - useWizardContextSpy.mockReturnValueOnce({ setFooter } as any); + renderHook(() => useWizardFooter(customFooter, 'curr-step-id')); + expect(setFooter).toHaveBeenCalledWith(customFooter); +}); - const { unmount } = renderHook(() => useWizardFooter(customFooter)); - unmount(); +test(`does not set the footer when the provided stepId does not match the currentStep's id`, () => { + useWizardContextSpy.mockReturnValueOnce({ setFooter, currentStep: { id: 'curr-step-id' } } as any); - expect(setFooter).toHaveBeenCalledWith(null); - }); + renderHook(() => useWizardFooter(customFooter, 'some-other-step-id')); + expect(setFooter).not.toHaveBeenCalled(); +}); - it('sets footer with default props instead of react element', () => { - const footerProps = { onNext: jest.fn(), onBack: jest.fn(), onClose: jest.fn() }; - useWizardContextSpy.mockReturnValueOnce({ setFooter } as any); +test('sets the footer to null on unmount', () => { + useWizardContextSpy.mockReturnValueOnce({ setFooter } as any); - renderHook(() => useWizardFooter(footerProps)); + const { unmount } = renderHook(() => useWizardFooter(customFooter)); + unmount(); - expect(setFooter).toHaveBeenCalledWith(footerProps); - }); + expect(setFooter).toHaveBeenCalledWith(null); }); diff --git a/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx b/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx index 0d7c99b3515..56a49afd9ea 100644 --- a/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx +++ b/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx @@ -1,18 +1,18 @@ import React from 'react'; -import { DefaultWizardFooterProps } from '../types'; import { useWizardContext } from '../WizardContext'; +import { WizardFooterProps } from '../WizardFooter'; /** * Set a unique footer for the wizard. stepId is only required if inactive steps are hidden instead of unmounted. * @param footer * @param stepId */ -export const useWizardFooter = (footer: DefaultWizardFooterProps | React.ReactElement, stepId?: string | number) => { - const { activeStep, setFooter } = useWizardContext(); +export const useWizardFooter = (footer: React.ReactElement | Partial, stepId?: string | number) => { + const { currentStep, setFooter } = useWizardContext(); React.useEffect(() => { - if (!stepId || activeStep.id === stepId) { + if (footer && (!stepId || currentStep?.id === stepId)) { setFooter(footer); // Reset the footer on unmount. @@ -20,5 +20,5 @@ export const useWizardFooter = (footer: DefaultWizardFooterProps | React.ReactEl setFooter(null); }; } - }, [activeStep, footer, setFooter, stepId]); + }, [currentStep, footer, setFooter, stepId]); }; diff --git a/packages/react-core/src/next/components/Wizard/types.tsx b/packages/react-core/src/next/components/Wizard/types.tsx index ba7df763962..6cd2c5294fe 100644 --- a/packages/react-core/src/next/components/Wizard/types.tsx +++ b/packages/react-core/src/next/components/Wizard/types.tsx @@ -1,34 +1,37 @@ import React from 'react'; -import { WizardNavProps, WizardNavItemProps } from '../Wizard'; +import { WizardNavProps, WizardNavItemProps, WizardFooterProps } from '../Wizard'; /** Type used to define 'basic' steps, or in other words, steps that are neither parents or children of parents. */ export interface WizardBasicStep { - /** Name of the step's nav item */ + /** Name of the step's navigation item */ name: React.ReactNode; /** Unique identifier */ id: string | number; - /** Flag to disable the step's nav item */ + /** Flag to disable the step's navigation item */ isDisabled?: boolean; /** Flag to represent whether the step has been visited (navigated to) */ - visited?: boolean; - /** Content shown when the step's nav item is selected. When treated as a parent step, only sub-step content will be shown. */ + isVisited?: boolean; + /** Flag to determine whether the step is hidden */ + isHidden?: boolean; + /** Content shown when the step's navigation item is selected. When treated as a parent step, only sub-step content will be shown. */ component?: React.ReactElement; - /** (Unused if nav is controlled) Custom WizardNavItem */ - navItem?: React.ReactElement; - /** (Unused if footer is controlled) Can change the Next button text. If nextButtonText is also set for the Wizard, this step specific one overrides it. */ - nextButtonText?: React.ReactNode; - /** (Unused if footer is controlled) The condition needed to disable the Next button */ - disableNext?: boolean; - /** (Unused if footer is controlled) True to hide the Cancel button */ - hideCancelButton?: boolean; - /** (Unused if footer is controlled) True to hide the Back button */ - hideBackButton?: boolean; + /** Replaces the step's navigation item or its properties. */ + navItem?: WizardNavItemType; + /** Replaces the step's footer. The step's footer takes precedance over the wizard's footer. */ + footer?: React.ReactElement | Partial; + /** Used to determine icon next to the step's navItem */ + status?: 'default' | 'error'; +} + +export enum WizardNavItemStatus { + Default = 'default', + Error = 'error' } /** Type used to define parent steps. */ export interface WizardParentStep extends WizardBasicStep { /** Nested step IDs */ - subStepIds: string[]; + subStepIds: (string | number)[]; } /** Type used to define sub-steps. */ @@ -37,34 +40,6 @@ export interface WizardSubStep extends WizardBasicStep { parentId: string | number; } -/** Used to customize aspects of the Wizard's default navigation. */ -export interface DefaultWizardNavProps { - /** Flag indicating nav items with sub steps are expandable */ - isExpandable?: boolean; - /** Aria-label for the Nav */ - ariaLabel?: string; - /** Sets aria-labelledby on nav element */ - ariaLabelledBy?: string; - /** Disable step nav items until they are visited */ - forceStepVisit?: boolean; -} - -/** Used to customize aspects of the Wizard's default footer. */ -export interface DefaultWizardFooterProps { - /** The Next button text */ - nextButtonText?: React.ReactNode; - /** The Back button text */ - backButtonText?: React.ReactNode; - /** The Cancel button text */ - cancelButtonText?: React.ReactNode; - /** Next button callback */ - onNext?: () => WizardNavStepFunction | void; - /** Back button callback */ - onBack?: () => WizardNavStepFunction | void; - /** Cancel link callback */ - onClose?: () => void; -} - /** Encompasses all step type variants that are internally controlled by the Wizard. */ export type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubStep; @@ -72,26 +47,57 @@ export type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubSt export type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => void; /** Data returned for either parameter of WizardNavStepFunction. */ -export type WizardNavStepData = Pick; +export interface WizardNavStepData { + /** Unique identifier */ + id: string | number; + /** Name of the step */ + name: string; + /** Index of the step (starts at 1) */ + index: number; +} + +export type WizardFooterType = Partial | CustomWizardFooterFunction | React.ReactElement; +export type WizardNavType = Partial | CustomWizardNavFunction | React.ReactElement; +export type WizardNavItemType = Partial | CustomWizardNavItemFunction | React.ReactElement; /** Callback for the Wizard's 'nav' property. Returns element which replaces the Wizard's default navigation. */ export type CustomWizardNavFunction = ( isExpanded: boolean, steps: WizardControlStep[], - activeStep: WizardControlStep, + currentStep: WizardControlStep, goToStepByIndex: (index: number) => void ) => React.ReactElement; -export function isCustomWizardNav( - nav: DefaultWizardNavProps | CustomWizardNavFunction -): nav is CustomWizardNavFunction { - return typeof nav === 'function'; +/** Callback for the Wizard's 'navItem' property. Returns element which replaces the WizardStep's default navigation item. */ +export type CustomWizardNavItemFunction = ( + step: WizardControlStep, + currentStep: WizardControlStep, + steps: WizardControlStep[], + goToStepByIndex: (index: number) => void +) => React.ReactElement; + +/** Callback for the Wizard's 'footer' property. Returns element which replaces the Wizard's default footer. */ +export type CustomWizardFooterFunction = ( + currentStep: WizardControlStep, + onNext: () => void, + onBack: () => void, + onClose: () => void +) => React.ReactElement; + +export function isCustomWizardNav(nav: WizardNavType): nav is CustomWizardNavFunction | React.ReactElement { + return typeof nav === 'function' || React.isValidElement(nav); +} + +export function isCustomWizardNavItem( + navItem: WizardNavItemType +): navItem is CustomWizardNavItemFunction | React.ReactElement { + return typeof navItem === 'function' || React.isValidElement(navItem); } export function isCustomWizardFooter( - footer: DefaultWizardFooterProps | React.ReactElement -): footer is React.ReactElement { - return React.isValidElement(footer); + footer: WizardFooterType +): footer is CustomWizardFooterFunction | React.ReactElement { + return typeof footer === 'function' || React.isValidElement(footer); } export function isWizardBasicStep(step: WizardControlStep): step is WizardBasicStep { diff --git a/packages/react-core/src/next/components/Wizard/utils.ts b/packages/react-core/src/next/components/Wizard/utils.ts index 071746f1319..ede2574412c 100644 --- a/packages/react-core/src/next/components/Wizard/utils.ts +++ b/packages/react-core/src/next/components/Wizard/utils.ts @@ -3,32 +3,23 @@ import React from 'react'; import { WizardControlStep, WizardNavStepData } from './types'; import { WizardStep, WizardStepProps } from './WizardStep'; -function hasWizardStepProps(props: WizardStepProps | any): props is WizardStepProps { - return props.name !== undefined && props.id !== undefined; -} - /** * Accumulate list of step & sub-step props pulled from child components * @param children * @returns WizardControlStep[] */ export const buildSteps = (children: React.ReactElement | React.ReactElement[]) => - React.Children.toArray(children).reduce((acc: WizardControlStep[], child) => { - if (React.isValidElement(child) && (child.type === WizardStep || hasWizardStepProps(child.props))) { - // Omit "children" and use the whole "child" (WizardStep) for the component prop. Sub-steps will do the same. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { steps: subSteps, id, children, ...stepProps } = child.props as WizardStepProps; + React.Children.toArray(children).reduce((acc: WizardControlStep[], child: React.ReactChild | React.ReactFragment) => { + if (isWizardStep(child)) { + const { steps: subSteps, id } = child.props; acc.push({ - id, component: child, - ...stepProps, + ...normalizeStep(child.props), ...(subSteps && { subStepIds: subSteps?.map(subStep => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { children, ...subStepProps } = subStep.props; acc.push({ - ...subStepProps, + ...normalizeStep(subStep.props), component: subStep, parentId: id }); @@ -44,6 +35,24 @@ export const buildSteps = (children: React.ReactElement | React return acc; }, []); -export const normalizeNavStep = ({ id, name }: WizardNavStepData) => ({ id, name }); -export const getActiveStep = (steps: WizardControlStep[], currentStepIndex: number) => +export function isWizardStep( + child: any | React.ReactElement +): child is React.ReactElement { + return ( + (React.isValidElement(child) && (child as React.ReactElement).type === WizardStep) || + (child.props?.name !== undefined && child.props?.id !== undefined) + ); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const normalizeStep = ({ children, steps, body, ...controlStep }: WizardStepProps): WizardControlStep => + controlStep; + +export const normalizeNavStep = (navStep: WizardControlStep, steps: WizardControlStep[]): WizardNavStepData => ({ + id: navStep.id, + name: navStep.name.toString(), + index: steps.indexOf(navStep) + 1 +}); + +export const getCurrentStep = (steps: WizardControlStep[], currentStepIndex: number) => steps.find((_, index) => index + 1 === currentStepIndex);