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