Merge pull request #148 from grafana/new-schedules

Awesome on-call calendar editor
This commit is contained in:
maskin25 2022-09-09 13:38:12 +03:00 committed by GitHub
commit 2c8a34d692
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 7779 additions and 56 deletions

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

@ -13,13 +13,13 @@ interface AvatarProps {
const cx = cn.bind(styles);
const Avatar: FC<AvatarProps> = (props) => {
const { src, size, className } = props;
const { src, size, className, ...rest } = props;
if (!src) {
return null;
}
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} />;
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} {...rest} />;
};
export default Avatar;

View file

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

View file

@ -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<PropsWithChildren<ModalProps>> = (props) => {
const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props;
return (
<ReactModal
style={{
overlay: {},
content: {
width,
},
}}
isOpen={isOpen}
onAfterOpen={() => {}}
onRequestClose={onDismiss}
contentLabel={title}
className={cx('root')}
overlayClassName={cx('overlay')}
bodyOpenClassName={cx('body-open')}
contentElement={contentElement}
>
{children}
</ReactModal>
);
};
export default Modal;

View file

@ -0,0 +1,7 @@
.root {
display: block;
}
.block {
width: 100%;
}

View file

@ -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<NewScheduleSelectorProps> = (props) => {
const { onHide, onCreate, onUpdate } = props;
const [showScheduleForm, setShowScheduleForm] = useState<boolean>(false);
const [type, setType] = useState<ScheduleType | undefined>();
const getCreateScheduleClickHandler = useCallback((type: ScheduleType) => {
return () => {
setType(type);
setShowScheduleForm(true);
};
}, []);
return (
<>
<Drawer scrollableContent title="Create new schedule" onClose={onHide} closeOnMaskClick>
<div className={cx('content')}>
<VerticalGroup spacing="lg">
{/*<Text type="secondary">
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
</Text>*/}
<Block bordered withBackground className={cx('block')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<Icon name="calendar-alt" size="xl" />
<VerticalGroup spacing="none">
<Text type="primary" size="large">
Set up on-call rotation schedule
</Text>
<Text type="secondary">Configure rotations and shifts directly in Grafana On-Call</Text>
</VerticalGroup>
</HorizontalGroup>
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button variant="primary" icon="plus" onClick={getCreateScheduleClickHandler(ScheduleType.API)}>
Create
</Button>
</WithPermissionControl>
</HorizontalGroup>
</Block>
<Block bordered withBackground className={cx('block')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<Icon name="download-alt" size="xl" />
<VerticalGroup spacing="none">
<Text type="primary" size="large">
Import schedule from iCal Url
</Text>
<Text type="secondary">Import rotations and shifts from your calendar app</Text>
</VerticalGroup>
</HorizontalGroup>
<Button variant="secondary" icon="plus" onClick={getCreateScheduleClickHandler(ScheduleType.Ical)}>
Create
</Button>
</HorizontalGroup>
</Block>
<Block bordered withBackground className={cx('block')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<Icon name="cog" size="xl" />
<VerticalGroup spacing="none">
<Text type="primary" size="large">
Create schedule by API
</Text>
<Text type="secondary">Configure rotations and upload calendar by Terraform file</Text>
</VerticalGroup>
</HorizontalGroup>
<Button variant="secondary" icon="plus" onClick={getCreateScheduleClickHandler(ScheduleType.Calendar)}>
Create
</Button>
</HorizontalGroup>
</Block>
</VerticalGroup>
</div>
</Drawer>
{showScheduleForm && (
<ScheduleForm
id="new"
type={type}
onUpdate={() => {
onHide();
onUpdate();
}}
onCreate={onCreate}
onHide={() => {
setType(undefined);
setShowScheduleForm(false);
}}
/>
)}
</>
);
};
export default NewScheduleSelector;

View file

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

View file

@ -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<ScheduleCounterProps> = (props) => {
const { type, count, tooltipTitle, tooltipContent, onHover } = props;
return (
<Tooltip
placement="bottom-start"
interactive
content={
<div className={cx('tooltip', { [`tooltip__type_${type}`]: true })}>
<VerticalGroup>
<Text type={typeToColor[type]}>{tooltipTitle}</Text>
<Text type="secondary">{tooltipContent}</Text>
</VerticalGroup>
</div>
}
>
<div className={cx('root', { [`root__type_${type}`]: true })} onMouseEnter={onHover}>
<HorizontalGroup spacing="xs">
<Icon className={cx('icon', { [`icon__type_${type}`]: true })} name={typeToIcon[type]} />
<Text type={typeToColor[type]}>{count}</Text>
</HorizontalGroup>
</div>
</Tooltip>
);
};
export default ScheduleCounter;

View file

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

View file

@ -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<ScheduleQualityProps> = (props) => {
const { quality } = props;
return (
<Tooltip placement="bottom-end" interactive content={<SheduleQualityDetails quality={quality} />}>
<div className={cx('root')}>
<HorizontalGroup spacing="sm">
<Icon name="like" />
<Text type="secondary">Quality:</Text>
<Text type="primary">{Math.floor(quality * 100)}%</Text>
</HorizontalGroup>
</div>
</Tooltip>
);
};
interface ScheduleQualityDetailsProps {
quality: number;
}
const SheduleQualityDetails = (props: ScheduleQualityDetailsProps) => {
const { quality } = props;
const [expanded, setExpanded] = useState<boolean>(false);
const type = quality > 0.8 ? 'success' : 'warning';
const qualityPercent = quality * 100;
const handleExpandClick = useCallback(() => {
setExpanded((expanded) => !expanded);
}, []);
return (
<div className={cx('details')}>
<VerticalGroup>
<Text type="secondary">Schedule quality</Text>
<div className={cx('progress')}>
<div
style={{ width: `${qualityPercent}%` }}
className={cx('progress-filler', {
[`progress-filler__type_${type}`]: true,
})}
>
<div
className={cx('quality-text', {
[`quality-text__type_${type}`]: true,
})}
>
{qualityPercent}%
</div>{' '}
</div>
</div>
{type === 'success' && (
<Text type="primary">
You are doing a great job! <br />
Schedule is well balanced for all members.
</Text>
)}
{type === 'warning' && <Text type="primary">Your schedule has balance problems.</Text>}
<hr style={{ width: '100%' }} />
<VerticalGroup>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="sm">
<Icon name="info-circle" />
<Text type="secondary">Calculation methodology</Text>
</HorizontalGroup>
<IconButton name="angle-down" onClick={handleExpandClick} />
</HorizontalGroup>
{expanded && (
<Text type="secondary">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum purus egestas porta ultricies.
Sed quis maximus sem. Phasellus semper pulvinar sapien ac euismod.
</Text>
)}
</VerticalGroup>
</VerticalGroup>
</div>
);
};
export default ScheduleQuality;

View file

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

View file

@ -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<ScheduleUserDetailsProps> = (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 (
<div className={cx('root')}>
<VerticalGroup spacing="sm">
<HorizontalGroup justify="space-between">
<Avatar src={user.avatar} size="large" />
{/*<Button variant="secondary">
<HorizontalGroup spacing="sm">
<Icon name="bell" />
Push
</HorizontalGroup>
</Button>*/}
</HorizontalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">{user.username}</Text>
<Text type="secondary">
{`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} {userOffsetHoursStr}
</Text>
{/* <div
className={cx('oncall-badge', {
[`oncall-badge__type_${userStatus}`]: true,
})}
>
{userOncallStatusToText[userStatus]}
</div>
<HorizontalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">Next shift</Text>
<div className={cx('times')}>
<HorizontalGroup>
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">30 apr, 00:00</Text>
<Text type="secondary">30 apr, 23:59</Text>
</VerticalGroup>
</HorizontalGroup>
</div>
</VerticalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">Last shift</Text>
<div className={cx('times')}>
<HorizontalGroup>
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">30 apr, 00:00</Text>
<Text type="secondary">30 apr, 23:59</Text>
</VerticalGroup>
</HorizontalGroup>
</div>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
<hr style={{ width: '100%' }} />
<VerticalGroup spacing="sm">
<Text type="primary">Contacts</Text>
<HorizontalGroup spacing="sm">
<Icon className={cx('icon')} name="message" />
<Text type="link">mail@grafana.com</Text>
</HorizontalGroup>
<HorizontalGroup spacing="sm">
<Icon className={cx('icon')} name="slack" />
<Text type="link">@slackid</Text>
</HorizontalGroup>
<HorizontalGroup spacing="sm">
<Icon className={cx('icon')} name="phone" />
<Text type="secondary">+39 555 449 00 00</Text>
</HorizontalGroup>*/}
</VerticalGroup>
</VerticalGroup>
</div>
);
};
export default ScheduleUserDetails;

View file

@ -0,0 +1,5 @@
<svg width="14" height="34" viewBox="0 0 14 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.2207 13.436C6.93327 13.436 7.60465 13.2982 8.23486 13.0225C8.86507 12.7503 9.42008 12.3743 9.8999 11.8945C10.3797 11.4147 10.7557 10.8615 11.0278 10.2349C11.3 9.60465 11.436 8.93148 11.436 8.21533C11.436 7.50277 11.2982 6.83138 11.0225 6.20117C10.7503 5.57096 10.3743 5.01595 9.89453 4.53613C9.41471 4.05632 8.8597 3.68034 8.22949 3.4082C7.59928 3.13607 6.9279 3 6.21533 3C5.50277 3 4.83138 3.13607 4.20117 3.4082C3.57454 3.68034 3.01953 4.05632 2.53613 4.53613C2.05632 5.01595 1.68034 5.57096 1.4082 6.20117C1.13607 6.83138 1 7.50277 1 8.21533C1 8.93148 1.13607 9.60465 1.4082 10.2349C1.68392 10.8615 2.06169 11.4147 2.5415 11.8945C3.02132 12.3743 3.57454 12.7503 4.20117 13.0225C4.83138 13.2982 5.50456 13.436 6.2207 13.436ZM6.2207 12.48C5.62988 12.48 5.07666 12.369 4.56104 12.147C4.04541 11.9285 3.59245 11.6242 3.20215 11.2339C2.81185 10.8436 2.50749 10.3906 2.28906 9.875C2.07064 9.35938 1.96143 8.80615 1.96143 8.21533C1.96143 7.62451 2.07064 7.07129 2.28906 6.55566C2.50749 6.04004 2.81185 5.58708 3.20215 5.19678C3.59245 4.80648 4.04362 4.50212 4.55566 4.28369C5.07129 4.06527 5.62451 3.95605 6.21533 3.95605C6.80615 3.95605 7.35938 4.06527 7.875 4.28369C8.39062 4.50212 8.84359 4.80648 9.23389 5.19678C9.62419 5.58708 9.92855 6.04004 10.147 6.55566C10.369 7.07129 10.48 7.62451 10.48 8.21533C10.4836 8.80615 10.3743 9.35938 10.1523 9.875C9.93392 10.3906 9.62956 10.8436 9.23926 11.2339C8.85254 11.6242 8.39958 11.9285 7.88037 12.147C7.36475 12.369 6.81152 12.48 6.2207 12.48ZM4.98535 9.39697C5.28255 9.69417 5.59229 9.95736 5.91455 10.1865C6.2404 10.4121 6.56266 10.584 6.88135 10.7021C7.20361 10.8167 7.50798 10.8579 7.79443 10.8257C8.08089 10.7935 8.33512 10.6663 8.55713 10.4443C8.57503 10.43 8.59115 10.4139 8.60547 10.396C8.61979 10.3781 8.63411 10.3602 8.64844 10.3423C8.75944 10.2134 8.8221 10.0719 8.83643 9.91797C8.85433 9.764 8.78988 9.63151 8.64307 9.52051C8.56071 9.46322 8.47656 9.40413 8.39062 9.34326C8.30827 9.28239 8.21338 9.21615 8.10596 9.14453C8.00212 9.06934 7.88216 8.98519 7.74609 8.89209C7.5957 8.78825 7.4668 8.74528 7.35938 8.76318C7.25195 8.78109 7.14095 8.84554 7.02637 8.95654L6.82764 9.1499C6.79541 9.18213 6.75781 9.19824 6.71484 9.19824C6.67188 9.19466 6.63607 9.18213 6.60742 9.16064C6.5179 9.10693 6.40332 9.02637 6.26367 8.91895C6.1276 8.80794 5.98796 8.68083 5.84473 8.5376C5.70866 8.39795 5.58333 8.2583 5.46875 8.11865C5.35417 7.979 5.27181 7.86621 5.22168 7.78027C5.20378 7.75163 5.19124 7.71761 5.18408 7.67822C5.1805 7.63883 5.19661 7.60124 5.23242 7.56543L5.43115 7.35596C5.54215 7.23421 5.60661 7.12142 5.62451 7.01758C5.646 6.91374 5.60124 6.78841 5.49023 6.6416L4.86719 5.76074C4.79915 5.66048 4.72038 5.59782 4.63086 5.57275C4.54134 5.54411 4.44287 5.54948 4.33545 5.58887C4.23161 5.62467 4.12419 5.68376 4.01318 5.76611C3.99886 5.77327 3.98633 5.78223 3.97559 5.79297C3.96484 5.80371 3.9541 5.81445 3.94336 5.8252C3.72135 6.0472 3.59424 6.30322 3.56201 6.59326C3.52979 6.87972 3.57096 7.18408 3.68555 7.50635C3.80371 7.82503 3.97559 8.1473 4.20117 8.47314C4.42676 8.79541 4.68815 9.10335 4.98535 9.39697Z" fill="#CCCCDC"/>
<path d="M6.0332 13.0295L6.0332 24.543" stroke="#CCCCDC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 31.0005C7.65685 31.0005 9 29.6573 9 28.0005C9 26.3436 7.65685 25.0005 6 25.0005C4.34315 25.0005 3 26.3436 3 28.0005C3 29.6573 4.34315 31.0005 6 31.0005ZM6 32.0005C8.20914 32.0005 10 30.2096 10 28.0005C10 25.7913 8.20914 24.0005 6 24.0005C3.79086 24.0005 2 25.7913 2 28.0005C2 30.2096 3.79086 32.0005 6 32.0005Z" fill="#CCCCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

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

View file

@ -0,0 +1,4 @@
.root {
display: inline-flex;
align-items: center;
}

View file

@ -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<HTMLInputElement>) => {
onChange({ ...value, searchTerm: e.currentTarget.value });
},
[value]
);
const handleStatusChange = useCallback(
(status) => {
onChange({ ...value, status });
},
[value]
);
const handleTypeChange = useCallback(
(type) => {
onChange({ ...value, type });
},
[value]
);
return (
<div className={cx('root')}>
<HorizontalGroup spacing="lg">
<Field label="Search by name, user or object ID">
<Input
autoFocus
className={cx('search')}
prefix={<Icon name="search" />}
placeholder="Search..."
value={value.searchTerm}
onChange={onSearchTermChangeCallback}
/>
</Field>
<Field label="Status">
<RadioButtonGroup
options={[
{ label: 'All', value: 'all' },
{
label: 'Used in escalations',
value: 'used',
},
{ label: 'Unused', value: 'unused' },
]}
value={value.status}
onChange={handleStatusChange}
/>
</Field>
<Field label="Type">
<RadioButtonGroup
disabled
options={[
{ label: 'All', value: 'all' },
{
label: 'Web',
value: ScheduleType.API,
},
{
label: 'ICal',
value: ScheduleType.Ical,
},
{
label: 'API',
value: ScheduleType.Calendar,
},
]}
value={value.type}
onChange={handleTypeChange}
/>
</Field>
</HorizontalGroup>
</div>
);
};
export default SchedulesFilters;

View file

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

View file

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

View file

@ -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<RecordType = unknown> extends TableProps<RecordType> {
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> = (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 (
<div className={cx('expand-icon', { [`expand-icon__expanded`]: expanded })}>
<ExpandIcon />
</div>
);
};
}
return (
<VerticalGroup justify="flex-end">
<Table
rowKey={rowKey}
className={cx('root', className)}
columns={columns}
data={data}
expandable={expandable}
{...restProps}
/>
{pagination && (
<div className={cx('pagination')}>
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={numberOfPages} onNavigate={onNavigate} />
</div>
)}
</VerticalGroup>
);
};
export default GTable;

View file

@ -21,6 +21,7 @@ interface TextProps extends HTMLAttributes<HTMLElement> {
onTextChange?: (value: string) => void;
clearBeforeEdit?: boolean;
hidden?: boolean;
editModalTitle?: string;
}
interface TextType extends React.FC<TextProps> {
@ -47,6 +48,7 @@ const Text: TextType = (props) => {
onTextChange,
clearBeforeEdit = false,
hidden = false,
editModalTitle = 'New value',
} = props;
const [isEditMode, setIsEditMode] = useState<boolean>(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) => {
</CopyToClipboard>
)}
{isEditMode && (
<Modal onDismiss={handleCancelEdit} closeOnEscape isOpen title="New value">
<Modal onDismiss={handleCancelEdit} closeOnEscape isOpen title={editModalTitle}>
<VerticalGroup>
<Input
autoFocus

View file

@ -0,0 +1,62 @@
.root {
position: absolute;
display: flex;
z-index: 1;
width: 100%;
top: 0;
bottom: 0;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: rgba(204, 204, 220, 0.65);
pointer-events: none;
}
.weekday {
width: calc(100% / 7);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.weekday-title {
width: 100%;
text-align: center;
padding-top: 4px;
flex-grow: 1;
}
.weekday:not(:last-child) .weekday-title {
border-right: var(--border-medium);
}
.weekday-times {
width: 100%;
display: flex;
height: 20px;
align-items: center;
}
.weekday-time {
width: 50%;
}
.weekday-time-title {
display: inline-block;
transform: translate(-50%, 0);
}
.weekday-time-title__hidden {
visibility: hidden;
}
/*
for debug purposes only
*/
.debug-scale {
position: absolute;
top: -6px;
width: 100%;
right: 0;
}

View file

@ -0,0 +1,84 @@
import React, { FC, useMemo } from 'react';
import cn from 'classnames/bind';
import * as dayjs from 'dayjs';
import styles from './TimelineMarks.module.css';
interface TimelineMarksProps {
startMoment: dayjs.Dayjs;
debug?: boolean;
}
const cx = cn.bind(styles);
const TimelineMarks: FC<TimelineMarksProps> = (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 (
<div className={cx('root')}>
{debug && (
<svg version="1.1" width="100%" height="6px" xmlns="http://www.w3.org/2000/svg" className={cx('debug-scale')}>
{cuts.map((cut, index) => (
<line
x1={`${(index * 100) / (24 * 7)}%`}
strokeWidth={1}
y1="0"
x2={`${(index * 100) / (24 * 7)}%`}
y2="6px"
stroke="rgba(204, 204, 220, 0.65)"
/>
))}
</svg>
)}
{momentsToRender.map((m, i) => {
return (
<div key={i} className={cx('weekday')}>
<div className={cx('weekday-title')}>{m.moment.format('D MMM')}</div>
<div className={cx('weekday-times')}>
{m.moments.map((mm, j) => (
<div key={j} className={cx('weekday-time')}>
<div
className={cx('weekday-time-title', {
'weekday-time-title__hidden': i === 0 && j === 0,
})}
>
{mm.format('HH:mm')}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
export default TimelineMarks;

View file

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

View file

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

View file

@ -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<Array<User['pk']>>;
onChange: (value: Array<Array<User['pk']>>) => void;
isMultipleGroups: boolean;
getItemData: (id: string) => ItemData;
renderUser: (id: string) => React.ReactElement;
showError?: boolean;
}
const cx = cn.bind(styles);
const DragHandle = () => <IconButton name="draggabledots" />;
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) => (
<li className={cx('user')}>
{renderUser(item.item)}
<div className={cx('user-buttons')}>
<HorizontalGroup>
<IconButton className={cx('delete-icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
<SortableHandleHoc />
</HorizontalGroup>
</div>
</li>
);
return (
<div className={cx('root')}>
<VerticalGroup>
<SortableList
renderItem={renderItem}
axis="y"
lockAxis="y"
helperClass={cx('sortable-helper')}
items={items}
onSortEnd={onSortEnd}
handleAddGroup={handleAddUserGroup}
handleDeleteItem={handleDeleteUser}
isMultipleGroups={isMultipleGroups}
useDragHandle
/>
<GSelect
key={items.length} // to completely rerender when users length change
showSearch
allowClear
modelName="userStore"
displayField="username"
valueField="pk"
placeholder="Add user"
className={cx('select')}
value={null}
onChange={handleUserAdd}
getOptionLabel={({ label, value }: SelectableValue) => <UserTooltip id={value} />}
showError={showError}
/>
</VerticalGroup>
</div>
);
};
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 (
<ul className={cx('groups')}>
{items.map((item, index) =>
item.type === 'item' ? (
<SortableItem key={item.key} index={index}>
{renderItem(item, index)}
</SortableItem>
) : isMultipleGroups ? (
<SortableItem key={item.key} index={index}>
<li className={cx('separator')}>{item.data.name}</li>
</SortableItem>
) : null
)}
{isMultipleGroups && items[items.length - 1]?.type === 'item' && (
<SortableItem disabled key="New Group" index={items.length + 1}>
<li onClick={handleAddGroup} className={cx('separator', { separator__clickable: true })}>
Add user group +
</li>
</SortableItem>
)}
</ul>
);
}
);
export default UserGroups;

View file

@ -0,0 +1,6 @@
export interface Item {
key: string;
type: string;
data: any;
item?: string;
}

View file

@ -0,0 +1,3 @@
.root {
width: 300px;
}

View file

@ -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<UserTimezoneSelectProps> = (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 (
<div className={cx('root')}>
<Select value={selectValue} onChange={handleChange} width={100} placeholder="UTC Timezone" options={options} />
</div>
);
};
export default UserTimezoneSelect;

View file

@ -0,0 +1,9 @@
export const default_working_hours = {
friday: [{ end: '17:00:00', start: '09:00:00' }],
monday: [{ end: '17:00:00', start: '09:00:00' }],
sunday: [],
tuesday: [{ end: '17:00:00', start: '09:00:00' }],
saturday: [],
thursday: [{ end: '17:00:00', start: '09:00:00' }],
wednesday: [{ end: '17:00:00', start: '09:00:00' }],
};

View file

@ -0,0 +1,84 @@
import dayjs from 'dayjs';
export const getWorkingMoments = (startMoment, endMoment, workingHours, timezone) => {
const weekdays = dayjs.weekdays();
const momentToStartIteration = dayjs().tz(timezone).utcOffset() === 0 ? startMoment : startMoment.tz(timezone);
const dayOfWeekToStartIteration = momentToStartIteration.format('dddd');
const weekDaysToIterateChunk = [
dayOfWeekToStartIteration,
...weekdays.slice(weekdays.indexOf(dayOfWeekToStartIteration) + 1),
...weekdays.slice(0, weekdays.indexOf(dayOfWeekToStartIteration)),
];
const weeks = endMoment.diff(startMoment, 'weeks');
const weekDaysToIterate = [...weekDaysToIterateChunk];
for (let i = 0; i < weeks; i++) {
weekDaysToIterate.push(...weekDaysToIterateChunk);
}
const workingMoments = [];
for (const [i, weekday] of weekDaysToIterate.entries()) {
for (const range of workingHours[weekday.toLowerCase()]) {
const rangeStartData = range.start;
const rangeEndData = range.end;
const [start_HH, start_mm, start_ss] = rangeStartData.split(':');
const [end_HH, end_mm, end_ss] = rangeEndData.split(':');
const rangeStartMoment = dayjs(momentToStartIteration)
.add(i, 'day')
.set('hour', Number(start_HH))
.set('minute', Number(start_mm))
.set('second', Number(start_ss));
const rangeEndMoment = dayjs(momentToStartIteration)
.add(i, 'day')
.set('hour', Number(end_HH))
.set('minute', Number(end_mm))
.set('second', Number(end_ss));
if (rangeEndMoment.isSameOrBefore(startMoment)) {
continue;
} else if (rangeStartMoment.isSameOrAfter(endMoment)) {
continue;
}
if (
rangeStartMoment.isSameOrBefore(startMoment) &&
rangeEndMoment.isSameOrAfter(startMoment) &&
rangeEndMoment.isSameOrBefore(endMoment)
) {
workingMoments.push({ start: startMoment, end: rangeEndMoment });
} else if (
rangeEndMoment.isSameOrAfter(endMoment) &&
rangeStartMoment.isSameOrBefore(endMoment) &&
rangeStartMoment.isSameOrAfter(startMoment)
) {
workingMoments.push({ start: rangeStartMoment, end: endMoment });
} else {
workingMoments.push({ start: rangeStartMoment, end: rangeEndMoment });
}
}
}
return workingMoments;
};
export const getNonWorkingMoments = (startMoment, endMoment, workingHours) => {
const nonWorkingMoments = [{ start: startMoment, end: endMoment }];
let lastNonWorkingRange = nonWorkingMoments[0];
for (const [i, range] of workingHours.entries()) {
lastNonWorkingRange.end = range.start;
lastNonWorkingRange = { start: range.end, end: undefined };
nonWorkingMoments.push(lastNonWorkingRange);
}
lastNonWorkingRange.end = endMoment;
return nonWorkingMoments;
};

View file

@ -0,0 +1,3 @@
.root {
display: block;
}

View file

@ -0,0 +1,97 @@
import React, { FC, useMemo } from 'react';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData';
import { Timezone } from 'models/timezone/timezone.types';
import { default_working_hours } from './WorkingHours.config';
import { getNonWorkingMoments, getWorkingMoments } from './WorkingHours.helpers';
import styles from './WorkingHours.module.css';
import { start } from 'repl';
interface WorkingHoursProps {
timezone: Timezone;
workingHours: any;
startMoment: dayjs.Dayjs;
duration: number; // in seconds
className: string;
style?: React.CSSProperties;
}
const cx = cn.bind(styles);
const WorkingHours: FC<WorkingHoursProps> = (props) => {
const { timezone, workingHours, startMoment, duration, className, style } = props;
const endMoment = startMoment.add(duration, 'seconds');
const workingMoments = useMemo(
() => getWorkingMoments(startMoment, endMoment, workingHours, timezone),
[startMoment, endMoment, workingHours, timezone]
);
/*console.log(
workingMoments.map(({ start, end }) => `${start.diff(startMoment, 'hours')} - ${end.diff(startMoment, 'hours')}`)
);*/
const nonWorkingMoments = useMemo(
() => getNonWorkingMoments(startMoment, endMoment, workingMoments),
[startMoment, endMoment, workingMoments]
);
// console.log(startMoment, startMoment.toString());
/* console.log(
workingMoments.map(
(range) =>
`${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}`
)
); */
// console.log(workingHours);
/*console.log(
nonWorkingMoments.map(
(range) =>
`${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}`
)
);*/
return (
<svg
version="1.1"
width="100%"
height="28px"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<defs>
<pattern id="stripes" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.15)" strokeWidth="10" />
</pattern>
</defs>
{nonWorkingMoments.map((moment, index) => {
const start = moment.start.diff(startMoment, 'seconds');
const diff = moment.end.diff(moment.start, 'seconds');
return (
<rect
className={cx('stripes')}
key={index}
x={`${(start * 100) / duration}%`}
y={0}
width={`${(diff * 100) / duration}%`}
height="100%"
fill="url(#stripes)"
/>
);
})}
</svg>
);
};
export default WorkingHours;

View file

@ -0,0 +1,3 @@
export const getLabel = (layerIndex: number, rotationIndex) => {
return `L ${layerIndex + 1}-${rotationIndex + 1}`;
};

View file

@ -0,0 +1,71 @@
.root {
transition: background-color 300ms;
min-height: 28px;
}
.loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.root:last-child {
padding-bottom: 26px;
}
.root:hover {
background: var(--secondary-background);
}
.timeline {
display: flex;
flex-direction: column;
gap: 5px;
padding-bottom: 4px;
overflow: hidden;
}
.root:first-child .timeline {
padding-top: 26px;
}
.root:last-child .timeline {
padding-bottom: 0;
}
.slots {
width: 100%;
display: flex;
transition: opacity 500ms ease;
opacity: 1;
}
.slots__animate {
transition: transform 500ms ease;
}
.slots__transparent {
opacity: 0.5;
}
.current-time {
position: absolute;
left: 450px;
width: 1px;
background: #fff;
top: -10px;
bottom: -10px;
z-index: 1;
}
.empty {
height: 28px;
cursor: pointer;
/* background: #5f505633;
border: 1px dashed #5c474d;
color: rgba(209, 14, 92, 0.5); */
margin: 0 2px;
}

View file

@ -0,0 +1,154 @@
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot';
import { getFromString } from 'models/schedule/schedule.helpers';
import { Rotation as RotationType, Schedule, Event } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { usePrevious } from 'utils/hooks';
import { getLabel } from './Rotation.helpers';
import styles from './Rotation.module.css';
const cx = cn.bind(styles);
interface ScheduleSlotState {}
interface RotationProps {
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
layerIndex?: number;
rotationIndex?: number;
color?: string;
events: Event[];
onClick?: (moment: dayjs.Dayjs) => void;
days?: number;
transparent?: boolean;
}
const Rotation: FC<RotationProps> = (props) => {
const {
events,
scheduleId,
layerIndex,
rotationIndex,
startMoment,
currentTimezone,
color,
onClick,
days = 7,
transparent = false,
} = props;
const [animate, setAnimate] = useState<boolean>(true);
const [width, setWidth] = useState<number | undefined>();
const startMomentString = useMemo(() => getFromString(startMoment), [startMoment]);
const prevStartMomentString = usePrevious(startMomentString);
// console.log(events);
// const rotation = store.scheduleStore.rotations[id]?.[prevStartMomentString];
/* useEffect(() => {
setTransparent(false);
}, [rotation]);
useEffect(() => {
setTransparent(true);
}, [startMoment]);*/
useEffect(() => {
const startMomentString = startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z');
// console.log('CHANGE START MOMENT', startMomentString);
// store.scheduleStore.updateEvents(scheduleId, startMomentString, currentTimezone);
}, [startMomentString]);
const slots = useCallback((node) => {
if (node) {
setWidth(node.offsetWidth);
}
}, []);
const handleClick = (event) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left; //x position within the element.
const width = event.currentTarget.offsetWidth;
const dayOffset = Math.floor((x / width) * 7);
onClick(startMoment.add(dayOffset, 'day'));
};
const x = useMemo(() => {
if (!events || !events.length) {
return 0;
}
const firstShift = events[0];
const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'seconds');
const base = 60 * 60 * 24 * days;
// const utcOffset = dayjs().tz(currentTimezone).utcOffset();
return firstShiftOffset / base;
}, [events]);
let eventIndexToShowLabel = -1;
if (!isNaN(layerIndex) && !isNaN(rotationIndex)) {
eventIndexToShowLabel = events.findIndex((event) => dayjs(event.start).isSameOrAfter(startMoment));
}
return (
<div className={cx('root')} onClick={handleClick}>
<div className={cx('timeline')}>
{events ? (
events.length ? (
<div
className={cx('slots', { slots__animate: animate, slots__transparent: transparent })}
style={{ transform: `translate(${x * 100}%, 0)` }}
>
{events.map((event, index) => {
return (
<ScheduleSlot
index={index}
scheduleId={scheduleId}
key={event.start}
event={event}
layerIndex={layerIndex}
rotationIndex={rotationIndex}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={color}
label={index === eventIndexToShowLabel && getLabel(layerIndex, rotationIndex)}
/>
);
})}
</div>
) : (
<Empty />
)
) : (
<HorizontalGroup align="center" justify="center">
<LoadingPlaceholder text="Loading shifts..." />
</HorizontalGroup>
)}
</div>
</div>
);
};
const Empty = () => {
return <div className={cx('empty')} />;
};
export default Rotation;

View file

@ -0,0 +1,60 @@
.root {
display: block;
}
.draggable {
top: 0;
transition: transform 300ms ease;
}
.header {
width: 100%;
display: flex;
justify-content: space-between;
}
.control {
width: 195px;
}
.user-title {
padding: 6px 10px;
z-index: 1;
color: #fff;
}
.working-hours {
position: absolute;
top: 0;
left: 0;
height: 100%;
pointer-events: none;
}
.date-time-picker {
display: block;
}
.inline-switch {
height: 22px;
}
.days {
display: flex;
gap: 14px;
width: 100%;
}
.day {
width: 28px;
height: 28px;
background: var(--secondary-background-shade);
border-radius: 2px;
line-height: 28px;
text-align: center;
cursor: pointer;
}
.days .day__selected {
background: #3d71d9;
}

View file

@ -0,0 +1,427 @@
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 { observer } from 'mobx-react';
import Draggable from 'react-draggable';
import Modal from 'components/Modal/Modal';
import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot';
import Text from 'components/Text/Text';
import UserGroups from 'components/UserGroups/UserGroups';
import { Item } from 'components/UserGroups/UserGroups.types';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
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 { makeRequest } from 'network';
import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers';
import { SelectOption } from 'state/types';
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 {
layerPriority: number;
onHide: () => void;
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
scheduleId: Schedule['id'];
shiftId: Shift['id'] | 'new';
shiftMoment?: dayjs.Dayjs;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
shiftColor?: string;
}
const cx = cn.bind(styles);
const RotationForm: FC<RotationFormProps> = observer((props) => {
const {
onHide,
onCreate,
startMoment,
currentTimezone,
scheduleId,
onUpdate,
onDelete,
layerPriority,
shiftId,
shiftMoment = dayjs().startOf('isoWeek'),
shiftColor = '#3D71D9',
} = props;
// console.log('shiftColor', shiftColor);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [repeatEveryValue, setRepeatEveryValue] = useState<number>(1);
const [repeatEveryPeriod, setRepeatEveryPeriod] = useState<number>(0);
const [selectedDays, setSelectedDays] = useState<string[]>([]);
const [shiftStart, setShiftStart] = useState<DateTime>(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss')));
const [shiftEnd, setShiftEnd] = useState<DateTime>(dateTime(shiftMoment.add(1, 'day').format('YYYY-MM-DD HH:mm:ss')));
const [rotationStart, setRotationStart] = useState<DateTime>(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss')));
const [endLess, setEndless] = useState<boolean>(true);
const [rotationEnd, setRotationEnd] = useState<DateTime>(
dateTime(shiftMoment.add(1, 'month').format('YYYY-MM-DD HH:mm:ss'))
);
const store = useStore();
const shift = store.scheduleStore.shifts[shiftId];
const [offsetTop, setOffsetTop] = useState<number>(0);
useEffect(() => {
if (isOpen) {
waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`).then((elm) => {
const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement;
const coords = getCoords(elm);
// elm.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
// setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0));
setOffsetTop(Math.max(coords.top - modal?.offsetHeight - 10, 10));
});
}
}, [isOpen]);
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 (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
</div>
<WorkingHours
timezone={timezone}
workingHours={workingHours}
startMoment={dayjs(params.shift_start)}
duration={dayjs(params.shift_end).diff(dayjs(params.shift_start), 'seconds')}
className={cx('working-hours')}
style={{ backgroundColor: shiftColor }}
/>
</>
);
};
const handleDeleteClick = useCallback(() => {
store.scheduleStore.deleteOncallShift(shiftId).then(() => {
onDelete();
});
}, []);
useEffect(() => {
if (shiftId !== 'new') {
store.scheduleStore.updateOncallShift(shiftId);
}
}, [shiftId]);
const params = useMemo(
() => ({
rotation_start: getUTCString(rotationStart, currentTimezone),
until: endLess ? null : getUTCString(rotationEnd, currentTimezone),
shift_start: getUTCString(shiftStart, currentTimezone),
shift_end: getUTCString(shiftEnd, currentTimezone),
rolling_users: userGroups,
interval: repeatEveryValue,
frequency: repeatEveryPeriod,
by_day: repeatEveryPeriod === 1 ? selectedDays : null,
priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level,
}),
[
rotationStart,
currentTimezone,
rotationEnd,
shiftStart,
shiftEnd,
userGroups,
repeatEveryValue,
repeatEveryPeriod,
selectedDays,
shiftId,
layerPriority,
shift,
endLess,
]
);
const handleCreate = useCallback(() => {
if (shiftId === 'new') {
store.scheduleStore.createRotation(scheduleId, false, 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), false, params)
.then(() => {
setIsOpen(true);
});
};
const handleChange = useDebouncedCallback(updatePreview, 200);
useEffect(handleChange, [params]);
useEffect(() => {
if (shift) {
setRotationStart(getDateTime(shift.rotation_start, currentTimezone));
setRotationEnd(getDateTime(shift.until, currentTimezone));
setShiftStart(getDateTime(shift.shift_start, currentTimezone));
setShiftEnd(getDateTime(shift.shift_end, currentTimezone));
setEndless(!shift.until);
setRepeatEveryValue(shift.interval);
setRepeatEveryPeriod(shift.frequency);
setSelectedDays(shift.by_day);
setUserGroups(shift.rolling_users);
}
}, [shift]);
const handleChangeEndless = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setEndless(!event.currentTarget.checked);
},
[endLess]
);
const handleRepeatEveryValueChange = useCallback((option) => {
setRepeatEveryValue(option.value);
}, []);
const moment = dayjs();
return (
<Modal
isOpen={isOpen}
width="430px"
onDismiss={onHide}
contentElement={(props, children) => (
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }}>
<div {...props}>{children}</div>
</Draggable>
)}
>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text size="medium">
<HorizontalGroup spacing="sm">
<span>[L{shiftId === 'new' ? layerPriority : shift?.priority_level}]</span>
{shiftId === 'new' ? 'New Rotation' : 'Update Rotation'}
</HorizontalGroup>
</Text>
<HorizontalGroup>
<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />
{shiftId !== 'new' && (
<WithConfirm>
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
</WithConfirm>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
</HorizontalGroup>
</HorizontalGroup>
<UserGroups
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={true}
getItemData={getUser}
renderUser={renderUser}
showError={!userGroups.some((group) => group.length)}
/>
{/*<hr />*/}
<VerticalGroup>
<HorizontalGroup>
<Field className={cx('control')} label="Repeat shifts every">
<Select
value={repeatEveryValue}
options={[
{ label: '1', value: 1 },
{ label: '2', value: 2 },
{ label: '3', value: 3 },
{ label: '4', value: 4 },
{ label: '5', value: 5 },
{ label: '6', value: 6 },
{ label: '7', value: 7 },
]}
onChange={handleRepeatEveryValueChange}
/>
</Field>
<Field className={cx('control')} label="">
<RemoteSelect
href="/oncall_shifts/frequency_options/"
value={repeatEveryPeriod}
onChange={setRepeatEveryPeriod}
/>
</Field>
</HorizontalGroup>
{repeatEveryPeriod === 1 && (
/*<HorizontalGroup justify="center">*/
<Field label="Select days to repeat">
<DaysSelector
options={store.scheduleStore.byDayOptions}
value={selectedDays}
onChange={(value) => setSelectedDays(value)}
/>
</Field>
/*</HorizontalGroup>*/
)}
<HorizontalGroup>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Shift start
</Text>
}
>
<DateTimePicker date={shiftStart} onChange={setShiftStart} />
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Shift end
</Text>
}
>
<DateTimePicker date={shiftEnd} onChange={setShiftEnd} />
</Field>
</HorizontalGroup>
<HorizontalGroup>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Rotation start
</Text>
}
>
<DateTimePicker date={rotationStart} onChange={setRotationStart} />
</Field>
<Field
label={
<HorizontalGroup spacing="xs">
<Text type="primary" size="small">
Rotation end
</Text>
<InlineSwitch
className={cx('inline-switch')}
transparent
value={!endLess}
onChange={handleChangeEndless}
/>
</HorizontalGroup>
}
>
{endLess ? (
<Input
value="endless"
onClick={() => {
setEndless(false);
}}
/>
) : (
<DateTimePicker date={rotationEnd} onChange={setRotationEnd} />
)}
</Field>
</HorizontalGroup>
</VerticalGroup>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="secondary">+ Override</Button>
<Button variant="primary" onClick={handleCreate}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup>
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
});
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 (
<div className={cx('days')}>
{options.map(({ display_name, value: itemValue }) => (
<div
onClick={getDayClickHandler(itemValue as string)}
className={cx('day', { day__selected: value.includes(itemValue as string) })}
>
{display_name.charAt(0)}
</div>
))}
</div>
);
};
export default RotationForm;

View file

@ -0,0 +1,3 @@
export interface RotationCreateData {}
export interface RotationData {}

View file

@ -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<RotationFormProps> = (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<number>(0);
const [isOpen, setIsOpen] = useState<boolean>(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>(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss')));
const [shiftEnd, setShiftEnd] = useState<DateTime>(
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 (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text type="primary">({desc})</Text>
</div>
<WorkingHours
timezone={timezone}
workingHours={workingHours}
startMoment={dayjs(params.shift_start)}
duration={dayjs(params.shift_end).diff(dayjs(params.shift_start), 'seconds')}
className={cx('working-hours')}
style={{ backgroundColor: shiftColor }}
/>
</>
);
};
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 (
<Modal
isOpen={isOpen}
width="430px"
onDismiss={onHide}
contentElement={(props, children) => (
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }}>
<div {...props}>{children}</div>
</Draggable>
)}
>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text size="medium">{shiftId === 'new' ? 'New Override' : 'Update Override'}</Text>
<HorizontalGroup>
<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />
{shiftId !== 'new' && (
<WithConfirm>
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
</WithConfirm>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
</HorizontalGroup>
</HorizontalGroup>
<UserGroups
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={false}
getItemData={getUser}
renderUser={renderUser}
showError={!userGroups.some((group) => group.length)}
/>
{/*<hr />*/}
<VerticalGroup>
<HorizontalGroup>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override start
</Text>
}
>
<DateTimePicker date={shiftStart} onChange={setShiftStart} />
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override end
</Text>
}
>
<DateTimePicker date={shiftEnd} onChange={setShiftEnd} />
</Field>
</HorizontalGroup>
</VerticalGroup>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="primary" onClick={handleCreate}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup>
</HorizontalGroup>
</VerticalGroup>
</Modal>
);
};
export default ScheduleOverrideForm;

View file

@ -0,0 +1,4 @@
export const DEFAULT_TRANSITION_TIMEOUT = {
enter: 500,
exit: 0,
};

View file

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

View file

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

View file

@ -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<RotationsProps, RotationsState> {
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 (
<>
<div className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Rotations</div>
<ValuePicker
label="Add rotation"
options={options}
onChange={this.handleAddRotation}
variant="secondary"
size="md"
/>
</HorizontalGroup>
</div>
<div className={cx('rotations-plus-title')}>
{layers && layers.length ? (
<TransitionGroup className={cx('layers')}>
{layers.map((layer, layerIndex) => (
<CSSTransition key={layerIndex} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<div id={`layer${layer.priority}`} className={cx('layer')}>
<div className={cx('layer-title')}>
<HorizontalGroup spacing="sm" justify="center">
<span>Layer {layer.priority}</span>
{/*<Icon name="info-circle" />*/}
</HorizontalGroup>
</div>
<div className={cx('rotations')}>
<TimelineMarks startMoment={startMoment} />
{!currentTimeHidden && (
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
)}
<TransitionGroup className={cx('rotations')}>
{layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
<CSSTransition
key={rotationIndex}
timeout={DEFAULT_TRANSITION_TIMEOUT}
classNames={{ ...styles }}
>
<Rotation
scheduleId={scheduleId}
onClick={(moment) => {
this.onRotationClick(shiftId, moment);
}}
color={getColor(layerIndex, rotationIndex)}
events={events}
layerIndex={layerIndex}
rotationIndex={rotationIndex}
startMoment={startMoment}
currentTimezone={currentTimezone}
transparent={isPreview}
/>
</CSSTransition>
))}
</TransitionGroup>
</div>
</div>
</CSSTransition>
))}
</TransitionGroup>
) : (
<div>
<div id={`layer1`} className={cx('layer')}>
<div className={cx('layer-title')}>
<HorizontalGroup spacing="sm" justify="center">
<span>Layer 1</span>
{/* <Icon name="info-circle" />*/}
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
<TimelineMarks startMoment={startMoment} />
<div className={cx('rotations')}>
<Rotation
scheduleId={scheduleId}
onClick={(moment) => {
this.handleAddLayer(nextPriority, moment);
}}
events={[]}
layerIndex={0}
rotationIndex={0}
startMoment={startMoment}
currentTimezone={currentTimezone}
/>
</div>
</div>
</div>
</div>
)}
{nextPriority > 1 && (
<div
className={cx('add-rotations-layer')}
onClick={() => {
this.handleAddLayer(nextPriority, startMoment);
}}
>
+ Add rotations layer
</div>
)}
</div>
</div>
{shiftIdToShowRotationForm && (
<RotationForm
shiftId={shiftIdToShowRotationForm}
shiftColor={findColor(shiftIdToShowRotationForm, layers)}
scheduleId={scheduleId}
layerPriority={layerPriority}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftMoment={shiftMomentToShowRotationForm}
onHide={() => {
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);

View file

@ -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<ScheduleFinalProps, ScheduleOverridesState> {
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 (
<>
<div className={cx('root')}>
{!hideHeader && (
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Final schedule</div>
{/*<Input
prefix={<Icon name="search" />}
placeholder="Search..."
value={searchTerm}
onChange={this.onSearchTermChangeCallback}
/>*/}
</HorizontalGroup>
</div>
)}
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={index}
scheduleId={scheduleId}
events={events}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={findColor(shiftId, layers, overrides)}
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
scheduleId={scheduleId}
events={[]}
startMoment={startMoment}
currentTimezone={currentTimezone}
/>
</CSSTransition>
)}
</TransitionGroup>
</div>
</div>
</>
);
}
onSearchTermChangeCallback = () => {};
}
export default withMobXProviderContext(ScheduleFinal);

View file

@ -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<ScheduleOverridesProps, ScheduleOverridesState> {
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 (
<>
<div id="overrides-list" className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>Overrides</div>
<Button icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
<CSSTransition key={rotationIndex} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={rotationIndex}
scheduleId={scheduleId}
events={events}
color={getOverrideColor(rotationIndex)}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(moment) => {
this.onRotationClick(shiftId, moment);
}}
transparent={isPreview}
/>
</CSSTransition>
))
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
events={[]}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(moment) => {
this.onRotationClick('new', moment);
}}
/>
</CSSTransition>
)}
</TransitionGroup>
</div>
{/* <div className={cx('add-rotations-layer')} onClick={this.handleAddOverride}>
+ Add override
</div>*/}
</div>
{shiftIdToShowOverrideForm && (
<ScheduleOverrideForm
shiftId={shiftIdToShowOverrideForm}
shiftColor={findColor(shiftIdToShowOverrideForm, undefined, shifts)}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftMoment={shiftMomentToShowOverrideForm}
onHide={() => {
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);

View file

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

View file

@ -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 (
<HorizontalGroup>
{item.label}
<Avatar src={team?.avatar_url} size="small" />
</HorizontalGroup>
);
};
const formConfig = scheduleTypeToForm[data.type];
return (
<Drawer

View file

@ -0,0 +1,31 @@
import dayjs from 'dayjs';
import { Shift } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
const USERS = [
'Innokentii Konstantinov',
'Ildar Iskhakov',
'Matias Bordese',
'Michael Derynck',
'Vadim Stepanov',
'Matvey Kukuy',
'Yulya Artyukhina',
'Raphael Batyrbaev',
];
export const getRandomUser = () => {
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) => {};

View file

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

View file

@ -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<ScheduleSlotProps> = observer((props) => {
const { event, scheduleId, startMoment, currentTimezone, color, label } = props;
const { users } = event;
const trackMouse = false;
const [mouseX, setMouseX] = useState<number>(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 (
<div className={cx('stack')} style={{ width: `${width * 100}%` /*left: `${x * 100}%`*/ }}>
{event.is_gap ? (
<Tooltip content={<ScheduleGapDetails event={event} currentTimezone={currentTimezone} />}>
<div className={cx('root', 'root__type_gap')} style={{}}>
{trackMouse && mouseX > 0 && <div style={{ left: `${mouseX}px` }} className={cx('time')} />}
{label && <div className={cx('label')}>{label}</div>}
</div>
</Tooltip>
) : event.is_empty ? (
<div
className={cx('root')}
style={{
backgroundColor: color,
}}
>
{label && (
<div className={cx('label')} style={{ color }}>
{label}
</div>
)}
</div>
) : (
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 (
<Tooltip
content={
<ScheduleSlotDetails
user={storeUser}
isOncall={isOncall}
currentTimezone={currentTimezone}
event={event}
/>
}
>
<div
className={cx('root', { root__inactive: inactive })}
style={{
backgroundColor: color,
}}
onMouseMove={trackMouse ? handleMouseMove : undefined}
onMouseLeave={trackMouse ? () => setMouseX(0) : undefined}
>
{trackMouse && mouseX > 0 && <div style={{ left: `${mouseX}px` }} className={cx('time')} />}
{storeUser && (
<WorkingHours
className={cx('working-hours')}
timezone={storeUser.timezone}
workingHours={storeUser.working_hours}
startMoment={start}
duration={duration}
/>
)}
{userIndex === 0 && label && (
<div className={cx('label')} style={{ color }}>
{label}
</div>
)}
<div className={cx('title')}>{title}</div>
</div>
</Tooltip>
);
})
)}
</div>
);
});
export default ScheduleSlot;
interface ScheduleSlotDetailsProps {
user: User;
isOncall: boolean;
currentTimezone: Timezone;
event: Event;
}
const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
const { user, currentTimezone, event, isOncall } = props;
return (
<div className={cx('details')}>
<HorizontalGroup>
<VerticalGroup spacing="sm">
<HorizontalGroup spacing="sm">
{isOncall && <IsOncallIcon className={cx('is-oncall-icon')} />}
<Text type="secondary">{user?.username}</Text>
</HorizontalGroup>
<HorizontalGroup>
<VerticalGroup spacing="none">
{/* <HorizontalGroup spacing="sm">
<Icon name="clock-nine" size="xs" />
<Text type="secondary">30 apr, 7:54 </Text>
</HorizontalGroup>*/}
<HorizontalGroup spacing="sm">
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">{dayjs(event.start).tz(user.timezone).format('DD MMM, HH:mm')}</Text>
<Text type="secondary">{dayjs(event.end).tz(user.timezone).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">{currentTimezone}</Text>
<VerticalGroup spacing="none">
{/* <Text type="primary">30 apr, 12:54 </Text>*/}
<Text type="primary">{dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
<Text type="primary">{dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</VerticalGroup>
</HorizontalGroup>
</div>
);
};
interface ScheduleGapDetailsProps {
currentTimezone: Timezone;
event: Event;
}
const ScheduleGapDetails = (props: ScheduleGapDetailsProps) => {
const { currentTimezone, event } = props;
return (
<div className={cx('details')}>
<VerticalGroup>
<HorizontalGroup spacing="sm">
<VerticalGroup spacing="none">
<Text type="primary">{currentTimezone}</Text>
<Text type="primary">{dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
<Text type="primary">{dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</HorizontalGroup>
{/*<Text type="primary">Gaps this week</Text>
<HorizontalGroup justify="space-between">
<Text type="secondary">Number of gaps</Text>
<Text type="secondary">12</Text>
</HorizontalGroup>
<HorizontalGroup justify="space-between">
<Text type="secondary">Time</Text>
<Text type="secondary">23h 12m</Text>
</HorizontalGroup>*/}
</VerticalGroup>
</div>
);
};

View file

@ -0,0 +1,3 @@
.root {
}

View file

@ -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 <div className={cx('root')} />;
});
export default SchedulesFilters;

View file

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

View file

@ -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<User['pk']>;
tz: Timezone;
onTzChange: (tz: Timezone) => void;
onCallNow: Array<Partial<User>>;
}
const cx = cn.bind(styles);
const hoursToSplit = 3;
const jLimit = 24 / hoursToSplit;
const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
const { userIds, tz, onTzChange, onCallNow } = props;
const store = useStore();
const [count, setCount] = useState<number>(0);
const [currentMoment, setCurrentMoment] = useState<dayjs.Dayjs>(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 (
<div className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('title')}>Schedule team and timezones</div>
{/* <HorizontalGroup>
<InlineSwitch transparent />
Current schedule users only
</HorizontalGroup>*/}
</HorizontalGroup>
<div className={cx('timezone-select')}>
<Text type="secondary">
Current timezone: {tz}, local time: {currentMoment.format('HH:mm')}
</Text>
</div>
</HorizontalGroup>
</div>
<div className={cx('users')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX}%` }} />
<UserAvatars users={users} onCallNow={onCallNow} onTzChange={onTzChange} currentMoment={currentMoment} />
</div>
<div className={cx('time-stripe')}>
<div className={cx('current-user-stripe')} />
<div className={cx('time-marks')}>
{momentsToRender.map((mm, index) => (
<div key={index} className={cx('time-mark')} style={{ width: `${100 / jLimit}%` }}>
<span
className={cx('time-mark-text', {
'time-mark-text__translated': index > 0,
})}
>
{mm.format('HH:mm')}
</span>
</div>
))}
<div key={jLimit} className={cx('time-mark')}>
<span className={cx('time-mark-text')}>24:00</span>
</div>
</div>
</div>
</div>
);
};
interface UserAvatarsProps {
users: User[];
currentMoment: dayjs.Dayjs;
onTzChange: (timezone: Timezone) => void;
onCallNow: Array<Partial<User>>;
}
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<number | undefined>(undefined);
return (
<div className={cx('user-avatars')}>
{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 (
<AvatarGroup
activeUtcOffset={activeUtcOffset}
utcOffset={group.utcOffset}
onSetActiveUtcOffset={setActiveUtcOffset}
onTzChange={onTzChange}
xPos={xPos}
users={group.users}
currentMoment={currentMoment}
onCallNow={onCallNow}
/>
);
})}
</div>
);
};
interface AvatarGroupProps {
users: User[];
xPos: number;
currentMoment: dayjs.Dayjs;
utcOffset: number;
onSetActiveUtcOffset: (utcOffset: number | undefined) => void;
activeUtcOffset: number;
onTzChange: (timezone: Timezone) => void;
onCallNow: Array<Partial<User>>;
}
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 (
<div
className={cx('avatar-group', {
[`avatar-group_inactive`]: !isNaN(activeUtcOffset) && activeUtcOffset !== utcOffset,
})}
style={{ width: `${width}px`, left: `${xPos}%`, transform: `translate(${translateLeft}px, 0)` }}
onMouseEnter={() => onSetActiveUtcOffset(utcOffset)}
onMouseLeave={() => onSetActiveUtcOffset(undefined)}
>
{users.map((user, index, array) => {
const isOncall = onCallNow.some((onCallUser) => user.pk === onCallUser.pk);
return (
<Tooltip
placement="top"
interactive
key={index}
content={<ScheduleUserDetails currentMoment={currentMoment} user={user} />}
>
<div
className={cx('avatar')}
style={{
left: active ? `${index * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${index * 10}px`,
opacity: active ? 1 : Math.max(1 - index * 0.25, 0.25),
visibility: !active && index >= LIMIT ? 'hidden' : 'visible',
zIndex: array.length - index - 1,
/* opacity: userHour >= 9 && userHour < 18 ? 1 : 0.5,*/
}}
onClick={getAvatarClickHandler(user.timezone)}
>
<Avatar src={user.avatar} size="large" />
{isOncall && <IsOncallIcon className={cx('is-oncall-icon')} />}
</div>
</Tooltip>
);
})}
<div
style={{
opacity: !active && users.length > 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}
</div>
</div>
);
};
export default UsersTimezones;

View file

@ -0,0 +1 @@
interface Dummy {}

View file

@ -232,3 +232,40 @@ export const GrafanaIcon = (props: IconProps) => (
</defs>
</svg>
);
export const ExpandIcon = (props: IconProps) => {
return (
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11 1.16994C10.8126 0.983692 10.5592 0.87915 10.295 0.87915C10.0308 0.87915 9.77737 0.983692 9.59001 1.16994L6.00001 4.70994L2.46001 1.16994C2.27265 0.983692 2.0192 0.87915 1.75501 0.87915C1.49082 0.87915 1.23737 0.983692 1.05001 1.16994C0.956281 1.26291 0.881887 1.37351 0.831118 1.49537C0.780349 1.61723 0.754211 1.74793 0.754211 1.87994C0.754211 2.01195 0.780349 2.14266 0.831118 2.26452C0.881887 2.38638 0.956281 2.49698 1.05001 2.58994L5.29001 6.82994C5.38297 6.92367 5.49357 6.99806 5.61543 7.04883C5.73729 7.0996 5.868 7.12574 6.00001 7.12574C6.13202 7.12574 6.26273 7.0996 6.38459 7.04883C6.50645 6.99806 6.61705 6.92367 6.71001 6.82994L11 2.58994C11.0937 2.49698 11.1681 2.38638 11.2189 2.26452C11.2697 2.14266 11.2958 2.01195 11.2958 1.87994C11.2958 1.74793 11.2697 1.61723 11.2189 1.49537C11.1681 1.37351 11.0937 1.26291 11 1.16994Z"
fill="#CCCCDC"
fillOpacity="0.65"
/>
</svg>
);
};
interface IsOncallIconProps {
className: string;
}
export const IsOncallIcon = (props: IsOncallIconProps) => {
const { className } = props;
return (
<svg
className={className}
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8.72021" cy="8" r="7.5" fill="#6CCF8E" stroke="currentColor" />
<path
d="M6.91558 9.7903C7.32816 10.2082 7.76482 10.584 8.22558 10.9177C8.68634 11.2514 9.14709 11.5145 9.60785 11.7069C10.0686 11.9023 10.5038 12 10.9133 12C11.1934 12 11.4539 11.9504 11.6948 11.8512C11.9357 11.752 12.1541 11.5941 12.3498 11.3777C12.4612 11.2514 12.5501 11.1161 12.6163 10.9718C12.6856 10.8275 12.7202 10.6817 12.7202 10.5344C12.7202 10.4262 12.6976 10.3209 12.6525 10.2187C12.6073 10.1165 12.532 10.0293 12.4266 9.95716L11.1121 9.02818C11.0097 8.95303 10.9148 8.89891 10.8275 8.86584C10.7432 8.83277 10.6619 8.81623 10.5836 8.81623C10.4872 8.81623 10.3923 8.84329 10.299 8.89741C10.2086 8.94852 10.1153 9.02368 10.0189 9.12289L9.70723 9.42052C9.66507 9.46561 9.61086 9.48816 9.54461 9.48816C9.51148 9.48816 9.47986 9.48365 9.44975 9.47463C9.41963 9.46261 9.39403 9.45209 9.37295 9.44307C9.23744 9.37091 9.06428 9.24615 8.85347 9.06877C8.64568 8.89139 8.43638 8.69748 8.22558 8.48703C8.01176 8.27659 7.81602 8.06614 7.63834 7.85569C7.46066 7.64525 7.33719 7.47388 7.26793 7.3416C7.25588 7.31755 7.24384 7.292 7.23179 7.26494C7.22276 7.23487 7.21824 7.2018 7.21824 7.16573C7.21824 7.10259 7.24082 7.04998 7.286 7.00789L7.58865 6.70124C7.68502 6.60203 7.76031 6.50733 7.81451 6.41714C7.86872 6.32394 7.89582 6.22773 7.89582 6.12852C7.89582 6.05036 7.87775 5.96918 7.84162 5.88501C7.80849 5.79782 7.75579 5.70312 7.68351 5.6009L6.75748 4.30665C6.68521 4.20143 6.59637 4.12477 6.49097 4.07666C6.38556 4.02555 6.27263 4 6.15217 4C5.85705 4 5.5815 4.12326 5.32552 4.36979C5.11472 4.57121 4.96113 4.79369 4.86477 5.0372C4.7684 5.28072 4.72021 5.53927 4.72021 5.81285C4.72021 6.22473 4.81508 6.65915 5.0048 7.11612C5.19753 7.57309 5.45953 8.03006 5.7908 8.48703C6.12206 8.941 6.49699 9.37542 6.91558 9.7903Z"
fill="currentColor"
/>
</svg>
);
};

View file

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

View file

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

View file

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

View file

@ -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<Shift>) {
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<Shift>
) {
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<Shift>) {
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',
});
}
}

View file

@ -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<Array<User['pk']>>;
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[] }>;
}

View file

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

View file

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

View file

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

View file

@ -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),
},
}),
{}
),

View file

@ -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]: [] };
}

View file

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

View file

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

View file

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

View file

@ -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<SchedulePageProps, SchedulePageState> {
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 (
<div className={cx('root')}>
<VerticalGroup spacing="lg">
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<PluginLink query={{ page: 'schedules-new' }}>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xxl" />
</PluginLink>
<Text.Title editable editModalTitle="Schedule name" level={3} onTextChange={this.handleNameChange}>
{schedule?.name}
</Text.Title>
{/*<ScheduleCounter
type="link"
count={5}
tooltipTitle="Used in escalations"
tooltipContent={
<>
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 1</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 2</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 3</PluginLink>
</>
}
/>
<ScheduleCounter
type="warning"
count={2}
tooltipTitle="Warnings"
tooltipContent="Schedule has unassigned time periods during next 7 days"
/>*/}
</HorizontalGroup>
<HorizontalGroup>
{users && (
<UserTimezoneSelect value={currentTimezone} users={users} onChange={this.handleTimezoneChange} />
)}
{/*<ScheduleQuality quality={0.89} />
<ToolbarButton icon="copy" tooltip="Copy" />
<ToolbarButton icon="brackets-curly" tooltip="Code" />
<ToolbarButton icon="share-alt" tooltip="Share" />
<ToolbarButton icon="cog" tooltip="Settings" />*/}
<WithConfirm>
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
</WithConfirm>
</HorizontalGroup>
</HorizontalGroup>
</div>
<Text className={cx('desc')} size="small" type="secondary">
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.
</Text>
<div className={cx('users-timezones')}>
<UsersTimezones
onCallNow={schedule?.on_call_now || []}
userIds={
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
}
tz={currentTimezone}
onTzChange={this.handleTimezoneChange}
/>
</div>
<div className={cx('controls')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Button variant="secondary" onClick={this.handleTodayClick}>
Today
</Button>
<HorizontalGroup spacing="xs">
<Button variant="secondary" onClick={this.handleLeftClick}>
<Icon name="angle-left" />
</Button>
<Button variant="secondary" onClick={this.handleRightClick}>
<Icon name="angle-right" />
</Button>
</HorizontalGroup>
<div>
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</div>
</HorizontalGroup>
{/*<HorizontalGroup width="auto">
<RadioButtonGroup
options={[
{ label: 'Day', value: 'day' },
{
label: 'Week',
value: 'week',
},
{ label: 'Month', value: 'month' },
{ label: 'Custom', value: 'custom' },
]}
value={schedulePeriodType}
onChange={this.handleShedulePeriodTypeChange}
/>
<RadioButtonGroup
options={[
{ label: 'Timeline', value: 'timeline' },
{
label: 'Grid',
value: 'grid',
},
]}
value={renderType}
onChange={this.handleRenderTypeChange}
/>
</HorizontalGroup>*/}
</HorizontalGroup>
</div>
{/* <div className={'current-time'} />*/}
<div className={cx('rotations')}>
<ScheduleFinal scheduleId={scheduleId} currentTimezone={currentTimezone} startMoment={startMoment} />
<Rotations
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onCreate={this.handleCreateRotation}
onUpdate={this.handleUpdateRotation}
onDelete={this.handleDeleteRotation}
/>
<ScheduleOverrides
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onCreate={this.handleCreateOverride}
onUpdate={this.handleUpdateOverride}
onDelete={this.handleDeleteOverride}
/>
</div>
</VerticalGroup>
</div>
);
}
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);

View file

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

View file

@ -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<SchedulesPageProps, SchedulesPageSta
{scheduleIdToEdit && (
<ScheduleForm
id={scheduleIdToEdit}
type={ScheduleType.Ical}
onUpdate={this.update}
onHide={() => {
this.setState({ scheduleIdToEdit: undefined });

View file

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

View file

@ -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);
}
*/

View file

@ -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<Schedule['id']>;
}
@observer
class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageState> {
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 (
<>
<div className={cx('root')}>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<SchedulesFilters value={filters} onChange={this.handleSchedulesFiltersChange} />
<HorizontalGroup spacing="lg">
{users && (
<UserTimezoneSelect
value={store.currentTimezone}
users={users}
onChange={this.handleTimezoneChange}
/>
)}
<Button variant="primary" onClick={this.handleCreateScheduleClick}>
+ New schedule
</Button>
</HorizontalGroup>
</HorizontalGroup>
<Table
columns={columns}
data={data}
pagination={{ page: 1, total: 1, onChange: this.handlePageChange }}
rowKey="id"
expandable={{
expandedRowKeys: expandedRowKeys,
onExpand: this.handleExpandRow,
expandedRowRender: this.renderSchedule,
expandRowByClick: true,
expandedRowClassName: () => cx('expanded-row'),
}}
emptyText={
<div className={cx('loader')}>
{data ? <Text type="secondary">Not found</Text> : <Text type="secondary">Loading schedules...</Text>}
</div>
}
/>
</VerticalGroup>
</div>
{showNewScheduleSelector && (
<NewScheduleSelector
onCreate={this.handleCreateSchedule}
onUpdate={this.update}
onHide={() => {
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 (
<div className={cx('schedule')}>
<TimelineMarks startMoment={startMoment} />
<div className={cx('rotations')}>
<ScheduleFinal
hideHeader
scheduleId={data.id}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
/>
</div>
</div>
);
};
renderStatus = (item: Schedule) => {
const {
store: { scheduleStore },
} = this.props;
const relatedEscalationChains = scheduleStore.relatedEscalationChains[item.id];
return (
<HorizontalGroup>
<ScheduleCounter
type="link"
count={item.number_of_escalation_chains}
tooltipTitle="Used in escalations"
tooltipContent={
<VerticalGroup spacing="sm">
{relatedEscalationChains ? (
relatedEscalationChains.length ? (
relatedEscalationChains.map((escalationChain) => (
<PluginLink query={{ page: 'escalations', id: escalationChain.pk }}>
{escalationChain.name}
</PluginLink>
))
) : (
'Not used yet'
)
) : (
<LoadingPlaceholder>Loading related escalation chains....</LoadingPlaceholder>
)}
</VerticalGroup>
}
onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)}
/>
{/* <ScheduleCounter
type="warning"
count={warningsCount}
tooltipTitle="Warnings"
tooltipContent="Schedule has unassigned time periods during next 7 days"
/>*/}
</HorizontalGroup>
);
};
renderName = (item: Schedule) => {
return <PluginLink query={{ page: 'schedule', id: item.id }}>{item.name}</PluginLink>;
};
renderOncallNow = (item: Schedule, index: number) => {
if (item.on_call_now?.length > 0) {
return (
<VerticalGroup>
{item.on_call_now.map((user, index) => {
return (
<PluginLink key={user.pk} query={{ page: 'users', id: user.pk }}>
<div>
<Avatar size="big" src={user.avatar} />
<Text type="secondary"> {user.username}</Text>
</div>
</PluginLink>
);
})}
</VerticalGroup>
);
}
return null;
};
renderChatOps = (item: Schedule) => {
return item.chatOps;
};
renderQuality = (item: Schedule) => {
const type = item.quality > 70 ? 'primary' : 'warning';
return <Text type={type}>{item.quality || 70}%</Text>;
};
renderButtons = (item: Schedule) => {
return (
<HorizontalGroup>
{/*<IconButton tooltip="Copy" name="copy" />
<IconButton tooltip="Settings" name="cog" />
<IconButton tooltip="Code" name="brackets-curly" />*/}
<WithConfirm>
<IconButton tooltip="Delete" name="trash-alt" onClick={this.getDeleteScheduleClickHandler(item.id)} />
</WithConfirm>
</HorizontalGroup>
);
};
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);

View file

@ -5,4 +5,5 @@ export enum AppFeature {
MobileApp = 'mobile_app',
CloudNotifications = 'grafana_cloud_notifications',
CloudConnection = 'grafana_cloud_connection',
WebSchedules = 'web_schedules',
}

View file

@ -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 = '';

View file

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

View file

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

View file

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

View file

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

View file

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