From 4ee2f09e7a32c47b985972c77fd1895145f8dbf9 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Fri, 10 Feb 2023 12:10:09 +0100 Subject: [PATCH] 823 direct paging users (#1215) # What this PR does Adds direct user paging ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/823 ## Checklist - [ ] Tests updated - [ ] Documentation added (documentation will be added later) - [x] `CHANGELOG.md` updated --------- Co-authored-by: Maxim --- CHANGELOG.md | 2 + grafana-plugin/CHANGELOG.md | 1 - grafana-plugin/README.md | 1 + grafana-plugin/src/PluginPage.tsx | 2 +- .../src/components/GTable/GTable.tsx | 3 + .../ManualAlertGroup.module.css | 8 + .../ManualAlertGroup/ManualAlertGroup.tsx | 104 ++++++++ .../ScheduleUserDetails.tsx | 2 +- .../SearchInput/SearchInput.module.scss | 8 + .../components/SearchInput/SearchInput.tsx | 44 ++++ .../DefaultPageLayout/DefaultPageLayout.tsx | 5 +- .../EscalationVariants.helpers.ts | 47 ++++ .../EscalationVariants.module.scss | 81 ++++++ .../EscalationVariants/EscalationVariants.tsx | 235 ++++++++++++++++++ .../EscalationVariants.types.ts | 15 ++ .../parts/EscalationVariantsPopup.tsx | 189 ++++++++++++++ .../src/containers/GSelect/GSelect.tsx | 1 + .../UserWarningModal/UserWarning.module.scss | 22 ++ .../UserWarningModal/UserWarning.tsx | 179 +++++++++++++ .../src/models/alertgroup/alertgroup.ts | 8 + .../src/models/alertgroup/alertgroup.types.ts | 1 + .../src/models/direct_paging/direct_paging.ts | 31 +++ .../direct_paging/direct_paging.types.ts | 7 + grafana-plugin/src/models/user/user.ts | 6 + .../src/pages/incident/Incident.helpers.tsx | 11 +- ...cident.module.css => Incident.module.scss} | 29 ++- .../src/pages/incident/Incident.tsx | 58 ++++- .../src/pages/incident/parts/PagedUsers.tsx | 69 +++++ .../src/pages/incidents/Incidents.module.scss | 5 + .../src/pages/incidents/Incidents.tsx | 45 +++- grafana-plugin/src/pages/index.tsx | 2 + grafana-plugin/src/plugin.json | 10 +- .../src/state/rootBaseStore/index.ts | 2 + grafana-plugin/src/utils/hooks.tsx | 18 ++ grafana-plugin/yarn.lock | 12 + 35 files changed, 1234 insertions(+), 29 deletions(-) delete mode 120000 grafana-plugin/CHANGELOG.md create mode 120000 grafana-plugin/README.md create mode 100644 grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css create mode 100644 grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx create mode 100644 grafana-plugin/src/components/SearchInput/SearchInput.module.scss create mode 100644 grafana-plugin/src/components/SearchInput/SearchInput.tsx create mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts create mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss create mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx create mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts create mode 100644 grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx create mode 100644 grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss create mode 100644 grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx create mode 100644 grafana-plugin/src/models/direct_paging/direct_paging.ts create mode 100644 grafana-plugin/src/models/direct_paging/direct_paging.types.ts rename grafana-plugin/src/pages/incident/{Incident.module.css => Incident.module.scss} (81%) create mode 100644 grafana-plugin/src/pages/incident/parts/PagedUsers.tsx 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. + + + )} + + + + + + + + ); +}; + +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) && ( + <> + +
    + {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))}) + + +
    + +
    +
  • + ); +}; + +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}? + + + + + +
    +
    + ); +}; + +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}) + + + + + +
  • + ); +}; + +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 = ( - @@ -158,7 +158,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const unacknowledgeButton = ( - @@ -166,7 +166,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const unresolveButton = ( - @@ -174,7 +174,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const acknowledgeButton = ( - @@ -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( - 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 + - @@ -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 + + + + +
    + {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"