Skip to content

Commit

Permalink
[7.x] [Index template] Refactor index template wizard (#69037) (#69535)
Browse files Browse the repository at this point in the history
Co-authored-by: Alison Goryachev <alisonmllr20@gmail.com>
  • Loading branch information
sebelga and alisonelizabeth authored Jun 18, 2020
1 parent 8ec8f33 commit 181c1cb
Show file tree
Hide file tree
Showing 42 changed files with 1,990 additions and 1,040 deletions.
45 changes: 45 additions & 0 deletions src/plugins/es_ui_shared/public/forms/form_wizard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# FormWizard

The `<FormWizard />` and `<FormWizardStep />` components lets us declare form wizard in a declarative way. It works hand in hand with the `MultiContent` explained above to make building form wizards a breeze. 😊

It takes care of enabling, disabling the `<EuiStepsHorizontal />` steps as well as the "Back" and "Next" button.

Let's see it through an example

```js
const MyForm = () => {
return (
<FormWizard<MyMultiContent>
defaultValue={wizardDefaultValue} // The MultiContent default value as explained above
onSave={onSaveTemplate} // A handler that will receive the multi-content data
isEditing={isEditing} // A boolean that will indicate if all steps are already "completed" and thus valid or if we need to complete them in order
isSaving={isSaving} // A boolean to show a "Saving..." text on the button on the last step
apiError={apiError} // Any API error to display on top of wizard
texts={i18nTexts} // i18n translations for the nav button.
>
<FormWizarStep id="contentOne" lable="Label for the step">
<div>
Here you can put anything... but you probably want to put a Container from the
MultiContent example above.
</div>
</FormWizarStep>

<FormWizarStep id="contentTwo" lable="Label for the step" isRequired>
<div>
Here you can put anything... but you probably want to put a Container from the
MultiContent example above.
</div>
</FormWizarStep>

<FormWizarStep id="contentThree" lable="Label for the step">
<div>
Here you can put anything... but you probably want to put a Container from the
MultiContent example above.
</div>
</FormWizarStep>
</FormWizard>
);
};
```

That's all we need to build a multi-step form wizard, making sure the data is cached when switching steps.
139 changes: 139 additions & 0 deletions src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React from 'react';
import { EuiStepsHorizontal, EuiSpacer } from '@elastic/eui';

import {
FormWizardProvider,
FormWizardConsumer,
Props as ProviderProps,
} from './form_wizard_context';
import { FormWizardNav, NavTexts } from './form_wizard_nav';

interface Props<T extends object> extends ProviderProps<T> {
isSaving?: boolean;
apiError: JSX.Element | null;
texts?: Partial<NavTexts>;
}

export function FormWizard<T extends object = { [key: string]: any }>({
texts,
defaultActiveStep,
defaultValue,
apiError,
isEditing,
isSaving,
onSave,
onChange,
children,
}: Props<T>) {
return (
<FormWizardProvider<T>
defaultValue={defaultValue}
isEditing={isEditing}
onSave={onSave}
onChange={onChange}
defaultActiveStep={defaultActiveStep}
>
<FormWizardConsumer>
{({ activeStepIndex, lastStep, steps, isCurrentStepValid, navigateToStep }) => {
const stepsRequiredArray = Object.values(steps).map(
(step) => Boolean(step.isRequired) && step.isComplete === false
);

const getIsStepDisabled = (stepIndex: number) => {
// Disable all steps when the current step is invalid
if (stepIndex !== activeStepIndex && isCurrentStepValid === false) {
return true;
}

let isDisabled = false;

if (stepIndex > activeStepIndex + 1) {
/**
* Rule explained:
* - all the previous steps are always enabled (we can go back anytime)
* - the next step is also always enabled (it acts as the "Next" button)
* - for the rest, the step is disabled if any of the previous step (_greater_ than the current
* active step), is marked as isRequired **AND** has not been completed.
*/
isDisabled = stepsRequiredArray.reduce((acc, isRequired, i) => {
if (acc === true || i <= activeStepIndex || i >= stepIndex) {
return acc;
}
return Boolean(isRequired);
}, false);
}

return isDisabled;
};

const euiSteps = Object.values(steps).map(({ index, label }) => {
return {
title: label,
isComplete: activeStepIndex > index,
isSelected: activeStepIndex === index,
disabled: getIsStepDisabled(index),
onClick: () => navigateToStep(index),
};
});

const onBack = () => {
const prevStep = activeStepIndex - 1;
navigateToStep(prevStep);
};

const onNext = () => {
const nextStep = activeStepIndex + 1;
navigateToStep(nextStep);
};

return (
<>
{/* Horizontal Steps indicator */}
<EuiStepsHorizontal steps={euiSteps} />

<EuiSpacer size="l" />

{/* Any possible API error when saving/updating */}
{apiError}

{/* Active step content */}
{children}

<EuiSpacer size="l" />

{/* Button navigation */}
<FormWizardNav
activeStepIndex={activeStepIndex}
lastStep={lastStep}
isStepValid={isCurrentStepValid}
isSaving={isSaving}
onBack={onBack}
onNext={onNext}
texts={texts}
/>
</>
);
}}
</FormWizardConsumer>
</FormWizardProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React, { useState, createContext, useContext, useCallback } from 'react';

import { WithMultiContent, useMultiContentContext, HookProps } from '../multi_content';

export interface Props<T extends object> {
onSave: (data: T) => void | Promise<void>;
children: JSX.Element | JSX.Element[];
isEditing?: boolean;
defaultActiveStep?: number;
defaultValue?: HookProps<T>['defaultValue'];
onChange?: HookProps<T>['onChange'];
}

interface State {
activeStepIndex: number;
steps: Steps;
}

export interface Step {
id: string;
index: number;
label: string;
isRequired: boolean;
isComplete: boolean;
}

export interface Steps {
[stepId: string]: Step;
}

export interface Context<Id extends string = any> extends State {
activeStepId: Id;
lastStep: number;
isCurrentStepValid: boolean | undefined;
navigateToStep: (stepId: number | Id) => void;
addStep: (id: Id, label: string, isRequired?: boolean) => void;
}

const formWizardContext = createContext<Context>({} as Context);

export const FormWizardProvider = WithMultiContent<Props<any>>(function FormWizardProvider<
T extends object = { [key: string]: any }
>({ children, defaultActiveStep = 0, isEditing, onSave }: Props<T>) {
const { getData, validate, validation } = useMultiContentContext<T>();

const [state, setState] = useState<State>({
activeStepIndex: defaultActiveStep,
steps: {},
});

const activeStepId = state.steps[state.activeStepIndex]?.id;
const lastStep = Object.keys(state.steps).length - 1;
const isCurrentStepValid = validation.contents[activeStepId as keyof T];

const addStep = useCallback(
(id: string, label: string, isRequired = false) => {
setState((prev) => {
const index = Object.keys(prev.steps).length;

return {
...prev,
steps: {
...prev.steps,
[index]: { id, index, label, isRequired, isComplete: isEditing ?? false },
},
};
});
},
[isEditing]
);

/**
* Get the step index from a step id.
*/
const getStepIndex = useCallback(
(stepId: number | string) => {
if (typeof stepId === 'number') {
return stepId;
}

// We provided a string stepId, we need to find the corresponding index
const targetStep: Step | undefined = Object.values(state.steps).find(
(_step) => _step.id === stepId
);
if (!targetStep) {
throw new Error(`Can't navigate to step "${stepId}" as there are no step with that ID.`);
}
return targetStep.index;
},
[state.steps]
);

const navigateToStep = useCallback(
async (stepId: number | string) => {
// Before navigating away we validate the active content in the DOM
const isValid = await validate();

// If step is not valid do not go any further
if (!isValid) {
return;
}

const nextStepIndex = getStepIndex(stepId);

if (nextStepIndex > lastStep) {
// We are on the last step, save the data and don't go any further
onSave(getData() as T);
return;
}

// Update the active step
setState((prev) => {
const currentStep = prev.steps[prev.activeStepIndex];

const nextState = {
...prev,
activeStepIndex: nextStepIndex,
};

if (nextStepIndex > prev.activeStepIndex && !currentStep.isComplete) {
// Mark the current step as completed
nextState.steps[prev.activeStepIndex] = {
...currentStep,
isComplete: true,
};
}

return nextState;
});
},
[getStepIndex, validate, onSave, getData]
);

const value: Context = {
...state,
activeStepId,
lastStep,
isCurrentStepValid,
addStep,
navigateToStep,
};

return <formWizardContext.Provider value={value}>{children}</formWizardContext.Provider>;
});

export const FormWizardConsumer = formWizardContext.Consumer;

export function useFormWizardContext<T extends string = any>() {
const ctx = useContext(formWizardContext);
if (ctx === undefined) {
throw new Error('useFormWizardContext() must be called within a <FormWizardProvider />');
}
return ctx as Context<T>;
}
Loading

0 comments on commit 181c1cb

Please sign in to comment.