glass-app-manager
Version:
Informatica's Glass Framework CLI for bootstrapping
232 lines (201 loc) • 8.34 kB
JavaScript
// @flow
import * as React from "react";
import Button from "../button/Button";
import Roadmap from "../roadmap/Roadmap";
type VisitedSteps = Array<boolean>;
type StepDefinition = {|
name: string,
render: () => React.Node,
beforeLeave?: () => Promise<boolean>,
beforeEnter?: () => Promise<boolean>,
isValid?: () => Promise<boolean>,
|};
type Steps = Array<StepDefinition>;
type RenderProps = {|
isFirstStep: boolean,
isLastStep: boolean,
isSubmitting: boolean,
step: number,
steps: Steps,
setStep: number => void,
visitedSteps: VisitedSteps,
|};
type WizardProps = {
children: RenderProps => React.Node | React.Node,
/**
* Each `step` is represented by a `StepDefinition` object with the following properties:
*
* * __`name: string`__:
* The name of the step. Shown in the `Roadmap` component.
*
* * __`render: () => React.Node`__:
* A function that returns the content that should be rendered for this step.
* Shown in the `Wizard.View` component.
*
* * __`isValid?: () => Promise<boolean>`__:
* An optional callback that will be called for validating existing step.
* This will be called only when the step that is being transitioned to is not previously visited.
* This function should always return a `Promise` — if a synchronous operation
* is used to determine the result, you can simply return `Promise.resolve(true | false)`.
* This is where you can define step validation logic (or use a third-party library like Formik)
* and block all step changes. This is useful for create wizards where users shouldnt be allowed
* to proceed to the next step if the current step is in an invalid state.
*
* * __`beforeEnter?: () => Promise<boolean>`__:
* An optional callback used when Wizard attempts to visit the step.
* This function should always return a `Promise` — if a synchronous operation
* is used to determine the result, you can simply return `Promise.resolve(true | false)`.
* This is where you can explicitly check for field values that must be valid before a step can
* be visitied and block step changes if the requested step depends on a previous invalid step
* or a field value. This is useful for editable Wizards where users can be allowed to switch between
* steps even when the steps are invalid.
*
* * __`beforeLeave?: () => Promise<boolean>`__:
* An optional callback used when Wizard attempts to change the step.
* This function should always return a `Promise` — if a synchronous operation
* is used to determine the result, you can simply return `Promise.resolve(true | false)`.
* This is where you can add the mandatory condition which need to be checked before leaving the current step,
* You can block the step change by returning `false` from the function. You can continue to move into next step by returning `true`.
* This is useful for Wizards where a step wants to enforce some logic before a step transition is triggered.
*
*/
steps: Steps,
/**
* When implementing an editable Wizard, you can specify the
* initialVisited prop to show specific (or all) steps as visited.
*/
initialVisited?: VisitedSteps,
/**
* Used when performing an asynchronous action that should block
* Wizard controls / step changes from occurring.
*/
isSubmitting?: boolean,
};
const WizardContext = React.createContext<RenderProps>({
isFirstStep: false,
isLastStep: false,
isSubmitting: false,
step: 0,
steps: [],
setStep: () => undefined,
visitedSteps: [],
});
function shouldAllowStepChange(
{ beforeLeave = () => Promise.resolve(true), isValid = () => Promise.resolve(true) },
{ beforeEnter = () => Promise.resolve(true) },
isNextStepVisited = false
): Promise<boolean> {
return new Promise((resolve, reject) => {
isValid = isNextStepVisited ? () => Promise.resolve(true) : isValid;
isValid().then(valid => {
if (!valid) {
return resolve(false);
} else {
beforeLeave().then(result => {
if (!result) {
return resolve(false);
} else {
beforeEnter().then(result => resolve(!!result));
}
});
}
});
});
}
function Wizard({ children, initialVisited = [true], isSubmitting = false, steps, ...rest }: WizardProps) {
if (!steps || steps.length === 0) {
throw new Error("Wizard: `steps` prop cannot be empty");
}
const [step, setStep] = React.useState(0);
const [visitedSteps, setVisitedSteps] = React.useState<VisitedSteps>(initialVisited);
const handleStepChange: (nextStep: number) => void = React.useCallback(
(nextStepIndex: number): void => {
if (nextStepIndex < 0 || nextStepIndex >= steps.length) {
throw new Error("Wizard: Attempted to access an out-of-bounds step.");
}
const currentStep = steps[step];
const nextStep = steps[nextStepIndex];
shouldAllowStepChange(currentStep, nextStep, visitedSteps[nextStepIndex]).then(allow => {
if (!allow) {
if (nextStepIndex < step) {
throw new Error(
"Wizard: beforeEnter should never return false when attempting to go backwards."
);
}
return;
}
setVisitedSteps(prevState => {
const newArray = prevState.slice();
newArray[nextStepIndex] = true;
return newArray;
});
setStep(nextStepIndex);
});
},
[steps, step, setStep, setVisitedSteps]
);
const renderProps: RenderProps = React.useMemo(
() => ({
isFirstStep: step === 0,
isLastStep: step === steps.length - 1,
isSubmitting,
step,
steps,
setStep: handleStepChange,
visitedSteps,
}),
[isSubmitting, step, handleStepChange, visitedSteps]
);
return (
<WizardContext.Provider value={renderProps}>
{typeof children === "function" ? children(renderProps) : children}
</WizardContext.Provider>
);
}
type WizardControlsProps = {|
renderNextOnLastStep?: React.Node,
|};
Wizard.Controls = ({ renderNextOnLastStep }: WizardControlsProps) => {
const { isSubmitting, isLastStep, isFirstStep, step, setStep } = React.useContext<RenderProps>(WizardContext);
return (
<span>
<Button onClick={() => setStep(step - 1)} disabled={isFirstStep || isSubmitting}>
< Back
</Button>{" "}
{isLastStep && renderNextOnLastStep ? (
renderNextOnLastStep
) : (
<Button onClick={() => setStep(step + 1)} disabled={isLastStep || isSubmitting} variant="primary">
Next >
</Button>
)}
</span>
);
};
Wizard.View = () => {
const { step, steps } = React.useContext<RenderProps>(WizardContext);
return steps[step].render();
};
Wizard.Roadmap = () => {
const { steps, step, setStep, visitedSteps } = React.useContext<RenderProps>(WizardContext);
const getStatus = idx => {
if (step === idx) {
return "current";
}
return visitedSteps[idx] ? "enabled" : "disabled";
};
return (
<Roadmap>
{steps.map((step, idx) => (
<Roadmap.Step
index={idx}
key={step.name}
name={step.name}
onClick={() => setStep(idx)}
status={getStatus(idx)}
/>
))}
</Roadmap>
);
};
export default Wizard;