diff --git a/CHANGELOG.md b/CHANGELOG.md
index b26540f7..ed743307 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+Add direct user paging ([823](https://github.com/grafana/oncall/issues/823))
+
### Fixed
- Cleaning of the name "Incident" ([704](https://github.com/grafana/oncall/pull/704))
diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md
deleted file mode 120000
index 04c99a55..00000000
--- a/grafana-plugin/CHANGELOG.md
+++ /dev/null
@@ -1 +0,0 @@
-../CHANGELOG.md
\ No newline at end of file
diff --git a/grafana-plugin/README.md b/grafana-plugin/README.md
new file mode 120000
index 00000000..32d46ee8
--- /dev/null
+++ b/grafana-plugin/README.md
@@ -0,0 +1 @@
+../README.md
\ No newline at end of file
diff --git a/grafana-plugin/src/PluginPage.tsx b/grafana-plugin/src/PluginPage.tsx
index 2fbb8dae..21a86341 100644
--- a/grafana-plugin/src/PluginPage.tsx
+++ b/grafana-plugin/src/PluginPage.tsx
@@ -23,7 +23,7 @@ function RealPlugin(props: AppPluginPageProps): React.ReactNode {
{/* Render alerts at the top */}
- {pages[page]?.text &&
{pages[page].text} }
+ {pages[page]?.text && !pages[page]?.hideTitle && {pages[page].text} }
{props.children}
);
diff --git a/grafana-plugin/src/components/GTable/GTable.tsx b/grafana-plugin/src/components/GTable/GTable.tsx
index 26ec3c6a..a778c0db 100644
--- a/grafana-plugin/src/components/GTable/GTable.tsx
+++ b/grafana-plugin/src/components/GTable/GTable.tsx
@@ -28,6 +28,7 @@ export interface Props extends TableProps {
expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode;
onExpand?: (expanded: boolean, item: any) => void;
};
+ showHeader?: boolean;
}
const GTable: FC = (props) => {
@@ -40,6 +41,7 @@ const GTable: FC = (props) => {
rowSelection,
rowKey,
expandable,
+ showHeader = true,
...restProps
} = props;
@@ -143,6 +145,7 @@ const GTable: FC = (props) => {
className={cx('filter-table', className)}
columns={columns}
data={data}
+ showHeader={showHeader}
{...restProps}
/>
{pagination && (
diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css
new file mode 100644
index 00000000..2b167d9c
--- /dev/null
+++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css
@@ -0,0 +1,8 @@
+.assign-responders-button {
+ display: flex;
+}
+
+.info-block {
+ background: var(--secondary-background);
+ width: 100%;
+}
diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx
new file mode 100644
index 00000000..50735092
--- /dev/null
+++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx
@@ -0,0 +1,104 @@
+import React, { FC, useCallback, useState } from 'react';
+
+import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
+import cn from 'classnames/bind';
+
+import Block from 'components/GBlock/Block';
+import GForm from 'components/GForm/GForm';
+import { FormItem, FormItemType } from 'components/GForm/GForm.types';
+import Text from 'components/Text/Text';
+import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
+import { prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
+import { Alert } from 'models/alertgroup/alertgroup.types';
+import { useStore } from 'state/useStore';
+
+import styles from './ManualAlertGroup.module.css';
+
+interface ManualAlertGroupProps {
+ onHide: () => void;
+ onCreate: (id: Alert['pk']) => void;
+}
+
+const cx = cn.bind(styles);
+
+const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
+ name: 'Manual Alert Group',
+ fields: [
+ {
+ name: 'title',
+ type: FormItemType.Input,
+ label: 'Title',
+ validation: { required: true },
+ },
+ {
+ name: 'message',
+ type: FormItemType.TextArea,
+ label: 'Describe what is going on',
+ },
+ ],
+};
+
+const ManualAlertGroup: FC = (props) => {
+ const store = useStore();
+ const [userResponders, setUserResponders] = useState([]);
+ const [scheduleResponders, setScheduleResponders] = useState([]);
+ const { onHide, onCreate } = props;
+ const data = {};
+
+ const handleFormSubmit = async (data) => {
+ store.directPagingStore
+ .createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, data))
+ .then(({ alert_group_id: id }: { alert_group_id: Alert['pk'] }) => {
+ onCreate(id);
+ })
+ .finally(() => {
+ onHide();
+ });
+ };
+
+ const onUpdateEscalationVariants = useCallback(
+ (value) => {
+ setUserResponders(value.userResponders);
+
+ setScheduleResponders(value.scheduleResponders);
+ },
+ [userResponders, scheduleResponders]
+ );
+
+ return (
+ <>
+
+
+
+
+ {store.teamStore.currentTeam.slack_team_identity && (
+
+ {' '}
+
+ The alert group will also be posted to #{store.teamStore.currentTeam?.slack_channel?.display_name} Slack
+ channel.
+
+
+ )}
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+ >
+ );
+};
+
+export default ManualAlertGroup;
diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx
index 664a9f43..e5a11b01 100644
--- a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx
+++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx
@@ -37,8 +37,8 @@ const ScheduleUserDetails: FC = (props) => {
const colorSchemeList = Array.from(colorSchemeMapping[user.pk] || []);
const { teamStore } = store;
-
const slackWorkspaceName = teamStore.currentTeam.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
+
return (
diff --git a/grafana-plugin/src/components/SearchInput/SearchInput.module.scss b/grafana-plugin/src/components/SearchInput/SearchInput.module.scss
new file mode 100644
index 00000000..a294b221
--- /dev/null
+++ b/grafana-plugin/src/components/SearchInput/SearchInput.module.scss
@@ -0,0 +1,8 @@
+.root {
+ display: inline-flex;
+ align-items: center;
+
+ & .search {
+ width: 320px;
+ }
+}
diff --git a/grafana-plugin/src/components/SearchInput/SearchInput.tsx b/grafana-plugin/src/components/SearchInput/SearchInput.tsx
new file mode 100644
index 00000000..e80bfbee
--- /dev/null
+++ b/grafana-plugin/src/components/SearchInput/SearchInput.tsx
@@ -0,0 +1,44 @@
+import React, { ChangeEvent, useCallback } from 'react';
+
+import { Icon, Input } from '@grafana/ui';
+import cn from 'classnames/bind';
+
+import styles from './SearchInput.module.scss';
+
+const cx = cn.bind(styles);
+
+interface SearchInputProps {
+ value: any;
+ onChange: (filters: any) => void;
+ className?: string;
+}
+
+const SearchInput = (props: SearchInputProps) => {
+ const { value = { searchTerm: '' }, onChange, className } = props;
+
+ const onSearchTermChangeCallback = useCallback(
+ (e: ChangeEvent) => {
+ const filters = {
+ ...value,
+ searchTerm: e.currentTarget.value,
+ };
+
+ onChange(filters);
+ },
+ [onChange, value]
+ );
+
+ return (
+
+ }
+ />
+
+ );
+};
+
+export default SearchInput;
diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx
index c1fbd2d5..3b2531a1 100644
--- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx
+++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx
@@ -9,7 +9,6 @@ import Alerts from 'containers/Alerts/Alerts';
import { pages } from 'pages';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { DEFAULT_PAGE } from 'utils/consts';
-import { useQueryParams } from 'utils/hooks';
import styles from './DefaultPageLayout.module.scss';
@@ -21,9 +20,7 @@ interface DefaultPageLayoutProps extends AppRootProps {
}
const DefaultPageLayout: FC = observer((props) => {
- const { children } = props;
- const queryParams = useQueryParams();
- const page = queryParams.get('page') || DEFAULT_PAGE;
+ const { children, page } = props;
if (isTopNavbar()) {
return renderTopNavbar();
diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts
new file mode 100644
index 00000000..b8de4370
--- /dev/null
+++ b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts
@@ -0,0 +1,47 @@
+import { User } from 'models/user/user.types';
+
+import { ResponderType } from './EscalationVariants.types';
+
+export const deduplicate = (value) => {
+ const deduplicatedUserResponders = [];
+ value.userResponders.forEach((userResponder) => {
+ if (!deduplicatedUserResponders.some((responder) => responder.data.pk === userResponder.data.pk)) {
+ deduplicatedUserResponders.push(userResponder);
+ }
+ });
+
+ const deduplicatedScheduleResponders = [];
+ value.scheduleResponders.forEach((scheduleResponder) => {
+ if (!deduplicatedScheduleResponders.some((responder) => responder.data.id === scheduleResponder.data.id)) {
+ deduplicatedScheduleResponders.push(scheduleResponder);
+ }
+ });
+
+ return {
+ ...value,
+ scheduleResponders: deduplicatedScheduleResponders,
+ userResponders: deduplicatedUserResponders,
+ };
+};
+
+export function prepareForUpdate(userResponders, scheduleResponders, data?) {
+ return {
+ ...data,
+ users: userResponders.map((userResponder) => ({ important: userResponder.important, id: userResponder.data.pk })),
+ schedules: scheduleResponders.map((scheduleResponder) => ({
+ important: scheduleResponder.important,
+ id: scheduleResponder.data.id,
+ })),
+ };
+}
+
+export function prepareForEdit(userResponders) {
+ return {
+ userResponders: (userResponders || []).map(({ pk }: { pk: User['pk'] }) => ({
+ type: ResponderType.User,
+ data: { pk },
+ important: false,
+ })),
+ scheduleResponders: [],
+ };
+}
diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss
new file mode 100644
index 00000000..a14e2d50
--- /dev/null
+++ b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss
@@ -0,0 +1,81 @@
+.escalation-variants-dropdown {
+ border: var(--border-medium);
+ position: absolute;
+ background: var(--primary-background);
+ width: 340px;
+ z-index: 10;
+}
+
+.assign-responders-picker {
+ padding: 8px 8px;
+ background: var(--primary-background);
+ height: 196px;
+}
+
+.assign-responders-list {
+ height: 146px;
+ overflow: auto;
+}
+
+.assign-responders-item {
+ overflow: scroll;
+}
+
+.schedule-table {
+ height: 120px;
+ overflow: auto;
+}
+
+.responders-filters {
+ margin: 8px;
+}
+
+.responder-item {
+ cursor: pointer;
+}
+
+.body {
+ width: 100%;
+}
+
+.responders-list {
+ list-style-type: none;
+ margin-bottom: 20px;
+ width: 100%;
+
+ & > li .trash-button {
+ display: none;
+ }
+
+ & > li:hover .trash-button {
+ display: block;
+ }
+
+ & > li {
+ padding: 10px 12px;
+ width: 100%;
+ }
+
+ & > li:hover {
+ background: var(--background-secondary);
+ }
+}
+
+.timeline-icon-background {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--timeline-icon-background);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & > img {
+ width: 100%;
+ height: 100%;
+ }
+
+ &--green {
+ background: #299c46;
+ }
+}
diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx
new file mode 100644
index 00000000..bc5be6b5
--- /dev/null
+++ b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx
@@ -0,0 +1,235 @@
+import React, { useState, useCallback } from 'react';
+
+import { SelectableValue } from '@grafana/data';
+import { ToolbarButton, ButtonGroup, HorizontalGroup, Icon, Select, IconButton, Label } from '@grafana/ui';
+import cn from 'classnames/bind';
+import dayjs from 'dayjs';
+import { observer } from 'mobx-react';
+
+import Avatar from 'components/Avatar/Avatar';
+import Text from 'components/Text/Text';
+import UserWarning from 'containers/UserWarningModal/UserWarning';
+import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
+import { getTzOffsetString } from 'models/timezone/timezone.helpers';
+import { User } from 'models/user/user.types';
+import { UserActions } from 'utils/authorization';
+
+import { deduplicate } from './EscalationVariants.helpers';
+import styles from './EscalationVariants.module.scss';
+import { ResponderType, UserAvailability } from './EscalationVariants.types';
+import EscalationVariantsPopup from './parts/EscalationVariantsPopup';
+
+const cx = cn.bind(styles);
+
+export interface EscalationVariantsProps {
+ onUpdateEscalationVariants: (data: any) => void;
+ value: { scheduleResponders; userResponders };
+ variant?: 'default' | 'primary';
+ hideSelected?: boolean;
+}
+
+const EscalationVariants = observer(
+ ({
+ onUpdateEscalationVariants: propsOnUpdateEscalationVariants,
+ value,
+ variant = 'primary',
+ hideSelected = false,
+ }: EscalationVariantsProps) => {
+ const [showEscalationVariants, setShowEscalationVariants] = useState(false);
+
+ const [showUserWarningModal, setShowUserWarningModal] = useState(false);
+ const [selectedUser, setSelectedUser] = useState(undefined);
+ const [userAvailability, setUserAvailability] = useState(undefined);
+
+ const onUpdateEscalationVariants = useCallback((newValue) => {
+ const deduplicatedValue = deduplicate(newValue);
+
+ propsOnUpdateEscalationVariants(deduplicatedValue);
+ }, []);
+
+ const getUserResponderImportChangeHandler = (index) => {
+ return ({ value: important }: SelectableValue) => {
+ const userResponders = [...value.userResponders];
+ const userResponder = userResponders[index];
+ userResponder.important = Boolean(important);
+
+ onUpdateEscalationVariants({
+ ...value,
+ userResponders,
+ });
+ };
+ };
+
+ const getUserResponderDeleteHandler = (index) => {
+ return () => {
+ const userResponders = [...value.userResponders];
+ userResponders.splice(index, 1);
+
+ onUpdateEscalationVariants({
+ ...value,
+ userResponders,
+ });
+ };
+ };
+
+ const getScheduleResponderImportChangeHandler = (index) => {
+ return ({ value: important }: SelectableValue) => {
+ const scheduleResponders = [...value.scheduleResponders];
+ const scheduleResponder = scheduleResponders[index];
+ scheduleResponder.important = Boolean(important);
+
+ onUpdateEscalationVariants({
+ ...value,
+ scheduleResponders,
+ });
+ };
+ };
+
+ const getScheduleResponderDeleteHandler = (index) => {
+ return () => {
+ const scheduleResponders = [...value.scheduleResponders];
+ scheduleResponders.splice(index, 1);
+
+ onUpdateEscalationVariants({
+ ...value,
+ scheduleResponders,
+ });
+ };
+ };
+
+ return (
+ <>
+
+ {!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && (
+ <>
+
Responders:
+
+ {value.userResponders.map((responder, index) => (
+
+ ))}
+ {value.scheduleResponders.map((responder, index) => (
+
+ ))}
+
+ >
+ )}
+
+
+
+ {
+ setShowEscalationVariants(true);
+ }}
+ >
+ Add responders
+
+
+
+ {
+ setShowEscalationVariants(true);
+ }}
+ />
+
+
+
+ {showEscalationVariants && (
+
+ )}
+
+ {showUserWarningModal && (
+ {
+ setShowUserWarningModal(false);
+ setSelectedUser(null);
+ }}
+ onUserSelect={(user: User) => {
+ onUpdateEscalationVariants({
+ ...value,
+ userResponders: [...value.userResponders, { type: ResponderType.User, data: user, important: false }],
+ });
+ }}
+ />
+ )}
+ >
+ );
+ }
+);
+
+const UserResponder = ({ important, data, onImportantChange, handleDelete }) => {
+ return (
+
+
+
+
+
+ {data?.username} ({getTzOffsetString(dayjs().tz(data?.timezone))})
+
+
+
+
+
+
+ );
+};
+
+const ScheduleResponder = ({ important, data, onImportantChange, handleDelete }) => {
+ return (
+
+
+
+
+
+
+ {data.name}
+
+
+
+
+
+ );
+};
+
+export default EscalationVariants;
diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts
new file mode 100644
index 00000000..0fac96ca
--- /dev/null
+++ b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts
@@ -0,0 +1,15 @@
+export enum EscalationVariantsTab {
+ Schedules,
+ Escalations,
+ Users,
+}
+
+export interface UserAvailability {
+ warnings: Array<{ error: string; data: any }>;
+}
+
+export enum ResponderType {
+ User,
+ Schedule,
+ // EscalationChain, // for future
+}
diff --git a/grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx b/grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx
new file mode 100644
index 00000000..8cbfde67
--- /dev/null
+++ b/grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx
@@ -0,0 +1,189 @@
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+
+import { Icon, RadioButtonGroup } from '@grafana/ui';
+import cn from 'classnames/bind';
+import { observer } from 'mobx-react';
+
+import GTable from 'components/GTable/GTable';
+import SearchInput from 'components/SearchInput/SearchInput';
+import Text from 'components/Text/Text';
+import { EscalationVariantsProps } from 'containers/EscalationVariants/EscalationVariants';
+import styles from 'containers/EscalationVariants/EscalationVariants.module.scss';
+import { ResponderType, UserAvailability } from 'containers/EscalationVariants/EscalationVariants.types';
+import { Schedule } from 'models/schedule/schedule.types';
+import { User } from 'models/user/user.types';
+import { useStore } from 'state/useStore';
+import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks';
+
+interface EscalationVariantsPopupProps extends EscalationVariantsProps {
+ setShowEscalationVariants: (value: boolean) => void;
+ setShowUserWarningModal: (value: boolean) => void;
+ setSelectedUser: (user: User) => void;
+ setUserAvailability: (data: UserAvailability) => void;
+}
+
+const cx = cn.bind(styles);
+
+const EscalationVariantsPopup = observer((props: EscalationVariantsPopupProps) => {
+ const {
+ onUpdateEscalationVariants,
+ setShowEscalationVariants,
+ value,
+ setSelectedUser,
+ setShowUserWarningModal,
+ setUserAvailability,
+ } = props;
+
+ const store = useStore();
+
+ const [activeOption, setActiveOption] = useState('schedules');
+ const [usersSearchTerm, setUsersSearchTerm] = useState('');
+ const [schedulesSearchTerm, setSchedulesSearchTerm] = useState('');
+
+ const handleOptionChange = useCallback((option: string) => {
+ setActiveOption(option);
+ }, []);
+
+ const addUserResponders = (user: User) => {
+ store.userStore.checkUserAvailability(user.pk).then((res) => {
+ setSelectedUser(user);
+ setUserAvailability(res);
+ setShowUserWarningModal(true);
+ });
+
+ setShowEscalationVariants(false);
+ };
+
+ const addSchedulesResponders = (schedule: Schedule) => {
+ setShowEscalationVariants(false);
+ onUpdateEscalationVariants({
+ ...value,
+ scheduleResponders: [
+ ...value.scheduleResponders,
+ { type: ResponderType.Schedule, data: schedule, important: false },
+ ],
+ });
+ };
+
+ const handleUsersSearchTermChange = useDebouncedCallback(() => {
+ store.userStore.updateItems(usersSearchTerm);
+ }, 500);
+
+ useEffect(handleUsersSearchTermChange, [usersSearchTerm]);
+
+ const handleSchedulesSearchTermChange = useDebouncedCallback(() => {
+ store.scheduleStore.updateItems(schedulesSearchTerm);
+ }, 500);
+
+ useEffect(handleSchedulesSearchTermChange, [schedulesSearchTerm]);
+
+ const scheduleColumns = [
+ {
+ width: 300,
+ render: (schedule: Schedule) => {
+ const disabled = value.scheduleResponders.some(
+ (scheduleResponder) => scheduleResponder.data.id === schedule.id
+ );
+
+ return (
+ (disabled ? undefined : addSchedulesResponders(schedule))}
+ className={cx('responder-item')}
+ >
+ {schedule.name}
+
+ );
+ },
+ key: 'Title',
+ },
+ {
+ width: 40,
+ render: (item: Schedule) =>
+ value.scheduleResponders.some((scheduleResponder) => scheduleResponder.data.id === item.id) ? (
+
+ ) : null,
+ key: 'Checked',
+ },
+ ];
+
+ const userColumns = [
+ {
+ width: 300,
+ render: (user: User) => {
+ const disabled = value.userResponders.some((userResponder) => userResponder.data?.pk === user.pk);
+ return (
+ (disabled ? undefined : addUserResponders(user))} className={cx('responder-item')}>
+
+ {user.username} ({user.timezone})
+
+
+ );
+ },
+ key: 'username',
+ },
+ {
+ width: 40,
+ render: (item: User) =>
+ value.userResponders.some((userResponder) => userResponder.data?.pk === item.pk) ? : null,
+ key: 'Checked',
+ },
+ ];
+
+ const ref = useRef();
+
+ useOnClickOutside(ref, () => {
+ setShowEscalationVariants(false);
+ });
+
+ return (
+
+
+ {activeOption === 'schedules' && (
+ <>
+
+
+ >
+ )}
+ {activeOption === 'users' && (
+ <>
+
+
+ >
+ )}
+
+ );
+});
+
+export default EscalationVariantsPopup;
diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx
index d448e75d..a45d3b9b 100644
--- a/grafana-plugin/src/containers/GSelect/GSelect.tsx
+++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx
@@ -35,6 +35,7 @@ interface GSelectProps {
dropdownRender?: (menu: ReactElement) => ReactElement;
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
getDescription?: (item: any) => React.ReactNode;
+ openMenuOnFocus?: boolean;
}
const GSelect = observer((props: GSelectProps) => {
diff --git a/grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss b/grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss
new file mode 100644
index 00000000..6b2b723f
--- /dev/null
+++ b/grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss
@@ -0,0 +1,22 @@
+.user-warning {
+ margin: 16px;
+}
+
+.users {
+ list-style-type: none;
+ margin-left: 23px;
+ width: 100%;
+
+ & > li {
+ width: 100%;
+ background: var(--background-secondary);
+ margin-bottom: 4px;
+ padding: 14px 12px;
+ }
+}
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #6ccf8e;
+}
diff --git a/grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx b/grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx
new file mode 100644
index 00000000..0d23d06c
--- /dev/null
+++ b/grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx
@@ -0,0 +1,179 @@
+import React, { FC, useCallback, useEffect, useMemo } from 'react';
+
+import { Button, HorizontalGroup, Icon, Modal, VerticalGroup } from '@grafana/ui';
+import cn from 'classnames/bind';
+import dayjs from 'dayjs';
+
+import Text from 'components/Text/Text';
+import { UserAvailability } from 'containers/EscalationVariants/EscalationVariants.types';
+import { getTzOffsetString } from 'models/timezone/timezone.helpers';
+import { User } from 'models/user/user.types';
+import { useStore } from 'state/useStore';
+
+import styles from './UserWarning.module.scss';
+
+interface UserWarningProps {
+ onHide: () => void;
+ user: User;
+ userAvailability: UserAvailability;
+ onUserSelect: (user: User) => void;
+}
+
+const cx = cn.bind(styles);
+
+const UserWarning: FC = (props) => {
+ const { onHide, user, userAvailability, onUserSelect } = props;
+ const store = useStore();
+
+ const { userStore } = store;
+
+ const getUserSelectHandler = useCallback(
+ (userId: User['pk']) => {
+ return async () => {
+ onHide();
+
+ if (!userStore.items[userId]) {
+ await userStore.updateItem(userId);
+ }
+
+ const user = userStore.items[userId];
+
+ onUserSelect(user);
+ };
+ },
+ [userStore.items]
+ );
+
+ const showUserHasNoNotificationPolicyWarning = useMemo(
+ () => userAvailability.warnings.some((warning) => warning.error === 'USER_HAS_NO_NOTIFICATION_POLICY'),
+ [userAvailability]
+ );
+
+ const showUserIsNotOncallWarning = useMemo(
+ () => userAvailability.warnings.some((warning) => warning.error === 'USER_IS_NOT_ON_CALL'),
+ [userAvailability]
+ );
+
+ const userSchedules = useMemo(
+ () =>
+ userAvailability.warnings.reduce((memo, warning) => {
+ if (warning.error === 'USER_IS_NOT_ON_CALL') {
+ const schedules = warning.data.schedules;
+ const userSchedulesKeys = Object.keys(schedules).filter((key: string) => schedules[key].includes(user.pk));
+ memo.push(...userSchedulesKeys);
+ }
+ return memo;
+ }, []),
+ [userAvailability]
+ );
+
+ const recommendedUsers = useMemo(
+ () =>
+ userAvailability.warnings.reduce((memo, warning) => {
+ if (warning.error === 'USER_IS_NOT_ON_CALL') {
+ const users = Object.keys(warning.data.schedules).reduce((memo, key) => {
+ const users = warning.data.schedules[key];
+ memo.push(...users);
+
+ return memo;
+ }, []);
+ memo.push(...users);
+ }
+
+ return memo;
+ }, []),
+ [userAvailability]
+ );
+
+ return (
+
+
+ {showUserHasNoNotificationPolicyWarning && (
+
+
+
+ {user.username} has no notification policy
+
+
+ )}
+ {showUserIsNotOncallWarning && (
+
+
+
+
+ {user.username} (Local time {dayjs().tz(user.timezone).format('HH:mm:ss')})
+ {' '}
+ is not currently on-call.
+
+
+ )}
+ {userSchedules.length && (
+
+
+
+ {user.username} appears in {userSchedules.join(', ')}
+
+
+ )}
+ {recommendedUsers.length && (
+
+
+ Recommended on-call users:
+
+ )}
+ {recommendedUsers.length && (
+
+ {recommendedUsers.map((userPk) => (
+
+ ))}
+
+ )}
+
+ Are you sure you want to select {user.username} ?
+
+
+
+ Cancel
+
+
+ Confirm
+
+
+
+
+ );
+};
+
+const RecommendedUser = ({ pk, onSelect }: { pk: User['pk']; onSelect: () => void }) => {
+ const store = useStore();
+
+ const { userStore } = store;
+
+ useEffect(() => {
+ if (!userStore.items[pk]) {
+ userStore.updateItem(pk);
+ }
+ }, [pk]);
+
+ const user = userStore.items[pk];
+
+ return (
+
+
+
+
+ {user?.username}
+
+ ({getTzOffsetString(dayjs().tz(user?.timezone))}, {user?.timezone})
+
+
+
+
+ Select
+
+
+
+ );
+};
+
+export default UserWarning;
diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts
index 93f2b096..7db35306 100644
--- a/grafana-plugin/src/models/alertgroup/alertgroup.ts
+++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts
@@ -2,6 +2,7 @@ import { action, observable } from 'mobx';
import qs from 'query-string';
import BaseStore from 'models/base_store';
+import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
import { Mixpanel } from 'services/mixpanel';
import { RootStore } from 'state';
@@ -426,4 +427,11 @@ export class AlertGroupStore extends BaseStore {
toggleLiveUpdate(value: boolean) {
this.liveUpdatesEnabled = value;
}
+
+ async unpageUser(alertId: Alert['pk'], userId: User['pk']) {
+ return await makeRequest(`${this.path}${alertId}/unpage_user`, {
+ method: 'POST',
+ data: { user_id: userId },
+ }).catch(this.onApiError);
+ }
}
diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts
index d1e7e65c..5705064e 100644
--- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts
+++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts
@@ -74,6 +74,7 @@ export interface Alert {
short?: boolean;
root_alert_group?: Alert;
alert_receive_channel: Partial;
+ paged_users: Array>;
// set by client
loading?: boolean;
diff --git a/grafana-plugin/src/models/direct_paging/direct_paging.ts b/grafana-plugin/src/models/direct_paging/direct_paging.ts
new file mode 100644
index 00000000..98a2e96f
--- /dev/null
+++ b/grafana-plugin/src/models/direct_paging/direct_paging.ts
@@ -0,0 +1,31 @@
+import { Alert } from 'models/alertgroup/alertgroup.types';
+import BaseStore from 'models/base_store';
+import { makeRequest } from 'network';
+import { RootStore } from 'state';
+
+import { ManualAlertGroupPayload } from './direct_paging.types';
+
+export class DirectPagingStore extends BaseStore {
+ constructor(rootStore: RootStore) {
+ super(rootStore);
+
+ this.path = '/direct_paging/';
+ }
+
+ async createManualAlertRule(data: ManualAlertGroupPayload) {
+ return await makeRequest(`${this.path}`, {
+ method: 'POST',
+ data,
+ }).catch(this.onApiError);
+ }
+
+ async updateAlertGroup(alertId: Alert['pk'], data: ManualAlertGroupPayload) {
+ return await makeRequest(`${this.path}`, {
+ method: 'POST',
+ data: {
+ alert_group_id: alertId,
+ ...data,
+ },
+ }).catch(this.onApiError);
+ }
+}
diff --git a/grafana-plugin/src/models/direct_paging/direct_paging.types.ts b/grafana-plugin/src/models/direct_paging/direct_paging.types.ts
new file mode 100644
index 00000000..8ad32563
--- /dev/null
+++ b/grafana-plugin/src/models/direct_paging/direct_paging.types.ts
@@ -0,0 +1,7 @@
+import { Schedule } from 'models/schedule/schedule.types';
+import { User } from 'models/user/user.types';
+
+export interface ManualAlertGroupPayload {
+ users: Array<{ id: User['pk']; important: boolean }>;
+ schedules: Array<{ id: Schedule['id']; important: boolean }>;
+}
diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts
index 76af6ebc..fffa31c1 100644
--- a/grafana-plugin/src/models/user/user.ts
+++ b/grafana-plugin/src/models/user/user.ts
@@ -377,4 +377,10 @@ export class UserStore extends BaseStore {
method: 'DELETE',
});
}
+
+ async checkUserAvailability(userPk: User['pk']) {
+ return await makeRequest(`/users/${userPk}/check_availability/`, {
+ method: 'GET',
+ });
+ }
}
diff --git a/grafana-plugin/src/pages/incident/Incident.helpers.tsx b/grafana-plugin/src/pages/incident/Incident.helpers.tsx
index 8ce21c9d..461ff01c 100644
--- a/grafana-plugin/src/pages/incident/Incident.helpers.tsx
+++ b/grafana-plugin/src/pages/incident/Incident.helpers.tsx
@@ -150,7 +150,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const resolveButton = (
-
+
Resolve
@@ -158,7 +158,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const unacknowledgeButton = (
-
+
Unacknowledge
@@ -166,7 +166,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const unresolveButton = (
-
+
Unresolve
@@ -174,7 +174,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
const acknowledgeButton = (
-
+
Acknowledge
@@ -190,7 +190,6 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
key="silence"
disabled={incident.loading}
onSelect={onSilence}
- buttonSize="sm"
/>
);
}
@@ -198,7 +197,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
if (incident.status === IncidentStatus.Silenced) {
buttons.push(
-
+
Unsilence
diff --git a/grafana-plugin/src/pages/incident/Incident.module.css b/grafana-plugin/src/pages/incident/Incident.module.scss
similarity index 81%
rename from grafana-plugin/src/pages/incident/Incident.module.css
rename to grafana-plugin/src/pages/incident/Incident.module.scss
index 44b4b212..9f598e01 100644
--- a/grafana-plugin/src/pages/incident/Incident.module.css
+++ b/grafana-plugin/src/pages/incident/Incident.module.scss
@@ -83,7 +83,7 @@
.timeline {
list-style-type: none;
- margin: 0 0 24px;
+ margin: 0 0 24px 12px;
}
.timeline-item {
@@ -121,3 +121,30 @@
color: var(--secondary-text-color);
margin-left: 4px;
}
+
+.paged-users {
+ width: 100%;
+}
+
+.paged-users-list {
+ list-style-type: none;
+ margin-bottom: 20px;
+ width: 100%;
+
+ & > li .trash-button {
+ display: none;
+ }
+
+ & > li:hover .trash-button {
+ display: block;
+ }
+
+ & > li {
+ padding: 8px 12px;
+ width: 100%;
+ }
+
+ & > li:hover {
+ background: var(--background-secondary);
+ }
+}
diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx
index 359e967a..0ae0ccee 100644
--- a/grafana-plugin/src/pages/incident/Incident.tsx
+++ b/grafana-plugin/src/pages/incident/Incident.tsx
@@ -35,6 +35,8 @@ import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import AttachIncidentForm from 'containers/AttachIncidentForm/AttachIncidentForm';
+import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
+import { prepareForEdit, prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import IntegrationSettings from 'containers/IntegrationSettings/IntegrationSettings';
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
@@ -47,6 +49,7 @@ import {
GroupedAlert,
} from 'models/alertgroup/alertgroup.types';
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
+import { User } from 'models/user/user.types';
import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@@ -56,8 +59,8 @@ import { PLUGIN_ROOT } from 'utils/consts';
import sanitize from 'utils/sanitize';
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers';
-
-import styles from './Incident.module.css';
+import styles from './Incident.module.scss';
+import PagedUsers from './parts/PagedUsers';
const cx = cn.bind(styles);
@@ -159,7 +162,12 @@ class IncidentPage extends React.Component
/>
- {this.renderTimeline()}
+
+
+
+ {this.renderTimeline()}
+
+
{showIntegrationSettings && (
);
}
+ handlePagedUserRemove = async (userId: User['pk']) => {
+ const {
+ store,
+ match: {
+ params: { id: alertId },
+ },
+ } = this.props;
+
+ await store.alertGroupStore.unpageUser(alertId, userId);
+
+ this.update();
+ };
+
renderHeader = () => {
const {
store,
@@ -297,22 +318,27 @@ class IncidentPage extends React.Component
+
-
+
Go to Integration
- Edit rendering, grouping and other templates
+ Edit templates
@@ -321,6 +347,22 @@ class IncidentPage extends React.Component
);
};
+ handleAddResponders = async (data) => {
+ const {
+ store,
+ match: {
+ params: { id: alertId },
+ },
+ } = this.props;
+
+ await store.directPagingStore.updateAlertGroup(
+ alertId,
+ prepareForUpdate(data.userResponders, data.scheduleResponders)
+ );
+
+ this.update();
+ };
+
showIntegrationSettings = () => {
this.setState({ showIntegrationSettings: true });
};
@@ -356,7 +398,7 @@ class IncidentPage extends React.Component
const { timelineFilter, resolutionNoteText } = this.state;
const isResolutionNoteTextEmpty = resolutionNoteText === '';
return (
- <>
+
Timeline
@@ -412,7 +454,7 @@ class IncidentPage extends React.Component
>
Add resolution note
- >
+
);
};
diff --git a/grafana-plugin/src/pages/incident/parts/PagedUsers.tsx b/grafana-plugin/src/pages/incident/parts/PagedUsers.tsx
new file mode 100644
index 00000000..41aa7f61
--- /dev/null
+++ b/grafana-plugin/src/pages/incident/parts/PagedUsers.tsx
@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+
+import { HorizontalGroup, IconButton } from '@grafana/ui';
+import cn from 'classnames/bind';
+
+import Avatar from 'components/Avatar/Avatar';
+import Text from 'components/Text/Text';
+import WithConfirm from 'components/WithConfirm/WithConfirm';
+import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
+import { Alert } from 'models/alertgroup/alertgroup.types';
+import { User } from 'models/user/user.types';
+import { UserActions } from 'utils/authorization';
+
+import styles from './../Incident.module.scss';
+
+const cx = cn.bind(styles);
+
+interface PagedUsersProps {
+ pagedUsers: Alert['paged_users'];
+ onRemove: (id: User['pk']) => void;
+}
+
+const PagedUsers = (props: PagedUsersProps) => {
+ const { pagedUsers, onRemove } = props;
+
+ const getPagedUserRemoveHandler = useCallback((id: User['pk']) => {
+ return () => {
+ onRemove(id);
+ };
+ }, []);
+
+ if (!pagedUsers || !pagedUsers.length) {
+ return null;
+ }
+
+ return (
+
+
+ Current responders
+
+
+ {pagedUsers.map((pagedUser) => (
+
+
+
+
+ {pagedUser.username}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default PagedUsers;
diff --git a/grafana-plugin/src/pages/incidents/Incidents.module.scss b/grafana-plugin/src/pages/incidents/Incidents.module.scss
index 423a2dcc..beffaee4 100644
--- a/grafana-plugin/src/pages/incidents/Incidents.module.scss
+++ b/grafana-plugin/src/pages/incidents/Incidents.module.scss
@@ -35,3 +35,8 @@
width: 100%;
margin-top: 20px;
}
+
+.title {
+ margin-bottom: 24px;
+ right: 0;
+}
diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx
index d51b0a28..8bb481a3 100644
--- a/grafana-plugin/src/pages/incidents/Incidents.tsx
+++ b/grafana-plugin/src/pages/incidents/Incidents.tsx
@@ -6,10 +6,12 @@ import { get } from 'lodash-es';
import { observer } from 'mobx-react';
import moment from 'moment-timezone';
import Emoji from 'react-emoji-render';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
import CursorPagination from 'components/CursorPagination/CursorPagination';
import GTable from 'components/GTable/GTable';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
+import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import Tutorial from 'components/Tutorial/Tutorial';
@@ -23,6 +25,7 @@ import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
+import { PLUGIN_ROOT } from 'utils/consts';
import styles from './Incidents.module.scss';
import { IncidentDropdown } from './parts/IncidentDropdown';
@@ -47,13 +50,14 @@ function withSkeleton(fn: (alert: AlertType) => ReactElement | ReactElement[]) {
return WithSkeleton;
}
-interface IncidentsPageProps extends WithStoreProps, PageProps {}
+interface IncidentsPageProps extends WithStoreProps, PageProps, RouteComponentProps {}
interface IncidentsPageState {
selectedIncidentIds: Array;
affectedRows: { [key: string]: boolean };
filters?: IncidentsFiltersType;
pagination: Pagination;
+ showAddAlertGroupForm: boolean;
}
const ITEMS_PER_PAGE = 25;
@@ -79,6 +83,7 @@ class Incidents extends React.Component
this.state = {
selectedIncidentIds: [],
affectedRows: {},
+ showAddAlertGroupForm: false,
pagination: {
start,
end: start + itemsPerPage - 1,
@@ -96,11 +101,35 @@ class Incidents extends React.Component
}
render() {
+ const { history } = this.props;
+ const { showAddAlertGroupForm } = this.state;
return (
-
- {this.renderIncidentFilters()}
- {this.renderTable()}
-
+ <>
+
+
+
+ Alert Groups
+
+
+ Manual alert group
+
+
+
+
+ {this.renderIncidentFilters()}
+ {this.renderTable()}
+
+ {showAddAlertGroupForm && (
+ {
+ this.setState({ showAddAlertGroupForm: false });
+ }}
+ onCreate={(id: Alert['pk']) => {
+ history.push(`${PLUGIN_ROOT}/incidents/${id}`);
+ }}
+ />
+ )}
+ >
);
}
@@ -114,6 +143,10 @@ class Incidents extends React.Component
);
}
+ handleOnClickEscalateTo = () => {
+ this.setState({ showAddAlertGroupForm: true });
+ };
+
handleFiltersChange = (filters: IncidentsFiltersType, isOnMount: boolean) => {
const { store } = this.props;
@@ -527,4 +560,4 @@ class Incidents extends React.Component
}
}
-export default withMobXProviderContext(Incidents);
+export default withRouter(withMobXProviderContext(Incidents));
diff --git a/grafana-plugin/src/pages/index.tsx b/grafana-plugin/src/pages/index.tsx
index 6de5109a..9e8d403c 100644
--- a/grafana-plugin/src/pages/index.tsx
+++ b/grafana-plugin/src/pages/index.tsx
@@ -15,6 +15,7 @@ export type PageDefinition = {
hideFromTabsFn?: (store: RootBaseStore) => boolean;
hideFromTabs?: boolean;
action?: UserAction;
+ hideTitle: boolean; // dont't automatically render title above page content
getPageNav(): { text: string; description: string };
};
@@ -29,6 +30,7 @@ export const pages: { [id: string]: PageDefinition } = [
id: 'incidents',
hideFromBreadcrumbs: true,
text: 'Alert Groups',
+ hideTitle: true,
path: getPath('incidents'),
action: UserActions.AlertGroupsRead,
},
diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json
index 32009213..9cd6239e 100644
--- a/grafana-plugin/src/plugin.json
+++ b/grafana-plugin/src/plugin.json
@@ -29,13 +29,21 @@
"updated": "%TODAY%"
},
"includes": [
+ {
+ "type": "page",
+ "name": "Home",
+ "path": "/a/grafana-oncall-app",
+ "role": "Viewer",
+ "action": "grafana-oncall-app.alert-groups:read",
+ "defaultNav": true,
+ "addToNav": true
+ },
{
"type": "page",
"name": "Alert Groups",
"path": "/a/grafana-oncall-app/incidents",
"role": "Viewer",
"action": "grafana-oncall-app.alert-groups:read",
- "defaultNav": true,
"addToNav": true
},
{
diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts
index 3f936175..8fa0ef42 100644
--- a/grafana-plugin/src/state/rootBaseStore/index.ts
+++ b/grafana-plugin/src/state/rootBaseStore/index.ts
@@ -9,6 +9,7 @@ import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_fi
import { AlertGroupStore } from 'models/alertgroup/alertgroup';
import { ApiTokenStore } from 'models/api_token/api_token';
import { CloudStore } from 'models/cloud/cloud';
+import { DirectPagingStore } from 'models/direct_paging/direct_paging';
import { EscalationChainStore } from 'models/escalation_chain/escalation_chain';
import { EscalationPolicyStore } from 'models/escalation_policy/escalation_policy';
import { GlobalSettingStore } from 'models/global_setting/global_setting';
@@ -76,6 +77,7 @@ export class RootBaseStore {
userStore: UserStore = new UserStore(this);
cloudStore: CloudStore = new CloudStore(this);
+ directPagingStore: DirectPagingStore = new DirectPagingStore(this);
grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this);
alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this);
outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this);
diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx
index 83a1b71c..9552d92c 100644
--- a/grafana-plugin/src/utils/hooks.tsx
+++ b/grafana-plugin/src/utils/hooks.tsx
@@ -7,6 +7,24 @@ export function useForceUpdate() {
return () => setValue((value) => value + 1);
}
+export function useOnClickOutside(ref, handler) {
+ useEffect(() => {
+ const listener = (event) => {
+ if (!ref.current || ref.current.contains(event.target)) {
+ return;
+ }
+
+ handler(event);
+ };
+ document.addEventListener('mousedown', listener);
+ document.addEventListener('touchstart', listener);
+ return () => {
+ document.removeEventListener('mousedown', listener);
+ document.removeEventListener('touchstart', listener);
+ };
+ }, [ref, handler]);
+}
+
export function usePrevious(value: any) {
const ref = useRef();
useEffect(() => {
diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock
index 2d2b20d0..dd418efb 100644
--- a/grafana-plugin/yarn.lock
+++ b/grafana-plugin/yarn.lock
@@ -7477,6 +7477,11 @@ hoist-non-react-statics@3.3.2, hoist-non-react-statics@^3.1.0, hoist-non-react-s
dependencies:
react-is "^16.7.0"
+hoist-non-react-statics@^2.1.1:
+ version "2.5.5"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
+ integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==
+
homedir-polyfill@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
@@ -11365,6 +11370,13 @@ react-calendar@3.9.0:
merge-class-names "^1.1.1"
prop-types "^15.6.0"
+react-click-outside@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/react-click-outside/-/react-click-outside-3.0.1.tgz#6e77e84d2f17afaaac26dbad743cbbf909f5e24c"
+ integrity sha512-d0KWFvBt+esoZUF15rL2UBB7jkeAqLU8L/Ny35oLK6fW6mIbOv/ChD+ExF4sR9PD26kVx+9hNfD0FTIqRZEyRQ==
+ dependencies:
+ hoist-non-react-statics "^2.1.1"
+
react-colorful@5.5.1:
version "5.5.1"
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784"