From 20973705e9393b0a46535b99d291c2830a2a0c85 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Mon, 4 Mar 2024 13:43:05 +0200 Subject: [PATCH] Refactored Integration Form to use react-hook-form + ServiceNow changes (#3979) # What this PR does - Migrates old Integration form to use `react-hook-form` instead - Adds new ServiceNow fields (no backend yet) ## Which issue(s) this PR fixes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 1 + grafana-plugin/package.json | 1 + .../HowTheIntegrationWorks.tsx | 78 ++ .../IntegrationForm.helpers.ts | 3 +- .../IntegrationForm.module.scss | 113 +- .../IntegrationForm/IntegrationForm.styles.ts | 64 ++ .../IntegrationForm/IntegrationForm.tsx | 966 ++++++++++-------- .../IntegrationFormContainer.module.scss | 74 ++ .../IntegrationFormContainer.tsx | 164 +++ .../PluginConfigPage/PluginConfigPage.tsx | 10 +- .../pages/integration/Integration.helper.ts | 2 +- .../src/pages/integration/Integration.tsx | 4 +- .../src/pages/integrations/Integrations.tsx | 4 +- grafana-plugin/src/utils/consts.ts | 2 + grafana-plugin/yarn.lock | 5 + 15 files changed, 982 insertions(+), 509 deletions(-) create mode 100644 grafana-plugin/src/components/HowTheIntegrationWorks/HowTheIntegrationWorks.tsx create mode 100644 grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts create mode 100644 grafana-plugin/src/containers/IntegrationForm/IntegrationFormContainer.module.scss create mode 100644 grafana-plugin/src/containers/IntegrationForm/IntegrationFormContainer.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5a3c15..88b44a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove explicit uWSGI and Django request size limits by @vadimkerr ([#3878](https://github.com/grafana/oncall/pull/3878)) - Migrate webhooks integration_filter to use a m2m field instead ([#3946](https://github.com/grafana/oncall/pull/3946)) - Updated Faro package version ([#3970](https://github.com/grafana/oncall/pull/3970)) +- Integration form migration to react-hook-form ([#3979](https://github.com/grafana/oncall/pull/3979)) ### Fixed diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 47ea7e64..5668be8b 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -154,6 +154,7 @@ "react-dom": "18.2.0", "react-draggable": "^4.4.5", "react-emoji-render": "^1.2.4", + "react-hook-form": "^7.50.1", "react-modal": "^3.15.1", "react-responsive": "^8.1.0", "react-router-dom": "5.3.3", diff --git a/grafana-plugin/src/components/HowTheIntegrationWorks/HowTheIntegrationWorks.tsx b/grafana-plugin/src/components/HowTheIntegrationWorks/HowTheIntegrationWorks.tsx new file mode 100644 index 00000000..d2f8d5dc --- /dev/null +++ b/grafana-plugin/src/components/HowTheIntegrationWorks/HowTheIntegrationWorks.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { Collapse } from 'components/Collapse/Collapse'; +import { Text } from 'components/Text/Text'; +import { ApiSchemas } from 'network/oncall-api/api.types'; + +export const HowTheIntegrationWorks: React.FC<{ + selectedOption: ApiSchemas['AlertReceiveChannelIntegrationOptions']; +}> = ({ selectedOption }) => { + const styles = useStyles2(getStyles); + + if (!selectedOption) { + return null; + } + + return ( + How the integration works} + contentClassName={styles.collapsableContent} + > + + The integration will generate the following: +
    +
  • Unique URL endpoint for receiving alerts
  • +
  • + Templates to interpret alerts, tailored for {selectedOption.display_name}{' '} +
  • +
  • {selectedOption.display_name} contact point
  • +
  • {selectedOption.display_name} notification
  • +
+ What you'll need to do next: +
    +
  • + Finish connecting Monitoring system using Unique URL that will be provided on the next step{' '} +
  • +
  • + Set up routes that are based on alert content, such as severity, region, and service{' '} +
  • +
  • Connect escalation chains to the routes
  • +
  • + Review templates and personalize according to your requirements +
  • +
+
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + collapse: css({ + width: '100%', + marginBottom: '24px', + ' svg': { + color: theme.colors.primary.text, + }, + }), + integrationInfoList: css({ + listStylePosition: 'inside', + margin: '16px 0', + }), + integrationInfoItem: css({ + marginLeft: '16px', + }), + collapsableContent: css({ + width: '100%', + backgroundColor: theme.colors.background.secondary, + fontSize: 'small', + }), + }; +}; diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.helpers.ts b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.helpers.ts index 5db6b598..20ae6d81 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.helpers.ts +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.helpers.ts @@ -1,10 +1,11 @@ import { ApiSchemas } from 'network/oncall-api/api.types'; -export function prepareForEdit(item: ApiSchemas['AlertReceiveChannel']) { +export function prepareForEdit(item: ApiSchemas['AlertReceiveChannel']): Partial { return { verbal_name: item.verbal_name, description_short: item.description_short, team: item.team, labels: item.labels, + integration: item.integration, }; } diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.module.scss b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.module.scss index 34e3b26a..43a5cf2f 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.module.scss +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.module.scss @@ -1,80 +1,7 @@ -.content { - margin: 4px 4px 50px 4px; - padding-bottom: 24px; -} - -.cards { - display: flex; - flex-wrap: wrap; - gap: 24px; - overflow: auto; - scroll-snap-type: y mandatory; +.form { width: 100%; } -.cards_centered { - justify-content: center; - align-items: center; -} - -.card { - width: 48%; - height: 88px; - scroll-snap-align: start; - scroll-snap-stop: normal; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - cursor: pointer; - position: relative; - gap: 20px; -} - -.card_featured { - width: 100%; - height: 106px; -} - -.title { - margin: 10px 0 10px 0; - max-width: 500px; -} - -.footer { - display: block; - margin-top: 10px; -} - -.search-integration { - width: 100%; - margin-bottom: 24px; -} - -.collapse { - width: 100%; - margin-bottom: 24px; -} - -.collapse svg { - color: var(--primary-text-link) !important; -} - -.collapsable-content { - width: 100%; - background-color: var(--background-secondary); - font-size: small; -} - -.integration-info-list { - list-style-position: inside; - margin: 16px 0; -} - -.integration-info-item { - margin-left: 16px; -} - .extra-fields { padding: 12px; margin-bottom: 24px; @@ -97,6 +24,40 @@ margin-bottom: -15px; } -.labels { - margin-bottom: 20px; +.textarea:hover { + // TODO: change this to fetch from emotion instead +} + +.collapse { + width: 100%; + margin-bottom: 24px; +} + +.collapse svg { + color: var(--primary-text-link) !important; +} + +.integration-info-list { + list-style-position: inside; + margin: 16px 0; +} + +.integration-info-item { + margin-left: 16px; +} + +.servicenow-heading { + margin-bottom: 16px; +} + +.webhook-test { + margin-bottom: 16px; +} + +.webhook-switch { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-bottom: 24px; } diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts new file mode 100644 index 00000000..23ae5123 --- /dev/null +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.styles.ts @@ -0,0 +1,64 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getIntegrationFormStyles = (theme: GrafanaTheme2) => { + return { + form: css` + width: 100%; + `, + + extraFields: css` + padding: 12px; + margin-bottom: 24px; + border: var(--border-weak); + border-radius: var(--border-radius); + `, + + extraFieldsRadio: css` + margin-bottom: 12px; + `, + + extraFieldsIcon: css` + margin-top: -4px; + `, + + selectorsContainer: css` + width: 100%; + display: flex; + flex-direction: column; + margin-bottom: -15px; + `, + + collapse: css` + width: 100%; + margin-bottom: 24px; + + svg { + color: ${theme.colors.primary.text} !important; + } + `, + + serviceNowHeading: css` + margin-bottom: 16px; + `, + + webhookTest: css` + margin-bottom: 16px; + `, + + webhookSwitch: css` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-bottom: 24px; + `, + + labels: css` + margin-bottom: 20px; + `, + + // TODO: figure out grafana bug on border + textarea: css``, + }; +}; diff --git a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx index a67b73d2..9f1d8b82 100644 --- a/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx +++ b/grafana-plugin/src/containers/IntegrationForm/IntegrationForm.tsx @@ -1,512 +1,632 @@ -import React, { useState, ChangeEvent, useEffect, useReducer, useRef, useMemo } from 'react'; +import React, { useEffect, useReducer, useRef, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { - Drawer, - VerticalGroup, - HorizontalGroup, - Input, - Tag, - EmptySearchResult, Button, + Field, + HorizontalGroup, + Icon, + Input, + Label, RadioButtonGroup, Select, - Icon, - Field, + Switch, + TextArea, + Tooltip, + VerticalGroup, + useStyles2, } from '@grafana/ui'; -import cn from 'classnames/bind'; import { observer } from 'mobx-react'; +import { Controller, useForm, useFormContext, FormProvider } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; -import { Collapse } from 'components/Collapse/Collapse'; -import { Block } from 'components/GBlock/Block'; -import { GForm, CustomFieldSectionRendererProps } from 'components/GForm/GForm'; -import { IntegrationLogo } from 'components/IntegrationLogo/IntegrationLogo'; +import { HowTheIntegrationWorks } from 'components/HowTheIntegrationWorks/HowTheIntegrationWorks'; import { PluginLink } from 'components/PluginLink/PluginLink'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { Text } from 'components/Text/Text'; +import { GSelect } from 'containers/GSelect/GSelect'; import { Labels } from 'containers/Labels/Labels'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers'; +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; -import { IntegrationHelper } from 'pages/integration/Integration.helper'; +import { IntegrationHelper, getIsBidirectionalIntegration } from 'pages/integration/Integration.helper'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization/authorization'; -import { PLUGIN_ROOT } from 'utils/consts'; -import { openErrorNotification } from 'utils/utils'; +import { PLUGIN_ROOT, URL_REGEX, generateAssignToTeamInputDescription } from 'utils/consts'; -import { getForm } from './IntegrationForm.config'; import { prepareForEdit } from './IntegrationForm.helpers'; -import styles from './IntegrationForm.module.scss'; +import { getIntegrationFormStyles } from './IntegrationForm.styles'; -const cx = cn.bind(styles); +enum FormFieldKeys { + Name = 'verbal_name', + Description = 'description_short', + Team = 'team', + AlertManager = 'alert_manager', + ContactPoint = 'contact_point', + IsExisting = 'is_existing', + Alerting = 'alerting', + Integration = 'integration', + + ServiceNowUrl = 'servicenow_url', + AuthUsername = 'auth_username', + AuthPassword = 'auth_password', + DefaultWebhooks = 'default_webhooks', +} + +interface FormFields { + [FormFieldKeys.Name]: string; + [FormFieldKeys.Description]: string; + [FormFieldKeys.Team]: string; + [FormFieldKeys.IsExisting]: boolean; + [FormFieldKeys.AlertManager]: string; + [FormFieldKeys.ContactPoint]: string; + [FormFieldKeys.Alerting]: string; + [FormFieldKeys.ServiceNowUrl]: string; + [FormFieldKeys.AuthUsername]: string; + [FormFieldKeys.AuthPassword]: string; + [FormFieldKeys.Integration]: string; + [FormFieldKeys.DefaultWebhooks]: boolean; +} interface IntegrationFormProps { id: ApiSchemas['AlertReceiveChannel']['id'] | 'new'; isTableView?: boolean; - onHide: () => void; - onSubmit: () => Promise; + selectedIntegration: ApiSchemas['AlertReceiveChannelIntegrationOptions']; + onBackClick: () => void; navigateToAlertGroupLabels: (id: ApiSchemas['AlertReceiveChannel']['id']) => void; + onSubmit: () => Promise; + onHide: () => void; } -export const IntegrationForm = observer((props: IntegrationFormProps) => { - const store = useStore(); - const history = useHistory(); +const RADIO_OPTIONS = [ + { + label: 'Connect existing Contact point', + value: 'existing', + }, + { + label: 'Create a new one', + value: 'new', + }, +]; - const labelsRef = useRef(null); +export const IntegrationForm = observer( + ({ + id, + isTableView, + navigateToAlertGroupLabels, + selectedIntegration, + onSubmit, + onHide, + onBackClick, + }: IntegrationFormProps) => { + const store = useStore(); + const history = useHistory(); + const styles = useStyles2(getIntegrationFormStyles); + const isNew = id === 'new'; + const { userStore, grafanaTeamStore, alertReceiveChannelStore } = store; - const { id, onHide, onSubmit, isTableView = true, navigateToAlertGroupLabels } = props; - const { - alertReceiveChannelStore, - userStore: { currentUser: user }, - grafanaTeamStore, - } = store; - - const [filterValue, setFilterValue] = useState(''); - const [showNewIntegrationForm, setShowNewIntegrationForm] = useState(false); - const [selectedOption, setSelectedOption] = useState(undefined); - const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new'); - const [allContactPoints, setAllContactPoints] = useState([]); - const [errors, setErrors] = useState>(); - - const form = useMemo(() => getForm(grafanaTeamStore), [grafanaTeamStore]); - - useEffect(() => { - (async function () { - setAllContactPoints(await AlertReceiveChannelHelper.getGrafanaAlertingContactPoints()); - })(); - }, []); - - const data = - id === 'new' - ? { integration: selectedOption?.value, team: user?.current_team, labels: [] } + const data = isNew + ? { integration: selectedIntegration?.value, team: userStore.currentUser?.current_team, labels: [] } : prepareForEdit(alertReceiveChannelStore.items[id]); - const { alertReceiveChannelOptions } = alertReceiveChannelStore; + const { integration } = data; - const options = alertReceiveChannelOptions - ? alertReceiveChannelOptions.filter((option: ApiSchemas['AlertReceiveChannelIntegrationOptions']) => { - if (option.value === 'grafana_alerting' && !window.grafanaBootData.settings.unifiedAlertingEnabled) { - return false; - } + const formMethods = useForm({ + defaultValues: isNew + ? { + // these are the default values for creating an integration + [FormFieldKeys.Integration]: integration, + [FormFieldKeys.DefaultWebhooks]: true, + } + : { + // existing values from existing integration (edit-mode) + ...data, + }, + mode: 'onChange', + }); + const { + control, + handleSubmit, + formState: { errors }, + } = formMethods; - // don't allow creating direct paging integrations - if (option.value === 'direct_paging') { - return false; - } + const [ + { + isExistingContactPoint, + dataSources, + contactPoints, + selectedAlertManagerOption, + selectedContactPointOption, + allContactPoints, + }, + setState, + ] = useReducer( + (state: GrafanaContactPointState, newState: Partial) => ({ + ...state, + ...newState, + }), + { + isExistingContactPoint: true, + selectedAlertManagerOption: undefined, + selectedContactPointOption: undefined, + dataSources: [], + contactPoints: [], + allContactPoints: [], + } + ); - return ( - option.display_name.toLowerCase().includes(filterValue.toLowerCase()) && - !option.value.toLowerCase().startsWith('legacy_') - ); - }) - : []; + useEffect(() => { + (async function () { + setState({ + allContactPoints: await AlertReceiveChannelHelper.getGrafanaAlertingContactPoints(), + }); + })(); + }, []); - const extraGFormProps: { customFieldSectionRenderer?: React.FC } = {}; + const labelsRef = useRef(null); - if (selectedOption && IntegrationHelper.isSpecificIntegration(selectedOption.value, 'grafana_alerting')) { - extraGFormProps.customFieldSectionRenderer = CustomFieldSectionRenderer; - } + const [labelsErrors, setLabelErrors] = useState([]); + const isServiceNow = getIsBidirectionalIntegration(data as Partial); + const isGrafanaAlerting = IntegrationHelper.isSpecificIntegration(integration, 'grafana_alerting'); - return ( - <> - {showIntegrarionsListDrawer && ( - -
- - - Integration receives alerts on an unique API URL, interprets them using set of templates tailored for - monitoring system and starts escalations. - + return ( + +
+ ( + + + + )} + /> -
- ) => setFilterValue(e.currentTarget.value)} + ( + +