Glue: Split correlations editor into 3 steps (#64818)

* Create simple Wizard for Correlations editor

* Allow using custom navigation in the wizard

* Update types

* Add more info

* Add comments

* Update comments

* Remove main info box to avoid having too many info boxes

* Fix CorrelationsPage.test.tsx

* Add Wizard test

* Simplify Correlations wizard

* Make expected typing error more explicit

* Don't use meaningless defaults
This commit is contained in:
Piotr Jamróz 2023-03-22 20:20:45 +01:00 committed by GitHub
parent 732f3da33f
commit b033fe8d73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 518 additions and 215 deletions

View File

@ -271,12 +271,12 @@ describe('CorrelationsPage', () => {
mocks.reportInteraction.mockClear();
});
it('shows CTA', async () => {
it('shows the first page of the wizard', async () => {
const CTAButton = await screen.findByRole('button', { name: /add correlation/i });
expect(CTAButton).toBeInTheDocument();
// insert form should not be present
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /next$/i })).not.toBeInTheDocument();
// "add new" button is the button on the top of the page, not visible when the CTA is rendered
expect(screen.queryByRole('button', { name: /add new$/i })).not.toBeInTheDocument();
@ -286,8 +286,8 @@ describe('CorrelationsPage', () => {
await userEvent.click(CTAButton);
// form's submit button
expect(await screen.findByRole('button', { name: /add$/i })).toBeInTheDocument();
// form's next button
expect(await screen.findByRole('button', { name: /next$/i })).toBeInTheDocument();
});
it('correctly adds first correlation', async () => {
@ -299,22 +299,27 @@ describe('CorrelationsPage', () => {
await userEvent.click(CTAButton);
// step 1: label and description
await userEvent.clear(screen.getByRole('textbox', { name: /label/i }));
await userEvent.type(screen.getByRole('textbox', { name: /label/i }), 'A Label');
await userEvent.clear(screen.getByRole('textbox', { name: /description/i }));
await userEvent.type(screen.getByRole('textbox', { name: /description/i }), 'A Description');
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
await userEvent.click(screen.getByText('loki'));
// step 2:
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
fireEvent.keyDown(screen.getByLabelText(/^target/i), { keyCode: 40 });
await userEvent.click(screen.getByText('prometheus'));
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
await userEvent.clear(screen.getByRole('textbox', { name: /target field/i }));
await userEvent.type(screen.getByRole('textbox', { name: /target field/i }), 'Line');
// step 3:
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source/i), { keyCode: 40 });
await userEvent.click(screen.getByText('loki'));
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_added');
@ -432,21 +437,26 @@ describe('CorrelationsPage', () => {
expect(addNewButton).toBeInTheDocument();
await userEvent.click(addNewButton);
// step 1:
await userEvent.clear(screen.getByRole('textbox', { name: /label/i }));
await userEvent.type(screen.getByRole('textbox', { name: /label/i }), 'A Label');
await userEvent.clear(screen.getByRole('textbox', { name: /description/i }));
await userEvent.type(screen.getByRole('textbox', { name: /description/i }), 'A Description');
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
// step 2:
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target/i), { keyCode: 40 });
await userEvent.click(screen.getByText('elastic'));
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
// step 3:
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
fireEvent.keyDown(screen.getByLabelText(/^source/i), { keyCode: 40 });
await userEvent.click(within(screen.getByLabelText('Select options menu')).getByText('prometheus'));
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
await userEvent.click(screen.getByText('elastic'));
await userEvent.clear(screen.getByRole('textbox', { name: /target field/i }));
await userEvent.type(screen.getByRole('textbox', { name: /target field/i }), 'Line');
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');
await userEvent.click(screen.getByRole('button', { name: /add$/i }));
@ -506,6 +516,8 @@ describe('CorrelationsPage', () => {
expect(screen.queryByRole('cell', { name: /edited label$/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
expect(await screen.findByRole('cell', { name: /edited label$/i })).toBeInTheDocument();

View File

@ -145,7 +145,6 @@ export default function CorrelationsPage() {
<div>
<HorizontalGroup justify="space-between">
<div>
<h4>Correlations</h4>
<p>Define how data living in different data sources relates to each other.</p>
</div>
{canWriteCorrelations && data?.length !== 0 && data !== undefined && !isAdding && (

View File

@ -1,16 +1,18 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, Field, HorizontalGroup, PanelContainer, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { PanelContainer, useStyles2 } from '@grafana/ui';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { Wizard } from '../components/Wizard';
import { useCorrelations } from '../useCorrelations';
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
import { ConfigureCorrelationBasicInfoForm } from './ConfigureCorrelationBasicInfoForm';
import { ConfigureCorrelationSourceForm } from './ConfigureCorrelationSourceForm';
import { ConfigureCorrelationTargetForm } from './ConfigureCorrelationTargetForm';
import { CorrelationFormNavigation } from './CorrelationFormNavigation';
import { CorrelationsFormContextProvider } from './correlationsFormContext';
import { FormDTO } from './types';
const getStyles = (theme: GrafanaTheme2) => ({
@ -19,20 +21,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: ${theme.spacing(1)};
margin-bottom: ${theme.spacing(2)};
`,
linksToContainer: css`
flex-grow: 1;
/* This is the width of the textarea minus the sum of the label&description fields,
* so that this element takes exactly the remaining space and the inputs will be
* nicely aligned with the textarea
**/
max-width: ${theme.spacing(80 - 64)};
margin-top: ${theme.spacing(3)};
text-align: right;
padding-right: ${theme.spacing(1)};
`,
// we can't use HorizontalGroup because it wraps elements in divs and sets margins on them
horizontalGroup: css`
display: flex;
infoBox: css`
margin-top: 20px; // give space for close button
`,
});
@ -41,8 +31,6 @@ interface Props {
onCreated: () => void;
}
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
export const AddCorrelationForm = ({ onClose, onCreated }: Props) => {
const styles = useStyles2(getStyles);
@ -56,75 +44,19 @@ export const AddCorrelationForm = ({ onClose, onCreated }: Props) => {
}
}, [error, loading, value, onCreated]);
const methods = useForm<FormDTO>({ defaultValues: { config: { type: 'query', target: {} } } });
const defaultValues: Partial<FormDTO> = { config: { type: 'query', target: {}, field: '' } };
return (
<PanelContainer className={styles.panelContainer}>
<CloseButton onClick={onClose} />
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(execute)}>
<div className={styles.horizontalGroup}>
<Controller
control={methods.control}
name="sourceUID"
rules={{
required: { value: true, message: 'This field is required.' },
validate: {
writable: (uid: string) =>
!getDatasourceSrv().getInstanceSettings(uid)?.readOnly ||
"Source can't be a read-only data source.",
},
}}
render={({ field: { onChange, value } }) => (
<Field
label="Source"
htmlFor="source"
invalid={!!methods.formState.errors.sourceUID}
error={methods.formState.errors.sourceUID?.message}
>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="source"
width={32}
/>
</Field>
)}
/>
<div className={styles.linksToContainer}>Links to</div>
<Controller
control={methods.control}
name="targetUID"
rules={{ required: { value: true, message: 'This field is required.' } }}
render={({ field: { onChange, value } }) => (
<Field
label="Target"
htmlFor="target"
invalid={!!methods.formState.errors.targetUID}
error={methods.formState.errors.targetUID?.message}
>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="target"
width={32}
/>
</Field>
)}
/>
</div>
<CorrelationDetailsFormPart />
<HorizontalGroup justify="flex-end">
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'plus'} type="submit" disabled={loading}>
Add
</Button>
</HorizontalGroup>
</form>
</FormProvider>
<CorrelationsFormContextProvider data={{ loading, readOnly: false, correlation: undefined }}>
<Wizard<FormDTO>
defaultValues={defaultValues}
pages={[ConfigureCorrelationBasicInfoForm, ConfigureCorrelationTargetForm, ConfigureCorrelationSourceForm]}
navigation={CorrelationFormNavigation}
onSubmit={execute}
/>
</CorrelationsFormContextProvider>
</PanelContainer>
);
};

View File

@ -0,0 +1,57 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, FieldSet, Input, TextArea, useStyles2 } from '@grafana/ui';
import { useCorrelationsFormContext } from './correlationsFormContext';
import { FormDTO } from './types';
import { getInputId } from './utils';
const getStyles = (theme: GrafanaTheme2) => ({
label: css`
max-width: ${theme.spacing(80)};
`,
description: css`
max-width: ${theme.spacing(80)};
`,
});
export const ConfigureCorrelationBasicInfoForm = () => {
const { register, formState } = useFormContext<FormDTO>();
const styles = useStyles2(getStyles);
const { correlation, readOnly } = useCorrelationsFormContext();
return (
<>
<FieldSet label="Define correlation name (1/3)">
<p>The name of the correlation is used as the label of the link.</p>
<input type="hidden" {...register('config.type')} />
<Field
label="Label"
description="This name is be used as the label of the link button"
className={styles.label}
invalid={!!formState.errors.label}
error={formState.errors.label?.message}
>
<Input
id={getInputId('label', correlation)}
{...register('label', { required: { value: true, message: 'This field is required.' } })}
readOnly={readOnly}
placeholder="e.g. Tempo traces"
/>
</Field>
<Field
label="Description"
description="Optional description with more information about the link"
// the Field component automatically adds margin to itself, so we are forced to workaround it by overriding its styles
className={cx(styles.description)}
>
<TextArea id={getInputId('description', correlation)} {...register('description')} readOnly={readOnly} />
</Field>
</FieldSet>
</>
);
};

View File

@ -0,0 +1,79 @@
import { css } from '@emotion/css';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Field, FieldSet, Input, useStyles2 } from '@grafana/ui';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { useCorrelationsFormContext } from './correlationsFormContext';
import { getInputId } from './utils';
const getStyles = (theme: GrafanaTheme2) => ({
label: css`
max-width: ${theme.spacing(80)};
`,
});
export const ConfigureCorrelationSourceForm = () => {
const { control, formState, register } = useFormContext();
const styles = useStyles2(getStyles);
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
const { correlation, readOnly } = useCorrelationsFormContext();
return (
<>
<FieldSet label="Configure source data source (3/3)">
<p>
Links are displayed with results of the selected origin source data. They shown along with the value of the
provided <em>results field</em>.
</p>
<Controller
control={control}
name="sourceUID"
rules={{
required: { value: true, message: 'This field is required.' },
validate: {
writable: (uid: string) =>
!getDatasourceSrv().getInstanceSettings(uid)?.readOnly || "Source can't be a read-only data source.",
},
}}
render={({ field: { onChange, value } }) => (
<Field
label="Source"
description="Results from selected source data source have links displayed in the panel"
htmlFor="source"
invalid={!!formState.errors.sourceUID}
error={formState.errors.sourceUID?.message}
>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="source"
width={32}
disabled={correlation !== undefined}
/>
</Field>
)}
/>
<Field
label="Results field"
description="The link will be shown next to the value of this field"
className={styles.label}
invalid={!!formState.errors?.config?.field}
error={formState.errors?.config?.field?.message}
>
<Input
id={getInputId('field', correlation)}
{...register('config.field', { required: 'This field is required.' })}
readOnly={readOnly}
/>
</Field>
</FieldSet>
</>
);
};

View File

@ -0,0 +1,54 @@
import React from 'react';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { DataSourceInstanceSettings } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Field, FieldSet } from '@grafana/ui';
import { QueryEditorField } from './QueryEditorField';
import { useCorrelationsFormContext } from './correlationsFormContext';
export const ConfigureCorrelationTargetForm = () => {
const { control, formState } = useFormContext();
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
const { correlation } = useCorrelationsFormContext();
const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID;
return (
<>
<FieldSet label="Setup target query (2/3)">
<p>Clicking on a link runs a provided target query.</p>
<Controller
control={control}
name="targetUID"
rules={{ required: { value: true, message: 'This field is required.' } }}
render={({ field: { onChange, value } }) => (
<Field
label="Target"
description="Specify which data source is queried when the link is clicked"
htmlFor="target"
invalid={!!formState.errors.targetUID}
error={formState.errors.targetUID?.message}
>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="target"
width={32}
disabled={correlation !== undefined}
/>
</Field>
)}
/>
<QueryEditorField
name="config.target"
dsUid={targetUID}
invalid={!!formState.errors?.config?.target}
error={formState.errors?.config?.target?.message}
/>
</FieldSet>
</>
);
};

View File

@ -1,87 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, Input, TextArea, useStyles2 } from '@grafana/ui';
import { Correlation } from '../types';
import { QueryEditorField } from './QueryEditorField';
import { FormDTO } from './types';
const getInputId = (inputName: string, correlation?: CorrelationBaseData) => {
if (!correlation) {
return inputName;
}
return `${inputName}_${correlation.sourceUID}-${correlation.uid}`;
};
const getStyles = (theme: GrafanaTheme2) => ({
label: css`
max-width: ${theme.spacing(32)};
`,
description: css`
max-width: ${theme.spacing(80)};
`,
});
type CorrelationBaseData = Pick<Correlation, 'uid' | 'sourceUID' | 'targetUID'>;
interface Props {
readOnly?: boolean;
correlation?: CorrelationBaseData;
}
export function CorrelationDetailsFormPart({ readOnly = false, correlation }: Props) {
const styles = useStyles2(getStyles);
const {
register,
formState: { errors },
} = useFormContext<FormDTO>();
const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID;
return (
<>
<input type="hidden" {...register('config.type')} />
<Field label="Label" className={styles.label}>
<Input
id={getInputId('label', correlation)}
{...register('label')}
readOnly={readOnly}
placeholder="i.e. Tempo traces"
/>
</Field>
<Field
label="Description"
// the Field component automatically adds margin to itself, so we are forced to workaround it by overriding its styles
className={cx(styles.description)}
>
<TextArea id={getInputId('description', correlation)} {...register('description')} readOnly={readOnly} />
</Field>
<Field
label="Target field"
className={styles.label}
invalid={!!errors?.config?.field}
error={errors?.config?.field?.message}
>
<Input
id={getInputId('field', correlation)}
{...register('config.field', { required: 'This field is required.' })}
readOnly={readOnly}
/>
</Field>
<QueryEditorField
name="config.target"
dsUid={targetUID}
invalid={!!errors?.config?.target}
// @ts-expect-error react-hook-form's errors do not work well with object types
error={errors?.config?.target?.message}
/>
</>
);
}

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Button, HorizontalGroup } from '@grafana/ui';
import { useWizardContext } from '../components/Wizard/wizardContext';
import { useCorrelationsFormContext } from './correlationsFormContext';
export const CorrelationFormNavigation = () => {
const { currentPage, prevPage, isLastPage } = useWizardContext();
const { readOnly, loading, correlation } = useCorrelationsFormContext();
const LastPageNext = !readOnly && (
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'save'} type="submit" disabled={loading}>
{correlation === undefined ? 'Add' : 'Save'}
</Button>
);
const NextPage = (
<Button variant="secondary" type="submit">
Next
</Button>
);
return (
<HorizontalGroup justify="flex-end">
{currentPage > 0 ? (
<Button variant="secondary" onClick={prevPage}>
Back
</Button>
) : undefined}
{isLastPage ? LastPageNext : NextPage}
</HorizontalGroup>
);
};

View File

@ -1,12 +1,14 @@
import React, { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Button, HorizontalGroup } from '@grafana/ui';
import { Wizard } from '../components/Wizard';
import { Correlation } from '../types';
import { useCorrelations } from '../useCorrelations';
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
import { ConfigureCorrelationBasicInfoForm } from './ConfigureCorrelationBasicInfoForm';
import { ConfigureCorrelationSourceForm } from './ConfigureCorrelationSourceForm';
import { ConfigureCorrelationTargetForm } from './ConfigureCorrelationTargetForm';
import { CorrelationFormNavigation } from './CorrelationFormNavigation';
import { CorrelationsFormContextProvider } from './correlationsFormContext';
import { EditFormDTO } from './types';
interface Props {
@ -30,23 +32,14 @@ export const EditCorrelationForm = ({ onUpdated, correlation, readOnly = false }
}
}, [error, loading, value, onUpdated]);
const { uid, sourceUID, targetUID, ...otherCorrelation } = correlation;
const methods = useForm<EditFormDTO>({ defaultValues: otherCorrelation });
return (
<FormProvider {...methods}>
<form onSubmit={readOnly ? (e) => e.preventDefault() : methods.handleSubmit(onSubmit)}>
<CorrelationDetailsFormPart readOnly={readOnly} correlation={correlation} />
{!readOnly && (
<HorizontalGroup justify="flex-end">
<Button variant="primary" icon={loading ? 'fa fa-spinner' : 'save'} type="submit" disabled={loading}>
Save
</Button>
</HorizontalGroup>
)}
</form>
</FormProvider>
<CorrelationsFormContextProvider data={{ loading, readOnly, correlation }}>
<Wizard<EditFormDTO>
defaultValues={correlation}
pages={[ConfigureCorrelationBasicInfoForm, ConfigureCorrelationTargetForm, ConfigureCorrelationSourceForm]}
onSubmit={readOnly ? (e) => () => {} : onSubmit}
navigation={CorrelationFormNavigation}
/>
</CorrelationsFormContextProvider>
);
};

View File

@ -101,7 +101,24 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
};
return (
<Field label="Query" invalid={invalid} error={error}>
<Field
label="Query"
description={
<span>
Define the query that is run when the link is clicked. You can use{' '}
<a
href="https://grafana.com/docs/grafana/latest/panels-visualizations/configure-data-links/"
target="_blank"
rel="noreferrer"
>
variables
</a>{' '}
to access specific field values.
</span>
}
invalid={invalid}
error={error}
>
<Controller
name={name}
rules={{

View File

@ -0,0 +1,28 @@
import React, { createContext, PropsWithChildren, useContext } from 'react';
import { Correlation } from '../types';
export type CorrelationsFormContextData = {
loading: boolean;
correlation?: Correlation;
readOnly: boolean;
};
export const CorrelationsFormContext = createContext<CorrelationsFormContextData>({
loading: false,
correlation: undefined,
readOnly: false,
});
type Props = {
data: CorrelationsFormContextData;
};
export const CorrelationsFormContextProvider = (props: PropsWithChildren<Props>) => {
const { data, children } = props;
return <CorrelationsFormContext.Provider value={data}>{children}</CorrelationsFormContext.Provider>;
};
export const useCorrelationsFormContext = () => {
return useContext(CorrelationsFormContext);
};

View File

@ -0,0 +1,11 @@
import { Correlation } from '../types';
type CorrelationBaseData = Pick<Correlation, 'uid' | 'sourceUID' | 'targetUID'>;
export const getInputId = (inputName: string, correlation?: CorrelationBaseData) => {
if (!correlation) {
return inputName;
}
return `${inputName}_${correlation.sourceUID}-${correlation.uid}`;
};

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Wizard } from './Wizard';
const MockPage1 = () => <span>Page 1</span>;
const MockPage2 = () => <span>Page 2</span>;
const MockNavigation = () => (
<span>
<button type="submit">next</button>
</span>
);
const onSubmitMock = jest.fn();
describe('Wizard', () => {
beforeEach(() => {
render(<Wizard pages={[MockPage1, MockPage2]} navigation={MockNavigation} onSubmit={onSubmitMock} />);
});
afterEach(() => {
onSubmitMock.mockReset();
});
it('Renders each page and submits at the end', async () => {
expect(screen.queryByText('Page 1')).toBeInTheDocument();
expect(screen.queryByText('Page 2')).not.toBeInTheDocument();
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
expect(onSubmitMock).not.toBeCalled();
expect(screen.queryByText('Page 1')).not.toBeInTheDocument();
expect(screen.queryByText('Page 2')).toBeInTheDocument();
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
expect(onSubmitMock).toBeCalled();
});
});

View File

@ -0,0 +1,18 @@
import React from 'react';
import { useForm, FormProvider, FieldValues } from 'react-hook-form';
import { WizardContent } from './WizardContent';
import { WizardProps } from './types';
import { WizardContextProvider } from './wizardContext';
export function Wizard<T extends FieldValues>(props: WizardProps<T>) {
const { defaultValues, pages, onSubmit, navigation } = props;
const formMethods = useForm<T>({ defaultValues });
return (
<FormProvider {...formMethods}>
<WizardContextProvider pages={pages} onSubmit={onSubmit}>
<WizardContent navigation={navigation} />
</WizardContextProvider>
</FormProvider>
);
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useWizardContext } from './wizardContext';
type Props = {
navigation: React.ComponentType;
};
export function WizardContent(props: Props) {
const { navigation } = props;
const { handleSubmit } = useFormContext();
const { CurrentPageComponent, isLastPage, nextPage, onSubmit } = useWizardContext();
const NavigationComponent = navigation;
return (
<form
onSubmit={handleSubmit((data) => {
if (isLastPage) {
onSubmit(data);
} else {
nextPage();
}
})}
>
<CurrentPageComponent />
<NavigationComponent />
</form>
);
}

View File

@ -0,0 +1,2 @@
export * from './Wizard';
export * from './types';

View File

@ -0,0 +1,30 @@
import { ComponentType } from 'react';
import { DeepPartial, UnpackNestedValue } from 'react-hook-form';
export type WizardProps<T> = {
/**
* Initial values for the form
*/
defaultValues?: UnpackNestedValue<DeepPartial<T>>;
/**
* List of steps/pages in the wizard.
* These are just React components. Wizard component uses react-form-hook. To access the form context
* inside a page component use useFormContext, e.g.
* const { register } = useFormContext();
*/
pages: ComponentType[];
/**
* Navigation component to move between previous and next pages.
*
* This is a React component. To get access to navigation logic use useWizardContext, e.g.
* const { currentPage, prevPage, isLastPage } = useWizardContext();
*/
navigation: ComponentType;
/**
* Final callback submitted on the last page
*/
onSubmit: (data: T) => void;
};

View File

@ -0,0 +1,54 @@
import React, { createContext, PropsWithChildren, useContext, useState } from 'react';
import { FieldValues } from 'react-hook-form';
export type WizardContextProps<T> = {
currentPage: number;
nextPage: () => void;
prevPage: () => void;
isLastPage: boolean;
onSubmit: (data: T) => void;
CurrentPageComponent: React.ComponentType;
};
export const WizardContext = createContext<WizardContextProps<FieldValues> | undefined>(undefined);
/**
* Dependencies provided to Wizard component required to build WizardContext
*/
type WizardContextProviderDeps<T> = {
pages: React.ComponentType[];
onSubmit: (data: T) => void;
};
/**
* Context providing current state and logic of a Wizard. Can be used by pages and navigation components.
*/
export function WizardContextProvider<T>(props: PropsWithChildren<WizardContextProviderDeps<T>>) {
const [currentPage, setCurrentPage] = useState(0);
const { pages, onSubmit, children } = props;
return (
<WizardContext.Provider
value={{
currentPage,
CurrentPageComponent: pages[currentPage],
isLastPage: currentPage === pages.length - 1,
nextPage: () => setCurrentPage(currentPage + 1),
prevPage: () => setCurrentPage(currentPage - 1),
// @ts-expect-error
onSubmit,
}}
>
{children}
</WizardContext.Provider>
);
}
export const useWizardContext = () => {
const ctx = useContext(WizardContext);
if (!ctx) {
throw new Error('useWizardContext must be used within a WizardContextProvider');
}
return ctx;
};