Merge pull request #148 from grafana/new-schedules
Awesome on-call calendar editor
This commit is contained in:
commit
2c8a34d692
85 changed files with 7779 additions and 56 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
33
grafana-plugin/src/components/Modal/Modal.module.css
Normal file
33
grafana-plugin/src/components/Modal/Modal.module.css
Normal 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;
|
||||
}
|
||||
49
grafana-plugin/src/components/Modal/Modal.tsx
Normal file
49
grafana-plugin/src/components/Modal/Modal.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
*/
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 |
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
44
grafana-plugin/src/components/Table/Table.module.css
Normal file
44
grafana-plugin/src/components/Table/Table.module.css
Normal 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);
|
||||
}
|
||||
69
grafana-plugin/src/components/Table/Table.tsx
Normal file
69
grafana-plugin/src/components/Table/Table.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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%;
|
||||
}
|
||||
184
grafana-plugin/src/components/UserGroups/UserGroups.tsx
Normal file
184
grafana-plugin/src/components/UserGroups/UserGroups.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface Item {
|
||||
key: string;
|
||||
type: string;
|
||||
data: any;
|
||||
item?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
width: 300px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' }],
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
97
grafana-plugin/src/components/WorkingHours/WorkingHours.tsx
Normal file
97
grafana-plugin/src/components/WorkingHours/WorkingHours.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export const getLabel = (layerIndex: number, rotationIndex) => {
|
||||
return `L ${layerIndex + 1}-${rotationIndex + 1}`;
|
||||
};
|
||||
71
grafana-plugin/src/containers/Rotation/Rotation.module.css
Normal file
71
grafana-plugin/src/containers/Rotation/Rotation.module.css
Normal 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;
|
||||
}
|
||||
154
grafana-plugin/src/containers/Rotation/Rotation.tsx
Normal file
154
grafana-plugin/src/containers/Rotation/Rotation.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
427
grafana-plugin/src/containers/RotationForm/RotationForm.tsx
Normal file
427
grafana-plugin/src/containers/RotationForm/RotationForm.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export interface RotationCreateData {}
|
||||
|
||||
export interface RotationData {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export const DEFAULT_TRANSITION_TIMEOUT = {
|
||||
enter: 500,
|
||||
exit: 0,
|
||||
};
|
||||
39
grafana-plugin/src/containers/Rotations/Rotations.helpers.ts
Normal file
39
grafana-plugin/src/containers/Rotations/Rotations.helpers.ts
Normal 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;
|
||||
};
|
||||
113
grafana-plugin/src/containers/Rotations/Rotations.module.css
Normal file
113
grafana-plugin/src/containers/Rotations/Rotations.module.css
Normal 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;
|
||||
}
|
||||
242
grafana-plugin/src/containers/Rotations/Rotations.tsx
Normal file
242
grafana-plugin/src/containers/Rotations/Rotations.tsx
Normal 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);
|
||||
122
grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx
Normal file
122
grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx
Normal 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);
|
||||
162
grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx
Normal file
162
grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx
Normal 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);
|
||||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {};
|
||||
|
|
@ -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;
|
||||
}
|
||||
219
grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx
Normal file
219
grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
294
grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx
Normal file
294
grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx
Normal 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;
|
||||
1
grafana-plugin/src/dummy/dummy.ts
Normal file
1
grafana-plugin/src/dummy/dummy.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
interface Dummy {}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
191
grafana-plugin/src/models/schedule/schedule.helpers.ts
Normal file
191
grafana-plugin/src/models/schedule/schedule.helpers.ts
Normal 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];
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] }>;
|
||||
}
|
||||
|
|
|
|||
609
grafana-plugin/src/models/timezone/timezone.helpers.ts
Normal file
609
grafana-plugin/src/models/timezone/timezone.helpers.ts
Normal 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;
|
||||
};
|
||||
598
grafana-plugin/src/models/timezone/timezone.types.ts
Normal file
598
grafana-plugin/src/models/timezone/timezone.types.ts
Normal 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];
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}),
|
||||
{}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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]: [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
694
grafana-plugin/src/pages/schedule/Schedule.helpers.ts
Normal file
694
grafana-plugin/src/pages/schedule/Schedule.helpers.ts
Normal 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')
|
||||
);
|
||||
};
|
||||
35
grafana-plugin/src/pages/schedule/Schedule.module.css
Normal file
35
grafana-plugin/src/pages/schedule/Schedule.module.css
Normal 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%;
|
||||
}
|
||||
356
grafana-plugin/src/pages/schedule/Schedule.tsx
Normal file
356
grafana-plugin/src/pages/schedule/Schedule.tsx
Normal 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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
42
grafana-plugin/src/pages/schedules_NEW/Schedules.helpers.ts
Normal file
42
grafana-plugin/src/pages/schedules_NEW/Schedules.helpers.ts
Normal 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;
|
||||
};
|
||||
22
grafana-plugin/src/pages/schedules_NEW/Schedules.module.css
Normal file
22
grafana-plugin/src/pages/schedules_NEW/Schedules.module.css
Normal 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);
|
||||
}
|
||||
*/
|
||||
376
grafana-plugin/src/pages/schedules_NEW/Schedules.tsx
Normal file
376
grafana-plugin/src/pages/schedules_NEW/Schedules.tsx
Normal 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);
|
||||
|
|
@ -5,4 +5,5 @@ export enum AppFeature {
|
|||
MobileApp = 'mobile_app',
|
||||
CloudNotifications = 'grafana_cloud_notifications',
|
||||
CloudConnection = 'grafana_cloud_connection',
|
||||
WebSchedules = 'web_schedules',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
38
grafana-plugin/src/utils/DOM.ts
Normal file
38
grafana-plugin/src/utils/DOM.ts
Normal 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) };
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue