Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a multistep dialog component #4462

Merged
merged 17 commits into from
Jan 9, 2021
Merged

Conversation

ycvfu
Copy link
Contributor

@ycvfu ycvfu commented Dec 17, 2020

This PR adds a multistep dialog component.

Checklist

  • Includes tests
  • Update documentation

Changes proposed in this pull request:

This PR aims to create a new multistep dialog component that enables the users to move through a sequence of steps in a dialog, each of which presents a different panel. It is implemented as a wrapper around Dialog.

This PR is an updated version of #4455 from a forked repository. I am temporarily keeping #4455 open as other contributors may be looking at it (@kmblake are you planning on updating the old PR/referencing the old PR, or is it okay for me to close it?).

Reviewers should focus on:

The dialog should present to the user a summary of steps on the left and the main panel associated with the currently selected panel on the right. When the user is on the first step, there should only be a 'next' or 'submit' footer button as appropriate. When the user is on the last step, there should only be a 'submit' footer button and potentially a 'back' footer button. When the user is on any page in between, they should see a back and next button.

The user also has the option of navigating between viewed steps on the left panel by clicking on a step. Only steps that have been previously accessed via clicking the "next" footer button can be clicked on. The user can select any pre-existing steps to see the panel.

All regular functionalities from Dialog should also be present.

Screenshot

image

@ycvfu ycvfu requested a review from kmblake December 17, 2020 17:28
Copy link
Contributor

@kmblake kmblake left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for contributing! I've provided some initial comments but ultimately defer to @adidahiya. Also @ycvfu if you want to offload this PR just let me know--I'm happy to own any additional changes to get it over the line after the holidays.

Comment on lines +52 to +54
The children you provide to this component are rendered as contents inside the
`Classes.DIALOG` element. Typically, you will want to render a panel with
`Classes.DIALOG_BODY` that contains the body content for each step.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should explain a little more about the Step interface similar to the docs for Tab. Also probably worth clarifying that only children of type Step will be rendered.

Copy link
Contributor Author

@ycvfu ycvfu Jan 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I added info under line 54.

/**
* Props for the button to display on the final step. To illustrate, this may be a submit button.
*/
finalButtonProps?: IFinalButtonProps;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know there was some earlier discussion about not using Partial<IButtonProps> here because we wanted to prevent users from changing things like button size, but it's a bit difficult to interpret what these IFinalButtonProps actually includes based on the type declaration. I would be in favor of Partial<IButtonProps> or at least adding some more clarity in the comment.

}

function isNextButtonEnabled(step: StepElement) {
return step.props.nextButtonEnabledByDefault !== undefined ? step.props.nextButtonEnabledByDefault : true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return step.props.nextButtonEnabledByDefault !== undefined ? step.props.nextButtonEnabledByDefault : true;
return step.props.nextButtonEnabledByDefault ?? true;

private renderLeftPanel() {
return (
<div className={Classes.MULTISTEP_DIALOG_LEFT_PANEL}>
{React.Children.toArray(this.props.children).filter(isStepElement).map(this.renderStep)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{React.Children.toArray(this.props.children).filter(isStepElement).map(this.renderStep)}
{this.getStepChildren().map(this.renderStep)}

const hasBeenViewed = this.state.lastViewedIndex >= index;
const currentlySelected = this.state.selectedIndex === index;
return (
<div className={classNames(Classes.STEP_CONTAINER, hasBeenViewed && Classes.ACTIVE)} key={index}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div className={classNames(Classes.STEP_CONTAINER, hasBeenViewed && Classes.ACTIVE)} key={index}>
<div className={classNames(Classes.STEP_CONTAINER, { [Classes.ACTIVE]: hasBeenViewed })} key={index}>

Looks like other places in BP use this syntax for conditional classes but defer to @adidahiya

Comment on lines 217 to 221
if (this.props.finalButtonProps !== undefined) {
return <Button key="final" {...this.props.finalButtonProps} />;
} else {
return <Button intent="danger" key="final" onClick={this.props.onClose} text="Submit" />;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (this.props.finalButtonProps !== undefined) {
return <Button key="final" {...this.props.finalButtonProps} />;
} else {
return <Button intent="danger" key="final" onClick={this.props.onClose} text="Submit" />;
}
return <Button intent="primary" key="final" onClick={this.props.onClose} text="Submit" {...this.props.finalButtonProps} />

Similar to how buttonProps are handled in TimezonePicker

Also, don't think the submit button should default to intent "danger"

/**
* Indicates whether the next button should be enabled by default on this step
*/
nextButtonEnabledByDefault?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should just expose nextButtonProps on MultistepDialog and make the next button fully controlled. Then we wouldn't need nextButtonEnabledByDefault or updateDialog, and Step would be a purely presentational component that could just render its children. If the next button needs to be disabled at the start of certain steps, the client would do that in their change handler for MultistepDialog's onChange. Since the parent component of MultistepDialog is ultimately responsible for keeping track of what data has been entered in the dialog, it might make sense to just allow it to control when the next button is enabled as well.

Copy link
Contributor Author

@ycvfu ycvfu Dec 30, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe we can expose a disableNext prop in the MultistepDialog instead of exposing nextButtonProps. I don't think that the user should be able to control any aspect of this button except its enabled state. If the user could change the button design such as the text, intent, etc. it would enable them to deviate from the Blueprint design, and I can't think of a case where this would be desirable (since we want to standardize this). For instance, enabling them to change a button on step 3 out of 5 to show "submit" would break the flow and cause confusion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I think just exposing disableNext on MultistepDialog is sufficient! In that case, we should remove this prop from Step, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! My bad, I missed that. I fixed it in the latest revision.

packages/core/src/components/dialog/_multistep-dialog.scss Outdated Show resolved Hide resolved
}

.#{$ns}-multistep-dialog-left-panel {
border-radius: 0 $dialog-border-radius 0 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this given that this div doesn't have a background color?

@ycvfu ycvfu requested review from adidahiya and kmblake and removed request for kmblake January 4, 2021 15:58
Copy link
Contributor

@kmblake kmblake left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, mostly just comments on completing the transition to make disableNext controlled at the dialog level rather than handled by each step.

/**
* Indicates whether the next button should be enabled by default on this step
*/
nextButtonEnabledByDefault?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I think just exposing disableNext on MultistepDialog is sufficient! In that case, we should remove this prop from Step, right?

}
}

function isNextButtonEnabled(step: StepElement) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer needed if we're exposing the disableNext prop

}
}

private getDefaultState() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we only need to define lastViewedIndex and selectedIndex, probably a little clearer to just define those in an INITIAL_STATE const and then set state to INITIAL_STATE in the constructor and componentDidUpdate

intent="primary"
key="next"
onClick={this.handleClickNext()}
text="Next"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adidahiya how does BP handle localization? It seems like for most components the client supplies all of the copy. Should that also be the case here (at least making it possible to override the button text)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we need to add a prop to override the button text for i18n. See my comment on IMultistepDialogProps.


export type StepId = string | number;

export interface IMultistepDialogPanelProps {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used?

@ycvfu ycvfu requested a review from kmblake January 6, 2021 02:49
Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR preview build

Looking great so far! The docs example is currently pretty simple, but we can flesh it out more in a future PR.

It's a little unfortunate how the header icon is misaligned with the step icons here. If we're going to keep the padding this way, we should change the header icon in the example to something that's not a circle.

image

align-items: center;
background-color: $light-gray5;
border-radius: $step-radius;
cursor: not-allowed;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is strictly necessary, it can just be grayed out and have a normal pointer with no hover state. Also, what about previous steps? Can't you click on those?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On further review, I realized the semantics of "active" here are a little different from what I expected. That's fine, I think what you have works. but I think it would help to leave a code comment along the lines of "by default, steps are inactive until they are visited"


public constructor(props: IMultistepDialogProps) {
super(props);
this.state = INITIAL_STATE;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: instead of using a constructor, we can just write

public state: IMultistepDialogState = {
    lastViewedIndex: 0,
    selectedIndex: 0,
};


public componentDidUpdate(prevProps: IMultistepDialogProps) {
if (!prevProps.isOpen && this.props.isOpen) {
this.setState(INITIAL_STATE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we always want to reset to initial state when a dialog is re-opened? Let's document this design decision somewhere in the docs. Maybe this behavior should be controlled by a prop in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah I made the assumption here that if we closed a dialog or clicked away from it, we would want any progress would be lost. We can expose a prop like "resetOnClose" to enable the user to control this.


private handleClickStep = (index: number) => {
if (index > this.state.lastViewedIndex) {
return undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this code path doesn't need to return a value; I would either return; here or reverse the condition to if (index <= this.state.lastViewedIndex)

intent="primary"
key="next"
onClick={this.handleClickNext()}
text="Next"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we need to add a prop to override the button text for i18n. See my comment on IMultistepDialogProps.

/**
* Whether to disable the next button in the panel footer.
*/
disableNext?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I propose making this a little more flexible and solving the problem of the button text mentioned below with a props object here.

nextButtonProps?: Partial<Pick<IButtonProps, "disabled" | "text">>;

};
}

private getFinalButton() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: renderFinalButton() since this returns JSX


/**
* Panel content, rendered by the parent `MultistepDialog` when this step is active.
* If omitted, no panel will be rendered for this step.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this mean / look like? why wouldn't there be a panel?

}

@polyfill
export class Step extends AbstractPureComponent2<IStepProps> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to be more explicit about the naming of this component and its CSS classes. They should all be prefixed with "dialog" because they correspond to MultiStepDialog. This helps disambiguate from other potential "step" components, like those proposed in #4058 / #260.

return (
<div className={classNames(Classes.STEP_CONTAINER, { [Classes.ACTIVE]: hasBeenViewed })} key={index}>
<div
className={classNames(Classes.STEP, { [Classes.ACTIVE]: hasBeenViewed })}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need all 4 active classes? I think we can remove this one and the one on L113 and change the Sass to be something like:

.step {
  .active & {
    ...
  }
}

cursor: not-allowed;
display: flex;
margin: 4px;
padding: 6px 14px;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed padding to better align with the title icon.

@ycvfu ycvfu requested a review from adidahiya January 8, 2021 02:16
@adidahiya
Copy link
Contributor

Latest changes look good! But it looks like we need to add dark theme support:

image

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good! thanks for finishing this up @ycvfu 🎉

@adidahiya adidahiya merged commit f4cf4a7 into palantir:develop Jan 9, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants