mirror of https://github.com/grafana/grafana
Migration: Edit notification channel (#25980)
* implement edit page * connectWithCleanup * remove angular related code * use loadingindicator * use the correct loading component * handle secureFields * fixed implementation of secure fields * Keep secureFields after rerendering the form * CollapsableSection and Page refactor * use checkbox instead of switch * fix comment * add cursor to section * Fixed issues after PR review * Fix issue with some settings being undefined * new reducer and start with test * algorithm to migrate secure fields * UX: Minor UI Tweaks * Added field around checkboxes, and missing required field * fixed test * tests for util * minor tweaks and changes * define as records * fix typ error * forward invalid to textarea and inputcontrol * merge formdata and redux data in test * fix issue with creating channel * do not figure out securefields in migration Co-authored-by: Torkel Ödegaard <torkel@grafana.com>pull/27475/head
parent
1e2f3ca599
commit
400aafa3b3
@ -0,0 +1,10 @@ |
||||
import { Meta, Props } from '@storybook/addon-docs/blocks'; |
||||
import { CollapsableSection } from './CollapsableSection'; |
||||
|
||||
<Meta title="MDX|CollapsableSection" component={CollapsableSection} /> |
||||
|
||||
# Collapsable Section |
||||
A simple container for enabling collapsing/expanding of content. |
||||
|
||||
|
||||
<Props of={CollapsableSection} /> |
||||
@ -0,0 +1,21 @@ |
||||
import React from 'react'; |
||||
import { CollapsableSection } from './CollapsableSection'; |
||||
import mdx from './CollapsableSection.mdx'; |
||||
|
||||
export default { |
||||
title: 'Layout/CollapsableSection', |
||||
component: CollapsableSection, |
||||
parameters: { |
||||
docs: { |
||||
page: mdx, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export const simple = () => { |
||||
return ( |
||||
<CollapsableSection label="Collapsable section" isOpen> |
||||
<div>Here's some content</div> |
||||
</CollapsableSection> |
||||
); |
||||
}; |
||||
@ -0,0 +1,38 @@ |
||||
import React, { FC, ReactNode, useState } from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
import { useStyles } from '../../themes'; |
||||
import { Icon } from '..'; |
||||
|
||||
export interface Props { |
||||
label: string; |
||||
isOpen: boolean; |
||||
children: ReactNode; |
||||
} |
||||
|
||||
export const CollapsableSection: FC<Props> = ({ label, isOpen, children }) => { |
||||
const [open, toggleOpen] = useState<boolean>(isOpen); |
||||
const styles = useStyles(collapsableSectionStyles); |
||||
|
||||
return ( |
||||
<div> |
||||
<div onClick={() => toggleOpen(!open)} className={styles.header}> |
||||
<Icon name={open ? 'angle-down' : 'angle-right'} size="xl" /> |
||||
{label} |
||||
</div> |
||||
<div className={styles.content}>{open && children}</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const collapsableSectionStyles = (theme: GrafanaTheme) => { |
||||
return { |
||||
header: css` |
||||
font-size: ${theme.typography.size.lg}; |
||||
cursor: pointer; |
||||
`,
|
||||
content: css` |
||||
padding: ${theme.spacing.md} 0 ${theme.spacing.md} ${theme.spacing.md}; |
||||
`,
|
||||
}; |
||||
}; |
||||
@ -0,0 +1,150 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux'; |
||||
import { NavModel } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { Form, Spinner } from '@grafana/ui'; |
||||
import Page from 'app/core/components/Page/Page'; |
||||
import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp'; |
||||
import { NotificationChannelForm } from './components/NotificationChannelForm'; |
||||
import { |
||||
loadNotificationChannel, |
||||
loadNotificationTypes, |
||||
testNotificationChannel, |
||||
updateNotificationChannel, |
||||
} from './state/actions'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { getRouteParamsId } from 'app/core/selectors/location'; |
||||
import { mapChannelsToSelectableValue, transformSubmitData, transformTestData } from './utils/notificationChannels'; |
||||
import { NotificationChannelType, NotificationChannelDTO, StoreState } from 'app/types'; |
||||
import { resetSecureField } from './state/reducers'; |
||||
|
||||
interface OwnProps {} |
||||
|
||||
interface ConnectedProps { |
||||
navModel: NavModel; |
||||
channelId: number; |
||||
notificationChannel: any; |
||||
notificationChannelTypes: NotificationChannelType[]; |
||||
} |
||||
|
||||
interface DispatchProps { |
||||
loadNotificationTypes: typeof loadNotificationTypes; |
||||
loadNotificationChannel: typeof loadNotificationChannel; |
||||
testNotificationChannel: typeof testNotificationChannel; |
||||
updateNotificationChannel: typeof updateNotificationChannel; |
||||
resetSecureField: typeof resetSecureField; |
||||
} |
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps; |
||||
|
||||
export class EditNotificationChannelPage extends PureComponent<Props> { |
||||
componentDidMount() { |
||||
const { channelId } = this.props; |
||||
|
||||
this.props.loadNotificationTypes(); |
||||
this.props.loadNotificationChannel(channelId); |
||||
} |
||||
|
||||
onSubmit = (formData: NotificationChannelDTO) => { |
||||
const { notificationChannel } = this.props; |
||||
|
||||
this.props.updateNotificationChannel({ |
||||
/* |
||||
Some settings which lives in a collapsed section will not be registered since |
||||
the section will not be rendered if a user doesn't expand it. Therefore we need to |
||||
merge the initialData with any changes from the form. |
||||
*/ |
||||
...transformSubmitData({ |
||||
...notificationChannel, |
||||
...formData, |
||||
settings: { ...notificationChannel.settings, ...formData.settings }, |
||||
}), |
||||
id: notificationChannel.id, |
||||
}); |
||||
}; |
||||
|
||||
onTestChannel = (formData: NotificationChannelDTO) => { |
||||
const { notificationChannel } = this.props; |
||||
/* |
||||
Same as submit |
||||
*/ |
||||
this.props.testNotificationChannel( |
||||
transformTestData({ |
||||
...notificationChannel, |
||||
...formData, |
||||
settings: { ...notificationChannel.settings, ...formData.settings }, |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
render() { |
||||
const { navModel, notificationChannel, notificationChannelTypes } = this.props; |
||||
|
||||
return ( |
||||
<Page navModel={navModel}> |
||||
<Page.Contents> |
||||
<h2 className="page-sub-heading">Edit notification channel</h2> |
||||
{notificationChannel && notificationChannel.id > 0 ? ( |
||||
<Form |
||||
width={600} |
||||
onSubmit={this.onSubmit} |
||||
defaultValues={{ |
||||
...notificationChannel, |
||||
type: notificationChannelTypes.find(n => n.value === notificationChannel.type), |
||||
}} |
||||
> |
||||
{({ control, errors, getValues, register, watch }) => { |
||||
const selectedChannel = notificationChannelTypes.find(c => c.value === getValues().type.value); |
||||
|
||||
return ( |
||||
<NotificationChannelForm |
||||
selectableChannels={mapChannelsToSelectableValue(notificationChannelTypes)} |
||||
selectedChannel={selectedChannel} |
||||
imageRendererAvailable={config.rendererAvailable} |
||||
onTestChannel={this.onTestChannel} |
||||
register={register} |
||||
watch={watch} |
||||
errors={errors} |
||||
getValues={getValues} |
||||
control={control} |
||||
resetSecureField={this.props.resetSecureField} |
||||
secureFields={notificationChannel.secureFields} |
||||
/> |
||||
); |
||||
}} |
||||
</Form> |
||||
) : ( |
||||
<div> |
||||
Loading notification channel |
||||
<Spinner /> |
||||
</div> |
||||
)} |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
} |
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => { |
||||
const channelId = getRouteParamsId(state.location) as number; |
||||
return { |
||||
navModel: getNavModel(state.navIndex, 'channels'), |
||||
channelId, |
||||
notificationChannel: state.notificationChannel.notificationChannel, |
||||
notificationChannelTypes: state.notificationChannel.notificationChannelTypes, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { |
||||
loadNotificationTypes, |
||||
loadNotificationChannel, |
||||
testNotificationChannel, |
||||
updateNotificationChannel, |
||||
resetSecureField, |
||||
}; |
||||
|
||||
export default connectWithCleanUp( |
||||
mapStateToProps, |
||||
mapDispatchToProps, |
||||
state => state.notificationChannel |
||||
)(EditNotificationChannelPage); |
||||
@ -1,132 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; |
||||
import { NavModel, SelectableValue } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { Form } from '@grafana/ui'; |
||||
import Page from 'app/core/components/Page/Page'; |
||||
import { NewNotificationChannelForm } from './components/NewNotificationChannelForm'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { createNotificationChannel, loadNotificationTypes, testNotificationChannel } from './state/actions'; |
||||
import { NotificationChannel, NotificationChannelDTO, StoreState } from '../../types'; |
||||
|
||||
interface OwnProps {} |
||||
|
||||
interface ConnectedProps { |
||||
navModel: NavModel; |
||||
notificationChannels: NotificationChannel[]; |
||||
} |
||||
|
||||
interface DispatchProps { |
||||
createNotificationChannel: typeof createNotificationChannel; |
||||
loadNotificationTypes: typeof loadNotificationTypes; |
||||
testNotificationChannel: typeof testNotificationChannel; |
||||
} |
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps; |
||||
|
||||
const defaultValues: NotificationChannelDTO = { |
||||
name: '', |
||||
type: { value: 'email', label: 'Email' }, |
||||
sendReminder: false, |
||||
disableResolveMessage: false, |
||||
frequency: '15m', |
||||
settings: { |
||||
uploadImage: config.rendererAvailable, |
||||
autoResolve: true, |
||||
httpMethod: 'POST', |
||||
severity: 'critical', |
||||
}, |
||||
isDefault: false, |
||||
}; |
||||
|
||||
class NewAlertNotificationPage extends PureComponent<Props> { |
||||
componentDidMount() { |
||||
this.props.loadNotificationTypes(); |
||||
} |
||||
|
||||
onSubmit = (data: NotificationChannelDTO) => { |
||||
/* |
||||
Some settings can be options in a select, in order to not save a SelectableValue<T> |
||||
we need to use check if it is a SelectableValue and use its value. |
||||
*/ |
||||
const settings = Object.fromEntries( |
||||
Object.entries(data.settings).map(([key, value]) => { |
||||
return [key, value.hasOwnProperty('value') ? value.value : value]; |
||||
}) |
||||
); |
||||
|
||||
this.props.createNotificationChannel({ |
||||
...defaultValues, |
||||
...data, |
||||
type: data.type.value, |
||||
settings: { ...defaultValues.settings, ...settings }, |
||||
}); |
||||
}; |
||||
|
||||
onTestChannel = (data: NotificationChannelDTO) => { |
||||
this.props.testNotificationChannel({ |
||||
name: data.name, |
||||
type: data.type.value, |
||||
frequency: data.frequency ?? defaultValues.frequency, |
||||
settings: { ...Object.assign(defaultValues.settings, data.settings) }, |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
const { navModel, notificationChannels } = this.props; |
||||
|
||||
/* |
||||
Need to transform these as we have options on notificationChannels, |
||||
this will render a dropdown within the select. |
||||
|
||||
TODO: Memoize? |
||||
*/ |
||||
const selectableChannels: Array<SelectableValue<string>> = notificationChannels.map(channel => ({ |
||||
value: channel.value, |
||||
label: channel.label, |
||||
description: channel.description, |
||||
})); |
||||
|
||||
return ( |
||||
<Page navModel={navModel}> |
||||
<Page.Contents> |
||||
<h2>New Notification Channel</h2> |
||||
<Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues}> |
||||
{({ register, errors, control, getValues, watch }) => { |
||||
const selectedChannel = notificationChannels.find(c => c.value === getValues().type.value); |
||||
|
||||
return ( |
||||
<NewNotificationChannelForm |
||||
selectableChannels={selectableChannels} |
||||
selectedChannel={selectedChannel} |
||||
onTestChannel={this.onTestChannel} |
||||
register={register} |
||||
errors={errors} |
||||
getValues={getValues} |
||||
control={control} |
||||
watch={watch} |
||||
imageRendererAvailable={config.rendererAvailable} |
||||
/> |
||||
); |
||||
}} |
||||
</Form> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
} |
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => { |
||||
return { |
||||
navModel: getNavModel(state.navIndex, 'channels'), |
||||
notificationChannels: state.alertRules.notificationChannels, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { |
||||
createNotificationChannel, |
||||
loadNotificationTypes, |
||||
testNotificationChannel, |
||||
}; |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NewAlertNotificationPage); |
||||
@ -0,0 +1,96 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; |
||||
import { NavModel } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { Form } from '@grafana/ui'; |
||||
import Page from 'app/core/components/Page/Page'; |
||||
import { NotificationChannelForm } from './components/NotificationChannelForm'; |
||||
import { |
||||
defaultValues, |
||||
mapChannelsToSelectableValue, |
||||
transformSubmitData, |
||||
transformTestData, |
||||
} from './utils/notificationChannels'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { createNotificationChannel, loadNotificationTypes, testNotificationChannel } from './state/actions'; |
||||
import { NotificationChannelType, NotificationChannelDTO, StoreState } from '../../types'; |
||||
import { resetSecureField } from './state/reducers'; |
||||
|
||||
interface OwnProps {} |
||||
|
||||
interface ConnectedProps { |
||||
navModel: NavModel; |
||||
notificationChannelTypes: NotificationChannelType[]; |
||||
} |
||||
|
||||
interface DispatchProps { |
||||
createNotificationChannel: typeof createNotificationChannel; |
||||
loadNotificationTypes: typeof loadNotificationTypes; |
||||
testNotificationChannel: typeof testNotificationChannel; |
||||
resetSecureField: typeof resetSecureField; |
||||
} |
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps; |
||||
|
||||
class NewNotificationChannelPage extends PureComponent<Props> { |
||||
componentDidMount() { |
||||
this.props.loadNotificationTypes(); |
||||
} |
||||
|
||||
onSubmit = (data: NotificationChannelDTO) => { |
||||
this.props.createNotificationChannel(transformSubmitData({ ...defaultValues, ...data })); |
||||
}; |
||||
|
||||
onTestChannel = (data: NotificationChannelDTO) => { |
||||
this.props.testNotificationChannel(transformTestData({ ...defaultValues, ...data })); |
||||
}; |
||||
|
||||
render() { |
||||
const { navModel, notificationChannelTypes } = this.props; |
||||
|
||||
return ( |
||||
<Page navModel={navModel}> |
||||
<Page.Contents> |
||||
<h2 className="page-sub-heading">New notification channel</h2> |
||||
<Form onSubmit={this.onSubmit} validateOn="onChange" defaultValues={defaultValues}> |
||||
{({ register, errors, control, getValues, watch }) => { |
||||
const selectedChannel = notificationChannelTypes.find(c => c.value === getValues().type.value); |
||||
|
||||
return ( |
||||
<NotificationChannelForm |
||||
selectableChannels={mapChannelsToSelectableValue(notificationChannelTypes)} |
||||
selectedChannel={selectedChannel} |
||||
onTestChannel={this.onTestChannel} |
||||
register={register} |
||||
errors={errors} |
||||
getValues={getValues} |
||||
control={control} |
||||
watch={watch} |
||||
imageRendererAvailable={config.rendererAvailable} |
||||
resetSecureField={this.props.resetSecureField} |
||||
secureFields={{}} |
||||
/> |
||||
); |
||||
}} |
||||
</Form> |
||||
</Page.Contents> |
||||
</Page> |
||||
); |
||||
} |
||||
} |
||||
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => { |
||||
return { |
||||
navModel: getNavModel(state.navIndex, 'channels'), |
||||
notificationChannelTypes: state.notificationChannel.notificationChannelTypes, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { |
||||
createNotificationChannel, |
||||
loadNotificationTypes, |
||||
testNotificationChannel, |
||||
resetSecureField, |
||||
}; |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(NewNotificationChannelPage); |
||||
@ -0,0 +1,44 @@ |
||||
import React, { FC } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { CollapsableSection, Field, Input, InputControl, Select } from '@grafana/ui'; |
||||
import { NotificationChannelOptions } from './NotificationChannelOptions'; |
||||
import { NotificationSettingsProps } from './NotificationChannelForm'; |
||||
import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types'; |
||||
|
||||
interface Props extends NotificationSettingsProps { |
||||
selectedChannel: NotificationChannelType; |
||||
channels: Array<SelectableValue<string>>; |
||||
secureFields: NotificationChannelSecureFields; |
||||
resetSecureField: (key: string) => void; |
||||
} |
||||
|
||||
export const BasicSettings: FC<Props> = ({ |
||||
control, |
||||
currentFormValues, |
||||
errors, |
||||
secureFields, |
||||
selectedChannel, |
||||
channels, |
||||
register, |
||||
resetSecureField, |
||||
}) => { |
||||
return ( |
||||
<CollapsableSection label="Channel" isOpen> |
||||
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}> |
||||
<Input name="name" ref={register({ required: 'Name is required' })} /> |
||||
</Field> |
||||
<Field label="Type"> |
||||
<InputControl name="type" as={Select} options={channels} control={control} rules={{ required: true }} /> |
||||
</Field> |
||||
<NotificationChannelOptions |
||||
selectedChannelOptions={selectedChannel.options.filter(o => o.required)} |
||||
currentFormValues={currentFormValues} |
||||
secureFields={secureFields} |
||||
onResetSecureField={resetSecureField} |
||||
register={register} |
||||
errors={errors} |
||||
control={control} |
||||
/> |
||||
</CollapsableSection> |
||||
); |
||||
}; |
||||
@ -0,0 +1,36 @@ |
||||
import React, { FC } from 'react'; |
||||
import { CollapsableSection, InfoBox } from '@grafana/ui'; |
||||
import { NotificationChannelOptions } from './NotificationChannelOptions'; |
||||
import { NotificationSettingsProps } from './NotificationChannelForm'; |
||||
import { NotificationChannelSecureFields, NotificationChannelType } from '../../../types'; |
||||
|
||||
interface Props extends NotificationSettingsProps { |
||||
selectedChannel: NotificationChannelType; |
||||
secureFields: NotificationChannelSecureFields; |
||||
resetSecureField: (key: string) => void; |
||||
} |
||||
|
||||
export const ChannelSettings: FC<Props> = ({ |
||||
control, |
||||
currentFormValues, |
||||
errors, |
||||
selectedChannel, |
||||
secureFields, |
||||
register, |
||||
resetSecureField, |
||||
}) => { |
||||
return ( |
||||
<CollapsableSection label={`Optional ${selectedChannel.heading}`} isOpen={false}> |
||||
{selectedChannel.info !== '' && <InfoBox>{selectedChannel.info}</InfoBox>} |
||||
<NotificationChannelOptions |
||||
selectedChannelOptions={selectedChannel.options.filter(o => !o.required)} |
||||
currentFormValues={currentFormValues} |
||||
register={register} |
||||
errors={errors} |
||||
control={control} |
||||
onResetSecureField={resetSecureField} |
||||
secureFields={secureFields} |
||||
/> |
||||
</CollapsableSection> |
||||
); |
||||
}; |
||||
@ -1,125 +0,0 @@ |
||||
import React, { FC, useEffect } from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data'; |
||||
import { |
||||
Button, |
||||
Field, |
||||
FormAPI, |
||||
HorizontalGroup, |
||||
InfoBox, |
||||
Input, |
||||
InputControl, |
||||
Select, |
||||
stylesFactory, |
||||
Switch, |
||||
useTheme, |
||||
} from '@grafana/ui'; |
||||
import { NotificationChannel, NotificationChannelDTO } from '../../../types'; |
||||
import { NotificationChannelOptions } from './NotificationChannelOptions'; |
||||
|
||||
interface Props extends Omit<FormAPI<NotificationChannelDTO>, 'formState'> { |
||||
selectableChannels: Array<SelectableValue<string>>; |
||||
selectedChannel?: NotificationChannel; |
||||
imageRendererAvailable: boolean; |
||||
|
||||
onTestChannel: (data: NotificationChannelDTO) => void; |
||||
} |
||||
|
||||
export const NewNotificationChannelForm: FC<Props> = ({ |
||||
control, |
||||
errors, |
||||
selectedChannel, |
||||
selectableChannels, |
||||
register, |
||||
watch, |
||||
getValues, |
||||
imageRendererAvailable, |
||||
onTestChannel, |
||||
}) => { |
||||
const styles = getStyles(useTheme()); |
||||
|
||||
useEffect(() => { |
||||
watch(['type', 'settings.priority', 'sendReminder', 'uploadImage']); |
||||
}, []); |
||||
|
||||
const currentFormValues = getValues(); |
||||
return ( |
||||
<> |
||||
<div className={styles.basicSettings}> |
||||
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}> |
||||
<Input name="name" ref={register({ required: 'Name is required' })} /> |
||||
</Field> |
||||
<Field label="Type"> |
||||
<InputControl |
||||
name="type" |
||||
as={Select} |
||||
options={selectableChannels} |
||||
control={control} |
||||
rules={{ required: true }} |
||||
/> |
||||
</Field> |
||||
<Field label="Default" description="Use this notification for all alerts"> |
||||
<Switch name="isDefault" ref={register} /> |
||||
</Field> |
||||
<Field label="Include image" description="Captures an image and include it in the notification"> |
||||
<Switch name="settings.uploadImage" ref={register} /> |
||||
</Field> |
||||
{currentFormValues.uploadImage && !imageRendererAvailable && ( |
||||
<InfoBox title="No image renderer available/installed"> |
||||
Grafana cannot find an image renderer to capture an image for the notification. Please make sure the Grafana |
||||
Image Renderer plugin is installed. Please contact your Grafana administrator to install the plugin. |
||||
</InfoBox> |
||||
)} |
||||
<Field |
||||
label="Disable Resolve Message" |
||||
description="Disable the resolve message [OK] that is sent when alerting state returns to false" |
||||
> |
||||
<Switch name="disableResolveMessage" ref={register} /> |
||||
</Field> |
||||
<Field label="Send reminders" description="Send additional notifications for triggered alerts"> |
||||
<Switch name="sendReminder" ref={register} /> |
||||
</Field> |
||||
{currentFormValues.sendReminder && ( |
||||
<> |
||||
<Field |
||||
label="Send reminder every" |
||||
description="Specify how often reminders should be sent, e.g. every 30s, 1m, 10m, 30m or 1h etc." |
||||
> |
||||
<Input name="frequency" ref={register} /> |
||||
</Field> |
||||
<InfoBox> |
||||
Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently |
||||
than a configured alert rule evaluation interval. |
||||
</InfoBox> |
||||
</> |
||||
)} |
||||
</div> |
||||
{selectedChannel && ( |
||||
<NotificationChannelOptions |
||||
selectedChannel={selectedChannel} |
||||
currentFormValues={currentFormValues} |
||||
register={register} |
||||
errors={errors} |
||||
control={control} |
||||
/> |
||||
)} |
||||
<HorizontalGroup> |
||||
<Button type="submit">Save</Button> |
||||
<Button type="button" variant="secondary" onClick={() => onTestChannel(getValues({ nest: true }))}> |
||||
Test |
||||
</Button> |
||||
<Button type="button" variant="secondary"> |
||||
Back |
||||
</Button> |
||||
</HorizontalGroup> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
return { |
||||
basicSettings: css` |
||||
margin-bottom: ${theme.spacing.xl}; |
||||
`,
|
||||
}; |
||||
}); |
||||
@ -0,0 +1,100 @@ |
||||
import React, { FC, useEffect } from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data'; |
||||
import { Button, FormAPI, HorizontalGroup, stylesFactory, useTheme, Spinner } from '@grafana/ui'; |
||||
import { NotificationChannelType, NotificationChannelDTO, NotificationChannelSecureFields } from '../../../types'; |
||||
import { NotificationSettings } from './NotificationSettings'; |
||||
import { BasicSettings } from './BasicSettings'; |
||||
import { ChannelSettings } from './ChannelSettings'; |
||||
|
||||
interface Props extends Omit<FormAPI<NotificationChannelDTO>, 'formState'> { |
||||
selectableChannels: Array<SelectableValue<string>>; |
||||
selectedChannel?: NotificationChannelType; |
||||
imageRendererAvailable: boolean; |
||||
secureFields: NotificationChannelSecureFields; |
||||
resetSecureField: (key: string) => void; |
||||
onTestChannel: (data: NotificationChannelDTO) => void; |
||||
} |
||||
|
||||
export interface NotificationSettingsProps |
||||
extends Omit<FormAPI<NotificationChannelDTO>, 'formState' | 'watch' | 'getValues'> { |
||||
currentFormValues: NotificationChannelDTO; |
||||
} |
||||
|
||||
export const NotificationChannelForm: FC<Props> = ({ |
||||
control, |
||||
errors, |
||||
selectedChannel, |
||||
selectableChannels, |
||||
register, |
||||
watch, |
||||
getValues, |
||||
imageRendererAvailable, |
||||
onTestChannel, |
||||
resetSecureField, |
||||
secureFields, |
||||
}) => { |
||||
const styles = getStyles(useTheme()); |
||||
|
||||
useEffect(() => { |
||||
watch(['type', 'settings.priority', 'sendReminder', 'uploadImage']); |
||||
}, []); |
||||
|
||||
const currentFormValues = getValues(); |
||||
return selectedChannel ? ( |
||||
<> |
||||
<div className={styles.basicSettings}> |
||||
<BasicSettings |
||||
selectedChannel={selectedChannel} |
||||
channels={selectableChannels} |
||||
secureFields={secureFields} |
||||
resetSecureField={resetSecureField} |
||||
currentFormValues={currentFormValues} |
||||
register={register} |
||||
errors={errors} |
||||
control={control} |
||||
/> |
||||
{/* If there are no non-required fields, don't render this section*/} |
||||
{selectedChannel.options.filter(o => !o.required).length > 0 && ( |
||||
<ChannelSettings |
||||
selectedChannel={selectedChannel} |
||||
secureFields={secureFields} |
||||
resetSecureField={resetSecureField} |
||||
currentFormValues={currentFormValues} |
||||
register={register} |
||||
errors={errors} |
||||
control={control} |
||||
/> |
||||
)} |
||||
<NotificationSettings |
||||
imageRendererAvailable={imageRendererAvailable} |
||||
currentFormValues={currentFormValues} |
||||
register={register} |
||||
errors={errors} |
||||
control={control} |
||||
/> |
||||
</div> |
||||
<HorizontalGroup> |
||||
<Button type="submit">Save</Button> |
||||
<Button type="button" variant="secondary" onClick={() => onTestChannel(getValues({ nest: true }))}> |
||||
Test |
||||
</Button> |
||||
<a href="/alerting/notifications"> |
||||
<Button type="button" variant="secondary"> |
||||
Back |
||||
</Button> |
||||
</a> |
||||
</HorizontalGroup> |
||||
</> |
||||
) : ( |
||||
<Spinner /> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
return { |
||||
basicSettings: css` |
||||
margin-bottom: ${theme.spacing.xl}; |
||||
`,
|
||||
}; |
||||
}); |
||||
@ -0,0 +1,59 @@ |
||||
import React, { FC } from 'react'; |
||||
import { Checkbox, CollapsableSection, Field, InfoBox, Input } from '@grafana/ui'; |
||||
import { NotificationSettingsProps } from './NotificationChannelForm'; |
||||
|
||||
interface Props extends NotificationSettingsProps { |
||||
imageRendererAvailable: boolean; |
||||
} |
||||
|
||||
export const NotificationSettings: FC<Props> = ({ currentFormValues, imageRendererAvailable, register }) => { |
||||
return ( |
||||
<CollapsableSection label="Notification settings" isOpen={false}> |
||||
<Field> |
||||
<Checkbox name="isDefault" ref={register} label="Default" description="Use this notification for all alerts" /> |
||||
</Field> |
||||
<Field> |
||||
<Checkbox |
||||
name="settings.uploadImage" |
||||
ref={register} |
||||
label="Include image" |
||||
description="Captures an image and include it in the notification" |
||||
/> |
||||
</Field> |
||||
{currentFormValues.uploadImage && !imageRendererAvailable && ( |
||||
<InfoBox title="No image renderer available/installed"> |
||||
Grafana cannot find an image renderer to capture an image for the notification. Please make sure the Grafana |
||||
Image Renderer plugin is installed. Please contact your Grafana administrator to install the plugin. |
||||
</InfoBox> |
||||
)} |
||||
<Field> |
||||
<Checkbox |
||||
name="disableResolveMessage" |
||||
ref={register} |
||||
label="Disable Resolve Message" |
||||
description="Disable the resolve message [OK] that is sent when alerting state returns to false" |
||||
/> |
||||
</Field> |
||||
<Field> |
||||
<Checkbox |
||||
name="sendReminder" |
||||
ref={register} |
||||
label="Send reminders" |
||||
description="Send additional notifications for triggered alerts" |
||||
/> |
||||
</Field> |
||||
{currentFormValues.sendReminder && ( |
||||
<> |
||||
<Field |
||||
label="Send reminder every" |
||||
description="Specify how often reminders should be sent, e.g. every 30s, 1m, 10m, 30m or 1h etc. |
||||
Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently |
||||
than a configured alert rule evaluation interval." |
||||
> |
||||
<Input name="frequency" ref={register} width={8} /> |
||||
</Field> |
||||
</> |
||||
)} |
||||
</CollapsableSection> |
||||
); |
||||
}; |
||||
@ -1,89 +0,0 @@ |
||||
<page-header model="ctrl.navModel"></page-header> |
||||
|
||||
<div class="page-container page-body"> |
||||
|
||||
<h3 class="page-sub-heading" ng-hide="ctrl.isNew">Edit Notification Channel</h3> |
||||
<h3 class="page-sub-heading" ng-show="ctrl.isNew">New Notification Channel</h3> |
||||
|
||||
<form name="ctrl.theForm" ng-if="ctrl.notifiers"> |
||||
<div class="gf-form-group"> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-12">Name</span> |
||||
<input type="text" required class="gf-form-input max-width-15" ng-model="ctrl.model.name" required></input> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-12">Type</span> |
||||
<div class="gf-form-select-wrapper width-15"> |
||||
<select class="gf-form-input" ng-model="ctrl.model.type" ng-options="t.type as t.name for t in ctrl.notifiers" ng-change="ctrl.typeChanged(notification, $index)"> |
||||
</select> |
||||
</div> |
||||
</div> |
||||
<gf-form-switch |
||||
class="gf-form" |
||||
label="Default (send on all alerts)" |
||||
label-class="width-14" |
||||
checked="ctrl.model.isDefault" |
||||
tooltip="Use this notification for all alerts"> |
||||
</gf-form-switch> |
||||
<gf-form-switch |
||||
class="gf-form" |
||||
label="Include image" |
||||
label-class="width-14" |
||||
checked="ctrl.model.settings.uploadImage" |
||||
tooltip="Captures an image and include it in the notification"> |
||||
</gf-form-switch> |
||||
<div class="grafana-info-box m-t-2" ng-show="ctrl.model.settings.uploadImage && !ctrl.rendererAvailable"> |
||||
<h5>No image renderer available/installed</h5> |
||||
<p> |
||||
Grafana cannot find an image renderer to capture an image for the notification. |
||||
Please make sure the <a href="https://grafana.com/grafana/plugins/grafana-image-renderer" target="_blank" rel="noreferrer">Grafana Image Renderer plugin</a> is installed. |
||||
</p> |
||||
<p> |
||||
Please contact your Grafana administrator to install the plugin. |
||||
</p> |
||||
</div> |
||||
<gf-form-switch |
||||
class="gf-form" |
||||
label="Disable Resolve Message" |
||||
label-class="width-14" |
||||
checked="ctrl.model.disableResolveMessage" |
||||
tooltip="Disable the resolve message [OK] that is sent when alerting state returns to false"> |
||||
</gf-form-switch> |
||||
<gf-form-switch |
||||
class="gf-form" |
||||
label="Send reminders" |
||||
label-class="width-14" |
||||
checked="ctrl.model.sendReminder" |
||||
tooltip="Send additional notifications for triggered alerts"> |
||||
</gf-form-switch> |
||||
<div class="gf-form-inline"> |
||||
<div class="gf-form" ng-if="ctrl.model.sendReminder"> |
||||
<span class="gf-form-label width-12">Send reminder every |
||||
<info-popover mode="right-normal" position="top center"> |
||||
Specify how often reminders should be sent, e.g. every 30s, 1m, 10m, 30m or 1h etc. |
||||
</info-popover> |
||||
</span> |
||||
<input type="text" placeholder="Select or specify custom" class="gf-form-input width-15" ng-model="ctrl.model.frequency" |
||||
bs-typeahead="ctrl.getFrequencySuggestion" data-min-length=0 ng-required="ctrl.model.sendReminder"> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<span class="alert alert-info width-30" ng-if="ctrl.model.sendReminder"> |
||||
Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently than a configured alert rule evaluation interval. |
||||
</span> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group" ng-include src="ctrl.notifierTemplateId"> |
||||
</div> |
||||
|
||||
<div class="gf-form-group gf-form-button-row"> |
||||
<button type="submit" ng-click="ctrl.save()" class="btn btn-primary width-7">Save</button> |
||||
<button type="submit" ng-click="ctrl.testNotification()" class="btn btn-secondary">Send Test</button> |
||||
<button type="delete" ng-if="!ctrl.isNew" ng-click="ctrl.deleteNotification()" class="btn btn-danger width-7">Delete</button> |
||||
<a href="alerting/notifications" class="btn btn-inverse">Back</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
|
||||
<footer /> |
||||
@ -0,0 +1,209 @@ |
||||
import { transformSubmitData } from './notificationChannels'; |
||||
import { NotificationChannelDTO } from '../../../types'; |
||||
|
||||
const basicFormData: NotificationChannelDTO = { |
||||
id: 1, |
||||
uid: 'pX7fbbHGk', |
||||
name: 'Pete discord', |
||||
type: { |
||||
value: 'discord', |
||||
label: 'Discord', |
||||
type: 'discord', |
||||
name: 'Discord', |
||||
heading: 'Discord settings', |
||||
description: 'Sends notifications to Discord', |
||||
info: '', |
||||
options: [ |
||||
{ |
||||
element: 'input', |
||||
inputType: 'text', |
||||
label: 'Message Content', |
||||
description: 'Mention a group using @ or a user using <@ID> when notifying in a channel', |
||||
placeholder: '', |
||||
propertyName: 'content', |
||||
selectOptions: null, |
||||
showWhen: { field: '', is: '' }, |
||||
required: false, |
||||
validationRule: '', |
||||
secure: false, |
||||
}, |
||||
{ |
||||
element: 'input', |
||||
inputType: 'text', |
||||
label: 'Webhook URL', |
||||
description: '', |
||||
placeholder: 'Discord webhook URL', |
||||
propertyName: 'url', |
||||
selectOptions: null, |
||||
showWhen: { field: '', is: '' }, |
||||
required: true, |
||||
validationRule: '', |
||||
secure: false, |
||||
}, |
||||
], |
||||
typeName: 'discord', |
||||
}, |
||||
isDefault: false, |
||||
sendReminder: false, |
||||
disableResolveMessage: false, |
||||
frequency: '', |
||||
created: '2020-08-24T10:46:43+02:00', |
||||
updated: '2020-09-02T14:08:27+02:00', |
||||
settings: { |
||||
url: 'https://discordapp.com/api/webhooks/', |
||||
uploadImage: true, |
||||
content: '', |
||||
autoResolve: true, |
||||
httpMethod: 'POST', |
||||
severity: 'critical', |
||||
}, |
||||
secureFields: {}, |
||||
secureSettings: {}, |
||||
}; |
||||
|
||||
const selectFormData: NotificationChannelDTO = { |
||||
id: 23, |
||||
uid: 'BxEN9rNGk', |
||||
name: 'Webhook', |
||||
type: { |
||||
value: 'webhook', |
||||
label: 'webhook', |
||||
type: 'webhook', |
||||
name: 'webhook', |
||||
heading: 'Webhook settings', |
||||
description: 'Sends HTTP POST request to a URL', |
||||
info: '', |
||||
options: [ |
||||
{ |
||||
element: 'input', |
||||
inputType: 'text', |
||||
label: 'Url', |
||||
description: '', |
||||
placeholder: '', |
||||
propertyName: 'url', |
||||
selectOptions: null, |
||||
showWhen: { field: '', is: '' }, |
||||
required: true, |
||||
validationRule: '', |
||||
secure: false, |
||||
}, |
||||
{ |
||||
element: 'select', |
||||
inputType: '', |
||||
label: 'Http Method', |
||||
description: '', |
||||
placeholder: '', |
||||
propertyName: 'httpMethod', |
||||
selectOptions: [ |
||||
{ value: 'POST', label: 'POST' }, |
||||
{ value: 'PUT', label: 'PUT' }, |
||||
], |
||||
showWhen: { field: '', is: '' }, |
||||
required: false, |
||||
validationRule: '', |
||||
secure: false, |
||||
}, |
||||
{ |
||||
element: 'input', |
||||
inputType: 'text', |
||||
label: 'Username', |
||||
description: '', |
||||
placeholder: '', |
||||
propertyName: 'username', |
||||
selectOptions: null, |
||||
showWhen: { field: '', is: '' }, |
||||
required: false, |
||||
validationRule: '', |
||||
secure: false, |
||||
}, |
||||
{ |
||||
element: 'input', |
||||
inputType: 'password', |
||||
label: 'Password', |
||||
description: '', |
||||
placeholder: '', |
||||
propertyName: 'password', |
||||
selectOptions: null, |
||||
showWhen: { field: '', is: '' }, |
||||
required: false, |
||||
validationRule: '', |
||||
secure: true, |
||||
}, |
||||
], |
||||
typeName: 'webhook', |
||||
}, |
||||
isDefault: false, |
||||
sendReminder: false, |
||||
disableResolveMessage: false, |
||||
frequency: '', |
||||
created: '2020-08-28T10:47:37+02:00', |
||||
updated: '2020-09-03T09:37:21+02:00', |
||||
settings: { |
||||
autoResolve: true, |
||||
httpMethod: 'POST', |
||||
password: '', |
||||
severity: 'critical', |
||||
uploadImage: true, |
||||
url: 'http://asdf', |
||||
username: 'asdf', |
||||
}, |
||||
secureFields: { password: true }, |
||||
secureSettings: {}, |
||||
}; |
||||
|
||||
describe('Transform submit data', () => { |
||||
it('basic transform', () => { |
||||
const expected = { |
||||
id: 1, |
||||
name: 'Pete discord', |
||||
type: 'discord', |
||||
sendReminder: false, |
||||
disableResolveMessage: false, |
||||
frequency: '15m', |
||||
settings: { |
||||
uploadImage: true, |
||||
autoResolve: true, |
||||
httpMethod: 'POST', |
||||
severity: 'critical', |
||||
url: 'https://discordapp.com/api/webhooks/', |
||||
content: '', |
||||
}, |
||||
secureSettings: {}, |
||||
secureFields: {}, |
||||
isDefault: false, |
||||
uid: 'pX7fbbHGk', |
||||
created: '2020-08-24T10:46:43+02:00', |
||||
updated: '2020-09-02T14:08:27+02:00', |
||||
}; |
||||
|
||||
expect(transformSubmitData(basicFormData)).toEqual(expected); |
||||
}); |
||||
|
||||
it('should transform form data with selects', () => { |
||||
const expected = { |
||||
created: '2020-08-28T10:47:37+02:00', |
||||
disableResolveMessage: false, |
||||
frequency: '15m', |
||||
id: 23, |
||||
isDefault: false, |
||||
name: 'Webhook', |
||||
secureFields: { password: true }, |
||||
secureSettings: {}, |
||||
sendReminder: false, |
||||
settings: { |
||||
autoResolve: true, |
||||
httpMethod: 'POST', |
||||
password: '', |
||||
severity: 'critical', |
||||
uploadImage: true, |
||||
url: 'http://asdf', |
||||
username: 'asdf', |
||||
}, |
||||
type: 'webhook', |
||||
uid: 'BxEN9rNGk', |
||||
updated: '2020-09-03T09:37:21+02:00', |
||||
}; |
||||
|
||||
expect(transformSubmitData(selectFormData)).toEqual(expected); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,63 @@ |
||||
import memoizeOne from 'memoize-one'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { NotificationChannelDTO, NotificationChannelType } from 'app/types'; |
||||
|
||||
export const defaultValues: NotificationChannelDTO = { |
||||
id: -1, |
||||
name: '', |
||||
type: { value: 'email', label: 'Email' }, |
||||
sendReminder: false, |
||||
disableResolveMessage: false, |
||||
frequency: '15m', |
||||
settings: { |
||||
uploadImage: config.rendererAvailable, |
||||
autoResolve: true, |
||||
httpMethod: 'POST', |
||||
severity: 'critical', |
||||
}, |
||||
secureSettings: {}, |
||||
secureFields: {}, |
||||
isDefault: false, |
||||
}; |
||||
|
||||
export const mapChannelsToSelectableValue = memoizeOne( |
||||
(notificationChannels: NotificationChannelType[]): Array<SelectableValue<string>> => { |
||||
return notificationChannels.map(channel => ({ |
||||
value: channel.value, |
||||
label: channel.label, |
||||
description: channel.description, |
||||
})); |
||||
} |
||||
); |
||||
|
||||
export const transformSubmitData = (formData: NotificationChannelDTO) => { |
||||
/* |
||||
Some settings can be options in a select, in order to not save a SelectableValue<T> |
||||
we need to use check if it is a SelectableValue and use its value. |
||||
*/ |
||||
const settings = Object.fromEntries( |
||||
Object.entries(formData.settings).map(([key, value]) => { |
||||
return [key, value && value.hasOwnProperty('value') ? value.value : value]; |
||||
}) |
||||
); |
||||
|
||||
return { |
||||
...defaultValues, |
||||
...formData, |
||||
frequency: formData.frequency === '' ? defaultValues.frequency : formData.frequency, |
||||
type: formData.type.value, |
||||
settings: { ...defaultValues.settings, ...settings }, |
||||
secureSettings: { ...formData.secureSettings }, |
||||
}; |
||||
}; |
||||
|
||||
export const transformTestData = (formData: NotificationChannelDTO) => { |
||||
return { |
||||
name: formData.name, |
||||
type: formData.type.value, |
||||
frequency: formData.frequency ?? defaultValues.frequency, |
||||
settings: { ...Object.assign(defaultValues.settings, formData.settings) }, |
||||
secureSettings: { ...formData.secureSettings }, |
||||
}; |
||||
}; |
||||
Loading…
Reference in new issue