mirror of https://github.com/grafana/grafana
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 defaultspull/65229/head
parent
732f3da33f
commit
b033fe8d73
@ -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> |
||||
); |
||||
}; |
@ -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); |
||||
}; |
@ -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'; |
@ -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