mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
732f3da33f
commit
b033fe8d73
@ -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();
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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={{
|
||||
|
@ -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);
|
||||
};
|
11
public/app/features/correlations/Forms/utils.ts
Normal file
11
public/app/features/correlations/Forms/utils.ts
Normal 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}`;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './Wizard';
|
||||
export * from './types';
|
30
public/app/features/correlations/components/Wizard/types.ts
Normal file
30
public/app/features/correlations/components/Wizard/types.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user