diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index 97527e17..52f8c4a8 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -9,7 +9,7 @@ module.exports = { '^assets|^components|^containers|^declare|^icons|^img|^interceptors|^models|^network|^pages|^services|^state|^utils', }, rules: { - 'no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: false }], + 'no-unused-vars': ['warn', { vars: 'all', args: 'after-used', ignoreRestSiblings: false }], 'react/prop-types': 'warn', 'react/display-name': 'warn', 'react/jsx-key': 'warn', diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index d0149316..fffef322 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -42,7 +42,7 @@ "@grafana/data": "7.5.7", "@grafana/runtime": "7.5.7", "@grafana/toolkit": "7.5.7", - "@grafana/ui": "8.2.1", + "@grafana/ui": "9.0.0-beta3", "@types/dompurify": "^2.0.2", "@types/lodash-es": "^4.17.3", "@types/moment-timezone": "^0.5.12", @@ -61,6 +61,7 @@ }, "dependencies": { "@types/query-string": "^6.3.0", + "array-move": "^4.0.0", "change-case": "^4.1.1", "circular-dependency-plugin": "^5.2.2", "dompurify": "^2.0.12", @@ -71,7 +72,9 @@ "moment-timezone": "^0.5.34", "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", diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index b6f0cc65..ee56ed40 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -2,6 +2,12 @@ import React, { useEffect, useMemo } from 'react'; import { AppRootProps } from '@grafana/data'; import { Button, HorizontalGroup, LinkButton, VerticalGroup } from '@grafana/ui'; +import dayjs from 'dayjs'; +dayjs.extend(utc); +dayjs.extend(timezone); + +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; import { observer, Provider } from 'mobx-react'; import 'interceptors'; diff --git a/grafana-plugin/src/components/Avatar/Avatar.tsx b/grafana-plugin/src/components/Avatar/Avatar.tsx index 092e3c08..15b93552 100644 --- a/grafana-plugin/src/components/Avatar/Avatar.tsx +++ b/grafana-plugin/src/components/Avatar/Avatar.tsx @@ -13,13 +13,13 @@ interface AvatarProps { const cx = cn.bind(styles); const Avatar: FC = (props) => { - const { src, size, className } = props; + const { src, size, className, ...rest } = props; if (!src) { return null; } - return ; + return ; }; export default Avatar; diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css new file mode 100644 index 00000000..4c3fad93 --- /dev/null +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -0,0 +1,32 @@ +.root { + position: fixed; + width: 750px; + max-width: 100%; + left: 0px; + right: 0px; + 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: 0px 2px 4px 2px rgba(10, 10, 16, 0.1), 0px 8px 16px rgba(10, 10, 16, 0.2), 0px 12px 24px rgba(3, 3, 8, 0.3), 0px 16px 32px rgba(3, 3, 8, 0.8); + border-radius: 2px; +} + +.overlay { + position: fixed; + inset: 0px; + z-index: 10; + /*background-color: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(1px);*/ +} + +.body-open { + overflow: hidden; +} diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..5f6352ce --- /dev/null +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -0,0 +1,45 @@ +import React, { FC, PropsWithChildren } from 'react'; +import ReactModal from 'react-modal'; +import cn from 'classnames/bind'; + +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; +} + +const cx = cn.bind(styles); + +const Modal: FC> = (props) => { + const { title, children, onDismiss, width = '600px', contentElement } = props; + + return ( + {}} + onRequestClose={onDismiss} + contentLabel={title} + className={cx('root')} + overlayClassName={cx('overlay')} + bodyOpenClassName={cx('body-open')} + contentElement={contentElement} + > + {children} + + ); +}; + +export default Modal; diff --git a/grafana-plugin/src/components/Rotation/Rotation.module.css b/grafana-plugin/src/components/Rotation/Rotation.module.css new file mode 100644 index 00000000..1da067f3 --- /dev/null +++ b/grafana-plugin/src/components/Rotation/Rotation.module.css @@ -0,0 +1,28 @@ + +.root { + background: var(--primary-background); + transition: background-color 300ms; +} + +.root:last-child{ + padding-bottom: 26px; +} + +.root:hover { + background: var(--secondary-background); +} + +.timeline { + display: flex; + flex-direction: column; + gap: 5px; + padding-bottom: 8px; +} + +.root:first-child .timeline { + padding-top: 26px; +} + +.root:last-child .timeline { + padding-bottom: 0; +} diff --git a/grafana-plugin/src/components/Rotation/Rotation.tsx b/grafana-plugin/src/components/Rotation/Rotation.tsx new file mode 100644 index 00000000..ba0e4873 --- /dev/null +++ b/grafana-plugin/src/components/Rotation/Rotation.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; + +import cn from 'classnames/bind'; + +import ScheduleTimeline from 'components/ScheduleTimeline/ScheduleTimeline'; + +import styles from './Rotation.module.css'; + +interface RotationProps {} + +const cx = cn.bind(styles); + +const Rotation: FC = (props) => { + const { layerIndex, rotationIndex, slots, color, label } = props; + + return ( +
+
+ +
+
+ ); +}; + +export default Rotation; diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.module.css b/grafana-plugin/src/components/RotationForm/RotationForm.module.css new file mode 100644 index 00000000..5d03f916 --- /dev/null +++ b/grafana-plugin/src/components/RotationForm/RotationForm.module.css @@ -0,0 +1,9 @@ +.root { + +} + +.header{ + width: 100%; + display: flex; + justify-content: space-between; +} diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.tsx b/grafana-plugin/src/components/RotationForm/RotationForm.tsx new file mode 100644 index 00000000..76619aef --- /dev/null +++ b/grafana-plugin/src/components/RotationForm/RotationForm.tsx @@ -0,0 +1,51 @@ +import React, { FC } from 'react'; + +import { IconButton, VerticalGroup, HorizontalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import Draggable from 'react-draggable'; + +import Modal from 'components/Modal/Modal'; +import Text from 'components/Text/Text'; +import UserGroups from 'components/UserGroups/UserGroups'; + +import styles from './RotationForm.module.css'; + +interface RotationFormProps { + layerId: string; + onHide: () => void; + id: number | 'new'; +} + +const cx = cn.bind(styles); + +const RotationForm: FC = (props) => { + const { onHide } = props; + + return ( + ( + +
{children}
+
+ )} + > + +
+ Rotation 1 +
+ +
+
+ + {/* + + */} +
+
+ ); +}; + +export default RotationForm; diff --git a/grafana-plugin/src/components/Rotations/Rotations.helpers.ts b/grafana-plugin/src/components/Rotations/Rotations.helpers.ts new file mode 100644 index 00000000..25427611 --- /dev/null +++ b/grafana-plugin/src/components/Rotations/Rotations.helpers.ts @@ -0,0 +1,83 @@ +import dayjs from 'dayjs'; + +export const getRandomTimeslots = (count: number = 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(), getRandomUser()], + }); + } + return slots; +}; + +const L1_COLORS = [ + '#3D71D9', + '#1A6BE8', + '#6D609C', + '#50639C', + '#8214A0', + '#44449F', + '#4D3B72', + '#273C6C', +]; + +const L2_COLORS = [ + '#3CB979', + '#A49E7C', + '#188343', + '#746D46', + '#84362A', + '#464121', + '#521913', + '#414130', +]; + +const L3_COLORS = [ + '#377277', + '#797B83', + '#638282', + '#626779', + '#364E4E', + '#47494F', + '#423220', + '#44321D', +]; + +const OVERRIDE_COLORS = ['#C69B06', '#797B83', '#638282', '#626779']; + +const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS, OVERRIDE_COLORS]; + +export const getColor = (layerIndex: number, rotationIndex) => { + return COLORS[layerIndex][rotationIndex]; +}; + +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 getLabel = (layerIndex: number, rotationIndex) => { + return `L ${layerIndex + 1}-${rotationIndex + 1}`; +}; diff --git a/grafana-plugin/src/components/Rotations/Rotations.module.css b/grafana-plugin/src/components/Rotations/Rotations.module.css new file mode 100644 index 00000000..83f063ca --- /dev/null +++ b/grafana-plugin/src/components/Rotations/Rotations.module.css @@ -0,0 +1,87 @@ +.root { + border: var(--border-medium); + border-radius: 2px; + background: var(--primary-background); +} + +.current-time { + position: absolute; + left: 650px; + width: 1px; + background: #fff; + top: 0; + bottom: 0; + z-index: 1; +} + +.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 { + +} + +.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; +} + +.rotation { + background: var(--primary-background); + transition: background-color 300ms; +} + +.add-rotations-layer { + font-weight: 400; + font-size: 12px; + line-height: 16px; + text-align: center; + padding: 12px; + color: rgba(204, 204, 220, 0.65); +} diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx new file mode 100644 index 00000000..90f803c4 --- /dev/null +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -0,0 +1,115 @@ +import React, { Component, useMemo, useState } from 'react'; + +import { ValuePicker, IconButton, Icon, HorizontalGroup, Button } from '@grafana/ui'; +import cn from 'classnames/bind'; +import * as dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +import Rotation from 'components/Rotation/Rotation'; +import RotationForm from 'components/RotationForm/RotationForm'; +import ScheduleTimeline from 'components/ScheduleTimeline/ScheduleTimeline'; +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; + +import { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; + +import styles from './Rotations.module.css'; + +const cx = cn.bind(styles); + +interface RotationsProps { + title: string; + startMoment: dayjs.Dayjs; +} + +type Layer = { + id: string; +}; + +interface RotationsState { + layerIdToCreateRotation?: Layer['id']; +} + +class Rotations extends Component { + state: RotationsState = { + //layerIdToCreateRotation: '12', + }; + + render() { + const { title, startMoment } = this.props; + const { layerIdToCreateRotation } = this.state; + + const layers = [ + { id: 0, title: 'Layer 1' }, + { id: 1, title: 'Layer 2' }, + { id: 2, title: 'Layer 3' }, + { id: 3, title: 'Layer 4' }, + ]; + + const rotations = [{ slots: getRandomTimeslots() }, { slots: getRandomTimeslots() }]; + + return ( + <> +
+
+ +
{title}
+ ({ + label: title, + value: id, + }))} + onChange={this.handleAddRotation} + variant="secondary" + size="md" + /> +
+
+
+ {layers.map((layer, layerIndex) => ( +
+
+
+ + Layer {layerIndex + 1} + +
+
+
+ +
+ {rotations.map((rotation, rotationIndex) => ( + + ))} +
+
+
+
+ ))} +
Add rotations layer +
+
+
+ {layerIdToCreateRotation && ( + { + this.setState({ layerIdToCreateRotation: undefined }); + }} + /> + )} + + ); + } + + handleAddRotation = (option) => { + this.setState({ layerIdToCreateRotation: option.value }); + }; +} + +export default Rotations; diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css new file mode 100644 index 00000000..090e8665 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css @@ -0,0 +1,43 @@ +.root { + +} + +.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 { + min-width: 276px; +} + +/* +.tooltip__type_link { + border: 1px solid #6CCF8E; + background: #132322; +} + +.tooltip__type_warning { + border: 1px solid #F8D06B; + background: #3A301E; +} +*/ + diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx new file mode 100644 index 00000000..6f4fdac1 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx @@ -0,0 +1,67 @@ +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: string; +} + +const typeToIcon = { + link: 'link', + warning: 'exclamation-triangle', +}; + +const typeToColor = { + link: 'success', + warning: 'warning', +}; + +const typeToBorderColor = { + link: '#6CCF8E', + warning: '#F8D06B', +}; + +const typeToBackgroundColor = { + link: '#132322', + warning: '#3A301E', +}; + +const cx = cn.bind(styles); + +const ScheduleCounter: FC = (props) => { + const { type, count, tooltipTitle, tooltipContent } = props; + + return ( + + + {tooltipTitle} + +
+ + +
+ } + > +
+ + + {count} + +
+
+ ); +}; + +export default ScheduleCounter; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css new file mode 100644 index 00000000..fde9b4f0 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css @@ -0,0 +1,45 @@ +.root { + padding: 6px 10px; + gap: 10px; + background: var(--primary-background); + border: var(--border-medium); + border-radius: 2px; +} + +.details{ + width: 276px; +} + +.progress{ + width:100%; + height: 16px; + background-color: var( --secondary-background-shade); + position: relative; +} + +.progress-filler{ + height: 100%; + position: absolute; +} + +.progress-filler__type_success{ + background-color: var(--success-text-color); +} + +.progress-filler__type_warning{ + background-color: var(--warning-text-color); +} + +.quality-text{ + float: right; + line-height: 16px; + margin-right: 3px; +} + +.quality-text__type_success{ + color: var(--primary-text-color); +} + +.quality-text__type_warning{ + color: #111217 +} diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx new file mode 100644 index 00000000..772a267c --- /dev/null +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -0,0 +1,97 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { HorizontalGroup, VerticalGroup, Icon, IconButton, Tooltip } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Text from 'components/Text/Text'; + +import styles from './ScheduleQuality.module.css'; + +interface ScheduleQualityProps { + quality: number; +} + +const cx = cn.bind(styles); + +const ScheduleQuality: FC = (props) => { + const { quality } = props; + + return ( + }> +
+ + + Quality: + {Math.floor(quality * 100)}% + +
+
+ ); +}; + +interface ScheduleQualityDetailsProps { + quality: number; +} + +const SheduleQualityDetails = (props: ScheduleQualityDetailsProps) => { + const { quality } = props; + + const [expanded, setExpanded] = useState(true); + + const type = quality > 0.8 ? 'success' : 'warning'; + + const qualityPercent = quality * 100; + + const handleExpandClick = useCallback(() => { + setExpanded((expanded) => !expanded); + }, []); + + return ( +
+ + Schedule quality +
+
+
+ {qualityPercent}% +
{' '} +
+
+ {type === 'success' && ( + + You are doing a great job!
+ Schedule is well balanced for all members. +
+ )} + {type === 'warning' && Your schedule has balance problems.} +
+ + + + + Calculation methodology + + + + {expanded && ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum purus egestas porta ultricies. + Sed quis maximus sem. Phasellus semper pulvinar sapien ac euismod. + + )} + +
+
+ ); +}; + +export default ScheduleQuality; diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css new file mode 100644 index 00000000..f9989044 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -0,0 +1,60 @@ +.root { + height: 28px; + background: #3274D9; + border-radius: 2px; + position: relative; + display: flex; + gap: 4px; +} + +.root__inactive { + opacity: 0.5; +} + +.title { + padding: 5px; + z-index: 1; + color: #ffffff; + font-size: 12px; + font-weight: 500; +} + +.label { + background: rgba(255, 255, 255, 0.7); + border-radius: 2px; + display: inline-block; + padding: 2px 4px; + margin: 4px; + line-height: 16px; + z-index: 1; +} + +.striped { + --color: rgba(17, 18, 23, 0.3); + position: absolute; + top: 0; + bottom: 0; + opacity: 0.4; + height: 100%; + background: repeating-linear-gradient( + -45deg, + var(--color), + var(--color) 4px, + transparent 4px, + transparent 8px + ); +} + +.details{ + width: 300px; +} + +.details-user-status{ + width: 10px; + height: 10px; + border-radius: 50%; +} + +.details-user-status__type_success{ + background-color: var(--success-text-color); +} diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx new file mode 100644 index 00000000..a3461c59 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -0,0 +1,104 @@ +import React, { FC } from 'react'; + +import { HorizontalGroup, VerticalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Line from 'components/ScheduleUserDetails/img/line.svg'; +import Text from 'components/Text/Text'; + +import styles from './ScheduleSlot.module.css'; + +interface ScheduleSlotProps { + color: string; + user: string; + label: string; +} + +const cx = cn.bind(styles); + +const ScheduleSlot: FC = (props) => { + const { color, user, inactive, label } = props; + + const left = Math.random() * 50; + const right = 100 - (left + 20 + Math.random() * 30); + + const width = Math.random() * 150 + 100; + + let title = user; + if (width < 150) { + title = title + .split(' ') + .map((word) => word.charAt(0).toUpperCase()) + .join(''); + } + + return ( + }> +
+
+ {label && ( +
+ {label} +
+ )} +
{title}
+
+ + ); +}; + +export default ScheduleSlot; + +interface ScheduleSlotDetailsProps {} + +const ScheduleSlotDetails = (props) => { + const { user, currentUser } = props; + + const userStatus = 'success'; + + return ( +
+ + + +
+ {user} + + + + + + 30 apr, 7:54 + + + + + 30 apr, 00:00 + 30 apr, 23:59 + + + + + + + Maxim Mordasov + + 30 apr, 12:54 + 29 apr, 20:00 + 30 apr, 20:00 + + + +
+ ); +}; diff --git a/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css b/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css new file mode 100644 index 00000000..ca4a762d --- /dev/null +++ b/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css @@ -0,0 +1,24 @@ +.root { +} + +.slots { + display: flex; + gap: 4px; + padding: 0 2px; +} + +.users{ + display: flex; + flex-direction: column; + gap:1px; +} + +.current-time { + position: absolute; + left: 450px; + width: 1px; + background: #fff; + top: -10px; + bottom: -10px; + z-index: 1; +} diff --git a/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx b/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx new file mode 100644 index 00000000..76e2be77 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx @@ -0,0 +1,47 @@ +import React, { FC, useMemo, useState } from 'react'; + +import cn from 'classnames/bind'; +import * as dayjs from 'dayjs'; + +import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; +import Text from 'components/Text/Text'; + +import styles from './ScheduleTimeline.module.css'; + +const cx = cn.bind(styles); + +interface ScheduleSlotState {} + +interface ScheduleTimelineProps { + layerIndex: number; + rotationIndex: number; +} + +const ScheduleTimeline: FC = (props) => { + const { layerIndex, rotationIndex, color, slots, label } = props; + + return ( +
+ {/*
*/} +
+ {slots.map(({ users, inactive }, slotIndex) => { + return ( +
+ {users.map((user, userIndex) => ( + + ))} +
+ ); + })} +
+
+ ); +}; + +export default ScheduleTimeline; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css new file mode 100644 index 00000000..552802a6 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css @@ -0,0 +1,39 @@ +.root { + width: 220px; + padding: 10px; +} + +.oncall-badge { + line-height: 16px; + color: var(--primary-background); + padding: 2px 7px; + border-radius: 4px; + margin-bottom: 10px; +} + +.oncall-badge__type_now{ + background: #6CCF8E; +} + +.oncall-badge__type_inside{ + background: #CCCCDC; +} + +.oncall-badge__type_outside{ + background: rgba(204, 204, 220, 0.4); +} + +.hr{ + width: 100%; + margin:0 -11px; +} + +.times{ + display: flex; + flex-direction: column; +} +.icon{ + color: #CCCCDC; +} + + diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx new file mode 100644 index 00000000..11688a49 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx @@ -0,0 +1,127 @@ +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 Line from './img/line.svg'; + +import styles from './ScheduleUserDetails.module.css'; + +interface ScheduleUser { + name: string; + avatar: string; + tz: string; +} + +interface ScheduleUserDetailsProps { + currentMoment: dayjs.Dayjs; + user: ScheduleUser; +} + +const cx = cn.bind(styles); + +enum UserOncallStatus { + Now = 'now', + Outside = 'outside', + Inside = 'inside', +} + +const userOncallStatusToText = { + [UserOncallStatus.Now]: 'Oncall now', + [UserOncallStatus.Inside]: 'Inside working hours', + [UserOncallStatus.Outside]: 'Outside working hours', +}; + +const ScheduleUserDetails: FC = (props) => { + const { user, currentMoment } = props; + + const userStatus = + Math.random() > 0.66 + ? UserOncallStatus.Now + : Math.random() > 0.33 + ? UserOncallStatus.Inside + : UserOncallStatus.Outside; + + const userMoment = currentMoment.tz(user.tz); + const userOffset = userMoment.utcOffset(); + const userOffsetHours = userOffset / 60; + const userOffsetHoursStr = + userOffsetHours > 0 ? `( +${userOffsetHours} GMT)` : userOffset < 0 ? `( ${userOffsetHours} GMT)` : `(GMT)`; + + return ( +
+ + + + + + + {user.name} + + {`${userMoment.format('DD MMM, HH:mm')}`} {userOffsetHoursStr} + +
+ {userOncallStatusToText[userStatus]} +
+ + + Next shift +
+ + + + 30 apr, 00:00 + 30 apr, 23:59 + + +
+
+ + Last shift +
+ + + + 30 apr, 00:00 + 30 apr, 23:59 + + +
+
+
+
+
+ + Contacts + + + mail@grafana.com + + + + @slackid + + + + +39 555 449 00 00 + + +
+
+ ); +}; + +export default ScheduleUserDetails; diff --git a/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg b/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg new file mode 100644 index 00000000..6dcaf594 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleUserDetails/img/line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css new file mode 100644 index 00000000..b5742c32 --- /dev/null +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css @@ -0,0 +1,50 @@ +.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: 16px; +} + +.weekday-time { + width: 50%; +} + +.weekday-time-title { + display: inline-block; + transform: translate(-50%, 0); +} + +.weekday-time-title__hidden{ + visibility: hidden; +} diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx new file mode 100644 index 00000000..1693da75 --- /dev/null +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -0,0 +1,65 @@ +import React, { FC, useMemo } from 'react'; +import cn from 'classnames/bind'; + +import styles from './TimelineMarks.module.css'; +import * as dayjs from 'dayjs'; + +interface TimelineMarksProps { + hideTimeMarks: boolean; + startMoment: dayjs.Dayjs; +} + +const cx = cn.bind(styles); + +const TimelineMarks: FC = (props) => { + const { hideTimeMarks, startMoment } = props; + + const momentsToRender = useMemo(() => { + const hoursToSplit = 12; + + const momentsToRender = []; + const jLimit = 24 / hoursToSplit; + + for (let i = 0; i < 7; i++) { + const d = dayjs(startMoment).utc().add(i, 'days'); + const obj = { moment: d, moments: [] }; + for (let j = 0; j < jLimit; j++) { + const m = dayjs(d) + .utc() + .add(j * hoursToSplit, 'hour'); + obj.moments.push(m); + } + momentsToRender.push(obj); + } + return momentsToRender; + }, [startMoment]); + + return ( +
+ {momentsToRender.map((m, i) => { + return ( +
+
+ {m.moment.format('DD MMM')} +
+
+ {m.moments.map((mm, j) => ( +
+
+ {mm.format('HH:mm')} +
+
+ ))} +
+
+ ); + })} +
+ ); +}; + +export default TimelineMarks; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts new file mode 100644 index 00000000..5f6348a5 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -0,0 +1,74 @@ +import { getRandomTimezone } from '../UsersTimezones/UsersTimezones.helpers'; + +export const getRandomGroups = () => { + return [ + [ + { id: 13, name: 'Maxim Mordasov', tz: 'Europe/Moscow' }, + { id: 2, name: 'Raphael Batyrbaev', tz: 'Europe/Rome' }, + ], + [ + { id: 5, name: 'Michael Derynck', tz: 'America/Vancouver' }, + { id: 10, name: 'Ildar Iskhakov', tz: 'Asia/Yerevan' }, + { id: 7, name: 'Innokentii Konstantinov', tz: 'Asia/Yerevan' }, + ], + [ + { id: 5, name: 'Michael Derynck', tz: 'America/Vancouver' }, + { id: 10, name: 'Vadim Stepanov', tz: 'Asia/Yekaterinburg' }, + { id: 7, name: 'Innokentii Konstantinov', tz: 'Asia/Yerevan' }, + ], + ]; +}; + +export const toPlainArray = (groups) => { + let i = 0; + + const items = []; + groups.forEach((group, groupIndex) => { + items.push({ + index: i++, + key: `group-${groupIndex}`, + type: 'group', + data: { name: `Group ${groupIndex + 1}` }, + }); + + groups[groupIndex].forEach((item, itemIndex) => { + items.push({ + index: i++, + key: `item-${groupIndex}-${itemIndex}`, + type: 'item', + data: item, + }); + }); + }); + + return items; +}; + +export const fromPlainArray = ( + items, + createNewGroup = false, + deleteEmptyGroups = true, +) => { + const groups = []; + + return items + .reduce((memo, item, currentIndex) => { + if (item.type === 'item') { + let lastGroup = memo[memo.length - 1]; + if ( + !lastGroup || + (createNewGroup && currentIndex === items.length - 1) + ) { + lastGroup = []; + memo.push(lastGroup); + } + + lastGroup.push(item.data); + } else { + memo.push([]); + } + + return memo; + }, []) + .filter((group) => !deleteEmptyGroups || group.length); +}; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css new file mode 100644 index 00000000..43b142d9 --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -0,0 +1,84 @@ +.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; + padding: 6px 10px; + display: flex; + justify-content: space-between; +} + +.user:hover{ + background: var(--hover-selected-hardcoded); +} + +.delete-icon{ + display: none; +} + +.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; + +} diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx new file mode 100644 index 00000000..9c1821ca --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -0,0 +1,94 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +import { VerticalGroup, HorizontalGroup, IconButton } 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 { fromPlainArray, getRandomGroups, toPlainArray } from './UserGroups.helpers'; + +import styles from './UserGroups.module.css'; + +interface UserGroupsProps {} + +const cx = cn.bind(styles); + +const DragHandle = () => ; + +const SortableHandleHoc = SortableHandle(DragHandle); + +const SortableItem = SortableElement(({ children }) => children); + +const SortableList = SortableContainer(({ items, onAddUserGroup }) => { + return ( +
    + {items.map((item) => + item.type === 'item' ? ( + +
  • +
    + {item.data.name} ({item.data.tz}) +
    +
    + + + +
    +
  • +
    + ) : ( + +
  • {item.data.name}
  • +
    + ) + )} + +
  • + Add user group + +
  • +
    +
+ ); +}); + +const UserGroups = () => { + const [groups, setGroups] = useState(getRandomGroups()); + + const handleAddUserGroup = useCallback(() => { + setGroups((oldGroups) => [...oldGroups, []]); + }, [groups]); + + const items = useMemo(() => toPlainArray(groups), [groups]); + + const onSortEnd = useCallback( + ({ oldIndex, newIndex, collection, isKeySorting }) => { + const newPlainArray = arrayMoveImmutable(items, oldIndex, newIndex); + + setGroups(fromPlainArray(newPlainArray, newIndex > items.length)); + }, + [items] + ); + + return ( +
+ + + {/*
+ Add user group + +
*/} +
+
+ ); +}; + +export default UserGroups; diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css new file mode 100644 index 00000000..3584b59a --- /dev/null +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css @@ -0,0 +1,3 @@ +.root { + width: 300px; +} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx new file mode 100644 index 00000000..4d5a8f5d --- /dev/null +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -0,0 +1,60 @@ +import React, { FC, useCallback, useMemo } from 'react'; + +import { Select } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { get } from 'lodash-es'; + +import { Timezone } from 'models/timezone/timezone.types'; +import { User } from 'models/user/user.types'; + +import styles from './UserTimezoneSelect.module.css'; + +interface UserTimezoneSelectProps { + users: User[]; + value: Timezone; + onChange: (value: Timezone) => void; +} + +const cx = cn.bind(styles); + +const UserTimezoneSelect: FC = (props) => { + const { users, value, onChange } = props; + + const options = useMemo(() => { + return users.reduce((memo, user) => { + let item = memo.find((item) => item.label === user.tz); + + if (!item) { + item = { value: user.pk, label: user.tz, imgUrl: user.avatar, description: user.name }; + memo.push(item); + } else { + item.description += ', ' + user.name; + // item.imgUrl = undefined; + } + + return memo; + }, []); + }, [users]); + + const selectValue = useMemo(() => { + const user = users.find((user) => user.tz === value); + return user.pk; + }, [value, users]); + + const handleChange = useCallback( + ({ value }) => { + const user = users.find((user) => user.pk === value); + + onChange(user.tz); + }, + [users] + ); + + return ( +
+