1/* 2 * Copyright 2020 Palantir Technologies, Inc. All rights reserved. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import classNames from "classnames"; 18import * as React from "react"; 19import { polyfill } from "react-lifecycles-compat"; 20 21import { AbstractPureComponent2, Classes, Utils } from "../../common"; 22import { DISPLAYNAME_PREFIX } from "../../common/props"; 23import { Button, ButtonProps } from "../button/buttons"; 24import { Dialog, DialogProps } from "./dialog"; 25import { DialogStep, DialogStepId, DialogStepProps, DialogStepButtonProps } from "./dialogStep"; 26 27type DialogStepElement = React.ReactElement<DialogStepProps & { children: React.ReactNode }>; 28 29// eslint-disable-next-line deprecation/deprecation 30export type MultistepDialogProps = IMultistepDialogProps; 31/** @deprecated use MultistepDialogProps */ 32export interface IMultistepDialogProps extends DialogProps { 33 /** 34 * Props for the back button. 35 */ 36 backButtonProps?: DialogStepButtonProps; 37 38 /** 39 * Props for the button to display on the final step. 40 */ 41 finalButtonProps?: Partial<ButtonProps>; 42 43 /** 44 * Props for the next button. 45 */ 46 nextButtonProps?: DialogStepButtonProps; 47 48 /** 49 * A callback that is invoked when the user selects a different step by clicking on back, next, or a step itself. 50 */ 51 onChange?( 52 newDialogStepId: DialogStepId, 53 prevDialogStepId: DialogStepId | undefined, 54 event: React.MouseEvent<HTMLElement>, 55 ): void; 56 57 /** 58 * Whether to reset the dialog state to its initial state on close. 59 * By default, closing the dialog will reset its state. 60 * 61 * @default true 62 */ 63 resetOnClose?: boolean; 64 65 /** 66 * A 0 indexed initial step to start off on, to start in the middle of the dialog, for example. 67 * If the provided index exceeds the number of steps, it defaults to the last step. 68 * If a negative index is provided, it defaults to the first step. 69 */ 70 initialStepIndex?: number; 71} 72 73interface IMultistepDialogState { 74 lastViewedIndex: number; 75 selectedIndex: number; 76} 77 78const PADDING_BOTTOM = 0; 79 80const MIN_WIDTH = 800; 81 82@polyfill 83export class MultistepDialog extends AbstractPureComponent2<MultistepDialogProps, IMultistepDialogState> { 84 public static displayName = `${DISPLAYNAME_PREFIX}.MultistepDialog`; 85 86 public static defaultProps: Partial<MultistepDialogProps> = { 87 canOutsideClickClose: true, 88 isOpen: false, 89 resetOnClose: true, 90 }; 91 92 public state: IMultistepDialogState = this.getInitialIndexFromProps(this.props); 93 94 public render() { 95 return ( 96 <Dialog {...this.props} style={this.getDialogStyle()}> 97 <div className={Classes.MULTISTEP_DIALOG_PANELS}> 98 {this.renderLeftPanel()} 99 {this.maybeRenderRightPanel()} 100 </div> 101 </Dialog> 102 ); 103 } 104 105 public componentDidUpdate(prevProps: MultistepDialogProps) { 106 if ( 107 (prevProps.resetOnClose || prevProps.initialStepIndex !== this.props.initialStepIndex) && 108 !prevProps.isOpen && 109 this.props.isOpen 110 ) { 111 this.setState(this.getInitialIndexFromProps(this.props)); 112 } 113 } 114 115 private getDialogStyle() { 116 return { minWidth: MIN_WIDTH, paddingBottom: PADDING_BOTTOM, ...this.props.style }; 117 } 118 119 private renderLeftPanel() { 120 return ( 121 <div className={Classes.MULTISTEP_DIALOG_LEFT_PANEL}> 122 {this.getDialogStepChildren().filter(isDialogStepElement).map(this.renderDialogStep)} 123 </div> 124 ); 125 } 126 127 private renderDialogStep = (step: DialogStepElement, index: number) => { 128 const stepNumber = index + 1; 129 const hasBeenViewed = this.state.lastViewedIndex >= index; 130 const currentlySelected = this.state.selectedIndex === index; 131 return ( 132 <div 133 className={classNames(Classes.DIALOG_STEP_CONTAINER, { 134 [Classes.ACTIVE]: currentlySelected, 135 [Classes.DIALOG_STEP_VIEWED]: hasBeenViewed, 136 })} 137 key={index} 138 > 139 <div className={Classes.DIALOG_STEP} onClick={this.handleClickDialogStep(index)}> 140 <div className={Classes.DIALOG_STEP_ICON}>{stepNumber}</div> 141 <div className={Classes.DIALOG_STEP_TITLE}>{step.props.title}</div> 142 </div> 143 </div> 144 ); 145 }; 146 147 private handleClickDialogStep = (index: number) => { 148 if (index > this.state.lastViewedIndex) { 149 return; 150 } 151 return this.getDialogStepChangeHandler(index); 152 }; 153 154 private maybeRenderRightPanel() { 155 const steps = this.getDialogStepChildren(); 156 if (steps.length <= this.state.selectedIndex) { 157 return null; 158 } 159 160 const { className, panel, panelClassName } = steps[this.state.selectedIndex].props; 161 return ( 162 <div className={classNames(Classes.MULTISTEP_DIALOG_RIGHT_PANEL, className, panelClassName)}> 163 {panel} 164 {this.renderFooter()} 165 </div> 166 ); 167 } 168 169 private renderFooter() { 170 return ( 171 <div className={Classes.MULTISTEP_DIALOG_FOOTER}> 172 <div className={Classes.DIALOG_FOOTER_ACTIONS}>{this.renderButtons()}</div> 173 </div> 174 ); 175 } 176 177 private renderButtons() { 178 const { selectedIndex } = this.state; 179 const steps = this.getDialogStepChildren(); 180 const buttons = []; 181 182 if (this.state.selectedIndex > 0) { 183 const backButtonProps = steps[selectedIndex].props.backButtonProps ?? this.props.backButtonProps; 184 185 buttons.push( 186 <Button 187 key="back" 188 onClick={this.getDialogStepChangeHandler(selectedIndex - 1)} 189 text="Back" 190 {...backButtonProps} 191 />, 192 ); 193 } 194 195 if (selectedIndex === this.getDialogStepChildren().length - 1) { 196 buttons.push(<Button intent="primary" key="final" text="Submit" {...this.props.finalButtonProps} />); 197 } else { 198 const nextButtonProps = steps[selectedIndex].props.nextButtonProps ?? this.props.nextButtonProps; 199 200 buttons.push( 201 <Button 202 intent="primary" 203 key="next" 204 onClick={this.getDialogStepChangeHandler(selectedIndex + 1)} 205 text="Next" 206 {...nextButtonProps} 207 />, 208 ); 209 } 210 211 return buttons; 212 } 213 214 private getDialogStepChangeHandler(index: number) { 215 return (event: React.MouseEvent<HTMLElement>) => { 216 if (this.props.onChange !== undefined) { 217 const steps = this.getDialogStepChildren(); 218 const prevStepId = steps[this.state.selectedIndex].props.id; 219 const newStepId = steps[index].props.id; 220 this.props.onChange(newStepId, prevStepId, event); 221 } 222 this.setState({ 223 lastViewedIndex: Math.max(this.state.lastViewedIndex, index), 224 selectedIndex: index, 225 }); 226 }; 227 } 228 229 /** Filters children to only `<DialogStep>`s */ 230 private getDialogStepChildren(props: MultistepDialogProps & { children?: React.ReactNode } = this.props) { 231 return React.Children.toArray(props.children).filter(isDialogStepElement); 232 } 233 234 private getInitialIndexFromProps(props: MultistepDialogProps) { 235 if (props.initialStepIndex !== undefined) { 236 const boundedInitialIndex = Math.max( 237 0, 238 Math.min(props.initialStepIndex, this.getDialogStepChildren(props).length - 1), 239 ); 240 return { 241 lastViewedIndex: boundedInitialIndex, 242 selectedIndex: boundedInitialIndex, 243 }; 244 } else { 245 return { 246 lastViewedIndex: 0, 247 selectedIndex: 0, 248 }; 249 } 250 } 251} 252 253function isDialogStepElement(child: any): child is DialogStepElement { 254 return Utils.isElementOfType(child, DialogStep); 255} 256