Replace GForm with react-hook-form (#4171)
# What this PR does Replace GForm with react-hook-form ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall/issues/4142 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
This commit is contained in:
parent
92600c05a7
commit
bfeb286637
21 changed files with 1305 additions and 1345 deletions
|
|
@ -1,7 +0,0 @@
|
|||
.collapse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Field, Form, FormFieldErrors, Input, InputControl, Label, Select, Switch, TextArea } from '@grafana/ui';
|
||||
import { capitalCase } from 'change-case';
|
||||
import cn from 'classnames/bind';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import { Collapse } from 'components/Collapse/Collapse';
|
||||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
|
||||
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { GSelect } from 'containers/GSelect/GSelect';
|
||||
import { RemoteSelect } from 'containers/RemoteSelect/RemoteSelect';
|
||||
|
||||
import styles from './GForm.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export interface CustomFieldSectionRendererProps {
|
||||
control: any;
|
||||
formItem: FormItem;
|
||||
errors: any;
|
||||
register: any;
|
||||
setValue: (fieldName: string, fieldValue: any) => void;
|
||||
getValues: <T = unknown>(fieldName: string | string[]) => T;
|
||||
}
|
||||
|
||||
interface GFormProps {
|
||||
form: { name: string; fields: FormItem[] };
|
||||
data: any;
|
||||
onSubmit: (data: any) => void;
|
||||
onChange?: (formIsValid: boolean) => void;
|
||||
|
||||
customFieldSectionRenderer?: React.FC<CustomFieldSectionRendererProps>;
|
||||
onFieldRender?: (
|
||||
formItem: FormItem,
|
||||
disabled: boolean,
|
||||
renderedControl: React.ReactElement,
|
||||
values: any,
|
||||
setValue: (value: string) => void
|
||||
) => React.ReactElement;
|
||||
}
|
||||
|
||||
const nullNormalizer = (value: string) => {
|
||||
return value || null;
|
||||
};
|
||||
|
||||
function renderFormControl(
|
||||
formItem: FormItem,
|
||||
register: any,
|
||||
control: any,
|
||||
disabled: boolean,
|
||||
onChangeFn: (field, value) => void
|
||||
) {
|
||||
switch (formItem.type) {
|
||||
case FormItemType.Input:
|
||||
return (
|
||||
<Input
|
||||
{...register(formItem.name, formItem.validation)}
|
||||
placeholder={formItem.placeholder}
|
||||
onChange={(value) => onChangeFn(undefined, value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.Password:
|
||||
return (
|
||||
<Input
|
||||
{...register(formItem.name, formItem.validation)}
|
||||
placeholder={formItem.placeholder}
|
||||
type="password"
|
||||
onChange={(value) => onChangeFn(undefined, value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.TextArea:
|
||||
return (
|
||||
<TextArea
|
||||
rows={formItem.extra?.rows || 4}
|
||||
placeholder={formItem.placeholder}
|
||||
{...register(formItem.name, formItem.validation)}
|
||||
onChange={(value) => onChangeFn(undefined, value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.MultiSelect:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<GSelect isMulti={true} {...field} {...formItem.extra} onChange={(value) => onChangeFn(field, value)} />
|
||||
);
|
||||
}}
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.Select:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { ...field } }) => {
|
||||
return (
|
||||
<Select
|
||||
{...field}
|
||||
{...formItem.extra}
|
||||
{...register(formItem.name, formItem.validation)}
|
||||
onChange={(value) => onChangeFn(field, value.value)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.GSelect:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { ...field } }) => {
|
||||
return <GSelect {...field} {...formItem.extra} onChange={(value) => onChangeFn(field, value)} />;
|
||||
}}
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.Switch:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { ...field } }) => {
|
||||
return (
|
||||
<Switch
|
||||
{...register(formItem.name, formItem.validation)}
|
||||
onChange={(value) => onChangeFn(field, value)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.RemoteSelect:
|
||||
return (
|
||||
<InputControl
|
||||
render={({ field: { ...field } }) => {
|
||||
return <RemoteSelect {...field} {...formItem.extra} onChange={(value) => onChangeFn(field, value)} />;
|
||||
}}
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
case FormItemType.Monaco:
|
||||
return (
|
||||
<InputControl
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
render={({ field: { ...field } }) => {
|
||||
return (
|
||||
<MonacoEditor
|
||||
{...field}
|
||||
{...formItem.extra}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={{
|
||||
...MONACO_READONLY_CONFIG,
|
||||
readOnly: disabled,
|
||||
}}
|
||||
onChange={(value) => onChangeFn(field, value)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class GForm extends React.Component<GFormProps, {}> {
|
||||
render() {
|
||||
const { form, data, onFieldRender, customFieldSectionRenderer: CustomFieldSectionRenderer } = this.props;
|
||||
|
||||
const openFields = form.fields.filter((field) => !field.collapsed);
|
||||
const collapsedfields = form.fields.filter((field) => field.collapsed);
|
||||
|
||||
return (
|
||||
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={this.handleSubmit}>
|
||||
{({ register, errors, control, getValues, setValue }) => {
|
||||
const renderField = (formItem: FormItem, formIndex: number) => {
|
||||
if (this.isFormItemHidden(formItem, getValues())) {
|
||||
return null; // don't render the field
|
||||
}
|
||||
|
||||
if (formItem.type === FormItemType.PlainLabel) {
|
||||
return (
|
||||
<Label className={cx('label')}>
|
||||
<Text type="primary">{formItem.label}</Text>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = formItem.disabled
|
||||
? true
|
||||
: formItem.getDisabled
|
||||
? formItem.getDisabled(getValues())
|
||||
: false;
|
||||
|
||||
const formControl = renderFormControl(
|
||||
formItem,
|
||||
register,
|
||||
control,
|
||||
disabled,
|
||||
this.onChange.bind(this, errors)
|
||||
);
|
||||
|
||||
if (CustomFieldSectionRenderer && formItem.type === FormItemType.Other && formItem.render) {
|
||||
return (
|
||||
<CustomFieldSectionRenderer
|
||||
control={control}
|
||||
formItem={formItem}
|
||||
setValue={(fName: string, fValue: any) => {
|
||||
setValue(fName, fValue);
|
||||
this.forceUpdate();
|
||||
}}
|
||||
errors={errors}
|
||||
register={register}
|
||||
getValues={getValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// skip input render when there's no Custom Renderer
|
||||
if (formItem.type === FormItemType.Other) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={formIndex}
|
||||
disabled={disabled}
|
||||
label={formItem.label || capitalCase(formItem.name)}
|
||||
invalid={!!errors[formItem.name]}
|
||||
error={formItem.label ? `${formItem.label} is required` : `${capitalCase(formItem.name)} is required`}
|
||||
description={formItem.description}
|
||||
>
|
||||
<div className="u-margin-top-xs">
|
||||
{onFieldRender
|
||||
? onFieldRender(formItem, disabled, formControl, getValues(), (value) =>
|
||||
setValue(formItem.name, value)
|
||||
)
|
||||
: formControl}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{openFields.map(renderField)}
|
||||
{collapsedfields.length > 0 && (
|
||||
<Collapse isOpen={false} label="Notification settings" className={cx('collapse')}>
|
||||
{collapsedfields.map(renderField)}
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
onChange = (errors: FormFieldErrors, field: any, value: string) => {
|
||||
this.props.onChange?.(isEmpty(errors));
|
||||
|
||||
field?.onChange(value);
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
isFormItemHidden(formItem: FormItem, data) {
|
||||
return formItem?.isHidden?.(data);
|
||||
}
|
||||
|
||||
handleSubmit = (data) => {
|
||||
const { form, onSubmit } = this.props;
|
||||
|
||||
const normalizedData = Object.keys(data).reduce((acc, key) => {
|
||||
const formItem = form.fields.find((formItem) => formItem.name === key);
|
||||
|
||||
let value = formItem?.normalize ? formItem.normalize(data[key]) : nullNormalizer(data[key]);
|
||||
|
||||
if (this.isFormItemHidden(formItem, data)) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[key]: value,
|
||||
};
|
||||
}, {});
|
||||
|
||||
onSubmit(normalizedData);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
export enum FormItemType {
|
||||
'Input' = 'input',
|
||||
'Password' = 'password',
|
||||
'TextArea' = 'textarea',
|
||||
'MultiSelect' = 'multiselect',
|
||||
'Select' = 'select',
|
||||
'GSelect' = 'gselect',
|
||||
'Switch' = 'switch',
|
||||
'RemoteSelect' = 'remoteselect',
|
||||
'Monaco' = 'monaco',
|
||||
'Other' = 'other',
|
||||
'PlainLabel' = 'plainlabel',
|
||||
}
|
||||
|
||||
export interface FormItem {
|
||||
name: string;
|
||||
label?: ReactNode;
|
||||
type: FormItemType;
|
||||
disabled?: boolean;
|
||||
description?: ReactNode;
|
||||
placeholder?: string;
|
||||
normalize?: (value: any) => any;
|
||||
isHidden?: (data: any) => any;
|
||||
getDisabled?: (value: any) => any;
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
validation?: (v: any) => boolean;
|
||||
};
|
||||
extra?: any;
|
||||
collapsed?: boolean;
|
||||
render?: boolean;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
|
||||
export type ManualAlertGroupFormData = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
|
||||
name: 'Manual Alert Group',
|
||||
fields: [
|
||||
{
|
||||
name: 'message',
|
||||
type: FormItemType.TextArea,
|
||||
label: 'What is going on?',
|
||||
validation: { required: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { FC, useCallback, useState } from 'react';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
|
||||
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import { Button, Drawer, Field, HorizontalGroup, TextArea, useStyles2, VerticalGroup } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { GForm } from 'components/GForm/GForm';
|
||||
import { AddResponders } from 'containers/AddResponders/AddResponders';
|
||||
import { prepareForUpdate } from 'containers/AddResponders/AddResponders.helpers';
|
||||
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
|
||||
|
|
@ -11,7 +12,9 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { openWarningNotification } from 'utils/utils';
|
||||
|
||||
import { manualAlertFormConfig, ManualAlertGroupFormData } from './ManualAlertGroup.config';
|
||||
export type FormData = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface ManualAlertGroupProps {
|
||||
onHide: () => void;
|
||||
|
|
@ -19,63 +22,79 @@ interface ManualAlertGroupProps {
|
|||
alertReceiveChannelStore: AlertReceiveChannelStore;
|
||||
}
|
||||
|
||||
const data: ManualAlertGroupFormData = {
|
||||
message: '',
|
||||
};
|
||||
|
||||
export const ManualAlertGroup: FC<ManualAlertGroupProps> = observer(({ onCreate, onHide }) => {
|
||||
const { directPagingStore } = useStore();
|
||||
const { selectedTeamResponder, selectedUserResponders } = directPagingStore;
|
||||
|
||||
const [formIsValid, setFormIsValid] = useState<boolean>(false);
|
||||
|
||||
const onHideDrawer = useCallback(() => {
|
||||
directPagingStore.resetSelectedUsers();
|
||||
directPagingStore.resetSelectedTeam();
|
||||
onHide();
|
||||
}, [onHide]);
|
||||
|
||||
const hasSelectedEitherATeamOrAUser = selectedTeamResponder !== null || selectedUserResponders.length > 0;
|
||||
const formIsSubmittable = hasSelectedEitherATeamOrAUser && formIsValid;
|
||||
const formMethods = useForm<FormData>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { message: '' },
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = formMethods;
|
||||
|
||||
const formIsSubmittable = selectedTeamResponder !== null || selectedUserResponders.length > 0;
|
||||
|
||||
// TODO: add a loading state while we're waiting to hear back from the API when submitting
|
||||
// const [directPagingLoading, setdirectPagingLoading] = useState<boolean>();
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (data: ManualAlertGroupFormData) => {
|
||||
const transformedData = prepareForUpdate(selectedUserResponders, selectedTeamResponder, data);
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const transformedData = prepareForUpdate(selectedUserResponders, selectedTeamResponder, data);
|
||||
|
||||
const resp = await directPagingStore.createManualAlertRule(transformedData);
|
||||
const resp = await directPagingStore.createManualAlertRule(transformedData);
|
||||
|
||||
if (!resp) {
|
||||
openWarningNotification('There was an issue creating the alert group, please try again');
|
||||
return;
|
||||
}
|
||||
if (!resp) {
|
||||
openWarningNotification('There was an issue creating the alert group, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
directPagingStore.resetSelectedUsers();
|
||||
directPagingStore.resetSelectedTeam();
|
||||
directPagingStore.resetSelectedUsers();
|
||||
directPagingStore.resetSelectedTeam();
|
||||
|
||||
onCreate(resp.alert_group_id);
|
||||
onHide();
|
||||
},
|
||||
[prepareForUpdate, selectedUserResponders, selectedTeamResponder]
|
||||
);
|
||||
onCreate(resp.alert_group_id);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const utils = useStyles2(getUtilStyles);
|
||||
|
||||
return (
|
||||
<Drawer scrollableContent title="New escalation" onClose={onHideDrawer} closeOnMaskClick={false} width="70%">
|
||||
<VerticalGroup>
|
||||
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} onChange={setFormIsValid} />
|
||||
<AddResponders mode="create" />
|
||||
<div className="buttons">
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHideDrawer}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={manualAlertFormConfig.name} disabled={!formIsSubmittable}>
|
||||
Create
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<FormProvider {...formMethods}>
|
||||
<form id="Manual Alert Group" onSubmit={handleSubmit(onSubmit)} className={utils.width100}>
|
||||
<Controller
|
||||
name="message"
|
||||
control={control}
|
||||
rules={{ required: 'Message is required' }}
|
||||
render={({ field }) => (
|
||||
<Field label="What is going on?" invalid={Boolean(errors.message)} error={errors.message?.message}>
|
||||
<TextArea name="message" rows={4} {...field} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<AddResponders mode="create" />
|
||||
<div className="buttons">
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHideDrawer}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!formIsSubmittable}>
|
||||
Create
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ManualAlertGroupFormData } from 'components/ManualAlertGroup/ManualAlertGroup.config';
|
||||
import { FormData as ManualAlertGroupFormData } from 'components/ManualAlertGroup/ManualAlertGroup';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
|
||||
import { UserResponders } from './AddResponders.types';
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface GSelectProps<Item> {
|
|||
fetchItemsFn: (query?: string) => Promise<Item[] | void>;
|
||||
fetchItemFn: (id: string) => Promise<Item | void>;
|
||||
getSearchResult: (query?: string) => Item[] | { page_size: number; count: number; results: Item[] };
|
||||
placeholder: string;
|
||||
placeholder?: string;
|
||||
isLoading?: boolean;
|
||||
value?: string | string[] | null;
|
||||
defaultValue?: string | string[] | null;
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import { FormItemType } from 'components/GForm/GForm.types';
|
||||
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
|
||||
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
|
||||
export const getForm = (alertReceiveChannelStore: AlertReceiveChannelStore) => ({
|
||||
name: 'Maintenance',
|
||||
fields: [
|
||||
{
|
||||
name: 'alert_receive_channel_id',
|
||||
label: 'Integration',
|
||||
type: FormItemType.GSelect,
|
||||
validation: { required: true },
|
||||
extra: {
|
||||
items: alertReceiveChannelStore.items,
|
||||
fetchItemsFn: alertReceiveChannelStore.fetchItems,
|
||||
fetchItemFn: alertReceiveChannelStore.fetchItemById,
|
||||
getSearchResult: () => AlertReceiveChannelHelper.getSearchResult(alertReceiveChannelStore),
|
||||
displayField: 'verbal_name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
|
||||
disabled: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Mode',
|
||||
description:
|
||||
'Choose maintenance mode: Debug (test routing and escalations without real notifications) or Maintenance (group alerts into one during infrastructure work).',
|
||||
type: FormItemType.Select,
|
||||
validation: { required: true },
|
||||
normalize: (value) => value,
|
||||
extra: {
|
||||
placeholder: 'Choose mode',
|
||||
options: [
|
||||
{
|
||||
value: MaintenanceMode.Debug,
|
||||
label: 'Debug (silence all escalations)',
|
||||
},
|
||||
{
|
||||
value: MaintenanceMode.Maintenance,
|
||||
label: 'Maintenance (collect everything in one alert group)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
label: 'Duration',
|
||||
description: 'Specify duration of the maintenance',
|
||||
type: FormItemType.Select,
|
||||
validation: { required: true },
|
||||
extra: {
|
||||
placeholder: 'Choose duration',
|
||||
options: [
|
||||
{
|
||||
value: 3600,
|
||||
label: '1 hour',
|
||||
},
|
||||
{
|
||||
value: 10800,
|
||||
label: '3 hours',
|
||||
},
|
||||
{
|
||||
value: 21600,
|
||||
label: '6 hours',
|
||||
},
|
||||
{
|
||||
value: 43200,
|
||||
label: '12 hours',
|
||||
},
|
||||
{
|
||||
value: 86400,
|
||||
label: '24 hours',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1,20 +1,22 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Button, Drawer, Field, HorizontalGroup, Select, VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import Emoji from 'react-emoji-render';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { GForm } from 'components/GForm/GForm';
|
||||
import { GSelect } from 'containers/GSelect/GSelect';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { openNotification, showApiError } from 'utils/utils';
|
||||
|
||||
import { getForm } from './MaintenanceForm.config';
|
||||
|
||||
import styles from './MaintenanceForm.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
|
@ -22,19 +24,22 @@ const cx = cn.bind(styles);
|
|||
interface MaintenanceFormProps {
|
||||
initialData: {
|
||||
alert_receive_channel_id?: ApiSchemas['AlertReceiveChannel']['id'];
|
||||
disabled?: boolean;
|
||||
};
|
||||
onHide: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
interface FormFields {
|
||||
alert_receive_channel_id: ApiSchemas['AlertReceiveChannel']['id'];
|
||||
mode: MaintenanceMode;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
||||
const { onUpdate, onHide, initialData = {} } = props;
|
||||
const { alertReceiveChannelStore } = useStore();
|
||||
const form = useMemo(() => getForm(alertReceiveChannelStore), [alertReceiveChannelStore]);
|
||||
const maintenanceForm = useMemo(() => (initialData.disabled ? cloneDeep(form) : form), [initialData]);
|
||||
|
||||
const handleSubmit = useCallback(async (data) => {
|
||||
const onSubmit = useCallback(async (data) => {
|
||||
try {
|
||||
await AlertReceiveChannelHelper.startMaintenanceMode(
|
||||
initialData.alert_receive_channel_id,
|
||||
|
|
@ -49,14 +54,18 @@ export const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
if (initialData.disabled) {
|
||||
const alertReceiveChannelIdField = maintenanceForm.fields.find((f) => f.name === 'alert_receive_channel_id');
|
||||
const formMethods = useForm<FormFields>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { ...initialData },
|
||||
});
|
||||
|
||||
if (alertReceiveChannelIdField) {
|
||||
// Integration page requires this field to be preset and disabled, therefore we add extra field `disabled` for the cloned form
|
||||
alertReceiveChannelIdField.extra.disabled = true;
|
||||
}
|
||||
}
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = formMethods;
|
||||
|
||||
const utils = useStyles2(getUtilStyles);
|
||||
|
||||
return (
|
||||
<Drawer width="640px" scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
|
||||
|
|
@ -64,17 +73,124 @@ export const MaintenanceForm = observer((props: MaintenanceFormProps) => {
|
|||
<VerticalGroup>
|
||||
Start maintenance mode when performing scheduled maintenance or updates on the infrastructure, which may
|
||||
trigger false alarms.
|
||||
<GForm form={maintenanceForm} data={initialData} onSubmit={handleSubmit} />
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<Button form={form.name} type="submit" data-testid="create-maintenance-button">
|
||||
Start
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
<FormProvider {...formMethods}>
|
||||
<form id="Maintenance" onSubmit={handleSubmit(onSubmit)} className={utils.width100}>
|
||||
<Controller
|
||||
name="alert_receive_channel_id"
|
||||
control={control}
|
||||
rules={{ required: 'Integration is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Integration"
|
||||
invalid={!!errors.alert_receive_channel_id}
|
||||
error={errors.alert_receive_channel_id?.message}
|
||||
>
|
||||
<GSelect<ApiSchemas['AlertReceiveChannel']>
|
||||
disabled
|
||||
showSearch
|
||||
items={alertReceiveChannelStore.items}
|
||||
fetchItemsFn={alertReceiveChannelStore.fetchItems}
|
||||
fetchItemFn={alertReceiveChannelStore.fetchItemById}
|
||||
getSearchResult={() => AlertReceiveChannelHelper.getSearchResult(alertReceiveChannelStore)}
|
||||
displayField="verbal_name"
|
||||
valueField="id"
|
||||
getOptionLabel={(item: SelectableValue) => <Emoji text={item?.label || ''} />}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="mode"
|
||||
control={control}
|
||||
rules={{ required: 'Mode is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Mode"
|
||||
description="Choose maintenance mode: Debug (test routing and escalations without real notifications) or Maintenance (group alerts into one during infrastructure work)."
|
||||
invalid={!!errors.mode}
|
||||
error={errors.mode?.message}
|
||||
>
|
||||
<Select
|
||||
placeholder="Choose mode"
|
||||
value={field.value}
|
||||
menuShouldPortal
|
||||
options={[
|
||||
{
|
||||
value: MaintenanceMode.Debug,
|
||||
label: 'Debug (silence all escalations)',
|
||||
},
|
||||
{
|
||||
value: MaintenanceMode.Maintenance,
|
||||
label: 'Maintenance (collect everything in one alert group)',
|
||||
},
|
||||
]}
|
||||
onChange={(option: SelectableValue) => {
|
||||
field.onChange(option.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="duration"
|
||||
control={control}
|
||||
rules={{ required: 'Duration is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Duration"
|
||||
description="Specify duration of the maintenance"
|
||||
invalid={!!errors.duration}
|
||||
error={errors.duration?.message}
|
||||
>
|
||||
<Select
|
||||
placeholder="Choose duration"
|
||||
value={field.value}
|
||||
menuShouldPortal
|
||||
options={[
|
||||
{
|
||||
value: 3600,
|
||||
label: '1 hour',
|
||||
},
|
||||
{
|
||||
value: 10800,
|
||||
label: '3 hours',
|
||||
},
|
||||
{
|
||||
value: 21600,
|
||||
label: '6 hours',
|
||||
},
|
||||
{
|
||||
value: 43200,
|
||||
label: '12 hours',
|
||||
},
|
||||
{
|
||||
value: 86400,
|
||||
label: '24 hours',
|
||||
},
|
||||
]}
|
||||
onChange={(option: SelectableValue) => {
|
||||
field.onChange(option.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
|
||||
<Button type="submit" data-testid="create-maintenance-button">
|
||||
Start
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import Emoji from 'react-emoji-render';
|
||||
|
||||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
|
||||
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
|
||||
import {
|
||||
HTTP_METHOD_OPTIONS,
|
||||
OutgoingWebhookPreset,
|
||||
WebhookTriggerType,
|
||||
WEBHOOK_TRIGGGER_TYPE_OPTIONS,
|
||||
} from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { generateAssignToTeamInputDescription } from 'utils/consts';
|
||||
|
||||
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
|
||||
export function createForm({
|
||||
presets = [],
|
||||
grafanaTeamStore,
|
||||
alertReceiveChannelStore,
|
||||
hasLabelsFeature,
|
||||
}: {
|
||||
presets: OutgoingWebhookPreset[];
|
||||
grafanaTeamStore: GrafanaTeamStore;
|
||||
alertReceiveChannelStore: AlertReceiveChannelStore;
|
||||
hasLabelsFeature?: boolean;
|
||||
}): {
|
||||
name: string;
|
||||
fields: FormItem[];
|
||||
} {
|
||||
return {
|
||||
name: 'OutgoingWebhook',
|
||||
fields: [
|
||||
{
|
||||
name: WebhookFormFieldName.Name,
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.IsWebhookEnabled,
|
||||
label: 'Enabled',
|
||||
normalize: (value) => Boolean(value),
|
||||
type: FormItemType.Switch,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Team,
|
||||
label: 'Assign to Team',
|
||||
description: `${generateAssignToTeamInputDescription(
|
||||
'Outgoing Webhooks'
|
||||
)} This setting does not effect execution of the webhook.`,
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
items: grafanaTeamStore.items,
|
||||
fetchItemsFn: grafanaTeamStore.updateItems,
|
||||
fetchItemFn: grafanaTeamStore.fetchItemById,
|
||||
getSearchResult: grafanaTeamStore.getSearchResult,
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
placeholder: 'Choose (Optional)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.TriggerType,
|
||||
label: 'Trigger Type',
|
||||
description: 'The type of event which will cause this webhook to execute.',
|
||||
type: FormItemType.Select,
|
||||
extra: {
|
||||
placeholder: 'Choose (Required)',
|
||||
options: WEBHOOK_TRIGGGER_TYPE_OPTIONS,
|
||||
},
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.HttpMethod,
|
||||
label: 'HTTP Method',
|
||||
type: FormItemType.Select,
|
||||
extra: {
|
||||
placeholder: 'Choose (Required)',
|
||||
options: HTTP_METHOD_OPTIONS,
|
||||
},
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.IntegrationFilter,
|
||||
label: 'Integrations',
|
||||
type: FormItemType.MultiSelect,
|
||||
isHidden: (data) =>
|
||||
!isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.IntegrationFilter) ||
|
||||
data.trigger_type === WebhookTriggerType.EscalationStep.key,
|
||||
extra: {
|
||||
placeholder: 'Choose (Optional)',
|
||||
items: alertReceiveChannelStore.items,
|
||||
fetchItemsFn: alertReceiveChannelStore.fetchItems,
|
||||
fetchItemFn: alertReceiveChannelStore.fetchItemById,
|
||||
getSearchResult: () => AlertReceiveChannelHelper.getSearchResult(alertReceiveChannelStore),
|
||||
displayField: 'verbal_name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
|
||||
},
|
||||
description:
|
||||
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Labels,
|
||||
label: 'Labels',
|
||||
type: FormItemType.Other,
|
||||
render: true,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Url,
|
||||
label: 'Webhook URL',
|
||||
type: FormItemType.Monaco,
|
||||
extra: {
|
||||
height: 30,
|
||||
},
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Url),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Headers,
|
||||
label: 'Webhook Headers',
|
||||
description: 'Request headers should be in JSON format.',
|
||||
type: FormItemType.Monaco,
|
||||
extra: {
|
||||
rows: 3,
|
||||
},
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Headers),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Username,
|
||||
type: FormItemType.Input,
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Username),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Password,
|
||||
type: FormItemType.Password,
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Password),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.AuthorizationHeader,
|
||||
description:
|
||||
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
|
||||
type: FormItemType.Password,
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.AuthorizationHeader),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.TriggerTemplate,
|
||||
type: FormItemType.Monaco,
|
||||
description:
|
||||
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
|
||||
extra: {
|
||||
rows: 2,
|
||||
},
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerTemplate),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.ForwardAll,
|
||||
normalize: (value) => (value ? Boolean(value) : value),
|
||||
type: FormItemType.Switch,
|
||||
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.ForwardAll),
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Data,
|
||||
getDisabled: (data) => Boolean(data?.forward_all),
|
||||
type: FormItemType.Monaco,
|
||||
description: `Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${
|
||||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`,
|
||||
extra: {},
|
||||
isHidden: (data) => !isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Data),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: WebhookFormFieldName) {
|
||||
if (presetId == null) {
|
||||
return true;
|
||||
}
|
||||
const selectedPreset = presets.find((item) => item.id === presetId);
|
||||
if (selectedPreset && selectedPreset.controlled_fields.includes(fieldName)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getStyles = (_theme: GrafanaTheme2) => ({
|
||||
formRow: css`
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
`,
|
||||
formField: css`
|
||||
flex-grow: 1;
|
||||
`,
|
||||
});
|
||||
|
|
@ -1,35 +1,25 @@
|
|||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
||||
import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
ConfirmModal,
|
||||
ConfirmModalProps,
|
||||
Drawer,
|
||||
EmptySearchResult,
|
||||
HorizontalGroup,
|
||||
Input,
|
||||
Tab,
|
||||
TabsBar,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import { capitalCase } from 'change-case';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
import { GForm, CustomFieldSectionRendererProps } from 'components/GForm/GForm';
|
||||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import { IntegrationLogo } from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config';
|
||||
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { Labels, LabelsProps } from 'containers/Labels/Labels';
|
||||
import { getWebhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config';
|
||||
import { OutgoingWebhookStatus } from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus';
|
||||
import { WebhooksTemplateEditor } from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types';
|
||||
|
|
@ -39,10 +29,11 @@ import { UserActions } from 'utils/authorization/authorization';
|
|||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
import { KeyValuePair } from 'utils/utils';
|
||||
|
||||
import { createForm } from './OutgoingWebhookForm.config';
|
||||
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
import { TemplateParams, WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
import { OutgoingWebhookFormFields } from './OutgoingWebhookFormFields';
|
||||
import { WebhookPresetBlocks } from './WebhookPresetBlocks';
|
||||
|
||||
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
|
||||
import styles from './OutgoingWebhookForm.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
|
|
@ -59,165 +50,135 @@ export const WebhookTabs = {
|
|||
LastRun: new KeyValuePair('LastRun', 'Last Event'),
|
||||
};
|
||||
|
||||
const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = observer(({ setValue, getValues }) => {
|
||||
const {
|
||||
hasFeature,
|
||||
outgoingWebhookStore: { labelsFormErrors },
|
||||
} = useStore();
|
||||
const onDataUpdate: LabelsProps['onDataUpdate'] = useCallback(
|
||||
(val) => setValue(WebhookFormFieldName.Labels, val),
|
||||
[]
|
||||
);
|
||||
function prepareDataForEdit(
|
||||
action: WebhookFormActionType,
|
||||
item: ApiSchemas['Webhook'],
|
||||
selectedPreset: OutgoingWebhookPreset
|
||||
) {
|
||||
if (action === WebhookFormActionType.NEW) {
|
||||
return {
|
||||
is_webhook_enabled: true,
|
||||
is_legacy: false,
|
||||
trigger_type: null,
|
||||
preset: selectedPreset?.id,
|
||||
http_method: 'POST',
|
||||
forward_all: true,
|
||||
labels: [],
|
||||
};
|
||||
} else if (action === WebhookFormActionType.COPY) {
|
||||
return { ...item, is_legacy: false, name: '' };
|
||||
} else {
|
||||
return { ...item };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderConditionally shouldRender={hasFeature(AppFeature.Labels)}>
|
||||
<Labels
|
||||
value={getValues<LabelKeyValue[]>(WebhookFormFieldName.Labels) || []}
|
||||
errors={labelsFormErrors}
|
||||
onDataUpdate={onDataUpdate}
|
||||
description="Labels applied to the webhook will be included in the webhook payload, along with alert group and integration labels."
|
||||
/>
|
||||
</RenderConditionally>
|
||||
);
|
||||
});
|
||||
|
||||
export const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
||||
const history = useHistory();
|
||||
const { id, action, onUpdate, onHide, onDelete } = props;
|
||||
const [onFormChangeFn, setOnFormChangeFn] = useState<{ fn: (value: string) => void }>(undefined);
|
||||
const [templateToEdit, setTemplateToEdit] = useState(undefined);
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key
|
||||
);
|
||||
const [showPresetsListDrawer, setShowPresetsListDrawer] = useState(id === 'new');
|
||||
const [showCreateWebhookDrawer, setShowCreateWebhookDrawer] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<OutgoingWebhookPreset>(undefined);
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
|
||||
const { outgoingWebhookStore, hasFeature, grafanaTeamStore, alertReceiveChannelStore } = useStore();
|
||||
const isNew = action === WebhookFormActionType.NEW;
|
||||
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
|
||||
const form = createForm({
|
||||
presets: outgoingWebhookStore.outgoingWebhookPresets,
|
||||
grafanaTeamStore,
|
||||
alertReceiveChannelStore,
|
||||
hasLabelsFeature: hasFeature(AppFeature.Labels),
|
||||
function prepareForSave(rawData: Partial<ApiSchemas['Webhook']>, selectedPreset: OutgoingWebhookPreset) {
|
||||
const data = { ...rawData };
|
||||
selectedPreset.controlled_fields.forEach((field) => {
|
||||
delete data[field];
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: Partial<ApiSchemas['Webhook']>) => {
|
||||
return data;
|
||||
}
|
||||
|
||||
export const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
||||
const { id, action, onUpdate, onHide, onDelete } = props;
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState<OutgoingWebhookPreset>(undefined);
|
||||
const [templateToEdit, setTemplateToEdit] = useState<TemplateParams>(undefined);
|
||||
|
||||
const { outgoingWebhookStore } = useStore();
|
||||
|
||||
const item = outgoingWebhookStore.items[id];
|
||||
const data = prepareDataForEdit(action, item, selectedPreset);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
const preset = outgoingWebhookStore.outgoingWebhookPresets.find((item) => item.id === data.preset);
|
||||
setSelectedPreset(preset);
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPreset) {
|
||||
reset(data);
|
||||
}
|
||||
}, [selectedPreset]);
|
||||
|
||||
const formMethods = useForm<ApiSchemas['Webhook']>({
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
});
|
||||
|
||||
const { setValue, reset, setError } = formMethods;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (rawData: Partial<ApiSchemas['Webhook']>) => {
|
||||
const data = prepareForSave(rawData, selectedPreset);
|
||||
|
||||
try {
|
||||
if (isNewOrCopy) {
|
||||
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
|
||||
await outgoingWebhookStore.create(data);
|
||||
} else {
|
||||
await outgoingWebhookStore.update(id, data);
|
||||
}
|
||||
outgoingWebhookStore.setLabelsFormErrors(undefined);
|
||||
onHide();
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
if (err.response?.data?.labels) {
|
||||
outgoingWebhookStore.setLabelsFormErrors(err.response.data.labels);
|
||||
}
|
||||
} catch (error) {
|
||||
Object.keys(error.response.data).forEach((key) => {
|
||||
setError(key as WebhookFormFieldName, { message: error.response.data[key][0] });
|
||||
});
|
||||
}
|
||||
},
|
||||
[id]
|
||||
[id, selectedPreset]
|
||||
);
|
||||
|
||||
const getTemplateEditClickHandler = (formItem: FormItem, values, setFormFieldValue) => {
|
||||
return () => {
|
||||
const formValue = values[formItem.name];
|
||||
setTemplateToEdit({
|
||||
value: formValue,
|
||||
displayName: `Webhook ${capitalCase(formItem.name)}`,
|
||||
description: undefined,
|
||||
name: formItem.name,
|
||||
});
|
||||
setOnFormChangeFn({ fn: (value) => setFormFieldValue(value) });
|
||||
};
|
||||
};
|
||||
const mainContent = useMemo(() => {
|
||||
if (action === WebhookFormActionType.NEW && !selectedPreset) {
|
||||
return <Presets onHide={onHide} onSelect={setSelectedPreset} />;
|
||||
}
|
||||
|
||||
const enrichField = (
|
||||
formItem: FormItem,
|
||||
disabled: boolean,
|
||||
renderedControl: React.ReactElement,
|
||||
values,
|
||||
setFormFieldValue
|
||||
) => {
|
||||
if (formItem.type === FormItemType.Monaco) {
|
||||
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
|
||||
return (
|
||||
<div className={cx('form-row')}>
|
||||
<div className={cx('form-field')}>{renderedControl}</div>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={getTemplateEditClickHandler(formItem, values, setFormFieldValue)}
|
||||
/>
|
||||
</div>
|
||||
<NewWebhook
|
||||
action={action}
|
||||
data={data}
|
||||
preset={selectedPreset}
|
||||
onBack={() => setSelectedPreset(undefined)}
|
||||
onHide={onHide}
|
||||
onTemplateEditClick={setTemplateToEdit}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return renderedControl;
|
||||
};
|
||||
return (
|
||||
<EditWebhookTabs
|
||||
action={action}
|
||||
data={data}
|
||||
id={id}
|
||||
onDelete={onDelete}
|
||||
onHide={onHide}
|
||||
onUpdate={onUpdate}
|
||||
onSubmit={onSubmit}
|
||||
onTemplateEditClick={setTemplateToEdit}
|
||||
preset={selectedPreset}
|
||||
/>
|
||||
);
|
||||
}, [action, selectedPreset]);
|
||||
|
||||
if (
|
||||
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
|
||||
!outgoingWebhookStore.items[id]
|
||||
) {
|
||||
if ((action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) && !item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data:
|
||||
| ApiSchemas['Webhook']
|
||||
| {
|
||||
is_webhook_enabled: boolean;
|
||||
is_legacy: boolean;
|
||||
preset: string;
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
data = {
|
||||
is_webhook_enabled: true,
|
||||
is_legacy: false,
|
||||
preset: selectedPreset?.id,
|
||||
trigger_type: null,
|
||||
http_method: 'POST',
|
||||
forward_all: true,
|
||||
};
|
||||
} else if (isNewOrCopy) {
|
||||
data = { ...outgoingWebhookStore.items[id], is_legacy: false, name: '' };
|
||||
} else {
|
||||
data = outgoingWebhookStore.items[id];
|
||||
}
|
||||
|
||||
if (
|
||||
(action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) &&
|
||||
!outgoingWebhookStore.items[id]
|
||||
) {
|
||||
// nothing to show if we open invalid ID for edit/last_run
|
||||
return null;
|
||||
}
|
||||
|
||||
const formElement = (
|
||||
<GForm
|
||||
form={form}
|
||||
data={data}
|
||||
onSubmit={handleSubmit}
|
||||
onFieldRender={enrichField}
|
||||
customFieldSectionRenderer={CustomFieldSectionRenderer}
|
||||
/>
|
||||
);
|
||||
const createWebhookParameters = (
|
||||
return (
|
||||
<>
|
||||
<Drawer scrollableContent title={'New Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
|
||||
</Drawer>
|
||||
<FormProvider {...formMethods}>{mainContent}</FormProvider>
|
||||
{templateToEdit && (
|
||||
<WebhooksTemplateEditor
|
||||
id={id}
|
||||
handleSubmit={(value) => {
|
||||
onFormChangeFn?.fn(value);
|
||||
setValue(templateToEdit.name, value);
|
||||
setTemplateToEdit(undefined);
|
||||
}}
|
||||
onHide={() => setTemplateToEdit(undefined)}
|
||||
|
|
@ -226,190 +187,196 @@ export const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) =>
|
|||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface PresetsProps {
|
||||
onHide: () => void;
|
||||
onSelect: (preset: OutgoingWebhookPreset) => void;
|
||||
}
|
||||
|
||||
const Presets = (props: PresetsProps) => {
|
||||
const { onHide, onSelect } = props;
|
||||
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
|
||||
const { outgoingWebhookStore } = useStore();
|
||||
|
||||
const presets = outgoingWebhookStore.outgoingWebhookPresets.filter((preset: OutgoingWebhookPreset) =>
|
||||
preset.name.toLowerCase().includes(filterValue.toLowerCase())
|
||||
);
|
||||
|
||||
if (action === WebhookFormActionType.NEW) {
|
||||
return (
|
||||
<>
|
||||
{showPresetsListDrawer && (
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title="New Outgoing Webhook"
|
||||
onClose={onHide}
|
||||
closeOnMaskClick={false}
|
||||
width="640px"
|
||||
>
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">
|
||||
Outgoing webhooks can send alert data to other systems. They can be triggered by various conditions
|
||||
and can use templates to transform data to fit the recipient system. Presets listed below provide a
|
||||
starting point to customize these connections.
|
||||
</Text>
|
||||
return (
|
||||
<Drawer scrollableContent title="New Outgoing Webhook" onClose={onHide} closeOnMaskClick={false} width="640px">
|
||||
<div className={cx('content')}>
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">
|
||||
Outgoing webhooks can send alert data to other systems. They can be triggered by various conditions and can
|
||||
use templates to transform data to fit the recipient system. Presets listed below provide a starting point
|
||||
to customize these connections.
|
||||
</Text>
|
||||
|
||||
{presets.length > 8 && (
|
||||
<div className={cx('search-integration')}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={filterValue}
|
||||
placeholder="Search webhook presets ..."
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterValue(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WebhookPresetBlocks presets={presets} onBlockClick={onBlockClick} />
|
||||
</VerticalGroup>
|
||||
{presets.length > 8 && (
|
||||
<div className={cx('search-integration')}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={filterValue}
|
||||
placeholder="Search webhook presets ..."
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterValue(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
)}
|
||||
{(showCreateWebhookDrawer || !showPresetsListDrawer) && createWebhookParameters}
|
||||
</>
|
||||
);
|
||||
} else if (action === WebhookFormActionType.COPY) {
|
||||
return createWebhookParameters;
|
||||
}
|
||||
)}
|
||||
|
||||
<WebhookPresetBlocks presets={presets} onBlockClick={onSelect} />
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
interface NewWebhookProps {
|
||||
data: Partial<ApiSchemas['Webhook']>;
|
||||
preset: OutgoingWebhookPreset;
|
||||
onHide: () => void;
|
||||
onBack: () => void;
|
||||
action: WebhookFormActionType;
|
||||
onTemplateEditClick: (params: TemplateParams) => void;
|
||||
onSubmit: (data: Partial<ApiSchemas['Webhook']>) => void;
|
||||
}
|
||||
|
||||
const NewWebhook = (props: NewWebhookProps) => {
|
||||
const { data, preset, onHide, action, onBack, onTemplateEditClick, onSubmit } = props;
|
||||
|
||||
const { hasFeature } = useStore();
|
||||
|
||||
const { handleSubmit } = useFormContext();
|
||||
|
||||
return (
|
||||
// show tabbed drawer (edit/live_run)
|
||||
<>
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title={'Outgoing webhook details'}
|
||||
onClose={onHide}
|
||||
closeOnMaskClick={false}
|
||||
tabs={
|
||||
<div className={cx('tabsWrapper')}>
|
||||
<TabsBar>
|
||||
<Tab
|
||||
key={WebhookTabs.Settings.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.Settings.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.Settings.key}
|
||||
label={WebhookTabs.Settings.value}
|
||||
/>
|
||||
|
||||
<Tab
|
||||
key={WebhookTabs.LastRun.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.LastRun.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.LastRun.key}
|
||||
label={WebhookTabs.LastRun.value}
|
||||
/>
|
||||
</TabsBar>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={cx('webhooks__drawerContent')}>
|
||||
<WebhookTabsContent
|
||||
id={id}
|
||||
action={action}
|
||||
activeTab={activeTab}
|
||||
data={data}
|
||||
handleSubmit={handleSubmit}
|
||||
onDelete={onDelete}
|
||||
onHide={onHide}
|
||||
onUpdate={onUpdate}
|
||||
formElement={formElement}
|
||||
/>
|
||||
<Drawer scrollableContent title={'New Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className="webhooks__drawerContent">
|
||||
<div className={cx('content')}>
|
||||
<form id="OutgoingWebhook" onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||
<OutgoingWebhookFormFields
|
||||
preset={preset}
|
||||
hasLabelsFeature={hasFeature(AppFeature.Labels)}
|
||||
onTemplateEditClick={onTemplateEditClick}
|
||||
/>
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
{action === WebhookFormActionType.NEW ? (
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={data.is_legacy}>
|
||||
Create
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Drawer>
|
||||
{templateToEdit && (
|
||||
<WebhooksTemplateEditor
|
||||
id={id}
|
||||
handleSubmit={(value) => {
|
||||
onFormChangeFn?.fn(value);
|
||||
setTemplateToEdit(undefined);
|
||||
}}
|
||||
onHide={() => setTemplateToEdit(undefined)}
|
||||
template={templateToEdit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditWebhookTabsProps {
|
||||
id: OutgoingWebhookFormProps['id'];
|
||||
data: Partial<ApiSchemas['Webhook']>;
|
||||
action: WebhookFormActionType;
|
||||
onHide: OutgoingWebhookFormProps['onHide'];
|
||||
onUpdate: OutgoingWebhookFormProps['onUpdate'];
|
||||
onDelete: OutgoingWebhookFormProps['onDelete'];
|
||||
onSubmit: (data: Partial<ApiSchemas['Webhook']>) => void;
|
||||
onTemplateEditClick: (params: TemplateParams) => void;
|
||||
preset: OutgoingWebhookPreset;
|
||||
}
|
||||
|
||||
const EditWebhookTabs = (props: EditWebhookTabsProps) => {
|
||||
const { id, data, action, onHide, onUpdate, onDelete, onSubmit, onTemplateEditClick, preset } = props;
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key
|
||||
);
|
||||
|
||||
function onBlockClick(preset: OutgoingWebhookPreset) {
|
||||
setSelectedPreset(preset);
|
||||
setShowCreateWebhookDrawer(true);
|
||||
setShowPresetsListDrawer(false);
|
||||
}
|
||||
return (
|
||||
<Drawer
|
||||
scrollableContent
|
||||
title="Outgoing webhook details"
|
||||
onClose={onHide}
|
||||
closeOnMaskClick={false}
|
||||
tabs={
|
||||
<div className={cx('tabsWrapper')}>
|
||||
<TabsBar>
|
||||
<Tab
|
||||
key={WebhookTabs.Settings.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.Settings.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/edit/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.Settings.key}
|
||||
label={WebhookTabs.Settings.value}
|
||||
/>
|
||||
|
||||
function renderWebhookForm() {
|
||||
return (
|
||||
<>
|
||||
<div className={cx('content')}>
|
||||
<GForm
|
||||
form={form}
|
||||
data={data}
|
||||
onSubmit={handleSubmit}
|
||||
onFieldRender={enrichField}
|
||||
customFieldSectionRenderer={CustomFieldSectionRenderer}
|
||||
/>
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
{id === 'new' ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateWebhookDrawer(false);
|
||||
setShowPresetsListDrawer(true);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button form={form.name} type="submit" disabled={data.is_legacy}>
|
||||
{isNewOrCopy ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<Tab
|
||||
key={WebhookTabs.LastRun.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.LastRun.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks/last_run/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.LastRun.key}
|
||||
label={WebhookTabs.LastRun.value}
|
||||
/>
|
||||
</TabsBar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<div className={cx('webhooks__drawerContent')}>
|
||||
<WebhookTabsContent
|
||||
id={id}
|
||||
action={action}
|
||||
activeTab={activeTab}
|
||||
data={data}
|
||||
onDelete={onDelete}
|
||||
onHide={onHide}
|
||||
onUpdate={onUpdate}
|
||||
onSubmit={onSubmit}
|
||||
onTemplateEditClick={onTemplateEditClick}
|
||||
preset={preset}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
interface WebhookTabsProps {
|
||||
id: ApiSchemas['Webhook']['id'] | 'new';
|
||||
id: OutgoingWebhookFormProps['id'];
|
||||
activeTab: string;
|
||||
action: WebhookFormActionType;
|
||||
data:
|
||||
| ApiSchemas['Webhook']
|
||||
| {
|
||||
is_webhook_enabled: boolean;
|
||||
is_legacy: boolean;
|
||||
preset: string;
|
||||
};
|
||||
data: Partial<ApiSchemas['Webhook']>;
|
||||
onHide: () => void;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
handleSubmit: (data: Partial<ApiSchemas['Webhook']>) => void;
|
||||
formElement: React.ReactElement;
|
||||
preset: OutgoingWebhookPreset;
|
||||
onTemplateEditClick: (params: TemplateParams) => void;
|
||||
onSubmit: (data: Partial<ApiSchemas['Webhook']>) => void;
|
||||
}
|
||||
|
||||
const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
|
||||
({ id, action, activeTab, data, onHide, onDelete, formElement }) => {
|
||||
({ id, action, activeTab, data, onHide, onDelete, onSubmit, onTemplateEditClick, preset }) => {
|
||||
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
|
||||
const { outgoingWebhookStore, hasFeature, grafanaTeamStore, alertReceiveChannelStore } = useStore();
|
||||
const form = createForm({
|
||||
presets: outgoingWebhookStore.outgoingWebhookPresets,
|
||||
grafanaTeamStore,
|
||||
alertReceiveChannelStore,
|
||||
hasLabelsFeature: hasFeature(AppFeature.Labels),
|
||||
});
|
||||
|
||||
const { hasFeature } = useStore();
|
||||
|
||||
const { handleSubmit } = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={cx('tabs__content')}>
|
||||
{confirmationModal && (
|
||||
|
|
@ -422,39 +389,44 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
|
|||
{activeTab === WebhookTabs.Settings.key && (
|
||||
<>
|
||||
<div className={cx('content')}>
|
||||
{formElement}
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button
|
||||
form={form.name}
|
||||
variant="destructive"
|
||||
type="button"
|
||||
disabled={data.is_legacy}
|
||||
onClick={() => {
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
body: 'The action cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
dismissText: 'Cancel',
|
||||
onConfirm: onDelete,
|
||||
title: `Are you sure you want to delete webhook?`,
|
||||
} as ConfirmModalProps);
|
||||
}}
|
||||
>
|
||||
Delete Webhook
|
||||
<form id="OutgoingWebhook" onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||
<OutgoingWebhookFormFields
|
||||
preset={preset}
|
||||
hasLabelsFeature={hasFeature(AppFeature.Labels)}
|
||||
onTemplateEditClick={onTemplateEditClick}
|
||||
/>
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button form={form.name} type="submit" disabled={data.is_legacy}>
|
||||
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
disabled={data.is_legacy}
|
||||
onClick={() => {
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
body: 'The action cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
dismissText: 'Cancel',
|
||||
onConfirm: onDelete,
|
||||
title: `Are you sure you want to delete webhook?`,
|
||||
} as ConfirmModalProps);
|
||||
}}
|
||||
>
|
||||
Delete Webhook
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button type="submit" onClick={handleSubmit(onSubmit)} disabled={data.is_legacy}>
|
||||
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{data.is_legacy ? (
|
||||
<div className={cx('content')}>
|
||||
|
|
@ -470,46 +442,3 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
|
|||
);
|
||||
}
|
||||
);
|
||||
|
||||
const WebhookPresetBlocks: React.FC<{
|
||||
presets: OutgoingWebhookPreset[];
|
||||
onBlockClick: (preset: OutgoingWebhookPreset) => void;
|
||||
}> = observer(({ presets, onBlockClick }) => {
|
||||
const store = useStore();
|
||||
|
||||
const webhookPresetIcons = getWebhookPresetIcons(store.features);
|
||||
|
||||
return (
|
||||
<div className={cx('cards')} data-testid="create-outgoing-webhook-modal">
|
||||
{presets.length ? (
|
||||
presets.map((preset) => {
|
||||
let logo = <IntegrationLogo integration={{ value: 'webhook', display_name: preset.name }} scale={0.2} />;
|
||||
if (preset.logo in logoCoors) {
|
||||
logo = <IntegrationLogo integration={{ value: preset.logo, display_name: preset.name }} scale={0.2} />;
|
||||
} else if (preset.logo in webhookPresetIcons) {
|
||||
logo = webhookPresetIcons[preset.logo]();
|
||||
}
|
||||
return (
|
||||
<Block bordered hover shadowed onClick={() => onBlockClick(preset)} key={preset.id} className={cx('card')}>
|
||||
<div className={cx('card-bg')}>{logo}</div>
|
||||
<div className={cx('title')}>
|
||||
<VerticalGroup spacing="xs">
|
||||
<HorizontalGroup>
|
||||
<Text strong data-testid="webhook-preset-display-name">
|
||||
{preset.name}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<Text type="secondary" size="small">
|
||||
{preset.description}
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,4 +15,11 @@ export const WebhookFormFieldName = {
|
|||
ForwardAll: 'forward_all',
|
||||
Data: 'data',
|
||||
} as const;
|
||||
|
||||
export type WebhookFormFieldName = (typeof WebhookFormFieldName)[keyof typeof WebhookFormFieldName];
|
||||
|
||||
export interface TemplateParams {
|
||||
name: WebhookFormFieldName;
|
||||
value: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Button, Field, Input, Select, Switch, useStyles2 } from '@grafana/ui';
|
||||
import Emoji from 'react-emoji-render';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
|
||||
import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import { GSelect } from 'containers/GSelect/GSelect';
|
||||
import { Labels } from 'containers/Labels/Labels';
|
||||
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import {
|
||||
HTTP_METHOD_OPTIONS,
|
||||
OutgoingWebhookPreset,
|
||||
WEBHOOK_TRIGGGER_TYPE_OPTIONS,
|
||||
} from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { generateAssignToTeamInputDescription } from 'utils/consts';
|
||||
|
||||
import { getStyles } from './OutgoingWebhookForm.styles';
|
||||
import { TemplateParams, WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
|
||||
interface OutgoingWebhookFormFieldsProps {
|
||||
preset: OutgoingWebhookPreset;
|
||||
hasLabelsFeature: boolean;
|
||||
onTemplateEditClick: (params: TemplateParams) => void;
|
||||
}
|
||||
|
||||
export const OutgoingWebhookFormFields = ({
|
||||
preset,
|
||||
hasLabelsFeature,
|
||||
onTemplateEditClick,
|
||||
}: OutgoingWebhookFormFieldsProps) => {
|
||||
const { grafanaTeamStore, alertReceiveChannelStore } = useStore();
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useFormContext<ApiSchemas['Webhook']>();
|
||||
|
||||
const forwardAll = watch(WebhookFormFieldName.ForwardAll);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const controls = (
|
||||
<>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Name}
|
||||
control={control}
|
||||
rules={{ required: 'Name is required' }}
|
||||
render={({ field }) => (
|
||||
<Field label="Name" invalid={Boolean(errors.name)} error={errors.name?.message}>
|
||||
<Input name="name" value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.IsWebhookEnabled}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Enabled"
|
||||
invalid={Boolean(errors.is_webhook_enabled)}
|
||||
error={errors.is_webhook_enabled?.message}
|
||||
>
|
||||
<Switch value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Team}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Assign to team"
|
||||
description={`${generateAssignToTeamInputDescription(
|
||||
'Outgoing Webhooks'
|
||||
)} This setting does not effect execution of the webhook.`}
|
||||
invalid={!!errors.team}
|
||||
error={errors.team?.message}
|
||||
>
|
||||
<GSelect<GrafanaTeam>
|
||||
showSearch
|
||||
allowClear
|
||||
items={grafanaTeamStore.items}
|
||||
fetchItemsFn={grafanaTeamStore.updateItems}
|
||||
fetchItemFn={grafanaTeamStore.fetchItemById}
|
||||
getSearchResult={grafanaTeamStore.getSearchResult}
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Choose (Optional)"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.TriggerType}
|
||||
control={control}
|
||||
rules={{ required: 'Trigger Type is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Trigger Type"
|
||||
description="The type of event which will cause this webhook to execute."
|
||||
invalid={!!errors.trigger_type}
|
||||
error={errors.trigger_type?.message}
|
||||
>
|
||||
<Select
|
||||
placeholder="Choose (Required)"
|
||||
value={field.value}
|
||||
menuShouldPortal
|
||||
options={WEBHOOK_TRIGGGER_TYPE_OPTIONS}
|
||||
onChange={({ value }) => field.onChange(value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.HttpMethod}
|
||||
control={control}
|
||||
rules={{ required: 'HTTP Method is required' }}
|
||||
render={({ field }) => (
|
||||
<Field label="HTTP Method" invalid={!!errors.http_method} error={errors.http_method?.message}>
|
||||
<Select
|
||||
placeholder="Choose (Required)"
|
||||
value={field.value}
|
||||
menuShouldPortal
|
||||
options={HTTP_METHOD_OPTIONS}
|
||||
onChange={({ value }) => field.onChange(value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.IntegrationFilter}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Integrations"
|
||||
description="Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations"
|
||||
invalid={!!errors.integration_filter}
|
||||
error={errors.integration_filter?.message}
|
||||
>
|
||||
<GSelect<ApiSchemas['AlertReceiveChannel']>
|
||||
isMulti
|
||||
showSearch
|
||||
placeholder="Choose (Optional)"
|
||||
items={alertReceiveChannelStore.items}
|
||||
fetchItemsFn={alertReceiveChannelStore.fetchItems}
|
||||
fetchItemFn={alertReceiveChannelStore.fetchItemById}
|
||||
getSearchResult={() => AlertReceiveChannelHelper.getSearchResult(alertReceiveChannelStore)}
|
||||
displayField="verbal_name"
|
||||
valueField="id"
|
||||
getOptionLabel={(item: SelectableValue) => <Emoji text={item?.label || ''} />}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{hasLabelsFeature && (
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Labels}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Labels
|
||||
value={field.value}
|
||||
errors={errors.labels}
|
||||
onDataUpdate={field.onChange}
|
||||
description="Labels applied to the webhook will be included in the webhook payload, along with alert group and integration labels."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Url}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field label="Webhook URL" invalid={!!errors.url} error={errors.url?.message}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<MonacoEditor
|
||||
data={{}}
|
||||
height={30}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={MONACO_EDITABLE_CONFIG}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onTemplateEditClick({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
displayName: 'Webhook URL',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Headers}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field label="Webhook Headers" description="Request headers should be in JSON format.">
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<MonacoEditor
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={MONACO_EDITABLE_CONFIG}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onTemplateEditClick({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
displayName: 'Webhook Headers',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Username}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field label="Username" invalid={Boolean(errors.username)} error={errors.username?.message}>
|
||||
<Input value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Password}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field label="Password" invalid={Boolean(errors.password)} error={errors.password?.message}>
|
||||
<Input type="password" value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.AuthorizationHeader}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Authorization Header"
|
||||
invalid={Boolean(errors.authorization_header)}
|
||||
error={errors.authorization_header?.message}
|
||||
>
|
||||
<Input type="password" value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.TriggerTemplate}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Trigger Template"
|
||||
description="Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent"
|
||||
>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<MonacoEditor
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={MONACO_EDITABLE_CONFIG}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onTemplateEditClick({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
displayName: 'Webhook Trigger Template',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.ForwardAll}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Forward All"
|
||||
description="Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data"
|
||||
invalid={Boolean(errors.forward_all)}
|
||||
error={errors.forward_all?.message}
|
||||
>
|
||||
<Switch value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Data}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Data"
|
||||
description={`Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${
|
||||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<MonacoEditor
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={{ ...MONACO_EDITABLE_CONFIG, readOnly: forwardAll }}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onTemplateEditClick({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
displayName: 'Webhook Data',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.Children.toArray(controls.props.children).filter(
|
||||
(child) => !preset || !preset.controlled_fields.includes((child as React.ReactElement).props.name)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
|
||||
import { EmptySearchResult, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
import { IntegrationLogo } from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { getWebhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config';
|
||||
import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export const WebhookPresetBlocks: React.FC<{
|
||||
presets: OutgoingWebhookPreset[];
|
||||
onBlockClick: (preset: OutgoingWebhookPreset) => void;
|
||||
}> = observer(({ presets, onBlockClick }) => {
|
||||
const store = useStore();
|
||||
|
||||
const webhookPresetIcons = getWebhookPresetIcons(store.features);
|
||||
|
||||
return (
|
||||
<div className={cx('cards')} data-testid="create-outgoing-webhook-modal">
|
||||
{presets.length ? (
|
||||
presets.map((preset) => {
|
||||
let logo = <IntegrationLogo integration={{ value: 'webhook', display_name: preset.name }} scale={0.2} />;
|
||||
if (preset.logo in logoCoors) {
|
||||
logo = <IntegrationLogo integration={{ value: preset.logo, display_name: preset.name }} scale={0.2} />;
|
||||
} else if (preset.logo in webhookPresetIcons) {
|
||||
logo = webhookPresetIcons[preset.logo]();
|
||||
}
|
||||
return (
|
||||
<Block bordered hover shadowed onClick={() => onBlockClick(preset)} key={preset.id} className={cx('card')}>
|
||||
<div className={cx('card-bg')}>{logo}</div>
|
||||
<div className={cx('title')}>
|
||||
<VerticalGroup spacing="xs">
|
||||
<HorizontalGroup>
|
||||
<Text strong data-testid="webhook-preset-display-name">
|
||||
{preset.name}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<Text type="secondary" size="small">
|
||||
{preset.description}
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ import { useDebouncedCallback } from 'utils/hooks';
|
|||
interface RemoteSelectProps {
|
||||
autoFocus?: boolean;
|
||||
href: string;
|
||||
value: string | string[] | number | number[] | null;
|
||||
value: string | string[] | number | number[] | boolean | null;
|
||||
onChange: (value: any, item: any) => void;
|
||||
fieldToShow?: string;
|
||||
getFieldToShow?: (item: any) => string;
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
import { generateAssignToTeamInputDescription } from 'utils/consts';
|
||||
|
||||
const assignToTeamDescription = generateAssignToTeamInputDescription('Schedules');
|
||||
|
||||
const getCommonFields = ({ slackChannelStore, userGroupStore }: RootStore): FormItem[] =>
|
||||
[
|
||||
{
|
||||
name: 'slack_channel_id',
|
||||
label: 'Slack channel',
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
items: slackChannelStore.items,
|
||||
fetchItemsFn: slackChannelStore.updateItems,
|
||||
fetchItemFn: slackChannelStore.updateItem,
|
||||
getSearchResult: slackChannelStore.getSearchResult,
|
||||
displayField: 'display_name',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
nullItemName: PRIVATE_CHANNEL_NAME,
|
||||
},
|
||||
description:
|
||||
'Calendar parsing errors and notifications about the new on-call shift will be published in this channel.',
|
||||
},
|
||||
{
|
||||
name: 'user_group',
|
||||
label: 'Slack user group',
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
items: userGroupStore.items,
|
||||
fetchItemsFn: userGroupStore.updateItems,
|
||||
// TODO: fetchItemFn
|
||||
getSearchResult: userGroupStore.getSearchResult,
|
||||
displayField: 'handle',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
},
|
||||
description:
|
||||
'Group members will be automatically updated with current on-call. In case you want to ping on-call with @group_name.',
|
||||
},
|
||||
{
|
||||
name: 'notify_oncall_shift_freq',
|
||||
label: 'Notification frequency',
|
||||
type: FormItemType.RemoteSelect,
|
||||
normalize: (value) => value,
|
||||
extra: {
|
||||
href: '/schedules/notify_oncall_shift_freq_options/',
|
||||
displayField: 'display_name',
|
||||
openMenuOnFocus: false,
|
||||
},
|
||||
description: 'Specify the frequency that shift notifications are sent to scheduled team members.',
|
||||
},
|
||||
{
|
||||
name: 'notify_empty_oncall',
|
||||
label: 'Action for slot when no one is on-call',
|
||||
type: FormItemType.RemoteSelect,
|
||||
normalize: (value) => value,
|
||||
extra: {
|
||||
href: '/schedules/notify_empty_oncall_options/',
|
||||
displayField: 'display_name',
|
||||
openMenuOnFocus: false,
|
||||
},
|
||||
description: 'Specify how to notify team members when there is no one scheduled for an on-call shift.',
|
||||
},
|
||||
{
|
||||
name: 'mention_oncall_start',
|
||||
label: 'Current shift notification settings',
|
||||
type: FormItemType.RemoteSelect,
|
||||
normalize: (value) => value,
|
||||
extra: {
|
||||
href: '/schedules/mention_options/',
|
||||
displayField: 'display_name',
|
||||
openMenuOnFocus: false,
|
||||
},
|
||||
description: 'Specify how to notify a team member when their on-call shift begins ',
|
||||
},
|
||||
{
|
||||
name: 'mention_oncall_next',
|
||||
label: 'Next shift notification settings',
|
||||
type: FormItemType.RemoteSelect,
|
||||
normalize: (value) => value,
|
||||
extra: {
|
||||
href: '/schedules/mention_options/',
|
||||
displayField: 'display_name',
|
||||
openMenuOnFocus: false,
|
||||
},
|
||||
description: 'Specify how to notify a team member when their shift is the next one scheduled',
|
||||
},
|
||||
].map((field) => ({ ...field, collapsed: true }));
|
||||
|
||||
export const getICalForm = (rootStore: RootStore) => ({
|
||||
name: 'Schedule',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
label: 'Assign to team',
|
||||
description: assignToTeamDescription,
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
items: rootStore.grafanaTeamStore.items,
|
||||
fetchItemsFn: rootStore.grafanaTeamStore.updateItems,
|
||||
fetchItemFn: rootStore.grafanaTeamStore.fetchItemById,
|
||||
getSearchResult: rootStore.grafanaTeamStore.getSearchResult,
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ical_url_primary',
|
||||
label: 'Primary schedule iCal URL',
|
||||
type: FormItemType.TextArea,
|
||||
validation: { required: true },
|
||||
extra: { rows: 2 },
|
||||
},
|
||||
{
|
||||
name: 'ical_url_overrides',
|
||||
label: 'Overrides schedule iCal URL ',
|
||||
type: FormItemType.TextArea,
|
||||
extra: { rows: 2 },
|
||||
},
|
||||
|
||||
...getCommonFields(rootStore),
|
||||
],
|
||||
});
|
||||
|
||||
export const getCalendarForm = (rootStore: RootStore) => ({
|
||||
name: 'Schedule',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
label: 'Assign to team',
|
||||
description: assignToTeamDescription,
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
items: rootStore.grafanaTeamStore.items,
|
||||
fetchItemsFn: rootStore.grafanaTeamStore.updateItems,
|
||||
fetchItemFn: rootStore.grafanaTeamStore.fetchItemById,
|
||||
getSearchResult: rootStore.grafanaTeamStore.getSearchResult,
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enable_web_overrides',
|
||||
label: 'Enable web interface overrides ',
|
||||
type: FormItemType.Switch,
|
||||
description:
|
||||
'Allow overrides to be created using the web UI. \n' +
|
||||
'NOTE: when enabled, iCal URL overrides will be ignored.',
|
||||
},
|
||||
{
|
||||
name: 'ical_url_overrides',
|
||||
label: 'Overrides schedule iCal URL ',
|
||||
type: FormItemType.TextArea,
|
||||
extra: { rows: 2 },
|
||||
},
|
||||
|
||||
...getCommonFields(rootStore),
|
||||
],
|
||||
});
|
||||
|
||||
export const getApiForm = (rootStore: RootStore) => ({
|
||||
name: 'Schedule',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
label: 'Assign to team',
|
||||
description: assignToTeamDescription,
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
items: rootStore.grafanaTeamStore.items,
|
||||
fetchItemsFn: rootStore.grafanaTeamStore.updateItems,
|
||||
fetchItemFn: rootStore.grafanaTeamStore.fetchItemById,
|
||||
getSearchResult: rootStore.grafanaTeamStore.getSearchResult,
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
...getCommonFields(rootStore),
|
||||
],
|
||||
});
|
||||
|
|
@ -1,23 +1,36 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { css } from '@emotion/css';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Field,
|
||||
HorizontalGroup,
|
||||
Input,
|
||||
Switch,
|
||||
TextArea,
|
||||
VerticalGroup,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form';
|
||||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { GForm } from 'components/GForm/GForm';
|
||||
import { Collapse } from 'components/Collapse/Collapse';
|
||||
import { GSelect } from 'containers/GSelect/GSelect';
|
||||
import { RemoteSelect } from 'containers/RemoteSelect/RemoteSelect';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
|
||||
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
|
||||
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
|
||||
import { UserGroup } from 'models/user_group/user_group.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { openWarningNotification } from 'utils/utils';
|
||||
|
||||
import { getApiForm, getCalendarForm, getICalForm } from './ScheduleForm.config';
|
||||
import { prepareForEdit } from './ScheduleForm.helpers';
|
||||
|
||||
import styles from './ScheduleForm.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface ScheduleFormProps {
|
||||
id: Schedule['id'] | 'new';
|
||||
onHide: () => void;
|
||||
|
|
@ -25,26 +38,25 @@ interface ScheduleFormProps {
|
|||
type?: ScheduleType;
|
||||
}
|
||||
|
||||
interface FormFields extends Omit<Schedule, 'user_group'> {
|
||||
slack_channel_id: SlackChannel['id'];
|
||||
user_group: UserGroup['id'];
|
||||
}
|
||||
|
||||
export const ScheduleForm = observer((props: ScheduleFormProps) => {
|
||||
const { id, type, onSubmit, onHide } = props;
|
||||
const { id, type, onSubmit: propsOnSubmit, onHide } = props;
|
||||
const isNew = id === 'new';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const { scheduleStore, userStore } = store;
|
||||
|
||||
const scheduleTypeToForm = {
|
||||
[ScheduleType.Calendar]: getCalendarForm(store),
|
||||
[ScheduleType.Ical]: getICalForm(store),
|
||||
[ScheduleType.API]: getApiForm(store),
|
||||
};
|
||||
|
||||
const data = useMemo(() => {
|
||||
return isNew ? { team: userStore.currentUser?.current_team, type } : prepareForEdit(scheduleStore.items[id]);
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Partial<Schedule>): Promise<void> => {
|
||||
const onSubmit = useCallback(
|
||||
async (formData: FormFields): Promise<void> => {
|
||||
const apiData = { ...formData, type: data.type };
|
||||
|
||||
let schedule: Schedule | void;
|
||||
|
|
@ -59,13 +71,22 @@ export const ScheduleForm = observer((props: ScheduleFormProps) => {
|
|||
return;
|
||||
}
|
||||
|
||||
onSubmit(schedule);
|
||||
propsOnSubmit(schedule);
|
||||
onHide();
|
||||
},
|
||||
[id, isNew]
|
||||
);
|
||||
|
||||
const formConfig = scheduleTypeToForm[data.type];
|
||||
/* ---------------- */
|
||||
|
||||
const formMethods = useForm<FormFields>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { ...data },
|
||||
});
|
||||
|
||||
const { handleSubmit } = formMethods;
|
||||
|
||||
const utils = useStyles2(getUtilStyles);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
|
|
@ -74,23 +95,312 @@ export const ScheduleForm = observer((props: ScheduleFormProps) => {
|
|||
onClose={onHide}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<div className={cx('content')} data-testid="schedule-form">
|
||||
<VerticalGroup>
|
||||
<GForm form={formConfig} data={data} onSubmit={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
|
||||
<Button form={formConfig.name} type="submit">
|
||||
{id === 'new' ? 'Create' : 'Update'} Schedule
|
||||
<VerticalGroup>
|
||||
<FormProvider {...formMethods}>
|
||||
<form id="Schedule" data-testid="schedule-form" onSubmit={handleSubmit(onSubmit)} className={utils.width100}>
|
||||
<FormFields scheduleType={data.type} />
|
||||
<div className="buttons">
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
|
||||
<Button type="submit">{id === 'new' ? 'Create' : 'Update'} Schedule</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
);
|
||||
});
|
||||
|
||||
const FormFields = ({ scheduleType }: { scheduleType: Schedule['type'] }) => {
|
||||
const { control, formState } = useFormContext<FormFields>();
|
||||
const { errors } = formState;
|
||||
|
||||
switch (scheduleType) {
|
||||
case ScheduleType.Calendar:
|
||||
return (
|
||||
<>
|
||||
<ScheduleCommonFields />
|
||||
<Controller
|
||||
name="enable_web_overrides"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Enable web interface overrides"
|
||||
invalid={Boolean(errors.enable_web_overrides)}
|
||||
error={errors.enable_web_overrides?.message}
|
||||
>
|
||||
<Switch value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="ical_url_overrides"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Overrides schedule iCal URL"
|
||||
invalid={Boolean(errors.ical_url_overrides)}
|
||||
error={errors.ical_url_overrides?.message}
|
||||
>
|
||||
<TextArea rows={2} value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<ScheduleNotificationSettingsFields />
|
||||
</>
|
||||
);
|
||||
case ScheduleType.Ical:
|
||||
return (
|
||||
<>
|
||||
<ScheduleCommonFields />
|
||||
<Controller
|
||||
name="ical_url_primary"
|
||||
rules={{ required: 'Primary schedule is required' }}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Primary schedule iCal URL"
|
||||
invalid={Boolean(errors.ical_url_primary)}
|
||||
error={errors.ical_url_primary?.message}
|
||||
>
|
||||
<TextArea rows={2} value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="ical_url_overrides"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Overrides schedule iCal URL"
|
||||
invalid={Boolean(errors.ical_url_overrides)}
|
||||
error={errors.ical_url_overrides?.message}
|
||||
>
|
||||
<TextArea rows={2} value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<ScheduleNotificationSettingsFields />
|
||||
</>
|
||||
);
|
||||
case ScheduleType.API:
|
||||
return (
|
||||
<>
|
||||
<ScheduleCommonFields />
|
||||
<ScheduleNotificationSettingsFields />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ScheduleCommonFields = () => {
|
||||
const { grafanaTeamStore } = useStore();
|
||||
|
||||
const { control, formState } = useFormContext<FormFields>();
|
||||
const { errors } = formState;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: 'Name is required' }}
|
||||
render={({ field }) => (
|
||||
<Field label="Name" invalid={Boolean(errors.name)} error={errors.name?.message}>
|
||||
<Input name="name" value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="team"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field label="Assign to team" invalid={!!errors.team} error={errors.team?.message}>
|
||||
<GSelect<GrafanaTeam>
|
||||
items={grafanaTeamStore.items}
|
||||
fetchItemsFn={grafanaTeamStore.updateItems}
|
||||
fetchItemFn={grafanaTeamStore.fetchItemById}
|
||||
getSearchResult={grafanaTeamStore.getSearchResult}
|
||||
displayField="name"
|
||||
valueField="id"
|
||||
placeholder="Select Team"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleNotificationSettingsFields = () => {
|
||||
const store = useStore();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { slackChannelStore, userGroupStore } = store;
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<FormFields>();
|
||||
|
||||
return (
|
||||
<Collapse isOpen={false} label="Notification settings" className={styles.collapse}>
|
||||
<Controller
|
||||
name="slack_channel_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Slack channel"
|
||||
description="Calendar parsing errors and notifications about the new on-call shift will be published in this channel."
|
||||
invalid={!!errors.slack_channel_id}
|
||||
error={errors.slack_channel_id?.message}
|
||||
>
|
||||
<GSelect<SlackChannel>
|
||||
showSearch
|
||||
allowClear
|
||||
items={slackChannelStore.items}
|
||||
fetchItemsFn={slackChannelStore.updateItems}
|
||||
fetchItemFn={slackChannelStore.updateItem}
|
||||
getSearchResult={slackChannelStore.getSearchResult}
|
||||
displayField="display_name"
|
||||
valueField="id"
|
||||
placeholder="Select Slack Channel"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
nullItemName={PRIVATE_CHANNEL_NAME}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="user_group"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Slack user group"
|
||||
description="Group members will be automatically updated with current on-call. In case you want to ping on-call with @group_name."
|
||||
invalid={!!errors.user_group}
|
||||
error={errors.user_group?.message}
|
||||
>
|
||||
<GSelect<UserGroup[]>
|
||||
showSearch
|
||||
allowClear
|
||||
items={userGroupStore.items}
|
||||
fetchItemsFn={userGroupStore.updateItems}
|
||||
fetchItemFn={() => undefined}
|
||||
getSearchResult={userGroupStore.getSearchResult}
|
||||
displayField="handle"
|
||||
placeholder="Select User Group"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="notify_oncall_shift_freq"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Notification frequency"
|
||||
description="Specify the frequency that shift notifications are sent to scheduled team members."
|
||||
invalid={!!errors.notify_oncall_shift_freq}
|
||||
error={errors.notify_oncall_shift_freq?.message}
|
||||
>
|
||||
<RemoteSelect
|
||||
showSearch
|
||||
openMenuOnFocus={false}
|
||||
fieldToShow="display_name"
|
||||
href="/schedules/notify_oncall_shift_freq_options/"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="notify_empty_oncall"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Action for slot when no one is on-call"
|
||||
description="Specify how to notify team members when there is no one scheduled for an on-call shift."
|
||||
invalid={!!errors.notify_oncall_shift_freq}
|
||||
error={errors.notify_oncall_shift_freq?.message}
|
||||
>
|
||||
<RemoteSelect
|
||||
showSearch
|
||||
openMenuOnFocus={false}
|
||||
fieldToShow="display_name"
|
||||
href="/schedules/notify_empty_oncall_options/"
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="mention_oncall_start"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Current shift notification settings"
|
||||
description="Specify how to notify a team member when their on-call shift begins."
|
||||
invalid={!!errors.notify_oncall_shift_freq}
|
||||
error={errors.notify_oncall_shift_freq?.message}
|
||||
>
|
||||
<RemoteSelect
|
||||
showSearch
|
||||
openMenuOnFocus={false}
|
||||
fieldToShow="display_name"
|
||||
href="/schedules/mention_options/"
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="mention_oncall_next"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Next shift notification settings"
|
||||
description="Specify how to notify a team member when their shift is the next one scheduled."
|
||||
invalid={!!errors.notify_oncall_shift_freq}
|
||||
error={errors.notify_oncall_shift_freq?.message}
|
||||
>
|
||||
<RemoteSelect
|
||||
showSearch
|
||||
openMenuOnFocus={false}
|
||||
fieldToShow="display_name"
|
||||
href="/schedules/mention_options/"
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyles = () => ({
|
||||
collapse: css`
|
||||
margin-bottom: 16px;
|
||||
`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export class AlertReceiveChannelStore {
|
|||
});
|
||||
}
|
||||
|
||||
async fetchItems(query: any = '') {
|
||||
async fetchItems(query: any = ''): Promise<Array<ApiSchemas['AlertReceiveChannel']>> {
|
||||
const {
|
||||
data: { results },
|
||||
} = await onCallApi().GET('/alert_receive_channels/', {
|
||||
|
|
@ -149,7 +149,7 @@ export class AlertReceiveChannelStore {
|
|||
|
||||
this.fetchCounters();
|
||||
|
||||
return results;
|
||||
return results as Array<ApiSchemas['AlertReceiveChannel']>;
|
||||
}
|
||||
|
||||
@AutoLoadingState(ActionKey.FETCH_INTEGRATIONS)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { action, observable, makeObservable, runInAction } from 'mobx';
|
||||
|
||||
import { BaseStore } from 'models/base_store';
|
||||
import { LabelsErrors } from 'models/label/label.types';
|
||||
import { makeRequest } from 'network/network';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
|
|
@ -18,9 +17,6 @@ export class OutgoingWebhookStore extends BaseStore {
|
|||
@observable.shallow
|
||||
outgoingWebhookPresets: OutgoingWebhookPreset[] = [];
|
||||
|
||||
@observable
|
||||
labelsFormErrors?: LabelsErrors;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
|
|
@ -127,9 +123,4 @@ export class OutgoingWebhookStore extends BaseStore {
|
|||
this.outgoingWebhookPresets = response;
|
||||
});
|
||||
}
|
||||
|
||||
@action.bound
|
||||
setLabelsFormErrors(errors: LabelsErrors) {
|
||||
this.labelsFormErrors = errors;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -849,7 +849,6 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
const [isHeartbeatFormOpen, setIsHeartbeatFormOpen] = useState(false);
|
||||
const [isDemoModalOpen, setIsDemoModalOpen] = useState(false);
|
||||
const [maintenanceData, setMaintenanceData] = useState<{
|
||||
disabled: boolean;
|
||||
alert_receive_channel_id: ApiSchemas['AlertReceiveChannel']['id'];
|
||||
}>(undefined);
|
||||
|
||||
|
|
@ -1138,7 +1137,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
|
|||
}
|
||||
|
||||
function openStartMaintenance() {
|
||||
setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id });
|
||||
setMaintenanceData({ alert_receive_channel_id: alertReceiveChannel.id });
|
||||
}
|
||||
|
||||
async function onStopMaintenance() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue