diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index c66137b2..87f1dd00 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -9,15 +9,17 @@ module.exports = { '^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils', }, rules: { + 'no-unused-vars': ['warn', { vars: 'all', args: 'after-used', ignoreRestSiblings: false }], 'react/prop-types': 'warn', 'react/display-name': 'warn', 'react/jsx-key': 'warn', + 'react-hooks/exhaustive-deps': 'off', 'react/no-unescaped-entities': 'warn', 'react/jsx-no-target-blank': 'warn', 'react-hooks/exhaustive-deps': 'warn', 'no-restricted-imports': 'warn', eqeqeq: 'warn', - 'no-duplicate-imports': 'warn', + 'no-duplicate-imports': 'error', 'rulesdir/no-relative-import-paths': ['error', { allowSameFolder: true }], 'import/order': [ 'error', diff --git a/grafana-plugin/.stylelintrc b/grafana-plugin/.stylelintrc index 9144c1e6..7cda5c91 100644 --- a/grafana-plugin/.stylelintrc +++ b/grafana-plugin/.stylelintrc @@ -1,6 +1,7 @@ { "extends": "stylelint-config-standard", "rules": { + "block-no-empty": [true,{ "severity": "warning"}], "selector-pseudo-class-no-unknown": [ true, { @@ -8,4 +9,4 @@ } ] } -} \ No newline at end of file +} diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 4921d53e..0093af7d 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -78,18 +78,25 @@ }, "dependencies": { "@types/query-string": "^6.3.0", + "@types/react-transition-group": "^4.4.5", + "array-move": "^4.0.0", "change-case": "^4.1.1", "circular-dependency-plugin": "^5.2.2", + "dayjs": "^1.11.5", "eslint-plugin-import": "^2.25.4", "mobx": "5.13.0", "mobx-react": "6.1.1", + "prettier": "^2.7.1", "rc-table": "^7.17.1", "react-copy-to-clipboard": "^5.0.2", + "react-draggable": "^4.4.5", "react-emoji-render": "^1.2.4", + "react-modal": "^3.15.1", "react-responsive": "^8.1.0", "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", "react-string-replace": "^0.4.4", + "react-transition-group": "^4.4.5", "sass-loader": "^13.0.2", "stylelint": "^13.13.1", "stylelint-config-standard": "^22.0.0", diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 870c1cae..286bc509 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -2,6 +2,14 @@ import React, { useEffect, useMemo } from 'react'; import { AppRootProps } from '@grafana/data'; import { Button, HorizontalGroup, LinkButton, VerticalGroup } from '@grafana/ui'; +import dayjs from 'dayjs'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import localeData from 'dayjs/plugin/localeData'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import weekday from 'dayjs/plugin/weekday'; import { observer, Provider } from 'mobx-react'; import 'interceptors'; @@ -14,6 +22,16 @@ import { rootStore } from 'state'; import { useStore } from 'state/useStore'; import { useNavModel } from 'utils/hooks'; +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(weekday); +dayjs.extend(localeData); +dayjs.extend(isSameOrBefore); +dayjs.extend(isSameOrAfter); +dayjs.extend(isoWeek); + +// dayjs().weekday(0); + import './style/vars.css'; import './style/index.css'; @@ -121,9 +139,10 @@ export const Root = observer((props: AppRootProps) => { grafanaUser: window.grafanaBootData.user, enableLiveSettings: store.hasFeature(AppFeature.LiveSettings), enableCloudPage: store.hasFeature(AppFeature.CloudConnection), + enableNewSchedulesPage: store.hasFeature(AppFeature.WebSchedules), backendLicense, }), - [meta, pathWithoutLeadingSlash, page, store.features] + [meta, pathWithoutLeadingSlash, page, store.features, backendLicense] ) ); useEffect(() => { diff --git a/grafana-plugin/src/components/Avatar/Avatar.tsx b/grafana-plugin/src/components/Avatar/Avatar.tsx index 092e3c08..15b93552 100644 --- a/grafana-plugin/src/components/Avatar/Avatar.tsx +++ b/grafana-plugin/src/components/Avatar/Avatar.tsx @@ -13,13 +13,13 @@ interface AvatarProps { const cx = cn.bind(styles); const Avatar: FC = (props) => { - const { src, size, className } = props; + const { src, size, className, ...rest } = props; if (!src) { return null; } - return ; + return ; }; export default Avatar; diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css new file mode 100644 index 00000000..a117b44d --- /dev/null +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -0,0 +1,33 @@ +.root { + position: fixed; + width: 750px; + max-width: 100%; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + top: 10%; + max-height: 80%; + display: flex; + flex-direction: column; + border-image: initial; + outline: none; + padding: 15px; + background: #181b1f; + border: 1px solid #2d2e35; + box-shadow: 0 2px 4px 2px rgba(10, 10, 16, 0.1), 0 8px 16px rgba(10, 10, 16, 0.2), 0 12px 24px rgba(3, 3, 8, 0.3), 0 16px 32px rgba(3, 3, 8, 0.8); + border-radius: 2px; +} + +.overlay { + position: fixed; + inset: 0; + z-index: 10; + + /* background-color: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(1px); */ +} + +.body-open { + overflow: hidden; +} diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..4cff1595 --- /dev/null +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -0,0 +1,49 @@ +import React, { FC, PropsWithChildren } from 'react'; + +import cn from 'classnames/bind'; +import ReactModal from 'react-modal'; + +ReactModal.setAppElement('#reactRoot'); + +import styles from './Modal.module.css'; + +export interface ModalProps { + title: string | JSX.Element; + className?: string; + contentClassName?: string; + closeOnEscape?: boolean; + closeOnBackdropClick?: boolean; + onDismiss?: () => void; + width: string; + contentElement?: (props, children: React.ReactNode) => React.ReactNode; + isOpen: boolean; +} + +const cx = cn.bind(styles); + +const Modal: FC> = (props) => { + const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props; + + return ( + {}} + onRequestClose={onDismiss} + contentLabel={title} + className={cx('root')} + overlayClassName={cx('overlay')} + bodyOpenClassName={cx('body-open')} + contentElement={contentElement} + > + {children} + + ); +}; + +export default Modal; diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css new file mode 100644 index 00000000..719dfe35 --- /dev/null +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css @@ -0,0 +1,7 @@ +.root { + display: block; +} + +.block { + width: 100%; +} diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx new file mode 100644 index 00000000..f70df5b5 --- /dev/null +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx @@ -0,0 +1,118 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { getLocationSrv } from '@grafana/runtime'; +import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; +import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; +import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; +import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; +import { UserAction } from 'state/userAction'; + +import styles from './NewScheduleSelector.module.css'; + +interface NewScheduleSelectorProps { + onHide: () => void; + onCreate: (data: Schedule) => void; + onUpdate: () => void; +} + +const cx = cn.bind(styles); + +const NewScheduleSelector: FC = (props) => { + const { onHide, onCreate, onUpdate } = props; + + const [showScheduleForm, setShowScheduleForm] = useState(false); + const [type, setType] = useState(); + + const getCreateScheduleClickHandler = useCallback((type: ScheduleType) => { + return () => { + setType(type); + setShowScheduleForm(true); + }; + }, []); + + return ( + <> + +
+ + {/* + Manage on-call schedules using your favourite calendar app, such as Google Calendar or Microsoft Outlook. To + schedule on-call shifts create a new calendar and use events with the teammates usernames + */} + + + + + + + Set up on-call rotation schedule + + Configure rotations and shifts directly in Grafana On-Call + + + + + + + + + + + + + + Import schedule from iCal Url + + Import rotations and shifts from your calendar app + + + + + + + + + + + + Create schedule by API + + Configure rotations and upload calendar by Terraform file + + + + + + +
+
+ {showScheduleForm && ( + { + onHide(); + onUpdate(); + }} + onCreate={onCreate} + onHide={() => { + setType(undefined); + setShowScheduleForm(false); + }} + /> + )} + + ); +}; + +export default NewScheduleSelector; diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css new file mode 100644 index 00000000..f0f2dc1f --- /dev/null +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css @@ -0,0 +1,42 @@ +.root { + font-size: 12px; + line-height: 16px; +} + +.root__type_link { + padding: 2px 4px; + background: rgba(27, 133, 94, 0.15); + border: 1px solid var(--success-text-color); + border-radius: 2px; +} + +.root__type_warning { + padding: 2px 4px; + background: rgba(245, 183, 61, 0.18); + border: 1px solid var(--warning-text-color); + border-radius: 2px; +} + +.icon__type_link { + color: var(--success-text-color); +} + +.icon__type_warning { + color: var(--warning-text-color); +} + +.tooltip { + width: auto; +} + +/* +.tooltip__type_link { + border: 1px solid #6CCF8E; + background: #132322; +} + +.tooltip__type_warning { + border: 1px solid #F8D06B; + background: #3A301E; +} +*/ diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx new file mode 100644 index 00000000..d88013c6 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx @@ -0,0 +1,66 @@ +import React, { FC, useCallback } from 'react'; + +import { HorizontalGroup, VerticalGroup, Icon, IconButton, Tooltip } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Text from 'components/Text/Text'; + +import styles from './ScheduleCounter.module.css'; + +interface ScheduleCounterProps { + type: 'link' | 'warning'; + count: number; + tooltipTitle: string; + tooltipContent: React.ReactNode; + onHover: () => void; +} + +const typeToIcon = { + link: 'link', + warning: 'exclamation-triangle', +}; + +const typeToColor = { + link: 'success', + warning: 'warning', +}; + +const typeToBorderColor = { + link: '#6CCF8E', + warning: '#F8D06B', +}; + +const typeToBackgroundColor = { + link: '#132322', + warning: '#3A301E', +}; + +const cx = cn.bind(styles); + +const ScheduleCounter: FC = (props) => { + const { type, count, tooltipTitle, tooltipContent, onHover } = props; + + return ( + + + {tooltipTitle} + {tooltipContent} + + + } + > +
+ + + {count} + +
+
+ ); +}; + +export default ScheduleCounter; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css new file mode 100644 index 00000000..5f9d2674 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css @@ -0,0 +1,46 @@ +.root { + padding: 4px 10px; + gap: 10px; + background: var(--primary-background); + border: var(--border-medium); + border-radius: 2px; +} + +.details { + width: auto; + padding: 10px 0; +} + +.progress { + width: 100%; + height: 16px; + background-color: var(--secondary-background-shade); + position: relative; +} + +.progress-filler { + height: 100%; + position: absolute; +} + +.progress-filler__type_success { + background-color: var(--success-text-color); +} + +.progress-filler__type_warning { + background-color: var(--warning-text-color); +} + +.quality-text { + float: right; + line-height: 16px; + margin-right: 3px; +} + +.quality-text__type_success { + color: var(--primary-text-color); +} + +.quality-text__type_warning { + color: #111217; +} diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx new file mode 100644 index 00000000..ac554515 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -0,0 +1,97 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { HorizontalGroup, VerticalGroup, Icon, IconButton, Tooltip } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Text from 'components/Text/Text'; + +import styles from './ScheduleQuality.module.css'; + +interface ScheduleQualityProps { + quality: number; +} + +const cx = cn.bind(styles); + +const ScheduleQuality: FC = (props) => { + const { quality } = props; + + return ( + }> +
+ + + Quality: + {Math.floor(quality * 100)}% + +
+
+ ); +}; + +interface ScheduleQualityDetailsProps { + quality: number; +} + +const SheduleQualityDetails = (props: ScheduleQualityDetailsProps) => { + const { quality } = props; + + const [expanded, setExpanded] = useState(false); + + const type = quality > 0.8 ? 'success' : 'warning'; + + const qualityPercent = quality * 100; + + const handleExpandClick = useCallback(() => { + setExpanded((expanded) => !expanded); + }, []); + + return ( +
+ + Schedule quality +
+
+
+ {qualityPercent}% +
{' '} +
+
+ {type === 'success' && ( + + You are doing a great job!
+ Schedule is well balanced for all members. +
+ )} + {type === 'warning' && Your schedule has balance problems.} +
+ + + + + Calculation methodology + + + + {expanded && ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum purus egestas porta ultricies. + Sed quis maximus sem. Phasellus semper pulvinar sapien ac euismod. + + )} + +
+
+ ); +}; + +export default ScheduleQuality; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css new file mode 100644 index 00000000..84906035 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css @@ -0,0 +1,38 @@ +.root { + width: 220px; + padding: 10px; +} + +.oncall-badge { + line-height: 16px; + color: var(--primary-background); + padding: 2px 7px; + border-radius: 4px; + margin-bottom: 10px; +} + +.oncall-badge__type_now { + background: #6ccf8e; +} + +.oncall-badge__type_inside { + background: #ccccdc; +} + +.oncall-badge__type_outside { + background: rgba(204, 204, 220, 0.4); +} + +.hr { + width: 100%; + margin: 0 -11px; +} + +.times { + display: flex; + flex-direction: column; +} + +.icon { + color: #ccccdc; +} diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx new file mode 100644 index 00000000..423fb074 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx @@ -0,0 +1,121 @@ +import React, { FC } from 'react'; + +import { Icon, Button, HorizontalGroup, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; + +import Avatar from 'components/Avatar/Avatar'; +import Text from 'components/Text/Text'; +import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { User } from 'models/user/user.types'; + +import Line from './img/line.svg'; + +import styles from './ScheduleUserDetails.module.css'; + +interface ScheduleUserDetailsProps { + currentMoment: dayjs.Dayjs; + user: User; +} + +const cx = cn.bind(styles); + +enum UserOncallStatus { + Now = 'now', + Outside = 'outside', + Inside = 'inside', +} + +const userOncallStatusToText = { + [UserOncallStatus.Now]: 'Oncall now', + [UserOncallStatus.Inside]: 'Inside working hours', + [UserOncallStatus.Outside]: 'Outside working hours', +}; + +const ScheduleUserDetails: FC = (props) => { + const { user, currentMoment } = props; + + const userStatus = + Math.random() > 0.66 + ? UserOncallStatus.Now + : Math.random() > 0.33 + ? UserOncallStatus.Inside + : UserOncallStatus.Outside; + + const userMoment = currentMoment.tz(user.timezone); + + const userOffsetHoursStr = getTzOffsetString(userMoment); + + return ( +
+ + + + {/**/} + + + {user.username} + + {`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} {userOffsetHoursStr} + + {/*
+ {userOncallStatusToText[userStatus]} +
+ + + Next shift +
+ + + + 30 apr, 00:00 + 30 apr, 23:59 + + +
+
+ + Last shift +
+ + + + 30 apr, 00:00 + 30 apr, 23:59 + + +
+
+
+
+
+ + Contacts + + + mail@grafana.com + + + + @slackid + + + + +39 555 449 00 00 + */} + +
+
+ ); +}; + +export default ScheduleUserDetails; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg b/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg new file mode 100644 index 00000000..6dcaf594 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts new file mode 100644 index 00000000..3cfbe028 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.helpers.ts @@ -0,0 +1,25 @@ +import moment from 'moment'; + +export function optionToDateString(option: string) { + switch (option) { + case 'today': + return moment().startOf('day').format('YYYY-MM-DD'); + case 'tomorrow': + return moment().add(1, 'day').startOf('day').format('YYYY-MM-DD'); + default: + return moment().add(2, 'day').startOf('day').format('YYYY-MM-DD'); + } +} + +export function dateStringToOption(dateString: string) { + const today = moment().startOf('day').format('YYYY-MM-DD'); + if (dateString === today) { + return 'today'; + } + const tomorrow = moment().add(1, 'day').startOf('day').format('YYYY-MM-DD'); + if (dateString === tomorrow) { + return 'tomorrow'; + } + + return 'custom'; +} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css new file mode 100644 index 00000000..e7bbaff6 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.module.css @@ -0,0 +1,4 @@ +.root { + display: inline-flex; + align-items: center; +} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx new file mode 100644 index 00000000..dbcbd942 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx @@ -0,0 +1,97 @@ +import React, { ChangeEvent, useCallback, useMemo, useState } from 'react'; + +import { DatePickerWithInput, Field, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import { ScheduleType } from 'models/schedule/schedule.types'; + +import { dateStringToOption, optionToDateString } from './SchedulesFilters.helpers'; +import { SchedulesFiltersType } from './SchedulesFilters.types'; + +import styles from './SchedulesFilters.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesFiltersProps { + value: SchedulesFiltersType; + onChange: (filters: SchedulesFiltersType) => void; +} + +const SchedulesFilters = (props: SchedulesFiltersProps) => { + const { value, onChange } = props; + + const onSearchTermChangeCallback = useCallback( + (e: ChangeEvent) => { + onChange({ ...value, searchTerm: e.currentTarget.value }); + }, + [value] + ); + const handleStatusChange = useCallback( + (status) => { + onChange({ ...value, status }); + }, + [value] + ); + + const handleTypeChange = useCallback( + (type) => { + onChange({ ...value, type }); + }, + [value] + ); + + return ( +
+ + + } + placeholder="Search..." + value={value.searchTerm} + onChange={onSearchTermChangeCallback} + /> + + + + + + + + +
+ ); +}; + +export default SchedulesFilters; diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts new file mode 100644 index 00000000..ed9cad62 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts @@ -0,0 +1,13 @@ +import { Moment } from 'moment'; + +enum ScheduleType { + Web = 'Web', + iCal = 'iCal', + API = 'API', +} + +export interface SchedulesFiltersType { + searchTerm: string; + type: string; + status: string; +} diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css new file mode 100644 index 00000000..df6caa08 --- /dev/null +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -0,0 +1,44 @@ +.root { + width: 100%; +} + +.root table { + width: 100%; + background: #22252b; +} + +.root tr { + border-bottom: 1px solid #181b1f; + height: 60px; +} + +.root tr:hover { + /* background: var(--secondary-background); */ + background: rgba(63, 62, 62, 0.45); +} + +.root th:first-child { + padding-left: 20px; +} + +.root td { + min-height: 60px; + padding: 10px 0; +} + +.pagination { + width: 100%; + margin-top: 20px; +} + +.expand-icon { + padding: 10px; + pointer-events: none; + transform: rotate(-90deg); + transform-origin: center; + transition: transform 0.2s; +} + +.expand-icon__expanded { + transform: rotate(0deg); +} diff --git a/grafana-plugin/src/components/Table/Table.tsx b/grafana-plugin/src/components/Table/Table.tsx new file mode 100644 index 00000000..95bcbe8b --- /dev/null +++ b/grafana-plugin/src/components/Table/Table.tsx @@ -0,0 +1,69 @@ +import React, { FC, useState, useCallback, useMemo, ChangeEvent } from 'react'; + +import { Pagination, Checkbox, Icon, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import Table from 'rc-table'; +import { TableProps } from 'rc-table/lib/Table'; + +import { ExpandIcon } from 'icons'; + +import styles from './Table.module.css'; + +const cx = cn.bind(styles); + +export interface Props extends TableProps { + loading?: boolean; + pagination?: { + page: number; + total: number; + onChange: (page: number) => void; + }; + rowSelection?: { + selectedRowKeys: string[]; + onChange: (selectedRowKeys: string[]) => void; + }; + expandable?: { + expandedRowKeys: string[]; + expandedRowRender: (item: any) => React.ReactNode; + onExpandedRowsChange: (rows: string[]) => void; + expandRowByClick: boolean; + expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode; + onExpand?: (expanded: boolean, item: any) => void; + }; +} + +const GTable: FC = (props) => { + const { columns, data, className, pagination, loading, rowKey, expandable, ...restProps } = props; + + const { page, total: numberOfPages, onChange: onNavigate } = pagination || {}; + + if (expandable) { + expandable.expandIcon = ({ expanded, record }) => { + return ( +
+ +
+ ); + }; + } + + return ( + + + {pagination && ( +
+ +
+ )} + + ); +}; + +export default GTable; diff --git a/grafana-plugin/src/components/Text/Text.tsx b/grafana-plugin/src/components/Text/Text.tsx index 7d76b937..71357fdb 100644 --- a/grafana-plugin/src/components/Text/Text.tsx +++ b/grafana-plugin/src/components/Text/Text.tsx @@ -21,6 +21,7 @@ interface TextProps extends HTMLAttributes { onTextChange?: (value: string) => void; clearBeforeEdit?: boolean; hidden?: boolean; + editModalTitle?: string; } interface TextType extends React.FC { @@ -47,6 +48,7 @@ const Text: TextType = (props) => { onTextChange, clearBeforeEdit = false, hidden = false, + editModalTitle = 'New value', } = props; const [isEditMode, setIsEditMode] = useState(false); @@ -81,7 +83,7 @@ const Text: TextType = (props) => { 'text--strong': strong, 'text--underline': underline, 'no-wrap': !wrap, - keyboard + keyboard, })} > {hidden ? PLACEHOLDER : children} @@ -112,7 +114,7 @@ const Text: TextType = (props) => { )} {isEditMode && ( - + = (props) => { + const { startMoment, debug } = props; + + const momentsToRender = useMemo(() => { + const hoursToSplit = 12; + + const momentsToRender = []; + const jLimit = 24 / hoursToSplit; + + for (let i = 0; i < 7; i++) { + const d = dayjs(startMoment).add(i, 'days'); + const obj = { moment: d, moments: [] }; + for (let j = 0; j < jLimit; j++) { + const m = dayjs(d).add(j * hoursToSplit, 'hour'); + obj.moments.push(m); + } + momentsToRender.push(obj); + } + return momentsToRender; + }, [startMoment]); + + const cuts = useMemo(() => { + const cuts = []; + for (let i = 0; i <= 24 * 7; i++) { + cuts.push({}); + } + return cuts; + }, []); + + return ( +
+ {debug && ( + + {cuts.map((cut, index) => ( + + ))} + + )} + {momentsToRender.map((m, i) => { + return ( +
+
{m.moment.format('D MMM')}
+
+ {m.moments.map((mm, j) => ( +
+
+ {mm.format('HH:mm')} +
+
+ ))} +
+
+ ); + })} +
+ ); +}; + +export default TimelineMarks; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts new file mode 100644 index 00000000..2a8b53ce --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -0,0 +1,46 @@ +import { Item, ItemData } from './UserGroups.types'; + +export const toPlainArray = (groups: string[][], getItemData: (item: Item['item']) => ItemData) => { + let i = 0; + + const items: Item[] = []; + groups.forEach((group: string[], groupIndex: number) => { + items.push({ + key: `group-${groupIndex}`, + type: 'group', + data: { name: `Group ${groupIndex + 1}` }, + }); + + groups[groupIndex].forEach((item: string, itemIndex: number) => { + items.push({ + key: `item-${groupIndex}-${itemIndex}`, + type: 'item', + item, + data: getItemData(item), + }); + }); + }); + + return items; +}; + +export const fromPlainArray = (items: Item[], createNewGroup = false, deleteEmptyGroups = true) => { + const groups = []; + + return items + .reduce((memo: any, item: Item, currentIndex: number) => { + if (item.type === 'item') { + let lastGroup = memo[memo.length - 1]; + if (!lastGroup || (createNewGroup && currentIndex === items.length - 1)) { + lastGroup = []; + memo.push(lastGroup); + } + lastGroup.push(item.item); + } else { + memo.push([]); + } + + return memo; + }, []) + .filter((group: string[][]) => !deleteEmptyGroups || group.length); +}; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css new file mode 100644 index 00000000..178e3412 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -0,0 +1,93 @@ +.root { + width: 100%; +} + +.sortable-helper { + z-index: 1062; + box-shadow: var(--focused-box-shadow); + background: var(--hover-selected-hardcoded) !important; +} + +.separator { + font-weight: 400; + font-size: 12px; + line-height: 16px; + text-align: center; + color: rgba(204, 204, 220, 0.4); + margin: 4px 0; + display: flex; + align-items: center; +} + +.separator__clickable { + cursor: pointer; +} + +.separator::before { + display: block; + content: ""; + flex-grow: 1; + border-bottom: 1px solid rgba(204, 204, 220, 0.15); + height: 0; + margin-right: 5px; +} + +.separator::after { + display: block; + content: ""; + flex-grow: 1; + border-bottom: 1px solid rgba(204, 204, 220, 0.15); + height: 0; + margin-left: 5px; +} + +.groups { + width: 100%; + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +.user { + background: #22252b; + border-radius: 2px; + display: flex; + position: relative; + overflow: hidden; +} + +.user-buttons { + position: absolute; + top: 8px; + right: 5px; +} + +.user:hover { + background: var(--hover-selected-hardcoded); +} + +.delete-icon { + /* display: none; */ + display: block; +} + +.user:hover .delete-icon { + display: block; +} + +.add-user-group { + width: 100%; + text-align: center; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: var(--secondary-text-color); + cursor: pointer; +} + +.select { + width: 100%; +} diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx new file mode 100644 index 00000000..b726e6e4 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -0,0 +1,184 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { VerticalGroup, HorizontalGroup, IconButton, Field, Input } from '@grafana/ui'; +import { arrayMoveImmutable } from 'array-move'; +import cn from 'classnames/bind'; +import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc'; + +import Text from 'components/Text/Text'; +import WorkingHours from 'components/WorkingHours/WorkingHours'; +import GSelect from 'containers/GSelect/GSelect'; +import UserTooltip from 'containers/UserTooltip/UserTooltip'; +import { User } from 'models/user/user.types'; + +import { fromPlainArray, toPlainArray } from './UserGroups.helpers'; +import { Item, ItemData } from './UserGroups.types'; + +import styles from './UserGroups.module.css'; + +interface UserGroupsProps { + value: Array>; + onChange: (value: Array>) => void; + isMultipleGroups: boolean; + getItemData: (id: string) => ItemData; + renderUser: (id: string) => React.ReactElement; + showError?: boolean; +} + +const cx = cn.bind(styles); + +const DragHandle = () => ; + +const SortableHandleHoc = SortableHandle(DragHandle); + +const UserGroups = (props: UserGroupsProps) => { + const { value, onChange, isMultipleGroups, getItemData, renderUser, showError } = props; + + const handleAddUserGroup = useCallback(() => { + onChange([...value, []]); + }, [value]); + + const handleDeleteUser = (index: number) => { + const newGroups = [...value]; + let k = -1; + for (let i = 0; i < value.length; i++) { + k++; + const users = value[i]; + for (let j = 0; j < users.length; j++) { + k++; + + if (k === index) { + newGroups[i] = newGroups[i].filter((item, itemIndex) => itemIndex !== j); + onChange(newGroups.filter((group) => group.length)); + return; + } + } + } + }; + + const handleUserAdd = useCallback( + (pk: User['pk'], user: User) => { + if (!pk) { + return; + } + + const newGroups = [...value]; + let lastGroup = newGroups[newGroups.length - 1]; + if (!lastGroup) { + lastGroup = []; + newGroups.push(lastGroup); + } + + lastGroup.push(pk); + + onChange(newGroups); + }, + [value] + ); + + const items = useMemo(() => toPlainArray(value, getItemData), [value]); + + const onSortEnd = useCallback( + ({ oldIndex, newIndex }) => { + const newPlainArray = arrayMoveImmutable(items, oldIndex, newIndex); + + onChange(fromPlainArray(newPlainArray, newIndex > items.length)); + }, + [items] + ); + + const getDeleteItemHandler = (index: number) => { + return () => { + handleDeleteUser(index); + }; + }; + + const renderItem = (item: Item, index: number) => ( +
  • + {renderUser(item.item)} +
    + + + + +
    +
  • + ); + + return ( +
    + + + } + showError={showError} + /> + +
    + ); +}; + +interface SortableItemProps { + children: React.ReactElement; +} + +const SortableItem = SortableElement(({ children }: SortableItemProps) => children); + +interface SortableListProps { + items: Item[]; + handleAddGroup: () => void; + handleDeleteItem: (index: number) => void; + isMultipleGroups: boolean; + renderItem: (item: Item, index: number) => React.ReactElement; +} + +const SortableList = SortableContainer( + ({ items, handleAddGroup, handleDeleteItem, isMultipleGroups, renderItem }: SortableListProps) => { + return ( +
      + {items.map((item, index) => + item.type === 'item' ? ( + + {renderItem(item, index)} + + ) : isMultipleGroups ? ( + +
    • {item.data.name}
    • +
      + ) : null + )} + {isMultipleGroups && items[items.length - 1]?.type === 'item' && ( + +
    • + Add user group + +
    • +
      + )} +
    + ); + } +); + +export default UserGroups; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.types.ts b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts new file mode 100644 index 00000000..b3b85942 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts @@ -0,0 +1,6 @@ +export interface Item { + key: string; + type: string; + data: any; + item?: string; +} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css new file mode 100644 index 00000000..c6b6dd45 --- /dev/null +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css @@ -0,0 +1,3 @@ +.root { + width: 300px; +} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx new file mode 100644 index 00000000..453c578b --- /dev/null +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -0,0 +1,67 @@ +import React, { FC, useCallback, useMemo } from 'react'; + +import { Select } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { get } from 'lodash-es'; + +import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; + +import styles from './UserTimezoneSelect.module.css'; + +interface UserTimezoneSelectProps { + users: User[]; + value: Timezone; + onChange: (value: Timezone) => void; +} + +const cx = cn.bind(styles); + +const UserTimezoneSelect: FC = (props) => { + const { users, value, onChange } = props; + + const options = useMemo(() => { + return users.reduce((memo, user) => { + let item = memo.find((item) => item.label === user.timezone); + + if (!item) { + item = { + value: user.pk, + label: `${user.timezone} ${getTzOffsetString(dayjs().tz(user.timezone))}`, + imgUrl: user.avatar, + description: user.username, + }; + memo.push(item); + } else { + item.description += ', ' + user.name; + // item.imgUrl = undefined; + } + + return memo; + }, []); + }, [users]); + + const selectValue = useMemo(() => { + const user = users.find((user) => user.timezone === value); + return user?.pk; + }, [value, users]); + + const handleChange = useCallback( + ({ value }) => { + const user = users.find((user) => user.pk === value); + + onChange(user?.timezone); + }, + [users] + ); + + return ( +
    + + + + + + + {repeatEveryPeriod === 1 && ( + /**/ + + setSelectedDays(value)} + /> + + /**/ + )} + + + Shift start + + } + > + + + + Shift end + + } + > + + + + + + Rotation start + + } + > + + + + + Rotation end + + + + } + > + {endLess ? ( + { + setEndless(false); + }} + /> + ) : ( + + )} + + + + + Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + + + + + + + + ); +}); + +interface DaysSelectorProps { + value: string[]; + onChange: (value: string[]) => void; + options: SelectOption[]; +} + +const DaysSelector = ({ value, onChange, options }: DaysSelectorProps) => { + const getDayClickHandler = (day: string) => { + return () => { + const newValue = [...value]; + if (newValue.includes(day)) { + const index = newValue.indexOf(day); + newValue.splice(index, 1); + } else { + newValue.push(day); + } + onChange(newValue); + }; + }; + + return ( +
    + {options.map(({ display_name, value: itemValue }) => ( +
    + {display_name.charAt(0)} +
    + ))} +
    + ); +}; + +export default RotationForm; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts new file mode 100644 index 00000000..91c76468 --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts @@ -0,0 +1,3 @@ +export interface RotationCreateData {} + +export interface RotationData {} diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx new file mode 100644 index 00000000..b73373f3 --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -0,0 +1,259 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { dateTime, DateTime } from '@grafana/data'; +import { + IconButton, + VerticalGroup, + HorizontalGroup, + Field, + Input, + Button, + DateTimePicker, + Select, + InlineSwitch, +} from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import Draggable from 'react-draggable'; + +import Modal from 'components/Modal/Modal'; +import Text from 'components/Text/Text'; +import UserGroups from 'components/UserGroups/UserGroups'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import WorkingHours from 'components/WorkingHours/WorkingHours'; +import { getFromString } from 'models/schedule/schedule.helpers'; +import { Rotation, Schedule, Shift } from 'models/schedule/schedule.types'; +import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; +import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers'; +import { useStore } from 'state/useStore'; +import { getCoords, waitForElement } from 'utils/DOM'; +import { useDebouncedCallback } from 'utils/hooks'; + +import { RotationCreateData } from './RotationForm.types'; + +import styles from './RotationForm.module.css'; + +interface RotationFormProps { + onHide: () => void; + shiftId: Shift['id'] | 'new'; + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + scheduleId: Schedule['id']; + shiftMoment: dayjs.Dayjs; + shiftColor?: string; + onCreate: () => void; + onUpdate: () => void; + onDelete: () => void; +} + +const cx = cn.bind(styles); + +const ScheduleOverrideForm: FC = (props) => { + const { + onHide, + onCreate, + currentTimezone, + scheduleId, + onUpdate, + onDelete, + shiftId, + startMoment, + shiftMoment = dayjs().startOf('day').add(1, 'day'), + shiftColor = '#C69B06', + } = props; + + const store = useStore(); + + const [offsetTop, setOffsetTop] = useState(0); + + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (isOpen) { + waitForElement('#overrides-list').then((elm) => { + const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; + + const coords = getCoords(elm); + + setOffsetTop(Math.max(coords.top - modal?.offsetHeight - 10, 10)); + }); + } + }, [isOpen]); + + const [shiftStart, setShiftStart] = useState(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss'))); + const [shiftEnd, setShiftEnd] = useState( + dateTime(shiftMoment.add(24, 'hours').format('YYYY-MM-DD HH:mm:ss')) + ); + + const [userGroups, setUserGroups] = useState([[]]); + + const getUser = (pk: User['pk']) => { + return { + name: store.userStore.items[pk]?.username, + desc: store.userStore.items[pk]?.timezone, + }; + }; + + const renderUser = (userPk: User['pk']) => { + const name = store.userStore.items[userPk]?.username; + const desc = store.userStore.items[userPk]?.timezone; + const workingHours = store.userStore.items[userPk]?.working_hours; + const timezone = store.userStore.items[userPk]?.timezone; + + return ( + <> +
    + {name} ({desc}) +
    + + + ); + }; + + const shift = store.scheduleStore.shifts[shiftId]; + + useEffect(() => { + if (shiftId !== 'new') { + store.scheduleStore.updateOncallShift(shiftId); + } + }, [shiftId]); + + const params = useMemo( + () => ({ + rotation_start: getUTCString(shiftStart, currentTimezone), + shift_start: getUTCString(shiftStart, currentTimezone), + shift_end: getUTCString(shiftEnd, currentTimezone), + rolling_users: userGroups, + frequency: null, + }), + [currentTimezone, shiftStart, shiftEnd, userGroups] + ); + + useEffect(() => { + if (shift) { + setShiftStart(getDateTime(shift.shift_start, currentTimezone)); + setShiftEnd(getDateTime(shift.shift_end, currentTimezone)); + + setUserGroups(shift.rolling_users); + } + }, [shift]); + + const handleDeleteClick = useCallback(() => { + store.scheduleStore.deleteOncallShift(shiftId).then(() => { + onHide(); + + onDelete(); + }); + }, []); + + const handleCreate = useCallback(() => { + if (shiftId === 'new') { + store.scheduleStore.createRotation(scheduleId, true, params).then(() => { + onCreate(); + }); + } else { + store.scheduleStore.updateRotation(shiftId, params).then(() => { + onUpdate(); + }); + } + }, [scheduleId, shiftId, params]); + + useEffect(() => { + if (shiftId === 'new') { + updatePreview(); + } + }, []); + + const updatePreview = () => { + store.scheduleStore + .updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params) + .then(() => { + setIsOpen(true); + }); + }; + + const handleChange = useDebouncedCallback(updatePreview, 200); + + useEffect(handleChange, [params]); + + return ( + ( + +
    {children}
    +
    + )} + > + + + {shiftId === 'new' ? 'New Override' : 'Update Override'} + + + + {shiftId !== 'new' && ( + + + + )} + + + + group.length)} + /> + {/*
    */} + + + + Override start + + } + > + + + + Override end + + } + > + + + + + + Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + + + + +
    +
    + ); +}; + +export default ScheduleOverrideForm; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.config.ts b/grafana-plugin/src/containers/Rotations/Rotations.config.ts new file mode 100644 index 00000000..2fe0b109 --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/Rotations.config.ts @@ -0,0 +1,4 @@ +export const DEFAULT_TRANSITION_TIMEOUT = { + enter: 500, + exit: 0, +}; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts new file mode 100644 index 00000000..91225e8b --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts @@ -0,0 +1,39 @@ +import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers'; +import { Layer, Shift } from 'models/schedule/schedule.types'; + +export const findColor = (shiftId: Shift['id'], layers: Layer[], overrides?) => { + let color = undefined; + + let layerIndex = -1; + let rotationIndex = -1; + if (layers) { + outer: for (var i = 0; i < layers.length; i++) { + for (var j = 0; j < layers[i].shifts.length; j++) { + const shift = layers[i].shifts[j]; + if (shift.shiftId === shiftId || (shiftId === 'new' && shift.isPreview)) { + layerIndex = i; + rotationIndex = j; + break outer; + } + } + } + } + + let overrideIndex = -1; + if (layerIndex === -1 && rotationIndex === -1 && overrides) { + for (var k = 0; k < overrides.length; k++) { + const shift = overrides[k]; + if (shift.shiftId === shiftId || (shiftId === 'new' && shift.isPreview)) { + overrideIndex = k; + } + } + } + + if (layerIndex > -1 && rotationIndex > -1) { + color = getColor(layerIndex, rotationIndex); + } else if (overrideIndex > -1) { + color = getOverrideColor(overrideIndex); + } + + return color; +}; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.module.css b/grafana-plugin/src/containers/Rotations/Rotations.module.css new file mode 100644 index 00000000..e79d9f24 --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/Rotations.module.css @@ -0,0 +1,113 @@ +.root { + border: var(--rotations-border); + border-radius: 2px; + background: var(--rotations-background); +} + +.current-time { + position: absolute; + width: 1px; + background: #fff; + top: 0; + bottom: 0; + z-index: 1; + transition: left 500ms ease; +} + +.header { + padding: 0 10px; +} + +.title { + font-weight: 500; + font-size: 19px; + line-height: 24px; + color: rgba(204, 204, 220, 0.65); + margin: 16px 0; +} + +.rotations-plus-title { + display: flex; + flex-direction: column; +} + +.layer { + display: block; +} + +.rotations { + position: relative; +} + +.layer-title { + text-align: center; + font-weight: 500; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.1em; + color: rgba(204, 204, 220, 0.65); + text-transform: uppercase; + padding: 8px; + background: var(--secondary-background); +} + +.layer-title:hover { + background: rgba(204, 204, 220, 0.12); +} + +.header-plus-content { + position: relative; +} + +.layer-header { + padding: 12px; + display: flex; + justify-content: space-between; +} + +.layer-header-title { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(204, 204, 220, 0.65); +} + +.layer-content { + position: relative; +} + +.add-rotations-layer { + font-weight: 400; + font-size: 12px; + line-height: 16px; + text-align: center; + padding: 12px; + color: rgba(204, 204, 220, 0.65); + cursor: pointer; +} + +.add-rotations-layer:hover { + background: var(--secondary-background); +} + +/* +animation +*/ + +.enter { + opacity: 0; +} + +.enterActive { + opacity: 1; + transition: opacity 500ms ease-in; +} + +.exit { + opacity: 1; +} + +.exitActive { + opacity: 0; + transition: opacity 500ms ease-in; +} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx new file mode 100644 index 00000000..080dacab --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -0,0 +1,242 @@ +import React, { Component, useMemo, useState } from 'react'; + +import { ValuePicker, IconButton, Icon, HorizontalGroup, Button, LoadingPlaceholder } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import { toJS } from 'mobx'; +import { observer } from 'mobx-react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; +import Rotation from 'containers/Rotation/Rotation'; +import RotationForm from 'containers/RotationForm/RotationForm'; +import { RotationCreateData } from 'containers/RotationForm/RotationForm.types'; +import { getColor, getFromString } from 'models/schedule/schedule.helpers'; +import { Event, Layer, Schedule, Shift } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { SelectOption, WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; +import { findColor } from './Rotations.helpers'; + +import styles from './Rotations.module.css'; + +const cx = cn.bind(styles); + +interface RotationsProps extends WithStoreProps { + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + scheduleId: Schedule['id']; + onClick: (id: Shift['id'] | 'new') => void; + onCreate: () => void; + onUpdate: () => void; + onDelete: () => void; +} + +interface RotationsState { + shiftIdToShowRotationForm?: Shift['id']; + layerPriority?: Layer['priority']; + shiftMomentToShowRotationForm?: dayjs.Dayjs; +} + +@observer +class Rotations extends Component { + state: RotationsState = { + shiftIdToShowRotationForm: undefined, + shiftMomentToShowRotationForm: undefined, + }; + + render() { + const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, onClick } = this.props; + const { shiftIdToShowRotationForm, layerPriority, shiftMomentToShowRotationForm } = this.state; + + const base = 7 * 24 * 60; // in minutes + const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); + + const currentTimeX = diff / base; + + const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; + + const layers = store.scheduleStore.rotationPreview + ? store.scheduleStore.rotationPreview + : (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]); + + const options = layers + ? layers.map((layer) => ({ + label: `Layer ${layer.priority}`, + value: layer.priority, + })) + : []; + + const nextPriority = layers && layers.length ? layers[layers.length - 1].priority + 1 : 1; + + options.push({ label: 'New Layer', value: nextPriority }); + + return ( + <> +
    +
    + +
    Rotations
    + +
    +
    +
    + {layers && layers.length ? ( + + {layers.map((layer, layerIndex) => ( + +
    +
    + + Layer {layer.priority} + {/**/} + +
    +
    + + {!currentTimeHidden && ( +
    + )} + + {layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( + + { + this.onRotationClick(shiftId, moment); + }} + color={getColor(layerIndex, rotationIndex)} + events={events} + layerIndex={layerIndex} + rotationIndex={rotationIndex} + startMoment={startMoment} + currentTimezone={currentTimezone} + transparent={isPreview} + /> + + ))} + +
    +
    + + ))} + + ) : ( +
    +
    +
    + + Layer 1 + {/* */} + +
    +
    +
    + +
    + { + this.handleAddLayer(nextPriority, moment); + }} + events={[]} + layerIndex={0} + rotationIndex={0} + startMoment={startMoment} + currentTimezone={currentTimezone} + /> +
    +
    +
    +
    + )} + {nextPriority > 1 && ( +
    { + this.handleAddLayer(nextPriority, startMoment); + }} + > + + Add rotations layer +
    + )} +
    +
    + {shiftIdToShowRotationForm && ( + { + this.hideRotationForm(); + + store.scheduleStore.clearPreview(); + }} + onUpdate={() => { + this.hideRotationForm(); + + onUpdate(); + }} + onCreate={() => { + this.hideRotationForm(); + + onCreate(); + }} + onDelete={() => { + this.hideRotationForm(); + + onDelete(); + }} + /> + )} + + ); + } + + onRotationClick = (shiftId: Shift['id'], moment?: dayjs.Dayjs) => { + this.setState({ shiftIdToShowRotationForm: shiftId, shiftMomentToShowRotationForm: moment }); + }; + + handleAddLayer = (layerPriority: number, moment?: dayjs.Dayjs) => { + this.setState({ shiftIdToShowRotationForm: 'new', layerPriority, shiftMomentToShowRotationForm: moment }); + }; + + handleAddRotation = (option: SelectOption) => { + const { startMoment } = this.props; + + this.setState({ + shiftIdToShowRotationForm: 'new', + layerPriority: option.value, + shiftMomentToShowRotationForm: startMoment, + }); + }; + + hideRotationForm = () => { + const { store } = this.props; + + this.setState({ + shiftIdToShowRotationForm: undefined, + layerPriority: undefined, + shiftMomentToShowRotationForm: undefined, + }); + }; +} + +export default withMobXProviderContext(Rotations); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx new file mode 100644 index 00000000..a3f367c9 --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -0,0 +1,122 @@ +import React, { Component, useEffect } from 'react'; + +import { Button, HorizontalGroup, Icon, Input, ValuePicker } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { toJS } from 'mobx'; +import { observer } from 'mobx-react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; +import Rotation from 'containers/Rotation/Rotation'; +import { getColor, getFromString, getOverrideColor } from 'models/schedule/schedule.helpers'; +import { Layer, Schedule } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; +import { findColor } from './Rotations.helpers'; + +import styles from './Rotations.module.css'; + +const cx = cn.bind(styles); + +interface ScheduleFinalProps extends WithStoreProps { + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + scheduleId: Schedule['id']; + hideHeader?: boolean; +} + +interface ScheduleOverridesState { + searchTerm: string; +} + +@observer +class ScheduleFinal extends Component { + state: ScheduleOverridesState = { + searchTerm: '', + }; + + render() { + const { scheduleId, startMoment, currentTimezone, store, hideHeader } = this.props; + const { searchTerm } = this.state; + + const base = 7 * 24 * 60; // in minutes + const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); + + const currentTimeX = diff / base; + + const shifts = store.scheduleStore.finalPreview + ? store.scheduleStore.finalPreview + : store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)]; + + const layers = store.scheduleStore.rotationPreview + ? store.scheduleStore.rotationPreview + : (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]); + + const overrides = store.scheduleStore.overridePreview + ? store.scheduleStore.overridePreview + : store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)]; + const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; + + /* console.log('shifts', toJS(shifts)); + console.log('layers', toJS(layers)); */ + + return ( + <> +
    + {!hideHeader && ( +
    + +
    Final schedule
    + {/*} + placeholder="Search..." + value={searchTerm} + onChange={this.onSearchTermChangeCallback} + />*/} +
    +
    + )} +
    + {!currentTimeHidden &&
    } + + + {shifts && shifts.length ? ( + shifts.map(({ shiftId, events }, index) => { + return ( + + + + ); + }) + ) : ( + + + + )} + +
    +
    + + ); + } + + onSearchTermChangeCallback = () => {}; +} + +export default withMobXProviderContext(ScheduleFinal); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx new file mode 100644 index 00000000..2e884c17 --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -0,0 +1,162 @@ +import React, { Component } from 'react'; + +import { Button, HorizontalGroup, Icon, ValuePicker } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; +import Rotation from 'containers/Rotation/Rotation'; +import { RotationCreateData } from 'containers/RotationForm/RotationForm.types'; +import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm'; +import { getFromString, getOverrideColor } from 'models/schedule/schedule.helpers'; +import { Schedule, Shift } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; +import { findColor } from './Rotations.helpers'; + +import styles from './Rotations.module.css'; + +const cx = cn.bind(styles); + +interface ScheduleOverridesProps extends WithStoreProps { + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + scheduleId: Schedule['id']; + onCreate: () => void; + onUpdate: () => void; + onDelete: () => void; +} + +interface ScheduleOverridesState { + shiftIdToShowOverrideForm?: Shift['id'] | 'new'; + shiftMomentToShowOverrideForm?: dayjs.Dayjs; +} + +@observer +class ScheduleOverrides extends Component { + state: ScheduleOverridesState = { + shiftIdToShowOverrideForm: undefined, + shiftMomentToShowOverrideForm: undefined, + }; + + render() { + const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store } = this.props; + const { shiftIdToShowOverrideForm, shiftMomentToShowOverrideForm } = this.state; + + const shifts = store.scheduleStore.overridePreview + ? store.scheduleStore.overridePreview + : store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)]; + + const base = 7 * 24 * 60; // in minutes + const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); + + const currentTimeX = diff / base; + + const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; + + return ( + <> +
    +
    + +
    Overrides
    + +
    +
    +
    + {!currentTimeHidden &&
    } + + + {shifts && shifts.length ? ( + shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( + + { + this.onRotationClick(shiftId, moment); + }} + transparent={isPreview} + /> + + )) + ) : ( + + { + this.onRotationClick('new', moment); + }} + /> + + )} + +
    + {/*
    + + Add override +
    */} +
    + {shiftIdToShowOverrideForm && ( + { + this.handleHide(); + + store.scheduleStore.clearPreview(); + }} + onUpdate={() => { + this.handleHide(); + + onUpdate(); + }} + onCreate={() => { + this.handleHide(); + + onCreate(); + }} + onDelete={() => { + this.handleHide(); + + onDelete(); + }} + /> + )} + + ); + } + + onRotationClick = (shiftId: Shift['id'], moment: dayjs.Dayjs) => { + this.setState({ shiftIdToShowOverrideForm: shiftId, shiftMomentToShowOverrideForm: moment }); + }; + + handleAddOverride = () => { + const { startMoment } = this.props; + + this.setState({ shiftIdToShowOverrideForm: 'new', shiftMomentToShowOverrideForm: startMoment }); + }; + + handleHide = () => { + this.setState({ shiftIdToShowOverrideForm: undefined, shiftMomentToShowOverrideForm: undefined }); + }; +} + +export default withMobXProviderContext(ScheduleOverrides); diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts index aad545f1..fe40a251 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts @@ -143,3 +143,14 @@ export const calendarForm: { name: string; fields: FormItem[] } = { ...commonFields, ], }; + +export const apiForm: { name: string; fields: FormItem[] } = { + name: 'Schedule', + fields: [ + { + name: 'name', + type: FormItemType.Input, + validation: { required: true }, + }, + ], +}; diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 0273cb68..e562ec90 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -13,7 +13,7 @@ import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; -import { calendarForm, iCalForm } from './ScheduleForm.config'; +import { apiForm, calendarForm, iCalForm } from './ScheduleForm.config'; import { prepareForEdit } from './ScheduleForm.helpers'; import styles from './ScheduleForm.module.css'; @@ -24,19 +24,25 @@ interface ScheduleFormProps { id: Schedule['id'] | 'new'; onHide: () => void; onUpdate: () => void; + onCreate: (data: Schedule) => void; + type?: ScheduleType; } +const scheduleTypeToForm = { + [ScheduleType.Calendar]: calendarForm, + [ScheduleType.Ical]: iCalForm, + [ScheduleType.API]: apiForm, +}; + const ScheduleForm = observer((props: ScheduleFormProps) => { - const { id, onUpdate, onHide } = props; + const { id, type, onUpdate, onCreate, onHide } = props; const store = useStore(); const { scheduleStore, userStore } = store; const data = useMemo(() => { - return id === 'new' - ? { team: userStore.currentUser?.current_team, type: ScheduleType.Ical } - : prepareForEdit(scheduleStore.items[id]); + return id === 'new' ? { team: userStore.currentUser?.current_team, type } : prepareForEdit(scheduleStore.items[id]); }, [id]); const handleSubmit = useCallback( @@ -44,16 +50,30 @@ const ScheduleForm = observer((props: ScheduleFormProps) => { (id === 'new' ? scheduleStore.create({ ...formData, type: data.type }) : scheduleStore.update(id, { ...formData, type: data.type }) - ).then(() => { + ).then((data) => { onHide(); onUpdate(); + + if (id === 'new') { + onCreate(data); + } }); }, [id] ); - const formConfig = data.type === ScheduleType.Ical ? iCalForm : calendarForm; + const getOptionLabel = (item: SelectableValue) => { + const team = grafanaTeamStore.items[item.value]; + return ( + + {item.label} + + + ); + }; + + const formConfig = scheduleTypeToForm[data.type]; return ( { + return USERS[Math.floor(Math.random() * USERS.length)]; +}; + +export const getTitle = (user: User) => { + return user ? user.username.split(' ')[0] : null; + return user + ? user.username + .split(' ') + .map((word) => word.charAt(0).toUpperCase()) + .join('') + : null; +}; + +export const getOuRanges = (shift: Shift, user: User) => {}; diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css new file mode 100644 index 00000000..ce26a72d --- /dev/null +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css @@ -0,0 +1,86 @@ +.root { + height: 28px; + background: #595959; + border-radius: 2px; + position: relative; + display: flex; + overflow: hidden; + margin: 0 1px; + padding: 4px; + align-items: center; +} + +.working-hours { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.stack { + display: flex; + flex-direction: column; + gap: 1px; + flex-shrink: 0; +} + +.root__type_gap { + background: rgba(209, 14, 92, 0.2); + border: 1px dashed #ff5286; + color: rgba(209, 14, 92, 0.5); + visibility: hidden; +} + +.root__inactive { + opacity: 0.5; +} + +.title { + z-index: 1; + color: #fff; + font-size: 12px; + font-weight: 500; + pointer-events: none; +} + +.label { + background: rgba(255, 255, 255, 0.7); + border-radius: 2px; + display: inline-block; + padding: 2px 4px; + line-height: 16px; + z-index: 1; + font-size: 10px; + font-weight: bold; + margin-right: 5px; + flex-shrink: 0; + pointer-events: none; +} + +.details { + width: auto; +} + +.details-user-status { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.details-user-status__type_success { + background-color: var(--success-text-color); +} + +.time { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: white; + z-index: 2; +} + +.is-oncall-icon { + color: var(--oncall-icon-stroke-color); + margin-left: -2px; +} diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx new file mode 100644 index 00000000..406bef15 --- /dev/null +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -0,0 +1,219 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; + +import Line from 'components/ScheduleUserDetails/img/line.svg'; +import Text from 'components/Text/Text'; +import WorkingHours from 'components/WorkingHours/WorkingHours'; +import { IsOncallIcon } from 'icons'; +import { Event, Schedule } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; + +import { getTitle } from './ScheduleSlot.helpers'; + +import styles from './ScheduleSlot.module.css'; + +interface ScheduleSlotProps { + event: Event; + scheduleId: Schedule['id']; + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + color?: string; + label?: string; +} + +const cx = cn.bind(styles); + +const ScheduleSlot: FC = observer((props) => { + const { event, scheduleId, startMoment, currentTimezone, color, label } = props; + const { users } = event; + + const trackMouse = false; + + const [mouseX, setMouseX] = useState(0); + + const start = dayjs(event.start); + const end = dayjs(event.end); + + const duration = end.diff(start, 'seconds'); + + const store = useStore(); + + const base = 60 * 60 * 24 * 7; + + const width = duration / base; + + const handleMouseMove = useCallback((event) => { + setMouseX(event.nativeEvent.offsetX); + }, []); + + const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now; + + return ( +
    + {event.is_gap ? ( + }> +
    + {trackMouse && mouseX > 0 &&
    } + {label &&
    {label}
    } +
    + + ) : event.is_empty ? ( +
    + {label && ( +
    + {label} +
    + )} +
    + ) : ( + users.map(({ pk: userPk }, userIndex) => { + const storeUser = store.userStore.items[userPk]; + + // TODO remove + if (!storeUser) { + store.userStore.updateItem(userPk); + } + + const inactive = false; + + const title = getTitle(storeUser); + + const isOncall = Boolean( + storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk) + ); + + return ( + + } + > +
    setMouseX(0) : undefined} + > + {trackMouse && mouseX > 0 &&
    } + {storeUser && ( + + )} + {userIndex === 0 && label && ( +
    + {label} +
    + )} +
    {title}
    +
    + + ); + }) + )} +
    + ); +}); + +export default ScheduleSlot; + +interface ScheduleSlotDetailsProps { + user: User; + isOncall: boolean; + currentTimezone: Timezone; + event: Event; +} + +const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { + const { user, currentTimezone, event, isOncall } = props; + + return ( +
    + + + + {isOncall && } + {user?.username} + + + + {/* + + 30 apr, 7:54 + */} + + + + {dayjs(event.start).tz(user.timezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(user.timezone).format('DD MMM, HH:mm')} + + + + + + + {currentTimezone} + + {/* 30 apr, 12:54 */} + {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} + + + +
    + ); +}; + +interface ScheduleGapDetailsProps { + currentTimezone: Timezone; + event: Event; +} + +const ScheduleGapDetails = (props: ScheduleGapDetailsProps) => { + const { currentTimezone, event } = props; + + return ( +
    + + + + {currentTimezone} + {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} + + + {/*Gaps this week + + Number of gaps + 12 + + + Time + 23h 12m + */} + +
    + ); +}; diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css new file mode 100644 index 00000000..bedeb67b --- /dev/null +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css @@ -0,0 +1,3 @@ +.root { + +} diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx new file mode 100644 index 00000000..d6358951 --- /dev/null +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import { useStore } from 'state/useStore'; + +import styles from './SchedulesFilters.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesFiltersProps {} + +const SchedulesFilters = observer((props: SchedulesFiltersProps) => { + const {} = props; + + const store = useStore(); + + const {} = store; + + return
    ; +}); + +export default SchedulesFilters; diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css new file mode 100644 index 00000000..63d1081b --- /dev/null +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css @@ -0,0 +1,136 @@ +.root { + border: var(--border-medium); + display: flex; + flex-direction: column; + border-radius: 2px; + background: var(--primary-background); +} + +.header { + padding: 0 10px; +} + +.title { + font-weight: 500; + font-size: 19px; + line-height: 24px; + color: rgba(204, 204, 220, 0.65); + margin: 16px 0; +} + +.current-time { + position: absolute; + left: 0; + width: 1px; + background: #fff; + top: 0; + bottom: 0; + z-index: 0; +} + +@-webkit-keyframes run { + 0% { + left: 0; + } + + 100% { + left: 100%; + } +} + +.users { + position: relative; + height: 76px; +} + +.avatar-group { + position: absolute; + top: 10px; + height: 32px; +} + +.avatar { + position: absolute; + top: 0; + transition: opacity 200ms ease, left 200ms ease; + border-radius: 50%; +} + +.is-oncall-icon { + color: var(--oncall-icon-stroke-color); + position: absolute; + left: -1px; + bottom: -1px; +} + +.user-more { + position: absolute; + padding: 0 5px; + bottom: 0; + font-size: 12px; + line-height: 16px; + background: #454952; + border-radius: 8px; + text-align: center; + transition: opacity 200ms ease, left 200ms ease; + pointer-events: none; +} + +.avatar-group_inactive { + pointer-events: none; + opacity: 0.2; + transition: opacity 0.5s ease; +} + +.time-stripe { + position: relative; + height: 4px; + + --color: rgba(61, 113, 217, 0.2); + + background: + repeating-linear-gradient( + -45deg, + var(--color), + var(--color) 4px, + transparent 4px, + transparent 8px + ); +} + +.current-user-stripe { + position: absolute; + top: 0; + bottom: 0; + height: 4px; + background: #3d71d9; + border-radius: 2px; + left: calc((3 / 8) * 100%); + right: calc((2 / 8) * 100%); +} + +.time-marks { + position: absolute; + top: -24px; + display: flex; + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(204, 204, 220, 0.65); + width: 100%; +} + +.time-mark-text { + display: inline-block; + padding: 0 5px; +} + +.time-mark-text__translated { + transform: translate(-50%, 0); + padding: 0; +} + +.time-mark:last-child { + position: absolute; + right: 0; +} diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx new file mode 100644 index 00000000..07103b78 --- /dev/null +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -0,0 +1,294 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { HorizontalGroup, InlineSwitch, Tooltip } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; + +import Avatar from 'components/Avatar/Avatar'; +import ScheduleUserDetails from 'components/ScheduleUserDetails/ScheduleUserDetails'; +import Text from 'components/Text/Text'; +import { IsOncallIcon } from 'icons'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; + +import styles from './UsersTimezones.module.css'; + +interface UsersTimezonesProps { + userIds: Array; + tz: Timezone; + onTzChange: (tz: Timezone) => void; + onCallNow: Array>; +} + +const cx = cn.bind(styles); + +const hoursToSplit = 3; + +const jLimit = 24 / hoursToSplit; + +const UsersTimezones: FC = (props) => { + const { userIds, tz, onTzChange, onCallNow } = props; + + const store = useStore(); + + const [count, setCount] = useState(0); + const [currentMoment, setCurrentMoment] = useState(dayjs().tz(tz)); + + useEffect(() => { + userIds.forEach((userId) => { + if (!store.userStore.items[userId]) { + store.userStore.updateItem(userId); + } + }); + }, [userIds]); + + const users = useMemo( + () => userIds.map((userId) => store.userStore.items[userId]).filter(Boolean), + [userIds, store.userStore.items] + ); + + useEffect(() => { + setCurrentMoment(currentMoment.tz(tz).startOf('minute')); + }, [tz]); + + /*useInterval( + () => { + setCurrentMoment(currentMoment.add(10, 'minute')); + //setCount(count + 1); + }, + // Delay in milliseconds or null to stop it + 1000, + );*/ + + const currentTimeX = useMemo(() => { + const midnight = dayjs().tz(tz).startOf('day'); + const diff = currentMoment.diff(midnight, 'minutes'); + + return (diff / 1440) * 100; + }, [currentMoment, tz]); + + const momentsToRender = useMemo(() => { + const momentsToRender = []; + + const d = dayjs().utc().startOf('day'); + + for (let j = 0; j < jLimit; j++) { + const m = dayjs(d).add(j * hoursToSplit, 'hour'); + momentsToRender.push(m); + } + return momentsToRender; + }, []); + + return ( +
    +
    + + +
    Schedule team and timezones
    + {/* + + Current schedule users only + */} +
    +
    + + Current timezone: {tz}, local time: {currentMoment.format('HH:mm')} + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + {momentsToRender.map((mm, index) => ( +
    + 0, + })} + > + {mm.format('HH:mm')} + +
    + ))} +
    + 24:00 +
    +
    +
    +
    + ); +}; + +interface UserAvatarsProps { + users: User[]; + currentMoment: dayjs.Dayjs; + onTzChange: (timezone: Timezone) => void; + onCallNow: Array>; +} + +const UserAvatars = (props: UserAvatarsProps) => { + const { users, currentMoment, onTzChange, onCallNow } = props; + const userGroups = useMemo(() => { + return users + .reduce((memo, user) => { + const userUtcOffset = dayjs().tz(user.timezone).utcOffset(); + let group = memo.find((group) => group.utcOffset === userUtcOffset); + if (!group) { + group = { utcOffset: userUtcOffset, users: [] }; + memo.push(group); + } + group.users.push(user); + + return memo; + }, []) + .sort((a, b) => { + if (a.utcOffset > b.utcOffset) { + return 1; + } + if (a.utcOffset < b.utcOffset) { + return -1; + } + + return 0; + }); + }, [users]); + + const [activeUtcOffset, setActiveUtcOffset] = useState(undefined); + + return ( +
    + {userGroups.map((group) => { + const userCurrentMoment = dayjs(currentMoment).tz(group.users[0].timezone); // TODO try using group.utcOffset + const diff = userCurrentMoment.diff(userCurrentMoment.startOf('day'), 'minutes'); + + const xPos = (diff / (60 * 24)) * 100; + + return ( + + ); + })} +
    + ); +}; + +interface AvatarGroupProps { + users: User[]; + xPos: number; + currentMoment: dayjs.Dayjs; + utcOffset: number; + onSetActiveUtcOffset: (utcOffset: number | undefined) => void; + activeUtcOffset: number; + onTzChange: (timezone: Timezone) => void; + onCallNow: Array>; +} + +const LIMIT = 3; +const AVATAR_WIDTH = 32; +const AVATAR_GAP = 5; + +const AvatarGroup = (props: AvatarGroupProps) => { + const { + users: propsUsers, + currentMoment, + xPos, + onTzChange, + utcOffset, + onSetActiveUtcOffset, + activeUtcOffset, + onCallNow, + } = props; + + const active = !isNaN(activeUtcOffset) && activeUtcOffset === utcOffset; + + const translateLeft = -AVATAR_WIDTH / 2; + + const users = useMemo(() => { + return [...propsUsers].sort((a, b) => { + const aIsOncall = Number(onCallNow.some((onCallUser) => a.pk === onCallUser.pk)); + const bIsOncall = Number(onCallNow.some((onCallUser) => b.pk === onCallUser.pk)); + + if (aIsOncall < bIsOncall) { + return 1; + } + if (aIsOncall > bIsOncall) { + return -1; + } + + return 0; + }); + }, [propsUsers]); + + const getAvatarClickHandler = useCallback((timezone: Timezone) => { + return () => { + onTzChange(timezone); + }; + }, []); + + const width = active ? users.length * AVATAR_WIDTH + (users.length - 1) * AVATAR_GAP : AVATAR_WIDTH; + + return ( +
    onSetActiveUtcOffset(utcOffset)} + onMouseLeave={() => onSetActiveUtcOffset(undefined)} + > + {users.map((user, index, array) => { + const isOncall = onCallNow.some((onCallUser) => user.pk === onCallUser.pk); + + return ( + } + > +
    = LIMIT ? 'hidden' : 'visible', + zIndex: array.length - index - 1, + /* opacity: userHour >= 9 && userHour < 18 ? 1 : 0.5,*/ + }} + onClick={getAvatarClickHandler(user.timezone)} + > + + {isOncall && } +
    +
    + ); + })} +
    LIMIT ? '1' : '0', + zIndex: users.length, + left: active ? `${users.length * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${LIMIT * 10}px`, + }} + className={cx('user-more')} + > + +{users.length - LIMIT} +
    +
    + ); +}; + +export default UsersTimezones; diff --git a/grafana-plugin/src/dummy/dummy.ts b/grafana-plugin/src/dummy/dummy.ts new file mode 100644 index 00000000..e2e7845b --- /dev/null +++ b/grafana-plugin/src/dummy/dummy.ts @@ -0,0 +1 @@ +interface Dummy {} diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index eb4f543c..ffc6f75c 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -232,3 +232,40 @@ export const GrafanaIcon = (props: IconProps) => ( ); + +export const ExpandIcon = (props: IconProps) => { + return ( + + + + ); +}; + +interface IsOncallIconProps { + className: string; +} + +export const IsOncallIcon = (props: IsOncallIconProps) => { + const { className } = props; + + return ( + + + + + ); +}; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 8bc67fca..45344f60 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -6,8 +6,7 @@ import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { showApiError, refreshPageError } from 'utils'; -import { openErrorNotification } from 'utils'; +import { showApiError, refreshPageError, openErrorNotification } from 'utils'; import { Alert, AlertAction, IncidentStatus } from './alertgroup.types'; diff --git a/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts b/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts index 2ab202a8..55842020 100644 --- a/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts +++ b/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts @@ -1,5 +1,6 @@ export interface EscalationChain { id: string; + pk: string; //? because GET related_escalation_chains returns {name,pk}[] is_default: boolean; name: string; number_of_integrations: number; diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts new file mode 100644 index 00000000..d626d32e --- /dev/null +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -0,0 +1,191 @@ +import dayjs from 'dayjs'; + +import { Event, Layer, ScheduleType, Shift } from './schedule.types'; + +export const getFromString = (moment: dayjs.Dayjs) => { + return moment.format('YYYY-MM-DD'); +}; + +export const fillGaps = (events: Event[]) => { + const newEvents = []; + + for (const [i, event] of events.entries()) { + newEvents.push(event); + + const nextEvent = events[i + 1]; + + if (nextEvent) { + if (nextEvent.start !== event.end) { + newEvents.push({ + start: event.end, + end: nextEvent.start, + is_gap: true, + users: [], + all_day: false, + shift: null, + missing_users: [], + is_empty: true, + calendar_type: ScheduleType.API, + priority_level: null, + source: 'web', + }); + } + } + } + + return newEvents; +}; + +export const splitToShiftsAndFillGaps = (events: Event[]) => { + const shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> = []; + + for (const [i, event] of events.entries()) { + if (event.shift?.pk) { + let shift = shifts.find((shift) => shift.shiftId === event.shift?.pk); + if (!shift) { + shift = { shiftId: event.shift.pk, priority: event.priority_level, events: [] }; + shifts.push(shift); + } + shift.events.push(event); + } + } + + shifts.forEach((shift) => { + shift.events = fillGaps(shift.events); + }); + + return shifts; +}; + +export const splitToLayers = ( + shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> +) => { + return shifts + .reduce((memo, shift) => { + let layer = memo.find((level) => level.priority === shift.priority); + if (!layer) { + layer = { priority: shift.priority, shifts: [] }; + memo.push(layer); + } + layer.shifts.push(shift); + + return memo; + }, []) + .sort((a, b) => { + if (a.priority > b.priority) { + return 1; + } + if (a.priority < b.priority) { + return -1; + } + + return 0; + }); +}; + +export const enrichLayers = ( + layers: Layer[], + newEvents: Event[], + shiftId: Shift['id'] | 'new', + priority: Shift['priority_level'] +) => { + let shiftIdFromEvent = shiftId; + if (shiftId === 'new') { + const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftIdFromEvent = event.shift.pk; + } + } + + const updatingLayer = { + priority, + shifts: [ + { + shiftId: shiftIdFromEvent, + isPreview: true, + events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)), + }, + ], + }; + + let added = false; + layers = layers.reduce((memo, layer, index) => { + if (shiftId === 'new') { + if (layer.priority === priority) { + const newLayer = { ...layer }; + newLayer.shifts = [...layer.shifts, ...updatingLayer.shifts]; + + memo[index] = newLayer; + + added = true; + } + } else { + const oldShiftIndex = layer.shifts.findIndex((shift) => shift.shiftId === updatingLayer.shifts[0].shiftId); + if (oldShiftIndex > -1) { + const newLayer = { ...layer }; + newLayer.shifts = [...layer.shifts]; + newLayer.shifts[oldShiftIndex] = updatingLayer.shifts[0]; + + memo[index] = newLayer; + + added = true; + } + } + + return layers; + }, layers); + + if (!added) { + layers.push(updatingLayer); + } + + return layers; +}; + +export const enrichOverrides = ( + overrides: Array<{ shiftId: Shift['id']; events: Event[] }>, + newEvents: Event[], + shiftId: Shift['id'] +) => { + let shiftIdFromEvent = shiftId; + if (shiftId === 'new') { + const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftIdFromEvent = event.shift.pk; + } + } + + const newShift = { shiftId: shiftIdFromEvent, isPreview: true, events: fillGaps(newEvents) }; + + const index = overrides.findIndex((shift) => shift.shiftId === shiftId); + + if (index > -1) { + overrides[index] = newShift; + } else { + overrides.push(newShift); + } + + return overrides; +}; + +const L1_COLORS = ['#3D71D9', '#6D609C', '#4D3B72', '#8214A0']; + +const L2_COLORS = ['#3CB979', '#188343', '#84362A', '#521913']; + +const L3_COLORS = ['#377277', '#638282', '#364E4E', '#423220']; + +const OVERRIDE_COLORS = ['#C69B06', '#C2C837']; + +const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS]; + +export const getColor = (layerIndex: number, rotationIndex: number) => { + const normalizedLayerIndex = layerIndex % COLORS.length; + const normalizedRotationIndex = rotationIndex % COLORS[normalizedLayerIndex]?.length; + + return COLORS[normalizedLayerIndex]?.[normalizedRotationIndex]; +}; + +export const getOverrideColor = (rotationIndex: number) => { + const normalizedRotationIndex = rotationIndex % OVERRIDE_COLORS.length; + return OVERRIDE_COLORS[normalizedRotationIndex]; +}; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 14d02744..9d127b91 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -1,11 +1,31 @@ -import { omit } from 'lodash-es'; +import { SelectOptions } from '@grafana/ui'; +import dayjs from 'dayjs'; +import { omit, reject } from 'lodash-es'; import { action, observable, toJS } from 'mobx'; +import ReactCSSTransitionGroup from 'react-transition-group'; // ES6 import BaseStore from 'models/base_store'; +import { EscalationChain } from 'models/escalation_chain/escalation_chain.types'; +import { SlackChannel } from 'models/slack_channel/slack_channel.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; +import { SelectOption } from 'state/types'; -import { Schedule, ScheduleEvent } from './schedule.types'; +import { + enrichLayers, + enrichOverrides, + fillGaps, + getFromString, + splitToLayers, + splitToShiftsAndFillGaps, +} from './schedule.helpers'; +import { Events, Rotation, RotationType, Schedule, ScheduleEvent, Shift, Event, Layer } from './schedule.types'; + +const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; + +let I = 0; export class ScheduleStore extends BaseStore { @observable @@ -14,11 +34,48 @@ export class ScheduleStore extends BaseStore { @observable.shallow items: { [id: string]: Schedule } = {}; + @observable.shallow + shifts: { [id: string]: Shift } = {}; + + @observable.shallow + relatedEscalationChains: { [id: string]: EscalationChain[] } = {}; + + @observable.shallow + relatedUsers: { [id: string]: { [key: string]: Event } } = {}; + + @observable.shallow + rotations: { + [id: string]: { + [startMoment: string]: Rotation; + }; + } = {}; + + @observable.shallow + events: { + [scheduleId: string]: { + [type: string]: { + [startMoment: string]: Array<{ shiftId: string; events: Event[] }> | Layer[]; + }; + }; + } = {}; + + @observable + finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; + + @observable + rotationPreview?: Layer[]; + + @observable + overridePreview?: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>; + @observable scheduleToScheduleEvents: { [id: string]: ScheduleEvent[]; } = {}; + @observable + byDayOptions: SelectOption[]; + constructor(rootStore: RootStore) { super(rootStore); @@ -45,7 +102,7 @@ export class ScheduleStore extends BaseStore { @action async updateItems(query = '') { - const result = await this.getAll(); + const result = await makeRequest(this.path, { method: 'GET', params: { search: query } }); this.items = { ...this.items, @@ -107,4 +164,200 @@ export class ScheduleStore extends BaseStore { method: 'DELETE', }); } + + // ------- NEW SCHEDULES API ENDPOINTS --------- + + async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params: Partial) { + const type = isOverride ? 3 : 2; + + const response = await makeRequest(`/oncall_shifts/`, { + data: { type, schedule: scheduleId, ...params }, + method: 'POST', + }).catch(this.onApiError); + + this.shifts = { + ...this.shifts, + [response.id]: response, + }; + + return response; + } + + async updateRotationPreview( + scheduleId: Schedule['id'], + shiftId: Shift['id'] | 'new', + fromString: string, + isOverride: boolean, + params: Partial + ) { + const type = isOverride ? 3 : 2; + + const response = await makeRequest(`/oncall_shifts/preview/`, { + params: { date: fromString }, + data: { type, schedule: scheduleId, shift_pk: shiftId === 'new' ? undefined : shiftId, ...params }, + method: 'POST', + }).catch(this.onApiError); + + if (isOverride) { + this.overridePreview = enrichOverrides( + [...this.events[scheduleId]?.['override']?.[fromString]], + response.rotation, + shiftId + ); + } else { + const layers = enrichLayers( + [...(this.events[scheduleId]?.['rotation']?.[fromString] as Layer[])], + response.rotation, + shiftId, + params.priority_level + ); + + this.rotationPreview = layers; + } + + this.finalPreview = splitToShiftsAndFillGaps(response.final); /*.filter((shift) => shift.shiftId !== shiftId);*/ + } + + @action + clearPreview() { + this.finalPreview = undefined; + this.rotationPreview = undefined; + this.overridePreview = undefined; + } + + async updateRotation(shiftId: Shift['id'], params: Partial) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, { + data: { ...params }, + method: 'PUT', + }).catch(this.onApiError); + + this.shifts = { + ...this.shifts, + [response.id]: response, + }; + + return response; + } + + updateRelatedEscalationChains = async (id: Schedule['id']) => { + const response = await makeRequest(`/schedules/${id}/related_escalation_chains`, { + method: 'GET', + }); + + this.relatedEscalationChains = { + ...this.relatedEscalationChains, + [id]: response, + }; + + return response; + }; + + updateRelatedUsers = async (id: Schedule['id']) => { + const { users } = await makeRequest(`/schedules/${id}/next_shifts_per_user`, { + method: 'GET', + }); + + this.relatedUsers = { + ...this.relatedUsers, + [id]: users, + }; + + return users; + }; + + async updateOncallShifts(scheduleId: Schedule['id']) { + const { results } = await makeRequest(`/oncall_shifts/`, { + params: { + schedule_id: scheduleId, + }, + method: 'GET', + }); + + this.shifts = { + ...this.shifts, + ...results.reduce( + (acc: { [key: number]: Shift }, item: Shift) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + } + + @action + async updateOncallShift(shiftId: Shift['id']) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, {}); + + this.shifts = { + ...this.shifts, + [shiftId]: response, + }; + } + + async deleteOncallShift(shiftId: Shift['id']) { + return await makeRequest(`/oncall_shifts/${shiftId}`, { + method: 'DELETE', + }).catch(this.onApiError); + } + + async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days = 9) { + const dayBefore = startMoment.subtract(1, 'day'); + + const response = await makeRequest(`/schedules/${scheduleId}/filter_events/`, { + params: { + type, + date: getFromString(dayBefore), + days, + }, + method: 'GET', + }); + + const fromString = getFromString(startMoment); + + const shifts = splitToShiftsAndFillGaps(response.events); + + // merge users on frontend side, we don't need it now + /*shifts.forEach((shift) => { + for (let i = 0; i < shift.events.length; i++) { + const iEvent = shift.events[i]; + + for (let j = i + 1; j < shift.events.length; j++) { + const jEvent = shift.events[j]; + if (iEvent.start === jEvent.start && iEvent.end === jEvent.end) { + iEvent.users.push(...jEvent.users); + jEvent.merged = true; + } + } + shift.events = shift.events.filter((event) => !event.merged); + } + });*/ + + const layers = type === 'rotation' ? splitToLayers(shifts) : undefined; + + this.events = { + ...this.events, + [scheduleId]: { + ...this.events[scheduleId], + [type]: { + ...this.events[scheduleId]?.[type], + [fromString]: layers ? layers : shifts, + }, + }, + }; + + // console.log(toJS(this.events)); + } + + async updateFrequencyOptions() { + return await makeRequest(`/oncall_shifts/frequency_options/`, { + method: 'GET', + }); + } + + async updateDaysOptions() { + this.byDayOptions = await makeRequest(`/oncall_shifts/days_options/`, { + method: 'GET', + }); + } } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index dc3cc3f7..c5a02e4a 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,7 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { 'Calendar', 'Ical', - 'Web', + 'API', } export interface Schedule { @@ -25,6 +25,7 @@ export interface Schedule { mention_oncall_next: boolean; mention_oncall_start: boolean; notify_empty_oncall: number; + number_of_escalation_chains: number; } export interface ScheduleEvent { @@ -44,3 +45,53 @@ export interface CreateScheduleExportTokenResponse { created_at: string; export_url: string; } + +export interface Shift { + by_day: string[]; + frequency: number | null; + id: string; + interval: number; + priority_level: number; + rolling_users: Array>; + rotation_start: string; + schedule: Schedule['id']; + shift_end: string; + shift_start: string; + title: string; + type: 2; + until: null; + updated_shift: null; +} + +export interface Rotation { + id: string; + shifts: Shift[]; +} + +export type RotationType = 'final' | 'rotation' | 'override'; + +export interface Event { + all_day: boolean; + calendar_type: ScheduleType; + end: string; + is_empty: boolean; + is_gap: boolean; + missing_users: Array<{ display_name: User['username']; pk: User['pk'] }>; + priority_level: number; + shift: { pk: Shift['id'] | null }; + source: string; + start: string; + users: Array<{ display_name: User['username']; pk: User['pk'] }>; +} + +export interface Events { + events: Event[]; + id: string; + name: string; + type: number; //? +} + +export interface Layer { + priority: Shift['priority_level']; + shifts: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>; +} diff --git a/grafana-plugin/src/models/timezone/timezone.helpers.ts b/grafana-plugin/src/models/timezone/timezone.helpers.ts new file mode 100644 index 00000000..688b409b --- /dev/null +++ b/grafana-plugin/src/models/timezone/timezone.helpers.ts @@ -0,0 +1,609 @@ +import dayjs from 'dayjs'; + +const tzs = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Atka', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Buenos_Aires', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Catamarca', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Ensenada', + 'America/Fort_Nelson', + 'America/Fort_Wayne', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Godthab', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Jujuy', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Knox_IN', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Louisville', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Mendoza', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montreal', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Ojinaga', + 'America/Panama', + 'America/Pangnirtung', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Acre', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Rosario', + 'America/Santa_Isabel', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Shiprock', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Thunder_Bay', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Virgin', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'America/Yellowknife', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/South_Pole', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Ashkhabad', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Calcutta', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Colombo', + 'Asia/Dacca', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Harbin', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Istanbul', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kashgar', + 'Asia/Kathmandu', + 'Asia/Katmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macao', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qyzylorda', + 'Asia/Rangoon', + 'Asia/Riyadh', + 'Asia/Saigon', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Tel_Aviv', + 'Asia/Thimbu', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ujung_Pandang', + 'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', + 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/ACT', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Canberra', + 'Australia/Currie', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/LHI', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/NSW', + 'Australia/North', + 'Australia/Perth', + 'Australia/Queensland', + 'Australia/South', + 'Australia/Sydney', + 'Australia/Tasmania', + 'Australia/Victoria', + 'Australia/West', + 'Australia/Yancowinna', + 'Brazil/Acre', + 'Brazil/DeNoronha', + 'Brazil/East', + 'Brazil/West', + 'CET', + 'CST6CDT', + 'Canada/Atlantic', + 'Canada/Central', + 'Canada/Eastern', + 'Canada/Mountain', + 'Canada/Newfoundland', + 'Canada/Pacific', + 'Canada/Saskatchewan', + 'Canada/Yukon', + 'Chile/Continental', + 'Chile/EasterIsland', + 'Cuba', + 'EET', + 'EST', + 'EST5EDT', + 'Egypt', + 'Eire', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT+1', + 'Etc/GMT+10', + 'Etc/GMT+11', + 'Etc/GMT+12', + 'Etc/GMT+2', + 'Etc/GMT+3', + 'Etc/GMT+4', + 'Etc/GMT+5', + 'Etc/GMT+6', + 'Etc/GMT+7', + 'Etc/GMT+8', + 'Etc/GMT+9', + 'Etc/GMT-0', + 'Etc/GMT-1', + 'Etc/GMT-10', + 'Etc/GMT-11', + 'Etc/GMT-12', + 'Etc/GMT-13', + 'Etc/GMT-14', + 'Etc/GMT-2', + 'Etc/GMT-3', + 'Etc/GMT-4', + 'Etc/GMT-5', + 'Etc/GMT-6', + 'Etc/GMT-7', + 'Etc/GMT-8', + 'Etc/GMT-9', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belfast', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kiev', + 'Europe/Kirov', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Nicosia', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Tiraspol', + 'Europe/Ulyanovsk', + 'Europe/Uzhgorod', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zaporozhye', + 'Europe/Zurich', + 'GB', + 'GB-Eire', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'HST', + 'Hongkong', + 'Iceland', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Iran', + 'Israel', + 'Jamaica', + 'Japan', + 'Kwajalein', + 'Libya', + 'MET', + 'MST', + 'MST7MDT', + 'Mexico/BajaNorte', + 'Mexico/BajaSur', + 'Mexico/General', + 'NZ', + 'NZ-CHAT', + 'Navajo', + 'PRC', + 'PST8PDT', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Enderbury', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Ponape', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Samoa', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Truk', + 'Pacific/Wake', + 'Pacific/Wallis', + 'Pacific/Yap', + 'Poland', + 'Portugal', + 'ROC', + 'ROK', + 'Singapore', + 'Turkey', + 'UCT', + 'US/Alaska', + 'US/Aleutian', + 'US/Arizona', + 'US/Central', + 'US/East-Indiana', + 'US/Eastern', + 'US/Hawaii', + 'US/Indiana-Starke', + 'US/Michigan', + 'US/Mountain', + 'US/Pacific', + 'US/Pacific-New', + 'US/Samoa', + 'UTC', + 'Universal', + 'W-SU', + 'WET', + 'Zulu', +]; + +export const getRandomTimezone = () => { + return tzs[Math.floor(Math.random() * tzs.length)]; +}; + +export const getTzOffsetString = (moment: dayjs.Dayjs) => { + const userOffset = moment.utcOffset(); + const userOffsetHours = userOffset / 60; + const userOffsetHoursStr = + userOffsetHours > 0 ? `+${userOffsetHours} GMT` : userOffset < 0 ? `${userOffsetHours} GMT` : `GMT`; + + return userOffsetHoursStr; +}; diff --git a/grafana-plugin/src/models/timezone/timezone.types.ts b/grafana-plugin/src/models/timezone/timezone.types.ts new file mode 100644 index 00000000..0e330ef9 --- /dev/null +++ b/grafana-plugin/src/models/timezone/timezone.types.ts @@ -0,0 +1,598 @@ +import { concat } from 'lodash-es'; + +const tzs = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Atka', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Buenos_Aires', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Catamarca', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Ensenada', + 'America/Fort_Nelson', + 'America/Fort_Wayne', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Godthab', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Jujuy', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Knox_IN', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Louisville', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Mendoza', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montreal', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Ojinaga', + 'America/Panama', + 'America/Pangnirtung', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Acre', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Rosario', + 'America/Santa_Isabel', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Shiprock', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Thunder_Bay', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Virgin', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'America/Yellowknife', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/South_Pole', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Ashkhabad', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Calcutta', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Colombo', + 'Asia/Dacca', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Harbin', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Istanbul', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kashgar', + 'Asia/Kathmandu', + 'Asia/Katmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macao', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qyzylorda', + 'Asia/Rangoon', + 'Asia/Riyadh', + 'Asia/Saigon', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Tel_Aviv', + 'Asia/Thimbu', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ujung_Pandang', + 'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', + 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/ACT', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Canberra', + 'Australia/Currie', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/LHI', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/NSW', + 'Australia/North', + 'Australia/Perth', + 'Australia/Queensland', + 'Australia/South', + 'Australia/Sydney', + 'Australia/Tasmania', + 'Australia/Victoria', + 'Australia/West', + 'Australia/Yancowinna', + 'Brazil/Acre', + 'Brazil/DeNoronha', + 'Brazil/East', + 'Brazil/West', + 'CET', + 'CST6CDT', + 'Canada/Atlantic', + 'Canada/Central', + 'Canada/Eastern', + 'Canada/Mountain', + 'Canada/Newfoundland', + 'Canada/Pacific', + 'Canada/Saskatchewan', + 'Canada/Yukon', + 'Chile/Continental', + 'Chile/EasterIsland', + 'Cuba', + 'EET', + 'EST', + 'EST5EDT', + 'Egypt', + 'Eire', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT+1', + 'Etc/GMT+10', + 'Etc/GMT+11', + 'Etc/GMT+12', + 'Etc/GMT+2', + 'Etc/GMT+3', + 'Etc/GMT+4', + 'Etc/GMT+5', + 'Etc/GMT+6', + 'Etc/GMT+7', + 'Etc/GMT+8', + 'Etc/GMT+9', + 'Etc/GMT-0', + 'Etc/GMT-1', + 'Etc/GMT-10', + 'Etc/GMT-11', + 'Etc/GMT-12', + 'Etc/GMT-13', + 'Etc/GMT-14', + 'Etc/GMT-2', + 'Etc/GMT-3', + 'Etc/GMT-4', + 'Etc/GMT-5', + 'Etc/GMT-6', + 'Etc/GMT-7', + 'Etc/GMT-8', + 'Etc/GMT-9', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belfast', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kiev', + 'Europe/Kirov', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Nicosia', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Tiraspol', + 'Europe/Ulyanovsk', + 'Europe/Uzhgorod', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zaporozhye', + 'Europe/Zurich', + 'GB', + 'GB-Eire', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'HST', + 'Hongkong', + 'Iceland', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Iran', + 'Israel', + 'Jamaica', + 'Japan', + 'Kwajalein', + 'Libya', + 'MET', + 'MST', + 'MST7MDT', + 'Mexico/BajaNorte', + 'Mexico/BajaSur', + 'Mexico/General', + 'NZ', + 'NZ-CHAT', + 'Navajo', + 'PRC', + 'PST8PDT', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Enderbury', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Ponape', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Samoa', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Truk', + 'Pacific/Wake', + 'Pacific/Wallis', + 'Pacific/Yap', + 'Poland', + 'Portugal', + 'ROC', + 'ROK', + 'Singapore', + 'Turkey', + 'UCT', + 'US/Alaska', + 'US/Aleutian', + 'US/Arizona', + 'US/Central', + 'US/East-Indiana', + 'US/Eastern', + 'US/Hawaii', + 'US/Indiana-Starke', + 'US/Michigan', + 'US/Mountain', + 'US/Pacific', + 'US/Pacific-New', + 'US/Samoa', + 'UTC', + 'Universal', + 'W-SU', + 'WET', + 'Zulu', +] as const; + +export type Timezone = typeof tzs[number]; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index bbee6eac..7af6f17c 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { Tooltip } from '@grafana/ui'; +import dayjs from 'dayjs'; import { pick } from 'lodash-es'; +import { Timezone } from 'models/timezone/timezone.types'; + import { User, UserRole } from './user.types'; export const getIconType = (role: UserRole) => { @@ -31,6 +34,10 @@ export const getRole = (role: UserRole) => { } }; +export const getTimezone = (user: User) => { + return user.timezone || 'UTC'; +}; + export const getUserNotificationsSummary = (user: User) => { if (!user) { return null; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 2a388913..4bf99254 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -1,14 +1,17 @@ +import dayjs from 'dayjs'; import { get } from 'lodash-es'; import { action, computed, observable } from 'mobx'; +import moment from 'moment-timezone'; import BaseStore from 'models/base_store'; import { NotificationPolicyType } from 'models/notification_policy'; +import { getRandomTimezone } from 'models/timezone/timezone.helpers'; import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { move } from 'state/helpers'; -import { prepareForUpdate } from './user.helpers'; +import { getTimezone, prepareForUpdate } from './user.helpers'; import { User } from './user.types'; export class UserStore extends BaseStore { @@ -49,14 +52,20 @@ export class UserStore extends BaseStore { @action async loadCurrentUser() { - const user = await makeRequest('/user/', {}); + const response = await makeRequest('/user/', {}); + + let timezone; + if (!response.timezone) { + timezone = dayjs.tz.guess(); + this.update(response.pk, { timezone }); + } this.items = { ...this.items, - [user.pk]: user, + [response.pk]: { ...response, timezone: timezone || getTimezone(response) }, }; - this.currentUserPk = user.pk; + this.currentUserPk = response.pk; } @action @@ -65,7 +74,7 @@ export class UserStore extends BaseStore { this.items = { ...this.items, - [user.pk]: user, + [user.pk]: { ...user, timezone: getTimezone(user) }, }; } @@ -97,7 +106,10 @@ export class UserStore extends BaseStore { ...results.reduce( (acc: { [key: number]: User }, item: User) => ({ ...acc, - [item.pk]: item, + [item.pk]: { + ...item, + timezone: getTimezone(item), + }, }), {} ), diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 65f5efce..4d48e3d8 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -1,4 +1,5 @@ import { Team } from 'models/team/team.types'; +import { Timezone } from 'models/timezone/timezone.types'; import { UserAction } from 'state/userAction'; export enum UserRole { @@ -55,4 +56,6 @@ export interface User { link?: string; cloud_connection_status?: number; hidden_fields?: boolean; + timezone: Timezone; + working_hours: { [key: string]: [] }; } diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index 5df8fde2..15561d51 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -13,7 +13,9 @@ import MaintenancePage2 from 'pages/maintenance/Maintenance'; import MigrationTool from 'pages/migration-tool/MigrationTool'; import OrganizationLogPage2 from 'pages/organization-logs/OrganizationLog'; import OutgoingWebhooks2 from 'pages/outgoing_webhooks/OutgoingWebhooks'; +import SchedulePage from 'pages/schedule/Schedule'; import SchedulesPage2 from 'pages/schedules/Schedules'; +import SchedulesPage from 'pages/schedules_NEW/Schedules'; import SettingsPage2 from 'pages/settings/SettingsPage'; import Test from 'pages/test/Test'; import UsersPage2 from 'pages/users/Users'; @@ -65,6 +67,19 @@ export const pages: PageDefinition[] = [ id: 'schedules', text: 'Schedules', }, + { + component: SchedulesPage, + icon: 'calendar-alt', + id: 'schedules-new', + text: 'Schedules α', + }, + { + component: SchedulePage, + icon: 'calendar-alt', + id: 'schedule', + text: 'Schedule', + hideFromTabs: true, + }, { component: ChatOpsPage, icon: 'comments-alt', diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts new file mode 100644 index 00000000..3779a86f --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -0,0 +1,694 @@ +import { dateTime, DateTime } from '@grafana/data'; +import dayjs from 'dayjs'; +import { subtract } from 'lodash-es'; + +import { Timezone } from 'models/timezone/timezone.types'; + +const tzs = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Atka', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Buenos_Aires', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Catamarca', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Ensenada', + 'America/Fort_Nelson', + 'America/Fort_Wayne', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Godthab', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Jujuy', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Knox_IN', + 'America/Kralendijk', + 'America/La_Paz', + 'America/Lima', + 'America/Los_Angeles', + 'America/Louisville', + 'America/Lower_Princes', + 'America/Maceio', + 'America/Managua', + 'America/Manaus', + 'America/Marigot', + 'America/Martinique', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Mendoza', + 'America/Menominee', + 'America/Merida', + 'America/Metlakatla', + 'America/Mexico_City', + 'America/Miquelon', + 'America/Moncton', + 'America/Monterrey', + 'America/Montevideo', + 'America/Montreal', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Ojinaga', + 'America/Panama', + 'America/Pangnirtung', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Acre', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Rosario', + 'America/Santa_Isabel', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Shiprock', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Thunder_Bay', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Virgin', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'America/Yellowknife', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/South_Pole', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Ashkhabad', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Calcutta', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Colombo', + 'Asia/Dacca', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Harbin', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Istanbul', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kashgar', + 'Asia/Kathmandu', + 'Asia/Katmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macao', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qyzylorda', + 'Asia/Rangoon', + 'Asia/Riyadh', + 'Asia/Saigon', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Tel_Aviv', + 'Asia/Thimbu', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ujung_Pandang', + 'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', + 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/ACT', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Canberra', + 'Australia/Currie', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/LHI', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/NSW', + 'Australia/North', + 'Australia/Perth', + 'Australia/Queensland', + 'Australia/South', + 'Australia/Sydney', + 'Australia/Tasmania', + 'Australia/Victoria', + 'Australia/West', + 'Australia/Yancowinna', + 'Brazil/Acre', + 'Brazil/DeNoronha', + 'Brazil/East', + 'Brazil/West', + 'CET', + 'CST6CDT', + 'Canada/Atlantic', + 'Canada/Central', + 'Canada/Eastern', + 'Canada/Mountain', + 'Canada/Newfoundland', + 'Canada/Pacific', + 'Canada/Saskatchewan', + 'Canada/Yukon', + 'Chile/Continental', + 'Chile/EasterIsland', + 'Cuba', + 'EET', + 'EST', + 'EST5EDT', + 'Egypt', + 'Eire', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT+1', + 'Etc/GMT+10', + 'Etc/GMT+11', + 'Etc/GMT+12', + 'Etc/GMT+2', + 'Etc/GMT+3', + 'Etc/GMT+4', + 'Etc/GMT+5', + 'Etc/GMT+6', + 'Etc/GMT+7', + 'Etc/GMT+8', + 'Etc/GMT+9', + 'Etc/GMT-0', + 'Etc/GMT-1', + 'Etc/GMT-10', + 'Etc/GMT-11', + 'Etc/GMT-12', + 'Etc/GMT-13', + 'Etc/GMT-14', + 'Etc/GMT-2', + 'Etc/GMT-3', + 'Etc/GMT-4', + 'Etc/GMT-5', + 'Etc/GMT-6', + 'Etc/GMT-7', + 'Etc/GMT-8', + 'Etc/GMT-9', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belfast', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kiev', + 'Europe/Kirov', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Nicosia', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Tiraspol', + 'Europe/Ulyanovsk', + 'Europe/Uzhgorod', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zaporozhye', + 'Europe/Zurich', + 'GB', + 'GB-Eire', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'HST', + 'Hongkong', + 'Iceland', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Iran', + 'Israel', + 'Jamaica', + 'Japan', + 'Kwajalein', + 'Libya', + 'MET', + 'MST', + 'MST7MDT', + 'Mexico/BajaNorte', + 'Mexico/BajaSur', + 'Mexico/General', + 'NZ', + 'NZ-CHAT', + 'Navajo', + 'PRC', + 'PST8PDT', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Enderbury', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Ponape', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Samoa', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Truk', + 'Pacific/Wake', + 'Pacific/Wallis', + 'Pacific/Yap', + 'Poland', + 'Portugal', + 'ROC', + 'ROK', + 'Singapore', + 'Turkey', + 'UCT', + 'US/Alaska', + 'US/Aleutian', + 'US/Arizona', + 'US/Central', + 'US/East-Indiana', + 'US/Eastern', + 'US/Hawaii', + 'US/Indiana-Starke', + 'US/Michigan', + 'US/Mountain', + 'US/Pacific', + 'US/Pacific-New', + 'US/Samoa', + 'UTC', + 'Universal', + 'W-SU', + 'WET', + 'Zulu', +]; + +const USERS = [ + 'Innokentii Konstantinov', + 'Ildar Iskhakov', + 'Matias Bordese', + 'Michael Derynck', + 'Vadim Stepanov', + 'Matvey Kukuy', + 'Yulya Artyukhina', + 'Raphael Batyrbaev', +]; + +function getRandomUser() { + return USERS[Math.floor(Math.random() * USERS.length)]; +} + +export const getRandomTimezone = () => { + return tzs[Math.floor(Math.random() * tzs.length)]; +}; + +export const getRandomUsers = (count = 7) => { + const users = []; + for (let i = 0; i < count; i++) { + users.push({ + //name: getRandomUser(), + pk: i, + name: [ + 'Some UTC user', + 'Matias Bordese', + 'Michael Derynck', + 'Yulia Shanyrova', + 'Maxim Mordasov', + 'Vadim Stepanov', + 'Ildar Iskhakov', + /* 'Matvey Kukuy',*/ + ][i], + //avatar: `https://i.pravatar.cc/32?rnd=${Math.random()}`, + avatar: [ + 'https://image.shutterstock.com/image-vector/male-avatar-icon-simple-man-600w-1504887869.jpg', + 'https://avatars.githubusercontent.com/u/260710?v=4', + 'https://avatars.githubusercontent.com/u/28077050?s=60&v=4', + 'https://avatars.githubusercontent.com/u/20494436?v=4', + 'https://avatars.githubusercontent.com/u/3278022?v=4', + 'https://avatars.githubusercontent.com/u/20116910?s=60&v=4', + 'https://avatars.githubusercontent.com/u/2262529?v=4', + ][i], + //tz: getRandomTimezone(), + tz: [ + 'UTC', + 'America/Montevideo', + 'America/Vancouver', + 'Europe/Amsterdam', + 'Europe/Moscow', + 'Europe/London', + 'Asia/Yerevan', + /*'Asia/Tel_Aviv',*/ + ][i], + }); + } + + return users; +}; + +export const getStartOfWeek = (tz: Timezone) => { + return dayjs().tz(tz).utcOffset() === 0 ? dayjs().utc().startOf('isoWeek') : dayjs().tz(tz).startOf('isoWeek'); +}; + +export const getUTCString = (moment: dayjs.Dayjs | DateTime, timezone: Timezone) => { + const browserTimezone = dayjs.tz.guess(); + + const browserTimezoneOffset = dayjs().tz(browserTimezone).utcOffset(); + const timezoneOffset = dayjs().tz(timezone).utcOffset(); + + return moment + .clone() + .utc() + .add(browserTimezoneOffset, 'minutes') // we need these calculations because we can't specify timezone for DateTimePicker directly + .subtract(timezoneOffset, 'minutes') + .format('YYYY-MM-DDTHH:mm:ss.000Z'); +}; + +export const getDateTime = (date: string, timezone: Timezone) => { + const browserTimezone = dayjs.tz.guess(); + + const browserTimezoneOffset = dayjs().tz(browserTimezone).utcOffset(); + const timezoneOffset = dayjs().tz(timezone).utcOffset(); + + return dateTime( + dayjs(date) + .subtract(browserTimezoneOffset, 'minutes') + .add(timezoneOffset, 'minutes') + .format('YYYY-MM-DDTHH:mm:ss.000Z') + ); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css new file mode 100644 index 00000000..2e5746bd --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -0,0 +1,35 @@ + +.root { + max-width: 1600px; + margin: 0 auto; + margin-top: 24px; + + --rotations-border: var(--border-medium); + --rotations-background: var(--primary-background); +} + +.header { + position: sticky; /* TODO check */ + width: 100%; +} + +.desc { + width: 736px; +} + +.users-timezones { + width: 100%; + margin-bottom: 16px; +} + +.controls { + width: 100%; +} + +.rotations { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + width: 100%; +} diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx new file mode 100644 index 00000000..0cdbc4da --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -0,0 +1,356 @@ +import React, { useMemo } from 'react'; + +import { AppRootProps } from '@grafana/data'; +import { getLocationSrv } from '@grafana/runtime'; +import { Button, HorizontalGroup, VerticalGroup, RadioButtonGroup, IconButton, ToolbarButton, Icon } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; +import Draggable from 'react-draggable'; + +// import Rotations from 'components/Rotations/Rotations'; +import PluginLink from 'components/PluginLink/PluginLink'; +import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; +import ScheduleQuality from 'components/ScheduleQuality/ScheduleQuality'; +import Text from 'components/Text/Text'; +// import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; +import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import Rotations from 'containers/Rotations/Rotations'; +import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; +import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides'; +import UsersTimezones from 'containers/UsersTimezones/UsersTimezones'; +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import { getRandomUsers, getStartOfWeek, getUTCString } from './Schedule.helpers'; + +import styles from './Schedule.module.css'; + +const cx = cn.bind(styles); + +interface SchedulePageProps extends AppRootProps, WithStoreProps {} + +interface SchedulePageState { + startMoment: dayjs.Dayjs; + schedulePeriodType: string; + renderType: string; +} + +const INITIAL_TIMEZONE = 'UTC'; // todo check why doesn't work + +@observer +class SchedulePage extends React.Component { + constructor(props: SchedulePageProps) { + super(props); + + const { store } = this.props; + this.state = { + startMoment: getStartOfWeek(store.currentTimezone), + schedulePeriodType: 'week', + renderType: 'timeline', + }; + } + + async componentDidMount() { + const { store } = this.props; + const { startMoment } = this.state; + + /*if (!store.hasFeature(AppFeature.WebSchedules)) { + getLocationSrv().update({ query: { page: 'schedules' } }); + }*/ + + store.userStore.updateItems(); + + const { + query: { id }, + } = this.props; + + store.scheduleStore.updateFrequencyOptions(); + store.scheduleStore.updateDaysOptions(); + await store.scheduleStore.updateOncallShifts(id); // TODO we should know shifts to render Rotations + + this.updateEvents(); + } + + render() { + const { store } = this.props; + const { startMoment, schedulePeriodType, renderType } = this.state; + const { query } = this.props; + const { id: scheduleId } = query; + + const users = store.userStore.getSearchResult().results; + + const { scheduleStore, currentTimezone } = store; + + const schedule = scheduleStore.items[scheduleId]; + + return ( +
    + +
    + + + + + + + {schedule?.name} + + {/* + Grafana 1 +
    + Grafana 2 +
    + Grafana 3 + + } + /> + */} +
    + + {users && ( + + )} + {/* + + + + */} + + + + +
    +
    + + On-call Schedules. Use this to distribute notifications among team members you specified in the "Notify + Users from on-call schedule" step in escalation chains. + +
    + +
    +
    + + + + + + + +
    + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} +
    +
    + {/* + + + */} +
    +
    + {/*
    */} +
    + + + +
    + +
    + ); + } + + handleNameChange = (value: string) => { + const { store, query } = this.props; + const { id: scheduleId } = query; + + const schedule = store.scheduleStore.items[scheduleId]; + + store.scheduleStore + .update(scheduleId, { type: schedule.type, name: value }) + .then(() => store.scheduleStore.updateItem(scheduleId)); + }; + + updateEvents = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + const { startMoment } = this.state; + + store.scheduleStore.updateItem(scheduleId); // to refresh current oncall users + store.scheduleStore.updateRelatedUsers(scheduleId); // to refresh related users + + return Promise.all([ + store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'), + ]); + }; + + handleCreateRotation = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleCreateOverride = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleUpdateRotation = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleDeleteRotation = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleDeleteOverride = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleUpdateOverride = () => { + const { store } = this.props; + + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); + }; + + handleTimezoneChange = (value: Timezone) => { + const { store } = this.props; + + const oldTimezone = store.currentTimezone; + + this.setState((oldState) => { + const wDiff = oldState.startMoment.diff(getStartOfWeek(oldTimezone), 'weeks'); + + return { ...oldState, startMoment: getStartOfWeek(value).add(wDiff, 'weeks') }; + }, this.updateEvents); + + store.currentTimezone = value; + }; + + handleShedulePeriodTypeChange = (value: string) => { + this.setState({ schedulePeriodType: value }); + }; + + handleRenderTypeChange = (value: string) => { + this.setState({ renderType: value }); + }; + + handleTodayClick = () => { + const { store } = this.props; + + this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }, this.updateEvents); + }; + + handleLeftClick = () => { + const { startMoment } = this.state; + + this.setState({ startMoment: startMoment.add(-7, 'day') }, this.updateEvents); + }; + + handleDelete = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + store.scheduleStore.delete(scheduleId).then(() => { + getLocationSrv().update({ query: { page: 'schedules-new' } }); + }); + }; + + handleRightClick = () => { + const { startMoment } = this.state; + + this.setState({ startMoment: startMoment.add(7, 'day') }, this.updateEvents); + }; +} + +export default withMobXProviderContext(SchedulePage); diff --git a/grafana-plugin/src/pages/schedules/Schedules.module.css b/grafana-plugin/src/pages/schedules/Schedules.module.css index cc0cc165..102be56c 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules/Schedules.module.css @@ -53,12 +53,12 @@ text-align: center; font-size: 14px; font-weight: 500; + flex-shrink: 0; } .gap-between-shifts { width: 520px; - height: 32px; - padding: 4px 4px 24px 4px; + padding: 5px 5px 5px 24px; background-color: rgba(209, 14, 92, 0.15); border: 1px solid rgba(209, 14, 92, 0.15); border-radius: 50px; @@ -66,7 +66,3 @@ font-weight: 400; align-items: baseline; } - -.gap-between-shifts-icon { - margin-left: 24px; -} diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index d538a81d..9b2e5e3a 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -5,11 +5,10 @@ import { getLocationSrv } from '@grafana/runtime'; import { Button, ConfirmModal, - Modal, - DatePickerWithInput, HorizontalGroup, Icon, LoadingPlaceholder, + Modal, PENDING_COLOR, Tooltip, VerticalGroup, @@ -17,7 +16,7 @@ import { import cn from 'classnames/bind'; import { omit } from 'lodash-es'; import { observer } from 'mobx-react'; -import moment, { Moment } from 'moment-timezone'; +import moment from 'moment-timezone'; import instructionsImage from 'assets/img/events_instructions.png'; import Avatar from 'components/Avatar/Avatar'; @@ -28,12 +27,10 @@ import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilte import Text from 'components/Text/Text'; import Tutorial from 'components/Tutorial/Tutorial'; import { TutorialStep } from 'components/Tutorial/Tutorial.types'; -import GSelect from 'containers/GSelect/GSelect'; import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { Schedule, ScheduleEvent } from 'models/schedule/schedule.types'; -import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; +import { Schedule, ScheduleEvent, ScheduleType } from 'models/schedule/schedule.types'; import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers'; import { WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; @@ -217,6 +214,7 @@ class SchedulesPage extends React.Component { this.setState({ scheduleIdToEdit: undefined }); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.helpers.ts b/grafana-plugin/src/pages/schedules_NEW/Schedules.helpers.ts new file mode 100644 index 00000000..bedc4023 --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.helpers.ts @@ -0,0 +1,42 @@ +import dayjs from 'dayjs'; + +import { getColor, getOverrideColor, getRandomUser } from 'components/Rotations/Rotations.helpers'; +import { getRandomUsers } from 'pages/schedule/Schedule.helpers'; + +export const getRandomSchedules = () => { + const schedules = []; + for (let i = 0; i < 20; i++) { + schedules.push({ + id: i + 1, + name: `Schedule Team ${i + 1}`, + users: getRandomUsers(2), + chatOps: '#irm-incidents-primary', + quality: 20 + Math.floor(Math.random() * 80), + }); + } + + return schedules; +}; + +export const getRandomTimeslots = (count = 6) => { + const slots = []; + for (let i = 0; i < count; i++) { + const start = dayjs() + .startOf('day') + .add(i * 4, 'hour'); + const end = dayjs() + .startOf('day') + .add(i * 4 + 2, 'hour'); + //const inactive = end.isBefore(dayjs()); + const inactive = false; + + slots.push({ + start, + end, + inactive, + users: [getRandomUser()], + color: getOverrideColor(i), + }); + } + return slots; +}; diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css new file mode 100644 index 00000000..4857e7cc --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -0,0 +1,22 @@ +.root { + margin-top: 24px; +} + +.quality__type_success { + color: var(--warning-text-color); +} + +.schedule { + position: relative; + margin: 20px 0; +} + +.loader { + padding-left: 20px; +} + +/* +.root .expanded-row { + background: var(--secondary-background); +} +*/ diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx new file mode 100644 index 00000000..f9a76c89 --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -0,0 +1,376 @@ +import React from 'react'; + +import { getLocationSrv } from '@grafana/runtime'; +import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { debounce } from 'lodash-es'; +import { observer } from 'mobx-react'; + +import Avatar from 'components/Avatar/Avatar'; +import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector'; +import PluginLink from 'components/PluginLink/PluginLink'; +import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; +import SchedulesFilters from 'components/SchedulesFilters_NEW/SchedulesFilters'; +import { SchedulesFiltersType } from 'components/SchedulesFilters_NEW/SchedulesFilters.types'; +import Table from 'components/Table/Table'; +import Text from 'components/Text/Text'; +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; +import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; +import { getFromString } from 'models/schedule/schedule.helpers'; +import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; +import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; +import { AppFeature } from 'state/features'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './Schedules.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesPageProps extends WithStoreProps {} + +interface SchedulesPageState { + startMoment: dayjs.Dayjs; + filters: SchedulesFiltersType; + showNewScheduleSelector: boolean; + expandedRowKeys: Array; +} + +@observer +class SchedulesPage extends React.Component { + constructor(props: SchedulesPageProps) { + super(props); + + const { store } = this.props; + this.state = { + startMoment: getStartOfWeek(store.currentTimezone), + filters: { searchTerm: '', status: 'all', type: ScheduleType.API }, + showNewScheduleSelector: false, + expandedRowKeys: [], + }; + } + + async componentDidMount() { + const { store } = this.props; + + /* if (!store.hasFeature(AppFeature.WebSchedules)) { + getLocationSrv().update({ query: { page: 'schedules' } }); + } */ + + store.userStore.updateItems(); + store.scheduleStore.updateItems(); + } + + render() { + const { store } = this.props; + const { filters, showNewScheduleSelector, expandedRowKeys } = this.state; + + const { scheduleStore } = store; + + const schedules = scheduleStore.getSearchResult(/*filters.searchTerm*/); + const columns = [ + { + width: '10%', + title: 'Status', + key: 'name', + render: this.renderStatus, + }, + { + width: '40%', + title: 'Name', + key: 'name', + render: this.renderName, + }, + { + width: '45%', + title: 'Oncall', + key: 'users', + render: this.renderOncallNow, + }, + /* { + width: '20%', + title: 'ChatOps', + key: 'chatops', + render: this.renderChatOps, + },*/ + /*{ + width: '10%', + title: 'Quality', + key: 'quality', + render: this.renderQuality, + },*/ + { + width: '5%', + key: 'buttons', + render: this.renderButtons, + }, + ]; + + const users = store.userStore.getSearchResult().results; + + const data = schedules + ? schedules + .filter((schedule) => schedule.type === ScheduleType.API) + .filter( + (schedule) => + filters.status === 'all' || + (filters.status === 'used' && schedule.number_of_escalation_chains) || + (filters.status === 'unused' && !schedule.number_of_escalation_chains) + ) + .filter((schedule) => !filters.searchTerm || schedule.name.includes(filters.searchTerm)) + : undefined; + + return ( + <> +
    + + + + + {users && ( + + )} + + + +
    cx('expanded-row'), + }} + emptyText={ +
    + {data ? Not found : Loading schedules...} +
    + } + /> + + + {showNewScheduleSelector && ( + { + this.setState({ showNewScheduleSelector: false }); + }} + /> + )} + + ); + } + + handleTimezoneChange = (value: Timezone) => { + const { store } = this.props; + + store.currentTimezone = value; + + this.setState({ startMoment: getStartOfWeek(value) }, this.updateEvents); + }; + + handleCreateScheduleClick = () => { + this.setState({ showNewScheduleSelector: true }); + }; + + handleCreateSchedule = (data: Schedule) => { + const { store } = this.props; + + if (data.type === ScheduleType.API) { + getLocationSrv().update({ query: { page: 'schedule', id: data.id } }); + } + }; + + handleExpandRow = (expanded: boolean, data: Schedule) => { + const { store } = this.props; + const { expandedRowKeys } = this.state; + const { startMoment } = this.state; + if (expanded && !expandedRowKeys.includes(data.id)) { + this.setState({ expandedRowKeys: [...this.state.expandedRowKeys, data.id] }, this.updateEvents); + } else if (!expanded && expandedRowKeys.includes(data.id)) { + const index = expandedRowKeys.indexOf(data.id); + const newExpandedRowKeys = [...expandedRowKeys]; + newExpandedRowKeys.splice(index, 1); + this.setState({ expandedRowKeys: newExpandedRowKeys }, this.updateEvents); + } + }; + + updateEvents = () => { + const { store } = this.props; + const { expandedRowKeys, startMoment } = this.state; + + expandedRowKeys.forEach((scheduleId) => { + store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'); + }); + }; + + renderSchedule = (data: Schedule) => { + const { startMoment } = this.state; + const { store } = this.props; + + return ( +
    + +
    + +
    +
    + ); + }; + + renderStatus = (item: Schedule) => { + const { + store: { scheduleStore }, + } = this.props; + + const relatedEscalationChains = scheduleStore.relatedEscalationChains[item.id]; + + return ( + + + {relatedEscalationChains ? ( + relatedEscalationChains.length ? ( + relatedEscalationChains.map((escalationChain) => ( + + {escalationChain.name} + + )) + ) : ( + 'Not used yet' + ) + ) : ( + Loading related escalation chains.... + )} + + } + onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)} + /> + {/* */} + + ); + }; + + renderName = (item: Schedule) => { + return {item.name}; + }; + + renderOncallNow = (item: Schedule, index: number) => { + if (item.on_call_now?.length > 0) { + return ( + + {item.on_call_now.map((user, index) => { + return ( + +
    + + {user.username} +
    +
    + ); + })} +
    + ); + } + return null; + }; + + renderChatOps = (item: Schedule) => { + return item.chatOps; + }; + + renderQuality = (item: Schedule) => { + const type = item.quality > 70 ? 'primary' : 'warning'; + + return {item.quality || 70}%; + }; + + renderButtons = (item: Schedule) => { + return ( + + {/* + + */} + + + + + ); + }; + + getDeleteScheduleClickHandler = (id: Schedule['id']) => { + const { store } = this.props; + const { scheduleStore } = store; + + return () => { + scheduleStore.delete(id).then(this.update); + }; + }; + + handleSchedulesFiltersChange = (filters: SchedulesFiltersType) => { + this.setState({ filters }, this.debouncedUpdateSchedules); + }; + + applyFilters = () => { + const { filters } = this.state; + const { store } = this.props; + const { scheduleStore } = store; + + // scheduleStore.updateItems(filters.searchTerm); + }; + + debouncedUpdateSchedules = debounce(this.applyFilters, 1000); + + handlePageChange = (page: number) => {}; + + update = () => { + const { store } = this.props; + const { scheduleStore } = store; + + return scheduleStore.updateItems(); + }; + + getUpdateRelatedEscalationChainsHandler = (scheduleId: Schedule['id']) => { + const { store } = this.props; + const { scheduleStore } = store; + + return () => { + scheduleStore.updateRelatedEscalationChains(scheduleId).then(() => { + this.forceUpdate(); + }); + }; + }; +} + +export default withMobXProviderContext(SchedulesPage); diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index bf915f19..856d26d0 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -5,4 +5,5 @@ export enum AppFeature { MobileApp = 'mobile_app', CloudNotifications = 'grafana_cloud_notifications', CloudConnection = 'grafana_cloud_connection', + WebSchedules = 'web_schedules', } diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index 3746d9eb..e68a701b 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -24,6 +24,7 @@ import { SlackStore } from 'models/slack/slack'; import { SlackChannelStore } from 'models/slack_channel/slack_channel'; import { TeamStore } from 'models/team/team'; import { TelegramChannelStore } from 'models/telegram_channel/telegram_channel'; +import { Timezone } from 'models/timezone/timezone.types'; import { UserStore } from 'models/user/user'; import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; @@ -38,6 +39,9 @@ export class RootBaseStore { @observable appLoading = true; + @observable + currentTimezone: Timezone = 'UTC'; + @observable backendVersion = ''; diff --git a/grafana-plugin/src/style/vars.css b/grafana-plugin/src/style/vars.css index eb261c2e..b640158c 100644 --- a/grafana-plugin/src/style/vars.css +++ b/grafana-plugin/src/style/vars.css @@ -28,6 +28,7 @@ --primary-text-link: #1f62e0; --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); + --oncall-icon-stroke-color: #fff; } .theme-dark { @@ -46,4 +47,10 @@ --primary-text-link: #6e9fff; --timeline-icon-background: rgba(70, 76, 84, 1); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 1); + --focused-box-shadow: rgb(17 18 23) 0 0 0 2px, rgb(61 113 217) 0 0 0 4px; + --border-medium: 1px solid rgba(204, 204, 220, 0.15); + --hover-selected: rgba(204, 204, 220, 0.12); + --hover-selected-hardcoded: #34363d; + --secondary-background-shade: rgba(204, 204, 220, 0.2); + --oncall-icon-stroke-color: #181b1f; } diff --git a/grafana-plugin/src/utils/DOM.ts b/grafana-plugin/src/utils/DOM.ts new file mode 100644 index 00000000..b1ebae8c --- /dev/null +++ b/grafana-plugin/src/utils/DOM.ts @@ -0,0 +1,38 @@ +export const waitForElement = (selector: string) => { + return new Promise((resolve) => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver((mutations) => { + if (document.querySelector(selector)) { + resolve(document.querySelector(selector)); + observer.disconnect(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +}; + +export const getCoords = (elem) => { + // crossbrowser version + var box = elem.getBoundingClientRect(); + + var body = document.body; + var docEl = document.documentElement; + + var scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; + var scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; + + var clientTop = docEl.clientTop || body.clientTop || 0; + var clientLeft = docEl.clientLeft || body.clientLeft || 0; + + var top = box.top + scrollTop - clientTop; + var left = box.left + scrollLeft - clientLeft; + + return { top: Math.round(top), left: Math.round(left) }; +}; diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index 93052831..7c4adc74 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useMemo } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { AppRootProps, NavModelItem } from '@grafana/data'; @@ -18,11 +17,12 @@ type Args = { }; enableLiveSettings: boolean; enableCloudPage: boolean; + enableNewSchedulesPage: boolean; backendLicense: string; }; export function useForceUpdate() { - const [value, setValue] = useState(0); + const [, setValue] = useState(0); return () => setValue((value) => value + 1); } @@ -34,6 +34,7 @@ export function useNavModel({ grafanaUser, enableLiveSettings, enableCloudPage, + enableNewSchedulesPage, backendLicense, }: Args) { return useMemo(() => { @@ -49,7 +50,8 @@ export function useNavModel({ hideFromTabs || (role === 'Admin' && grafanaUser.orgRole !== role) || (id === 'live-settings' && !enableLiveSettings) || - (id === 'cloud' && !enableCloudPage), + (id === 'cloud' && !enableCloudPage) || + (id === 'schedules-new' && !enableNewSchedulesPage), }); if (page === id) { @@ -74,7 +76,17 @@ export function useNavModel({ node, main: node, }; - }, [meta.info.logos.large, pages, path, page, enableLiveSettings, enableCloudPage]); + }, [ + meta.info.logos.large, + pages, + path, + page, + enableLiveSettings, + enableCloudPage, + backendLicense, + enableNewSchedulesPage, + grafanaUser.orgRole, + ]); } export function usePrevious(value: any) { diff --git a/grafana-plugin/tsconfig.json b/grafana-plugin/tsconfig.json index eb27527f..931d63e8 100644 --- a/grafana-plugin/tsconfig.json +++ b/grafana-plugin/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@grafana/toolkit/src/config/tsconfig.plugin.json", - "include": ["src", "frontend_enterprise/src"], + "include": ["src/dummy", "frontend_enterprise/src"], "types": ["node", "@emotion/core"], "compilerOptions": { "rootDirs": ["src", "frontend_enterprise/src"], @@ -9,6 +9,6 @@ "noUnusedLocals": false, "strict": false, "resolveJsonModule": true, - "noImplicitAny": false + "noImplicitAny": false, } } diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 6dda27d4..a4440abf 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1381,6 +1381,15 @@ esquery "^1.4.0" jsdoc-type-pratt-parser "~2.2.5" +"@es-joy/jsdoccomment@~0.31.0": + version "0.31.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz#dbc342cc38eb6878c12727985e693eaef34302bc" + integrity sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ== + dependencies: + comment-parser "1.3.1" + esquery "^1.4.0" + jsdoc-type-pratt-parser "~3.1.0" + "@eslint/eslintrc@^1.2.1", "@eslint/eslintrc@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" @@ -1396,6 +1405,21 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.4.0" + globals "^13.15.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + "@formatjs/ecma402-abstract@1.11.10": version "1.11.10" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.10.tgz#1b61909ce069d1fa62bafb163aaff59d524c094d" @@ -1714,6 +1738,15 @@ uplot "1.6.22" uuid "8.3.2" +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.4" + "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -1723,6 +1756,16 @@ debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -3012,7 +3055,7 @@ "@types/history" "*" "@types/react" "*" -"@types/react-transition-group@^4.4.0": +"@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA== @@ -3167,6 +3210,21 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/eslint-plugin@^5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" + integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/type-utils" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/parser@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.16.0.tgz#e4de1bde4b4dad5b6124d3da227347616ed55508" @@ -3185,6 +3243,14 @@ "@typescript-eslint/types" "5.16.0" "@typescript-eslint/visitor-keys" "5.16.0" +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + "@typescript-eslint/type-utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.16.0.tgz#b482bdde1d7d7c0c7080f7f2f67ea9580b9e0692" @@ -3194,11 +3260,26 @@ debug "^4.3.2" tsutils "^3.21.0" +"@typescript-eslint/type-utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" + integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + tsutils "^3.21.0" + "@typescript-eslint/types@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee" integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g== +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== + "@typescript-eslint/typescript-estree@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.16.0.tgz#32259459ec62f5feddca66adc695342f30101f61" @@ -3212,6 +3293,19 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.16.0.tgz#42218b459d6d66418a4eb199a382bdc261650679" @@ -3224,6 +3318,18 @@ eslint-scope "^5.1.1" eslint-utils "^3.0.0" +"@typescript-eslint/utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" + integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + "@typescript-eslint/visitor-keys@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.16.0.tgz#f27dc3b943e6317264c7492e390c6844cd4efbbb" @@ -3232,6 +3338,14 @@ "@typescript-eslint/types" "5.16.0" eslint-visitor-keys "^3.0.0" +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== + dependencies: + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -3620,6 +3734,11 @@ array-includes@^3.1.5: get-intrinsic "^1.1.1" is-string "^1.0.7" +array-move@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/array-move/-/array-move-4.0.0.tgz#2c3730f056cc926f62a59769a5a8cda2fb6d8c55" + integrity sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ== + array-slice@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz" @@ -3649,7 +3768,7 @@ array.prototype.flat@^1.2.5: define-properties "^1.1.3" es-abstract "^1.19.0" -array.prototype.flatmap@^1.2.5: +array.prototype.flatmap@^1.2.5, array.prototype.flatmap@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz#a7e8ed4225f4788a70cd910abcf0791e76a5534f" integrity sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg== @@ -5059,6 +5178,11 @@ date-fns@2.29.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.1.tgz#9667c2615525e552b5135a3116b95b1961456e60" integrity sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw== +dayjs@^1.11.5: + version "1.11.5" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93" + integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA== + debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -5609,12 +5733,25 @@ eslint-plugin-jsdoc@38.0.6: semver "^7.3.5" spdx-expression-parse "^3.0.1" +eslint-plugin-jsdoc@^39.3.6: + version "39.3.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz#6ba29f32368d72a51335a3dc9ccd22ad0437665d" + integrity sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g== + dependencies: + "@es-joy/jsdoccomment" "~0.31.0" + comment-parser "1.3.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.4.0" + semver "^7.3.7" + spdx-expression-parse "^3.0.1" + eslint-plugin-react-hooks@4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.3.0.tgz#318dbf312e06fab1c835a4abef00121751ac1172" integrity sha512-XslZy0LnMn+84NEG9jSGR6eGqaZB3133L8xewQo3fQagbQuGt7a63gf+P1NGKZavEYEC3UXaWEAA/AqDkuN6xA== -eslint-plugin-react-hooks@4.6.0: +eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== @@ -5639,6 +5776,26 @@ eslint-plugin-react@7.29.4: semver "^6.3.0" string.prototype.matchall "^4.0.6" +eslint-plugin-react@^7.31.7: + version "7.31.7" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.31.7.tgz#36fb1c611a7db5f757fce09cbbcc01682f8b0fbb" + integrity sha512-8NldBTeYp/kQoTV1uT0XF6HcmDqbgZ0lNPkN0wlRw8DJKXEnaWu+oh/6gt3xIhzvQ35wB2Y545fJhIbJSZ2NNw== + dependencies: + array-includes "^3.1.5" + array.prototype.flatmap "^1.3.0" + doctrine "^2.1.0" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.5" + object.fromentries "^2.0.5" + object.hasown "^1.1.1" + object.values "^1.1.5" + prop-types "^15.8.1" + resolve "^2.0.0-next.3" + semver "^6.3.0" + string.prototype.matchall "^4.0.7" + eslint-plugin-rulesdir@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.1.tgz" @@ -5759,6 +5916,51 @@ eslint@8.20.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== + dependencies: + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + functional-red-black-tree "^1.0.1" + glob-parent "^6.0.1" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.1" + regexpp "^3.2.0" + strip-ansi "^6.0.1" + strip-json-comments "^3.1.0" + text-table "^0.2.0" + espree@^9.3.1, espree@^9.3.2: version "9.3.3" resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" @@ -5768,6 +5970,15 @@ espree@^9.3.1, espree@^9.3.2: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -5854,7 +6065,7 @@ execall@^2.0.0: dependencies: clone-regexp "^2.1.0" -exenv@^1.2.2: +exenv@^1.2.0, exenv@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== @@ -6449,7 +6660,7 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.3, globby@^11.0.4: +globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -6489,6 +6700,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz" @@ -7868,6 +8084,11 @@ jsdoc-type-pratt-parser@~2.2.5: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-2.2.5.tgz#c9f93afac7ee4b5ed4432fe3f09f7d36b05ed0ff" integrity sha512-2a6eRxSxp1BW040hFvaJxhsCMI9lT8QB8t14t+NY5tC5rckIR0U9cr2tjOeaFirmEOy6MHvmJnY7zTBHq431Lw== +jsdoc-type-pratt-parser@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e" + integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw== + jsdom@^16.6.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -8898,7 +9119,7 @@ object.fromentries@^2.0.5: define-properties "^1.1.3" es-abstract "^1.19.1" -object.hasown@^1.1.0: +object.hasown@^1.1.0, object.hasown@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.1.tgz#ad1eecc60d03f49460600430d97f23882cf592a3" integrity sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A== @@ -9946,7 +10167,7 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@2.7.1: +prettier@2.7.1, prettier@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== @@ -10381,6 +10602,14 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-draggable@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.5.tgz#9e37fe7ce1a4cf843030f521a0a4cc41886d7e7c" + integrity sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g== + dependencies: + clsx "^1.1.1" + prop-types "^15.8.1" + react-dropzone@14.2.2: version "14.2.2" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.2.tgz#a75a0676055fe9e2cb78578df4dedb4c42b54f98" @@ -10455,11 +10684,21 @@ react-is@^17.0.1, react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-modal@^3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.15.1.tgz#950ce67bfef80971182dd0ed38f2d9b1a681288b" + integrity sha512-duB9bxOaYg7Zt6TMFldIFxQRtSP+Dg3F1ZX3FXxSUn+3tZZ/9JCgeAQKDg7rhZSAqopq8TFRw3yIbnx77gyFTw== + dependencies: + exenv "^1.2.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.0" + warning "^4.0.3" + react-popper-tooltip@^4.3.1: version "4.4.2" resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-4.4.2.tgz#0dc4894b8e00ba731f89bd2d30584f6032ec6163" @@ -10579,7 +10818,7 @@ react-transition-group@4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" -react-transition-group@^4.3.0, react-transition-group@^4.4.2: +react-transition-group@^4.3.0, react-transition-group@^4.4.2, react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== @@ -11580,7 +11819,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2 is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string.prototype.matchall@^4.0.6: +string.prototype.matchall@^4.0.6, string.prototype.matchall@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz#8e6ecb0d8a1fb1fda470d81acecb2dba057a481d" integrity sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg== @@ -12486,7 +12725,7 @@ walker@^1.0.7: dependencies: makeerror "1.0.12" -warning@^4.0.2: +warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==