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:
Maxim Mordasov 2024-04-18 14:58:32 +01:00 committed by GitHub
parent 92600c05a7
commit bfeb286637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1305 additions and 1345 deletions

View file

@ -1,7 +0,0 @@
.collapse {
margin-bottom: 16px;
}
.label {
margin-bottom: 16px;
}

View file

@ -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);
};
}

View file

@ -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;
}

View file

@ -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 },
},
],
};

View file

@ -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>
);

View file

@ -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';

View file

@ -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;

View file

@ -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',
},
],
},
},
],
});

View file

@ -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>

View file

@ -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;
}

View file

@ -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;
`,
});

View file

@ -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>
);
});

View file

@ -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;
}

View file

@ -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)
)}
</>
);
};

View file

@ -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>
);
});

View file

@ -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;

View file

@ -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),
],
});

View file

@ -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;
`,
});

View file

@ -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)

View file

@ -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;
}
}

View file

@ -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() {