DEV Community

David Israel for Uclusion

Posted on • Updated on

Wizards aren’t just for Hogwarts

Uclusion, like any full featured app, needs a way to introduce concepts and workflows to new users. A long standing method of doing this is the Wizard. In our case we use wizards immediately from signup to let them create different kinds of structured communication spaces. One such space, Uclusion Dialog, has a loop inside it, and I’m going to cover how to make that work.

As an aside, if you have fairly linear and simple UI requirements, I’d recommend you stop reading this and visit the React Step Wizard package, as it covers that case well. Now, on to the Uclusion Dialog case which has neither a linear flow, nor simple UI requirements.

First, we need to know what kind of data we need to gather from the user. In this case a Uclusion Dialog is intended to let collaborators reach a decision by voting for one or more options. It also sets a deadline on the choice process, as most decisions have real world deadlines. Hence we need to collect the following:

  1. Dialog Name: used in emails, on card headers, etc.

  2. Dialog Reason (or context): Why are we making this decision, and any background info

  3. Dialog Expiration: How many days do we have before the deadline

  4. The list of options. Each option has itself a Name, and a Description telling people what they are voting for.

The above leads to basic flow of:

At a minimum we really have two wizards, one for the main flow and one for the option name and description. Lets start off with the code for the main flow, and see how we can extend it to do better. This code is for React:

function OnboardingWizard(props) {
  const { hidden, stepPrototypes, title, onStartOver, onFinish } = props;
  const classes = useStyles();
  // a place for the steps to store data in
  const [formData, updateFormData] = useReducer(reducer, {});
  const initialStepState = {
    currentStep: 0,
    totalSteps: stepPrototypes.length,
  };

  const [stepState, setStepState] = *useState*(initialStepState);

  function myOnStartOver () {
    // zero all form data
    updateFormData({});
    // reset the step state
    setStepState(initialStepState);
    onStartOver();
  }

  function myOnFinish(formData) {
    onFinish(formData);
    updateFormData(*resetValues*());
    // reset the step state
    setStepState(initialStepState);
  }

  function nextStep () {
    setStepState({
      ...stepState,
      currentStep: stepState.currentStep + 1,
    });
  }

  function previousStep () {
    if (stepState.currentStep === 0) {
      return;
    }
    setStepState({
      ...stepState,
      currentStep: stepState.currentStep - 1,
    });
  }

  function getCurrentStepContents () {
    const props = {
      ...stepState,
      formData,
      updateFormData,
      nextStep,
      previousStep,
      onStartOver: myOnStartOver,
      active: true,
      onFinish: myOnFinish,
      setOverrideUIContent,
      classes
    };
    const currentStep = stepPrototypes[stepState.currentStep];
    if (!currentStep) {
      return React.Fragment;
    }
    const { content } = currentStep;
    // because of clone element, individual steps have a hard time storing their own state,
    // so steps should use the form data if they need to store data between
    // executions of the main wizard element
    return React.cloneElement(content, props);
  }

  const currentStep = getCurrentStepContents();

  function getContent () {
    return (
      <Card>
        <div>
          {currentStep}
        </div>
      </Card>);
  }

  return (
    <Screen
      tabTitle={title}
      hidden={hidden}
    >
      {getContent()}
    </Screen>
  );
}
Enter fullscreen mode Exit fullscreen mode

The above renders a screen with step content, keeping track of what step we’re on, and allows the user of the OnboardingWizard to pass functions in that get called with the gathered form data when the user is finished.

In our case, we have fairly varied step UI that wants to render the next, go back, start over, and skip, buttons itself, so we do not auto render them below the content.

Here’s an example step, and how you might render the buttons:

function DialogNameStep(props) {

  const { updateFormData, formData, active, classes } = props;
  const intl = useIntl();

  const value = formData.dialogName || '';

  if (!active) {
    return React.Fragment;
  }
  const validForm = !_.isEmpty(value);

  function onNameChange(event) {
    const { value } = event.target;
    updateFormData(updateValues({
      dialogName: value,
    }));
  }

  return (
    <div>
      <div> Your intro text </div>
      <label className={classes.inputLabel} htmlFor="name">{intl.formatMessage({ id: 'DialogWizardDialogNamePlaceHolder' })}</label>
      <TextField
        id="name"
        className={classes.input}
        value={value}
        onChange={onNameChange}
      />
      <div className={classes.borderBottom}></div>
      <StepButtons {...props} validForm={validForm}/>
    </div>
  );

}

function StepButtons(props) {
  const {
    onStartOver,
    previousStep,
    nextStep,
    totalSteps,
    currentStep,
    validForm,
    onNext,
    onSkip,
    onPrevious,
    onFinish,
    formData,
    showSkip,
    showGoBack,
    finishLabel,
    startOverLabel,
    showStartOver,
    startOverDestroysData,
    classes
  } = props;
  const intl = *useIntl*();
  const lastStep = currentStep === totalSteps - 1; //zero indexed

  function myOnPrevious () {
    onPrevious();
    previousStep();
  }

  function myOnNext () {
    onNext();
    nextStep();
  }

  function myOnSkip () {
    onSkip();
    nextStep();
  }

  function myOnStartOver() {
    // TODO Pop A modal saying are you sure?
    onStartOver();
  }

  function myOnFinish() {
    onFinish(formData);
  }

  const startOverClass = startOverDestroysData? classes.actionStartOver : classes.actionPrimary;
  return (
    <div className={classes.buttonContainer}>
      {showStartOver && (
        <div className={classes.startOverContainer}>
          <Button className={startOverClass} onClick={myOnStartOver}>{intl.formatMessage({ id: startOverLabel })}</Button>
        </div>
      )}

      <div className={classes.actionContainer}>
        {(currentStep > 0) && showGoBack && (
          <Button className={classes.actionSecondary} onClick={myOnPrevious}>{intl.formatMessage({ id: 'OnboardingWizardGoBack' })}</Button>
        )}
        {showSkip && (
          <Button className={classes.actionSkip} variant="outlined" onClick={myOnSkip}>{intl.formatMessage({ id: 'OnboardingWizardSkip' })}</Button>
        )}
        {lastStep && (
          <Button className={classes.actionPrimary} disabled={!validForm} onClick={myOnFinish}>{intl.formatMessage({ id: finishLabel })}</Button>
        )}
        {!lastStep && (
          <Button className={classes.actionPrimary} disabled={!validForm}
                  onClick={myOnNext}>{intl.formatMessage({ id: 'OnboardingWizardContinue' })}</Button>
        )}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Putting that all together, useage of everything together would look like:

function DialogWizard(props) {

  const { hidden, onStartOver } = props;
  const intl = useIntl();


  const stepProtoTypes = [
  {
    label: 'DialogWizardDialogNameStepLabel',
    content: <DialogNameStep/>,
  },
  {
    label: 'DialogWizardDialogReasonStepLabel',
    content: <DialogReasonStep />,
  },
  {
    label: 'DialogWizardDialogExpirationStepLabel',
    content: <DialogExpirationStep />,
  },
  {
    label: 'DialogWizardAddOptionsStepLabel',
    content: <AddOptionsStep />,
  },
  {
    label: 'DialogWizardCreatingDialogStepLabel',
    content: <CreatingDialogStep />,
  }
];

  return (
    <OnboardingWizard
      hidden={hidden}
      title={intl.formatMessage({ id: 'DialogWizardTitle' })}
      onStartOver={onStartOver}
      stepPrototypes={stepProtoTypes}
    />
  );

}
Enter fullscreen mode Exit fullscreen mode

It’s fairly easy to see how this would work if we didn’t have loops. You’d just keep adding things into the stepPrototypes array. But since adding options is a wizard unto itself, how can we render that properly?

The answer is to first allow a step to override the outer wizards UI (since we’re going to be rendering a new wizard), and here’s an extension to Onboarding Wizard to do that

const [overrideUIContent, setOverrideUIContent] = *useState*(false);
// passed into the steps like so
function getCurrentStepContents () {
  const props = {
    ....
    setOverrideUIContent,
    classes
  };
  ...
  return React.cloneElement(content, props);
}

// if overrideUI content is set, turn the entirety of the ui over to the step
 if (overrideUIContent) {
   return currentStep;
 }
Enter fullscreen mode Exit fullscreen mode

Next we need to have a step that sets the overrideUIContent flag on the parent Wizard, and provides a function that stores the result of the child wizard in the parent’s form data. Here’s the code to do that for our AddOptions step, which also renders a simple ui letting the user remove an option from the list.

unction AddOptionsStep(props) {

  const {
    formData,
    updateFormData,
    active,
    setOverrideUIContent,
    classes
  } = props;

  const { addShowSubWizard } = formData;


  if (!active) {
    return React.Fragment;
  }

  const dialogOptions = formData.dialogOptions || [];

  function deleteOption (index) {
    const newOptions = [...dialogOptions];
    newOptions.splice(index, 1); *// remove the element
    updateFormData(updateValues({
      dialogOptions: newOptions,
    }));
  }

  function startSubWizard () {
    updateFormData(updateValues({
      addShowSubWizard: true,
    }));
    setOverrideUIContent(true);
  }

  function hideSubWizard () {
    updateFormData(updateValues({addShowSubWizard: false}));
    setOverrideUIContent(false);
  }

  function onSubWizardFinish (optionData) {
    const newOptions = [...dialogOptions, optionData];
    updateFormData(updateValues({
      dialogOptions: newOptions,
    }));
    hideSubWizard();
  }

  function onSubWizardStartOver() {
    hideSubWizard();
  }


  const validForm = dialogOptions.length >= 1;

  if (addShowSubWizard) {
   return (<AddOptionWizard
      hidden={false}
      onStartOver={onSubWizardStartOver}
      onFinish={onSubWizardFinish}
    />);
  }

  function currentOptions() {
    return (
      <List>
        {dialogOptions.map((option, index) => {
          return (
            <ListItem key={index}>
              <ListItemText>
                {option.optionName}
              </ListItemText>
              <ListItemSecondaryAction>
                <TooltipIconButton
                  translationId="delete"
                  icon={<DeleteIcon/>}
                  onClick={() => deleteOption(index)}
                />
              </ListItemSecondaryAction>
            </ListItem>
          );
        })}
      </List>
    );
  }

// now for the card UI
  return (
    <div>
      <Typography className={classes.introText} variant="body2">
        ... Explanatory Text
      </Typography>
      {currentOptions()}
      <Button onClick={startSubWizard}>Add New Option</Button>
      <div className={classes.borderBottom}></div>
      <StepButtons {...props} validForm={validForm}/>
    </div>
  );

}
Enter fullscreen mode Exit fullscreen mode

Now we’ve got steps that can take over the UI, conditionally launch new wizards, and mutate the form data with the results of that wizard. With that we can model pretty much any workflow we want.

Top comments (0)