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)
This commit is contained in:
Rares Mardare 2024-03-04 13:43:05 +02:00 committed by GitHub
parent 84b6b7cd29
commit 20973705e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 982 additions and 509 deletions

View file

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

View file

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

View file

@ -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 (
<Collapse
headerWithBackground
className={styles.collapse}
isOpen={false}
label={<Text type="link">How the integration works</Text>}
contentClassName={styles.collapsableContent}
>
<Text type="secondary">
The integration will generate the following:
<ul className={styles.integrationInfoList}>
<li className={styles.integrationInfoItem}>Unique URL endpoint for receiving alerts </li>
<li className={styles.integrationInfoItem}>
Templates to interpret alerts, tailored for {selectedOption.display_name}{' '}
</li>
<li className={styles.integrationInfoItem}>{selectedOption.display_name} contact point </li>
<li className={styles.integrationInfoItem}>{selectedOption.display_name} notification</li>
</ul>
What you'll need to do next:
<ul className={styles.integrationInfoList}>
<li className={styles.integrationInfoItem}>
Finish connecting Monitoring system using Unique URL that will be provided on the next step{' '}
</li>
<li className={styles.integrationInfoItem}>
Set up routes that are based on alert content, such as severity, region, and service{' '}
</li>
<li className={styles.integrationInfoItem}>Connect escalation chains to the routes</li>
<li className={styles.integrationInfoItem}>
Review templates and personalize according to your requirements
</li>
</ul>
</Text>
</Collapse>
);
};
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',
}),
};
};

View file

@ -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<ApiSchemas['AlertReceiveChannel']> {
return {
verbal_name: item.verbal_name,
description_short: item.description_short,
team: item.team,
labels: item.labels,
integration: item.integration,
};
}

View file

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

View file

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

View file

@ -0,0 +1,74 @@
.content {
margin: 4px 4px 50px 4px;
padding-bottom: 24px;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 24px;
overflow: auto;
scroll-snap-type: y mandatory;
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;
}
.extra-fields {
padding: 12px;
margin-bottom: 24px;
border: var(--border-weak);
border-radius: var(--border-radius);
&__radio {
margin-bottom: 12px;
}
&__icon {
margin-top: -4px;
}
}
.selectors-container {
width: 100%;
display: flex;
flex-direction: column;
margin-bottom: -15px;
}

View file

@ -0,0 +1,164 @@
import React, { useState, ChangeEvent } from 'react';
import { Drawer, VerticalGroup, HorizontalGroup, Input, Tag, EmptySearchResult } 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 { Text } from 'components/Text/Text';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { IntegrationForm } from './IntegrationForm';
import styles from './IntegrationFormContainer.module.scss';
const cx = cn.bind(styles);
interface IntegrationFormContainerProps {
id: ApiSchemas['AlertReceiveChannel']['id'] | 'new';
isTableView?: boolean;
onHide: () => void;
onSubmit: () => Promise<void>;
navigateToAlertGroupLabels: (id: ApiSchemas['AlertReceiveChannel']['id']) => void;
}
export const IntegrationFormContainer = observer((props: IntegrationFormContainerProps) => {
const store = useStore();
const { id, onHide, onSubmit, isTableView = true, navigateToAlertGroupLabels } = props;
const { alertReceiveChannelStore } = store;
const [filterValue, setFilterValue] = useState('');
const [showNewIntegrationForm, setShowNewIntegrationForm] = useState(false);
const [selectedOption, setSelectedOption] = useState<ApiSchemas['AlertReceiveChannelIntegrationOptions']>(undefined);
const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new');
const { alertReceiveChannelOptions } = alertReceiveChannelStore;
const options = alertReceiveChannelOptions
? alertReceiveChannelOptions.filter((option: ApiSchemas['AlertReceiveChannelIntegrationOptions']) => {
if (option.value === 'grafana_alerting' && !window.grafanaBootData.settings.unifiedAlertingEnabled) {
return false;
}
// don't allow creating direct paging integrations
if (option.value === 'direct_paging') {
return false;
}
return (
option.display_name.toLowerCase().includes(filterValue.toLowerCase()) &&
!option.value.toLowerCase().startsWith('legacy_')
);
})
: [];
return (
<>
{showIntegrarionsListDrawer && (
<Drawer scrollableContent title="New Integration" onClose={onHide} closeOnMaskClick={false} width="640px">
<div className={cx('content')}>
<VerticalGroup>
<Text type="secondary">
Integration receives alerts on an unique API URL, interprets them using set of templates tailored for
monitoring system and starts escalations.
</Text>
<div className={cx('search-integration')}>
<Input
autoFocus
value={filterValue}
placeholder="Search integrations ..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterValue(e.currentTarget.value)}
/>
</div>
<IntegrationBlocks options={options} onBlockClick={onBlockClick} />
</VerticalGroup>
</div>
</Drawer>
)}
{(showNewIntegrationForm || !showIntegrarionsListDrawer) && (
<Drawer scrollableContent title={getTitle()} onClose={onHide} closeOnMaskClick={false} width="640px">
<div className={cx('content')}>
<VerticalGroup>
<IntegrationForm
id={id}
onBackClick={onBackClick}
navigateToAlertGroupLabels={navigateToAlertGroupLabels}
selectedIntegration={selectedOption}
onSubmit={onSubmit}
onHide={onHide}
/>
</VerticalGroup>
</div>
</Drawer>
)}
</>
);
function onBackClick(): void {
setShowNewIntegrationForm(false);
setShowIntegrarionsListDrawer(true);
}
function onBlockClick(option: ApiSchemas['AlertReceiveChannelIntegrationOptions']): void {
setSelectedOption(option);
setShowNewIntegrationForm(true);
setShowIntegrarionsListDrawer(false);
}
function getTitle(): string {
if (!isTableView) {
return 'Integration Settings';
}
return id === 'new' ? `New ${selectedOption?.display_name} integration` : `Edit integration`;
}
});
const IntegrationBlocks: React.FC<{
options: Array<ApiSchemas['AlertReceiveChannelIntegrationOptions']>;
onBlockClick: (option: ApiSchemas['AlertReceiveChannelIntegrationOptions']) => void;
}> = ({ options, onBlockClick }) => {
return (
<div className={cx('cards')} data-testid="create-integration-modal">
{options.length ? (
options.map((alertReceiveChannelChoice) => {
return (
<Block
bordered
hover
shadowed
onClick={() => onBlockClick(alertReceiveChannelChoice)}
key={alertReceiveChannelChoice.value}
className={cx('card', { card_featured: alertReceiveChannelChoice.featured })}
>
<div className={cx('card-bg')}>
<IntegrationLogo integration={alertReceiveChannelChoice} scale={0.2} />
</div>
<div className={cx('title')}>
<VerticalGroup spacing={alertReceiveChannelChoice.featured ? 'xs' : 'none'}>
<HorizontalGroup>
<Text strong data-testid="integration-display-name">
{alertReceiveChannelChoice.display_name}
</Text>
{alertReceiveChannelChoice.featured && alertReceiveChannelChoice.featured_tag_name && (
<Tag name={alertReceiveChannelChoice.featured_tag_name} colorIndex={5} />
)}
</HorizontalGroup>
<Text type="secondary" size="small">
{alertReceiveChannelChoice.short_description}
</Text>
</VerticalGroup>
</div>
</Block>
);
})
) : (
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
)}
</div>
);
};

View file

@ -218,10 +218,12 @@ export const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
);
content =
licenseType === GRAFANA_LICENSE_OSS ? (
<HorizontalGroup>
{pluginLink}
<RemoveConfigButton />
</HorizontalGroup>
<div>
<HorizontalGroup>
{pluginLink}
<RemoveConfigButton />
</HorizontalGroup>
</div>
) : (
<VerticalGroup>
<Label>This is a cloud managed configuration.</Label>

View file

@ -121,5 +121,5 @@ export const IntegrationHelper = {
},
};
export const getIsBidirectionalIntegration = ({ integration }: ApiSchemas['AlertReceiveChannel']) =>
export const getIsBidirectionalIntegration = ({ integration }: Partial<ApiSchemas['AlertReceiveChannel']>) =>
integration === ('servicenow' as ApiSchemas['AlertReceiveChannel']['integration']); // TODO: add service now in backend schema as valid value and remove casting

View file

@ -44,7 +44,7 @@ import { CollapsedIntegrationRouteDisplay } from 'containers/IntegrationContaine
import { ExpandedIntegrationRouteDisplay } from 'containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay';
import { IntegrationHeartbeatForm } from 'containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm';
import { IntegrationTemplateList } from 'containers/IntegrationContainers/IntegrationTemplatesList';
import { IntegrationForm } from 'containers/IntegrationForm/IntegrationForm';
import { IntegrationFormContainer } from 'containers/IntegrationForm/IntegrationFormContainer';
import { IntegrationLabelsForm } from 'containers/IntegrationLabelsForm/IntegrationLabelsForm';
import { IntegrationTemplate } from 'containers/IntegrationTemplate/IntegrationTemplate';
import { MaintenanceForm } from 'containers/MaintenanceForm/MaintenanceForm';
@ -863,7 +863,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
)}
{isIntegrationSettingsOpen && (
<IntegrationForm
<IntegrationFormContainer
isTableView={false}
onHide={() => setIsIntegrationSettingsOpen(false)}
onSubmit={async () => {

View file

@ -34,7 +34,7 @@ import { Text } from 'components/Text/Text';
import { TextEllipsisTooltip } from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import { IntegrationForm } from 'containers/IntegrationForm/IntegrationForm';
import { IntegrationFormContainer } from 'containers/IntegrationForm/IntegrationFormContainer';
import { IntegrationLabelsForm } from 'containers/IntegrationLabelsForm/IntegrationLabelsForm';
import { RemoteFilters } from 'containers/RemoteFilters/RemoteFilters';
import { TeamName } from 'containers/TeamName/TeamName';
@ -289,7 +289,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
</div>
{alertReceiveChannelId && (
<IntegrationForm
<IntegrationFormContainer
onHide={() => {
this.setState({ alertReceiveChannelId: undefined });
}}

View file

@ -76,3 +76,5 @@ export const TEXT_ELLIPSIS_CLASS = 'overflow-child';
export const INCIDENT_HORIZONTAL_SCROLLING_STORAGE = 'isIncidentalTableHorizontalScrolling';
export const IRM_TAB = 'IRM';
export const URL_REGEX = /^((https?|ftp|smtp):\/\/)?(www.)?[a-z0-9]+\.[a-z]+(\/[a-zA-Z0-9#]+\/?)*$/;

View file

@ -10791,6 +10791,11 @@ react-hook-form@7.5.3:
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.5.3.tgz#9a624fa14ec153b154891c5ebddae02ec5c2e40f"
integrity sha512-5T0mfJ4kCPKljd7t3Rgp7lML4Y2+kaZIeMdN6Zo/J7gBQ+WkrDBHOftdOtz4X+7/eqHGak5yL5evNpYdA9abVA==
react-hook-form@^7.50.1:
version "7.50.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.50.1.tgz#f6aeb17a863327e5a0252de8b35b4fc8990377ed"
integrity sha512-3PCY82oE0WgeOgUtIr3nYNNtNvqtJ7BZjsbxh6TnYNbXButaD5WpjOmTjdxZfheuHKR68qfeFnEDVYoSSFPMTQ==
react-i18next@^12.0.0:
version "12.0.0"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-12.0.0.tgz#634015a2c035779c5736ae4c2e5c34c1659753b1"