import * as React from 'react'; import { KeyTypes } from '../../../helpers/constants'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; import { Modal, ModalVariant } from '../../../components/Modal'; import { WizardFooterInternal } from './WizardFooterInternal'; import { WizardToggle } from './WizardToggle'; import { WizardNav } from './WizardNav'; import { WizardNavItem, WizardNavItemProps } from './WizardNavItem'; import { WizardContextProvider } from './WizardContext'; import { PickOptional } from '../../../helpers/typeUtils'; import { WizardHeader } from './WizardHeader'; export interface WizardStep { /** Optional identifier */ id?: string | number; /** The name of the step */ name: React.ReactNode; /** The component to render in the main body */ component?: any; /** The content to render in the drawer panel (use when hasDrawer prop is set on the wizard). */ drawerPanelContent?: any; /** Custom drawer toggle button that opens the drawer. */ drawerToggleButton?: React.ReactNode; /** Setting to true hides the side nav and footer */ isFinishedStep?: boolean; /** Enables or disables the step in the navigation. Enabled by default. */ canJumpTo?: boolean; /** Sub steps */ steps?: WizardStep[]; /** Props to pass to the WizardNavItem */ stepNavItemProps?: React.HTMLProps | WizardNavItemProps; /** (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 enable the Next button */ enableNext?: 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; /** Flag to disable the step in the navigation */ isDisabled?: boolean; } export type WizardStepFunctionType = ( newStep: { id?: string | number; name: React.ReactNode }, prevStep: { prevId?: string | number; prevName: React.ReactNode } ) => void; export interface WizardProps extends React.HTMLProps { /** Custom width of the wizard */ width?: number | string; /** Custom height of the wizard */ height?: number | string; /** The wizard title to display if header is desired */ title?: string; /** An optional id for the title */ titleId?: string; /** An optional id for the description */ descriptionId?: string; /** The wizard description */ description?: React.ReactNode; /** Component type of the description */ descriptionComponent?: 'div' | 'p'; /** Flag indicating whether the close button should be in the header */ hideClose?: boolean; /** Callback function to close the wizard */ onClose?: () => void; /** Callback function when a step in the nav is clicked */ onGoToStep?: WizardStepFunctionType; /** Additional classes spread to the Wizard */ className?: string; /** The wizard steps configuration object */ steps: WizardStep[]; /** The current step the wizard is on (1 or higher) */ startAtStep?: number; /** Aria-label for the Nav */ navAriaLabel?: string; /** Sets aria-labelledby on nav element */ navAriaLabelledBy?: string; /** Adds an accessible name to the wizard body when the body content overflows and renders * a scrollbar. */ mainAriaLabel?: string; /** Adds an accessible name to the wizard body by passing the the id of one or more elements. * The aria-labelledby will only be applied when the body content overflows and renders a scrollbar. */ mainAriaLabelledBy?: string; /** Can remove the default padding around the main body content by setting this to true */ hasNoBodyPadding?: boolean; /** (Use to control the footer) Passing in a footer component lets you control the buttons yourself */ footer?: React.ReactNode; /** (Unused if footer is controlled) Callback function to save at the end of the wizard, if not specified uses onClose */ onSave?: () => void; /** (Unused if footer is controlled) Callback function after Next button is clicked */ onNext?: WizardStepFunctionType; /** (Unused if footer is controlled) Callback function after Back button is clicked */ onBack?: WizardStepFunctionType; /** (Unused if footer is controlled) The Next button text */ nextButtonText?: React.ReactNode; /** (Unused if footer is controlled) The Back button text */ backButtonText?: React.ReactNode; /** (Unused if footer is controlled) The Cancel button text */ cancelButtonText?: React.ReactNode; /** (Unused if footer is controlled) aria-label for the close button */ closeButtonAriaLabel?: string; /** The parent container to append the modal to. Defaults to document.body */ appendTo?: HTMLElement | (() => HTMLElement); /** Flag indicating Wizard modal is open. Wizard will be placed into a modal if this prop is provided */ isOpen?: boolean; /** Flag indicating nav items with sub steps are expandable */ isNavExpandable?: boolean; /** Callback function to signal the current step in the wizard */ onCurrentStepChanged?: (step: WizardStep) => void; /** Flag indicating the wizard has a drawer for at least one of the wizard steps */ hasDrawer?: boolean; /** Flag indicating the wizard drawer is expanded */ isDrawerExpanded?: boolean; /** Callback function for when the drawer is toggled. Can be used to set browser focus in the drawer. */ onExpandDrawer?: () => void; } interface WizardState { currentStep: number; isNavOpen: boolean; } class Wizard extends React.Component { static displayName = 'Wizard'; private static currentId = 0; static defaultProps: PickOptional = { title: null, description: '', descriptionComponent: 'p', className: '', startAtStep: 1, nextButtonText: 'Next', backButtonText: 'Back', cancelButtonText: 'Cancel', hideClose: false, closeButtonAriaLabel: 'Close', navAriaLabel: null, navAriaLabelledBy: null, mainAriaLabel: null, mainAriaLabelledBy: null, hasNoBodyPadding: false, onBack: null as WizardStepFunctionType, onNext: null as WizardStepFunctionType, onGoToStep: null as WizardStepFunctionType, width: null as string, height: null as string, footer: null as React.ReactNode, onClose: () => undefined as any, appendTo: null as HTMLElement, isOpen: undefined, isNavExpandable: false, hasDrawer: false, isDrawerExpanded: false, onExpandDrawer: () => undefined as any }; private titleId: string; private descriptionId: string; private drawerRef: React.RefObject; constructor(props: WizardProps) { super(props); const newId = Wizard.currentId++; this.titleId = props.titleId || `pf-wizard-title-${newId}`; this.descriptionId = props.descriptionId || `pf-wizard-description-${newId}`; this.state = { currentStep: this.props.startAtStep && Number.isInteger(this.props.startAtStep) ? this.props.startAtStep : 1, isNavOpen: false }; if (props.onCurrentStepChanged) { const flattenedSteps = this.getFlattenedSteps(); if (flattenedSteps.length >= this.state.currentStep) { const currentStep = flattenedSteps[this.state.currentStep - 1]; props.onCurrentStepChanged(currentStep); } } this.drawerRef = React.createRef(); } private handleKeyClicks = (event: KeyboardEvent): void => { if (event.key === KeyTypes.Escape) { if (this.state.isNavOpen) { this.setState({ isNavOpen: !this.state.isNavOpen }); } else if (this.props.isOpen) { this.props.onClose(); } } }; private onNext = (): void => { const { onNext, onClose, onSave } = this.props; const { currentStep } = this.state; const flattenedSteps = this.getFlattenedSteps(); const maxSteps = flattenedSteps.length; if (currentStep >= maxSteps) { // Hit the save button at the end of the wizard if (onSave) { return onSave(); } return onClose(); } else { let newStep = currentStep; for (let nextStep = currentStep; nextStep <= maxSteps; nextStep++) { if (!flattenedSteps[nextStep]) { return; } if (!flattenedSteps[nextStep].isDisabled) { newStep = nextStep + 1; break; } } this.setCurrentStep(newStep, flattenedSteps[newStep - 1]); const { id: prevId, name: prevName } = flattenedSteps[currentStep - 1]; const { id, name } = flattenedSteps[newStep - 1]; return onNext && onNext({ id, name }, { prevId, prevName }); } }; private onBack = (): void => { const { onBack } = this.props; const { currentStep } = this.state; const flattenedSteps = this.getFlattenedSteps(); if (flattenedSteps.length < currentStep) { // Previous step was removed, just update the currentStep state const adjustedStep = flattenedSteps.length; this.setCurrentStep(adjustedStep, flattenedSteps[adjustedStep - 1]); } else { let newStep = currentStep; for (let prevStep = currentStep; prevStep >= 0; prevStep--) { if (!flattenedSteps[prevStep - 2]) { return; } if (!flattenedSteps[prevStep - 2].isDisabled) { newStep = prevStep - 1 <= 1 ? 1 : prevStep - 1; break; } } this.setCurrentStep(newStep, flattenedSteps[newStep - 1]); const { id: prevId, name: prevName } = flattenedSteps[newStep]; const { id, name } = flattenedSteps[newStep - 1]; return onBack && onBack({ id, name }, { prevId, prevName }); } }; private goToStep = (step: number): void => { const flattenedSteps = this.getFlattenedSteps(); if (flattenedSteps[step - 1].isDisabled) { return; } const { onGoToStep } = this.props; const { currentStep } = this.state; const maxSteps = flattenedSteps.length; if (step < 1) { step = 1; } else if (step > maxSteps) { step = maxSteps; } this.setCurrentStep(step, flattenedSteps[step - 1]); this.setState({ isNavOpen: false }); const { id: prevId, name: prevName } = flattenedSteps[currentStep - 1]; const { id, name } = flattenedSteps[step - 1]; return onGoToStep && onGoToStep({ id, name }, { prevId, prevName }); }; private goToStepById = (stepId: number | string): void => { const flattenedSteps = this.getFlattenedSteps(); let step; for (let i = 0; i < flattenedSteps.length; i++) { if (flattenedSteps[i].id === stepId) { step = i + 1; break; } } if (step) { this.setCurrentStep(step, flattenedSteps[step - 1]); } }; private goToStepByName = (stepName: string): void => { const flattenedSteps = this.getFlattenedSteps(); let step; for (let i = 0; i < flattenedSteps.length; i++) { if (flattenedSteps[i].name === stepName) { step = i + 1; break; } } if (step) { this.setCurrentStep(step, flattenedSteps[step - 1]); } }; private getFlattenedSteps = (): WizardStep[] => { const { steps } = this.props; const flattenedSteps: WizardStep[] = []; for (const step of steps) { if (step.steps) { for (const childStep of step.steps) { flattenedSteps.push(childStep); } } else { flattenedSteps.push(step); } } return flattenedSteps; }; private getFlattenedStepsIndex = (flattenedSteps: WizardStep[], stepName: React.ReactNode): number => { for (let i = 0; i < flattenedSteps.length; i++) { if (flattenedSteps[i].name === stepName) { return i + 1; } } return 0; }; private initSteps = (steps: WizardStep[]): WizardStep[] => { // Set default Step values for (let i = 0; i < steps.length; i++) { if (steps[i].steps) { for (let j = 0; j < steps[i].steps.length; j++) { steps[i].steps[j] = Object.assign({ canJumpTo: true }, steps[i].steps[j]); } } steps[i] = Object.assign({ canJumpTo: true }, steps[i]); } return steps; }; getElement = (appendTo: HTMLElement | (() => HTMLElement)) => { if (typeof appendTo === 'function') { return appendTo(); } return appendTo || document.body; }; private setCurrentStep(currentStep: number, currentStepObject: WizardStep) { this.setState({ currentStep }); if (this.props.onCurrentStepChanged) { this.props.onCurrentStepChanged(currentStepObject); } } componentDidMount() { const target = typeof document !== 'undefined' ? document.body : null; if (target) { target.addEventListener('keydown', this.handleKeyClicks, false); } } componentWillUnmount() { const target = (typeof document !== 'undefined' && document.body) || null; if (target) { target.removeEventListener('keydown', this.handleKeyClicks, false); } } componentDidUpdate(prevProps: Readonly) { if (prevProps.startAtStep !== this.props.startAtStep) { this.setState({ currentStep: this.props.startAtStep }); } } render() { const { /* eslint-disable @typescript-eslint/no-unused-vars */ width, height, title, description, descriptionComponent, onClose, onSave, onBack, onNext, onGoToStep, className, steps, startAtStep, nextButtonText = 'Next', backButtonText = 'Back', cancelButtonText = 'Cancel', hideClose, closeButtonAriaLabel = 'Close', navAriaLabel, navAriaLabelledBy, mainAriaLabel, mainAriaLabelledBy, hasNoBodyPadding, footer, appendTo, isOpen, titleId, descriptionId, isNavExpandable, onCurrentStepChanged, hasDrawer, isDrawerExpanded, onExpandDrawer, ...rest /* eslint-enable @typescript-eslint/no-unused-vars */ } = this.props; const { currentStep } = this.state; const flattenedSteps = this.getFlattenedSteps(); const adjustedStep = flattenedSteps.length < currentStep ? flattenedSteps.length : currentStep; const activeStep = flattenedSteps[adjustedStep - 1]; const computedSteps: WizardStep[] = this.initSteps(steps); const firstStep = activeStep === flattenedSteps[0]; const isValid = activeStep && activeStep.enableNext !== undefined ? activeStep.enableNext : true; const nav = (isWizardNavOpen: boolean) => { const wizNavAProps = { isOpen: isWizardNavOpen, 'aria-label': navAriaLabel, 'aria-labelledby': (title || navAriaLabelledBy) && (navAriaLabelledBy || this.titleId) }; return ( {computedSteps.map((step, index) => { if (step.isFinishedStep) { // Don't show finished step in the side nav return; } let enabled; let navItemStep; if (step.steps) { let hasActiveChild = false; let canJumpToParent = false; for (const subStep of step.steps) { if (activeStep.name === subStep.name) { // one of the children matches hasActiveChild = true; } if (subStep.canJumpTo) { canJumpToParent = true; } } navItemStep = this.getFlattenedStepsIndex(flattenedSteps, step.steps[0].name); return ( {step.steps.map((childStep: WizardStep, indexChild: number) => { if (childStep.isFinishedStep) { // Don't show finished step in the side nav return; } navItemStep = this.getFlattenedStepsIndex(flattenedSteps, childStep.name); enabled = childStep.canJumpTo && !childStep.isDisabled; return ( ); })} ); } navItemStep = this.getFlattenedStepsIndex(flattenedSteps, step.name); enabled = step.canJumpTo && !step.isDisabled; return ( ); })} ); }; const context = { goToStepById: this.goToStepById, goToStepByName: this.goToStepByName, onNext: this.onNext, onBack: this.onBack, onClose, activeStep }; const divStyles = { ...(height ? { height } : {}), ...(width ? { width } : {}) }; const wizard = (
{title && ( )} this.setState({ isNavOpen })} nav={nav} steps={steps} activeStep={activeStep} hasNoBodyPadding={hasNoBodyPadding} > {footer || ( )}
); if (isOpen !== undefined) { return ( {wizard} ); } return wizard; } } export { Wizard };