From b83fcb092ca804176c4816f186ab21bc4cdab1a7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 15 Jun 2022 15:13:44 +0300 Subject: [PATCH 01/60] add new schedules page stub --- grafana-plugin/.eslintrc.js | 3 +- grafana-plugin/src/pages/index.ts | 15 ++++++++ .../src/pages/schedule/Schedule.module.css | 4 +++ .../src/pages/schedule/Schedule.tsx | 30 ++++++++++++++++ .../pages/schedules_NEW/Schedules.module.css | 4 +++ .../src/pages/schedules_NEW/Schedules.tsx | 34 +++++++++++++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 grafana-plugin/src/pages/schedule/Schedule.module.css create mode 100644 grafana-plugin/src/pages/schedule/Schedule.tsx create mode 100644 grafana-plugin/src/pages/schedules_NEW/Schedules.module.css create mode 100644 grafana-plugin/src/pages/schedules_NEW/Schedules.tsx diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index a3c46944..97527e17 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -9,6 +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 }], 'react/prop-types': 'warn', 'react/display-name': 'warn', 'react/jsx-key': 'warn', @@ -16,7 +17,7 @@ module.exports = { 'react/jsx-no-target-blank': 'warn', 'no-restricted-imports': 'warn', eqeqeq: 'warn', - 'no-duplicate-imports': 'warn', + 'no-duplicate-imports': 'error', 'rulesdir/no-relative-import-paths': ['error', { allowSameFolder: true }], 'import/order': [ 'error', diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index 5df8fde2..30f83e48 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -13,7 +13,9 @@ import MaintenancePage2 from 'pages/maintenance/Maintenance'; import MigrationTool from 'pages/migration-tool/MigrationTool'; import OrganizationLogPage2 from 'pages/organization-logs/OrganizationLog'; import OutgoingWebhooks2 from 'pages/outgoing_webhooks/OutgoingWebhooks'; +import SchedulePage from 'pages/schedule/Schedule'; import SchedulesPage2 from 'pages/schedules/Schedules'; +import SchedulesPage from 'pages/schedules_NEW/Schedules'; import SettingsPage2 from 'pages/settings/SettingsPage'; import Test from 'pages/test/Test'; import UsersPage2 from 'pages/users/Users'; @@ -62,9 +64,22 @@ export const pages: PageDefinition[] = [ { component: SchedulesPage2, icon: 'calendar-alt', + id: 'schedules-old', + text: 'Schedules OLD', + }, + { + component: SchedulesPage, + icon: 'calendar-alt', id: 'schedules', text: 'Schedules', }, + { + component: SchedulePage, + icon: 'calendar-alt', + id: 'schedule', + text: 'Schedule', + hideFromTabs: true, + }, { component: ChatOpsPage, icon: 'comments-alt', diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css new file mode 100644 index 00000000..538f682e --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -0,0 +1,4 @@ +.root { + margin-top: 24px; +} + diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx new file mode 100644 index 00000000..37e00567 --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import PluginLink from 'components/PluginLink/PluginLink'; +import GSelect from 'containers/GSelect/GSelect'; +import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './Schedule.module.css'; + +const cx = cn.bind(styles); + +interface SchedulePageProps {} + +interface SchedulePageState {} + +@observer +class SchedulePage extends React.Component { + async componentDidMount() {} + + componentDidUpdate() {} + + render() { + return
Hello!
; + } +} + +export default withMobXProviderContext(SchedulePage); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css new file mode 100644 index 00000000..538f682e --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -0,0 +1,4 @@ +.root { + margin-top: 24px; +} + diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx new file mode 100644 index 00000000..71195b36 --- /dev/null +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import PluginLink from 'components/PluginLink/PluginLink'; +import GSelect from 'containers/GSelect/GSelect'; +import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './Schedules.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesPageProps {} + +interface SchedulesPageState {} + +@observer +class SchedulesPage extends React.Component { + async componentDidMount() {} + + componentDidUpdate() {} + + render() { + return ( +
+ Schedule 1 +
+ ); + } +} + +export default withMobXProviderContext(SchedulesPage); From 9afb637aed6ff2d995c7229df23672a9cf2759e2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 17 Jun 2022 12:19:34 +0300 Subject: [PATCH 02/60] add user timezone select --- grafana-plugin/.eslintrc.js | 2 +- grafana-plugin/package.json | 5 +- grafana-plugin/src/GrafanaPluginRootPage.tsx | 6 + .../src/components/Avatar/Avatar.tsx | 4 +- .../src/components/Modal/Modal.module.css | 32 + grafana-plugin/src/components/Modal/Modal.tsx | 45 + .../components/Rotation/Rotation.module.css | 28 + .../src/components/Rotation/Rotation.tsx | 31 + .../RotationForm/RotationForm.module.css | 9 + .../components/RotationForm/RotationForm.tsx | 51 + .../components/Rotations/Rotations.helpers.ts | 83 + .../components/Rotations/Rotations.module.css | 87 + .../src/components/Rotations/Rotations.tsx | 115 ++ .../ScheduleCounter.module.css | 43 + .../ScheduleCounter/ScheduleCounter.tsx | 67 + .../ScheduleQuality.module.css | 45 + .../ScheduleQuality/ScheduleQuality.tsx | 97 ++ .../ScheduleSlot/ScheduleSlot.module.css | 60 + .../components/ScheduleSlot/ScheduleSlot.tsx | 104 ++ .../ScheduleTimeline.module.css | 24 + .../ScheduleTimeline/ScheduleTimeline.tsx | 47 + .../ScheduleUserDetails.module.css | 39 + .../ScheduleUserDetails.tsx | 127 ++ .../ScheduleUserDetails/img/line.svg | 5 + .../TimelineMarks/TimelineMarks.module.css | 50 + .../TimelineMarks/TimelineMarks.tsx | 65 + .../UserGroups/UserGroups.helpers.ts | 74 + .../UserGroups/UserGroups.module.css | 84 + .../src/components/UserGroups/UserGroups.tsx | 94 + .../UserTimezoneSelect.module.css | 3 + .../UserTimezoneSelect/UserTimezoneSelect.tsx | 60 + .../UsersTimezones/UsersTimezones.module.css | 110 ++ .../UsersTimezones/UsersTimezones.tsx | 135 ++ .../src/models/timezone/timezone.types.ts | 598 +++++++ grafana-plugin/src/models/user/user.types.ts | 2 + .../src/pages/schedule/Schedule.helpers.ts | 653 +++++++ .../src/pages/schedule/Schedule.module.css | 25 +- .../src/pages/schedule/Schedule.tsx | 155 +- grafana-plugin/src/vars.css | 9 + grafana-plugin/yarn.lock | 1541 ++++++++++++++--- 40 files changed, 4581 insertions(+), 233 deletions(-) create mode 100644 grafana-plugin/src/components/Modal/Modal.module.css create mode 100644 grafana-plugin/src/components/Modal/Modal.tsx create mode 100644 grafana-plugin/src/components/Rotation/Rotation.module.css create mode 100644 grafana-plugin/src/components/Rotation/Rotation.tsx create mode 100644 grafana-plugin/src/components/RotationForm/RotationForm.module.css create mode 100644 grafana-plugin/src/components/RotationForm/RotationForm.tsx create mode 100644 grafana-plugin/src/components/Rotations/Rotations.helpers.ts create mode 100644 grafana-plugin/src/components/Rotations/Rotations.module.css create mode 100644 grafana-plugin/src/components/Rotations/Rotations.tsx create mode 100644 grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css create mode 100644 grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx create mode 100644 grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css create mode 100644 grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx create mode 100644 grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css create mode 100644 grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx create mode 100644 grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css create mode 100644 grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx create mode 100644 grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css create mode 100644 grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx create mode 100644 grafana-plugin/src/components/ScheduleUserDetails/img/line.svg create mode 100644 grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css create mode 100644 grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx create mode 100644 grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts create mode 100644 grafana-plugin/src/components/UserGroups/UserGroups.module.css create mode 100644 grafana-plugin/src/components/UserGroups/UserGroups.tsx create mode 100644 grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css create mode 100644 grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx create mode 100644 grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css create mode 100644 grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx create mode 100644 grafana-plugin/src/models/timezone/timezone.types.ts create mode 100644 grafana-plugin/src/pages/schedule/Schedule.helpers.ts 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 ( +
+ } + placeholder="Search..." + value={value.searchTerm} + onChange={onSearchTermChangeCallback} + /> + + + + + + + + +
+ ); +}; + +export default SchedulesFilters; diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts new file mode 100644 index 00000000..ed9cad62 --- /dev/null +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.types.ts @@ -0,0 +1,13 @@ +import { Moment } from 'moment'; + +enum ScheduleType { + Web = 'Web', + iCal = 'iCal', + API = 'API', +} + +export interface SchedulesFiltersType { + searchTerm: string; + type: string; + status: string; +} diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css new file mode 100644 index 00000000..3822a863 --- /dev/null +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -0,0 +1,40 @@ +.root { + width: 100%; +} + +.root table{ + width: 100%; +} + + +.root tr { + border-bottom: 1px solid #33363B; + height: 60px; +} + +.root tr:hover { + background: var(--secondary-background) + +} + + +.root td { + min-height: 60px; +} + +.pagination { + width: 100%; + margin-top: 20px; +} + +.expand-icon { + padding: 10px; + pointer-events: none; + transform: rotate(-90deg); + transform-origin: center; + transition: transform 0.2s; +} + +.expand-icon__expanded { + transform: rotate(0deg); +} diff --git a/grafana-plugin/src/components/Table/Table.tsx b/grafana-plugin/src/components/Table/Table.tsx new file mode 100644 index 00000000..95bcbe8b --- /dev/null +++ b/grafana-plugin/src/components/Table/Table.tsx @@ -0,0 +1,69 @@ +import React, { FC, useState, useCallback, useMemo, ChangeEvent } from 'react'; + +import { Pagination, Checkbox, Icon, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import Table from 'rc-table'; +import { TableProps } from 'rc-table/lib/Table'; + +import { ExpandIcon } from 'icons'; + +import styles from './Table.module.css'; + +const cx = cn.bind(styles); + +export interface Props extends TableProps { + loading?: boolean; + pagination?: { + page: number; + total: number; + onChange: (page: number) => void; + }; + rowSelection?: { + selectedRowKeys: string[]; + onChange: (selectedRowKeys: string[]) => void; + }; + expandable?: { + expandedRowKeys: string[]; + expandedRowRender: (item: any) => React.ReactNode; + onExpandedRowsChange: (rows: string[]) => void; + expandRowByClick: boolean; + expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode; + onExpand?: (expanded: boolean, item: any) => void; + }; +} + +const GTable: FC = (props) => { + const { columns, data, className, pagination, loading, rowKey, expandable, ...restProps } = props; + + const { page, total: numberOfPages, onChange: onNavigate } = pagination || {}; + + if (expandable) { + expandable.expandIcon = ({ expanded, record }) => { + return ( +
+ +
+ ); + }; + } + + return ( + + + {pagination && ( +
+ +
+ )} + + ); +}; + +export default GTable; diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index 1693da75..c89f8cb6 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -1,18 +1,18 @@ import React, { FC, useMemo } from 'react'; -import cn from 'classnames/bind'; -import styles from './TimelineMarks.module.css'; +import cn from 'classnames/bind'; import * as dayjs from 'dayjs'; +import styles from './TimelineMarks.module.css'; + interface TimelineMarksProps { - hideTimeMarks: boolean; startMoment: dayjs.Dayjs; } const cx = cn.bind(styles); const TimelineMarks: FC = (props) => { - const { hideTimeMarks, startMoment } = props; + const { startMoment } = props; const momentsToRender = useMemo(() => { const hoursToSplit = 12; @@ -39,9 +39,7 @@ const TimelineMarks: FC = (props) => { {momentsToRender.map((m, i) => { return (
-
- {m.moment.format('DD MMM')} -
+
{m.moment.format('DD MMM')}
{m.moments.map((mm, j) => (
diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css new file mode 100644 index 00000000..a61fd276 --- /dev/null +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css @@ -0,0 +1,3 @@ +.root { + +} \ No newline at end of file diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx new file mode 100644 index 00000000..fb629a9b --- /dev/null +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import { useStore } from 'state/useStore'; + +import styles from './SchedulesFilters.module.css'; + +const cx = cn.bind(styles); + +interface SchedulesFiltersProps {} + +const SchedulesFilters = observer((props: SchedulesFiltersProps) => { + const { } = props; + + const store = useStore(); + + const { } = store; + + return ( +
+ ); +}); + +export default SchedulesFilters; diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index 7b77d8f6..f979ffb1 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -226,3 +226,15 @@ export const GrafanaIcon = (props: IconProps) => ( ); + +export const ExpandIcon = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index d22c4bd8..800df6d2 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,6 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { 'Calendar', 'Ical', + 'API', } export interface Schedule { diff --git a/grafana-plugin/src/models/timezone/timezone.helpers.ts b/grafana-plugin/src/models/timezone/timezone.helpers.ts new file mode 100644 index 00000000..c2627b97 --- /dev/null +++ b/grafana-plugin/src/models/timezone/timezone.helpers.ts @@ -0,0 +1,10 @@ +import dayjs from 'dayjs'; + +export const getTzOffsetString = (moment: dayjs.Dayjs) => { + const userOffset = moment.utcOffset(); + const userOffsetHours = userOffset / 60; + const userOffsetHoursStr = + userOffsetHours > 0 ? `+${userOffsetHours} GMT` : userOffset < 0 ? `${userOffsetHours} GMT` : `GMT`; + + return userOffsetHoursStr; +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 31ac6ea5..30fa1744 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; +import { AppRootProps } from '@grafana/data'; import { Button, HorizontalGroup, VerticalGroup, RadioButtonGroup, IconButton, ToolbarButton } from '@grafana/ui'; import cn from 'classnames/bind'; import * as dayjs from 'dayjs'; @@ -25,7 +26,7 @@ import styles from './Schedule.module.css'; const cx = cn.bind(styles); -interface SchedulePageProps {} +interface SchedulePageProps extends AppRootProps {} interface SchedulePageState { startMoment: dayjs.Dayjs; @@ -51,13 +52,17 @@ class SchedulePage extends React.Component render() { const { startMoment, schedulePeriodType, renderType, users, tz } = this.state; + const { query } = this.props; return (
- Schedule Team 1 + + + + Schedule Team {query.id} { + const schedules = []; + for (let i = 0; i < 20; i++) { + schedules.push({ + id: i + 1, + name: `Schedule Team ${i + 1}`, + users: getRandomUsers(2), + chatOps: '#irm-incidents-primary', + quality: 20 + Math.floor(Math.random() * 80), + }); + } + + return schedules; +}; + +export const getRandomTimeslots = (count = 6) => { + const slots = []; + for (let i = 0; i < count; i++) { + const start = dayjs() + .startOf('day') + .add(i * 4, 'hour'); + const end = dayjs() + .startOf('day') + .add(i * 4 + 2, 'hour'); + //const inactive = end.isBefore(dayjs()); + const inactive = false; + + slots.push({ + start, + end, + inactive, + users: [getRandomUser()], + color: getOverrideColor(i), + }); + } + return slots; +}; diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css index 538f682e..0884c8f0 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -2,3 +2,16 @@ margin-top: 24px; } +.quality__type_success{ + color: var(--warning-text-color); +} + + +.schedule{ + position: relative; + margin: 20px 0; +} + +.root .expanded-row{ + background: var(--secondary-background); +} diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 71195b36..0f2ac804 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -1,34 +1,210 @@ import React from 'react'; +import { Button, HorizontalGroup, IconButton, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; +import dayjs from 'dayjs'; import { observer } from 'mobx-react'; +import Avatar from 'components/Avatar/Avatar'; import PluginLink from 'components/PluginLink/PluginLink'; +import Rotation from 'components/Rotation/Rotation'; +import { getColor, getLabel } from 'components/Rotations/Rotations.helpers'; +import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; +import ScheduleTimeline from 'components/ScheduleTimeline/ScheduleTimeline'; +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 GSelect from 'containers/GSelect/GSelect'; +import { Schedule } from 'models/schedule/schedule.types'; import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; +import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { withMobXProviderContext } from 'state/withStore'; +import { getRandomSchedules, getRandomTimeslots } from './Schedules.helpers'; + import styles from './Schedules.module.css'; const cx = cn.bind(styles); interface SchedulesPageProps {} -interface SchedulesPageState {} +interface SchedulesPageState { + startMoment: dayjs.Dayjs; + filters: SchedulesFiltersType; +} @observer class SchedulesPage extends React.Component { + state: SchedulesPageState = { + startMoment: dayjs().utc().startOf('week'), + schedules: getRandomSchedules(), + filters: { searchTerm: '', status: 'all', type: 'all' }, + }; + async componentDidMount() {} componentDidUpdate() {} render() { + const { schedules, filters } = this.state; + + const columns = [ + { + width: '10%', + title: 'Status', + key: 'name', + render: this.renderStatus, + }, + { + width: '30%', + title: 'Name', + key: 'name', + render: this.renderName, + }, + { + width: '30%', + title: 'OnCall', + key: 'users', + render: this.renderUsers, + }, + { + width: '20%', + title: 'ChatOps', + key: 'chatops', + render: this.renderChatOps, + }, + { + width: '10%', + title: 'Quality', + key: 'quality', + render: this.renderQuality, + }, + { + key: 'buttons', + render: this.renderButtons, + }, + ]; + + const moment = dayjs(); + return (
- Schedule 1 + + + + + + Timezone: + + {getTzOffsetString(moment)} ({dayjs.tz.guess()}) + + + + + +
cx('expanded-row'), + }} + /> + ); } + + renderSchedule = () => { + const { startMoment } = this.props; + + return ( +
+ +
+ +
+
+ ); + }; + + renderStatus = () => { + const escalationsCount = Math.floor(Math.random() * 10) + 1; + const warningsCount = Math.floor(Math.random() * 10) + 1; + + return ( + + + Grafana 1 +
+ Grafana 2 +
+ Grafana 3 + + } + /> + +
+ ); + }; + + renderName = (item: Schedule) => { + return {item.name}; + }; + + renderUsers = (item: Schedule) => { + return ( + + {item.users.map((user) => ( + + {user.name} + + ))} + + ); + }; + + renderChatOps = (item: Schedule) => { + return item.chatOps; + }; + + renderQuality = (item: Schedule) => { + const type = item.quality > 70 ? 'primary' : 'warning'; + + return {item.quality}%; + }; + + renderButtons = (item: Schedule) => { + return ( + + + + + + + ); + }; + + handleSchedulesFiltersChange = (filters: SchedulesFiltersType) => { + this.setState({ filters }); + }; + + handlePageChange = (page: number) => {}; } export default withMobXProviderContext(SchedulesPage); From a195e09689a92235652c26adc6b2320ae4dbeaa0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 24 Jun 2022 18:08:45 +0300 Subject: [PATCH 05/60] add user groups user selector --- .../components/RotationForm/RotationForm.tsx | 56 ++++++++++++++++--- .../UserGroups/UserGroups.helpers.ts | 13 +---- .../UserGroups/UserGroups.module.css | 4 ++ .../src/components/UserGroups/UserGroups.tsx | 51 +++++++++++++++-- .../src/containers/GSelect/GSelect.tsx | 16 ++++-- 5 files changed, 110 insertions(+), 30 deletions(-) diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.tsx b/grafana-plugin/src/components/RotationForm/RotationForm.tsx index 76619aef..7018f81e 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/components/RotationForm/RotationForm.tsx @@ -1,12 +1,14 @@ import React, { FC } from 'react'; -import { IconButton, VerticalGroup, HorizontalGroup } from '@grafana/ui'; +import { IconButton, VerticalGroup, HorizontalGroup, Field, Input, Button } 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 { getTzOffsetString } from 'models/timezone/timezone.helpers'; import styles from './RotationForm.module.css'; @@ -21,6 +23,8 @@ const cx = cn.bind(styles); const RotationForm: FC = (props) => { const { onHide } = props; + const moment = dayjs(); + return ( = (props) => { )} > -
+ Rotation 1 -
- -
-
+ + + + + + + - {/* - - */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + Timezone: {getTzOffsetString(moment)} + + + + +
); diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts index 5f6348a5..11fb41a7 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -1,4 +1,4 @@ -import { getRandomTimezone } from '../UsersTimezones/UsersTimezones.helpers'; +import { getRandomTimezone } from 'components/UsersTimezones/UsersTimezones.helpers'; export const getRandomGroups = () => { return [ @@ -44,21 +44,14 @@ export const toPlainArray = (groups) => { return items; }; -export const fromPlainArray = ( - items, - createNewGroup = false, - deleteEmptyGroups = true, -) => { +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) - ) { + if (!lastGroup || (createNewGroup && currentIndex === items.length - 1)) { lastGroup = []; memo.push(lastGroup); } diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index 43b142d9..38886248 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -82,3 +82,7 @@ cursor: pointer; } + +.select{ + width: 100%; +} diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index 9c1821ca..bb03be3f 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -1,11 +1,16 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { VerticalGroup, HorizontalGroup, IconButton } from '@grafana/ui'; +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 GSelect from 'containers/GSelect/GSelect'; +import UserTooltip from 'containers/UserTooltip/UserTooltip'; +import { User } from 'models/user/user.types'; +import { getRandomTimezone } from 'pages/schedule/Schedule.helpers'; import { fromPlainArray, getRandomGroups, toPlainArray } from './UserGroups.helpers'; @@ -54,12 +59,38 @@ const SortableList = SortableContainer(({ items, onAddUserGroup }) => { }); const UserGroups = () => { - const [groups, setGroups] = useState(getRandomGroups()); + const [groups, setGroups] = useState([[]]); const handleAddUserGroup = useCallback(() => { setGroups((oldGroups) => [...oldGroups, []]); }, [groups]); + const handleUserAdd = useCallback((pk: User['pk'], user: User) => { + if (!pk) { + return; + } + + setGroups((groups) => { + const newGroups = [...groups]; + const lastGroup = newGroups[groups.length - 1]; + + lastGroup.push({ pk, name: user.username, tz: getRandomTimezone() }); + + return newGroups; + }); + }, []); + + const filterUsers = useCallback( + ({ value }) => { + const userAlreadyExist = groups.some((group) => group.some((user) => user.pk === value)); + + console.log('userAlreadyExist', userAlreadyExist); + + return !userAlreadyExist; + }, + [groups] + ); + const items = useMemo(() => toPlainArray(groups), [groups]); const onSortEnd = useCallback( @@ -83,9 +114,19 @@ const UserGroups = () => { onAddUserGroup={handleAddUserGroup} //useDragHandle /> - {/*
- Add user group + -
*/} + } + filterOptions={filterUsers} + /> ); diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx index 35e0a085..ae7ae736 100644 --- a/grafana-plugin/src/containers/GSelect/GSelect.tsx +++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx @@ -35,6 +35,7 @@ interface GSelectProps { dropdownRender?: (menu: ReactElement) => ReactElement; getOptionLabel?: (item: SelectableValue) => React.ReactNode; getDescription?: (item: any) => React.ReactNode; + filterOptions?: () => boolean; } const GSelect = observer((props: GSelectProps) => { @@ -59,6 +60,7 @@ const GSelect = observer((props: GSelectProps) => { getOptionLabel, showWarningIfEmptyValue = false, getDescription, + filterOptions = () => true, } = props; const store = useStore(); @@ -91,12 +93,14 @@ const GSelect = observer((props: GSelectProps) => { const searchResult = model.getSearchResult(query); const items = Array.isArray(searchResult.results) ? searchResult.results : searchResult; - const options = items.map((item: any) => ({ - value: item[valueField], - label: get(item, displayField), - imgUrl: item.avatar_url, - description: getDescription && getDescription(item), - })); + const options = items + .map((item: any) => ({ + value: item[valueField], + label: get(item, displayField), + imgUrl: item.avatar_url, + description: getDescription && getDescription(item), + })) + .filter(filterOptions); return options; }); }; From 87db3708a4b1ee307f4dc1000d1a8aecc92b7a35 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 28 Jun 2022 16:28:18 +0300 Subject: [PATCH 06/60] add proper controls to rotation form --- .../RotationForm/RotationForm.module.css | 37 ++++- .../components/RotationForm/RotationForm.tsx | 156 +++++++++++++++--- grafana-plugin/src/vars.css | 4 +- 3 files changed, 175 insertions(+), 22 deletions(-) diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.module.css b/grafana-plugin/src/components/RotationForm/RotationForm.module.css index 5d03f916..69aaa60f 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/components/RotationForm/RotationForm.module.css @@ -2,8 +2,43 @@ } -.header{ +.header { width: 100%; display: flex; justify-content: space-between; } + +.control { + width: 195px; +} + + +.date-time-picker { + display: block; +} + +.inline-switch { + height: 20px; +} + + +.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; +} diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.tsx b/grafana-plugin/src/components/RotationForm/RotationForm.tsx index 7018f81e..653425e0 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/components/RotationForm/RotationForm.tsx @@ -1,6 +1,17 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback, useState } from 'react'; -import { IconButton, VerticalGroup, HorizontalGroup, Field, Input, Button } from '@grafana/ui'; +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'; @@ -23,11 +34,35 @@ const cx = cn.bind(styles); const RotationForm: FC = (props) => { const { onHide } = props; + const [repeatEveryValue, setRepeatEveryValue] = useState(1); + const [repeatEveryPeriod, setRepeatEveryPeriod] = useState('days'); + const [selectedDays, setSelectedDays] = useState(['Tuesday']); + const [shiftStart, setShiftStart] = useState(dateTime('2021-05-05 12:00:00')); + const [shiftEnd, setShiftEnd] = useState(dateTime('2021-05-05 12:00:00')); + const [rotationStart, setRotationStart] = useState(dateTime('2021-05-05 12:00:00')); + const [endLess, setEndless] = useState(true); + const [rotationEnd, setRotationEnd] = useState(dateTime('2021-05-05 12:00:00')); + + const handleChangeEndless = useCallback( + (event: React.ChangeEvent) => { + setEndless(!event.currentTarget.checked); + }, + [endLess] + ); + + const handleRepeatEveryValueChange = useCallback((option) => { + setRepeatEveryValue(option.value); + }, []); + + const handleRepeatEveryPeriodChange = useCallback((option) => { + setRepeatEveryPeriod(option.value); + }, []); + const moment = dayjs(); return ( ( @@ -47,30 +82,79 @@ const RotationForm: FC = (props) => { -
+ {/*
*/} - - + + + + + + - - - - - - - - - - + + + Rotation end + + + + } + > + {endLess ? ( + { + setEndless(false); + }} + /> + ) : ( + + )} @@ -86,4 +170,36 @@ const RotationForm: FC = (props) => { ); }; +const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +interface DaysSelectorProps { + value: string[]; + onChange: (value: string[]) => void; +} + +const DaysSelector = ({ value, onChange }: 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 ( +
+ {DAYS.map((day: string) => ( +
+ {day.charAt(0).toUpperCase()} +
+ ))} +
+ ); +}; + export default RotationForm; diff --git a/grafana-plugin/src/vars.css b/grafana-plugin/src/vars.css index 448db1c4..936cd721 100644 --- a/grafana-plugin/src/vars.css +++ b/grafana-plugin/src/vars.css @@ -53,5 +53,7 @@ --hover-selected: rgba(204,204,220,0.12); --hover-selected-hardcoded: #34363d; - --secondary-background-shade: rgba(204, 204, 220, 0.2);; + --secondary-background-shade: rgba(204, 204, 220, 0.2); + + } From 4ed223ad6a3bbb05bf0099b5ee8c20e38e1c49d6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 29 Jun 2022 16:23:58 +0300 Subject: [PATCH 07/60] delete user from rotation feature --- .../RotationForm/RotationForm.module.css | 2 +- .../components/RotationForm/RotationForm.tsx | 27 +++++++- .../UserGroups/UserGroups.helpers.ts | 5 +- .../UserGroups/UserGroups.module.css | 3 +- .../src/components/UserGroups/UserGroups.tsx | 63 +++++++++++++------ 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.module.css b/grafana-plugin/src/components/RotationForm/RotationForm.module.css index 69aaa60f..fa811439 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/components/RotationForm/RotationForm.module.css @@ -18,7 +18,7 @@ } .inline-switch { - height: 20px; + height: 22px; } diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.tsx b/grafana-plugin/src/components/RotationForm/RotationForm.tsx index 653425e0..f1c1f5a1 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/components/RotationForm/RotationForm.tsx @@ -119,15 +119,36 @@ const RotationForm: FC = (props) => { /**/ )} - + + Shift start + + } + > - + + Shift end + + } + > - + + Rotation start + + } + > { const items = []; groups.forEach((group, groupIndex) => { items.push({ - index: i++, key: `group-${groupIndex}`, type: 'group', data: { name: `Group ${groupIndex + 1}` }, @@ -33,7 +32,6 @@ export const toPlainArray = (groups) => { groups[groupIndex].forEach((item, itemIndex) => { items.push({ - index: i++, key: `item-${groupIndex}-${itemIndex}`, type: 'item', data: item, @@ -55,7 +53,6 @@ export const fromPlainArray = (items, createNewGroup = false, deleteEmptyGroups lastGroup = []; memo.push(lastGroup); } - lastGroup.push(item.data); } else { memo.push([]); @@ -65,3 +62,5 @@ export const fromPlainArray = (items, createNewGroup = false, deleteEmptyGroups }, []) .filter((group) => !deleteEmptyGroups || group.length); }; + +export const deleteItemFromGroupByIndex = (groups) => {}; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index 38886248..de2ad2cd 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -65,7 +65,8 @@ } .delete-icon{ - display: none; + /*display: none;*/ + display: block; } .user:hover .delete-icon{ diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index bb03be3f..645983e2 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -20,40 +20,49 @@ interface UserGroupsProps {} const cx = cn.bind(styles); -const DragHandle = () => ; +const DragHandle = () => ; const SortableHandleHoc = SortableHandle(DragHandle); const SortableItem = SortableElement(({ children }) => children); -const SortableList = SortableContainer(({ items, onAddUserGroup }) => { +const SortableList = SortableContainer(({ items, handleAddGroup, handleDeleteItem }) => { + const getDeleteItemHandler = (index) => { + return () => { + handleDeleteItem(index); + }; + }; + return (
    - {items.map((item) => + {items.map((item, index) => item.type === 'item' ? ( - +
  • {item.data.name} ({item.data.tz})
    - + +
  • ) : ( - +
  • {item.data.name}
  • ) )} - -
  • - Add user group + -
  • -
    + {items[items.length - 1]?.type === 'item' && ( + +
  • + Add user group + +
  • +
    + )}
); }); @@ -65,6 +74,24 @@ const UserGroups = () => { setGroups((oldGroups) => [...oldGroups, []]); }, [groups]); + const handleDeleteUser = (index: number) => { + const newGroups = [...groups]; + let k = -1; + for (let i = 0; i < groups.length; i++) { + k++; + const users = groups[i]; + for (let j = 0; j < users.length; j++) { + k++; + + if (k === index) { + newGroups[i] = newGroups[i].filter((item, itemIndex) => itemIndex !== j); + setGroups(newGroups.filter((group, index) => index === 0 || group.length)); + return; + } + } + } + }; + const handleUserAdd = useCallback((pk: User['pk'], user: User) => { if (!pk) { return; @@ -81,13 +108,7 @@ const UserGroups = () => { }, []); const filterUsers = useCallback( - ({ value }) => { - const userAlreadyExist = groups.some((group) => group.some((user) => user.pk === value)); - - console.log('userAlreadyExist', userAlreadyExist); - - return !userAlreadyExist; - }, + ({ value }) => !groups.some((group) => group.some((user) => user.pk === value)), [groups] ); @@ -111,10 +132,12 @@ const UserGroups = () => { helperClass={cx('sortable-helper')} items={items} onSortEnd={onSortEnd} - onAddUserGroup={handleAddUserGroup} - //useDragHandle + handleAddGroup={handleAddUserGroup} + handleDeleteItem={handleDeleteUser} + useDragHandle /> Date: Mon, 4 Jul 2022 10:08:44 +0100 Subject: [PATCH 08/60] add fake rotation data --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 10 ++- .../components/Rotations/Rotations.helpers.ts | 2 +- .../src/components/Rotations/Rotations.tsx | 6 +- .../ScheduleSlot/ScheduleSlot.module.css | 13 ++- .../components/ScheduleSlot/ScheduleSlot.tsx | 43 ++++++---- .../ScheduleTimeline/ScheduleTimeline.tsx | 49 ------------ .../Rotation/Rotation.module.css} | 8 +- .../src/containers/Rotation/Rotation.tsx | 80 +++++++++++++++++++ .../src/models/schedule/schedule.ts | 63 ++++++++++++++- .../src/models/schedule/schedule.types.ts | 11 +++ .../src/pages/schedule/Schedule.tsx | 9 ++- .../src/pages/schedules/Schedules.module.css | 8 +- .../src/pages/schedules/Schedules.tsx | 7 +- .../src/pages/schedules_NEW/Schedules.tsx | 14 ++-- 14 files changed, 226 insertions(+), 97 deletions(-) delete mode 100644 grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx rename grafana-plugin/src/{components/ScheduleTimeline/ScheduleTimeline.module.css => containers/Rotation/Rotation.module.css} (93%) create mode 100644 grafana-plugin/src/containers/Rotation/Rotation.tsx diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index ee56ed40..4863734c 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -3,11 +3,9 @@ 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 weekday from 'dayjs/plugin/weekday'; import { observer, Provider } from 'mobx-react'; import 'interceptors'; @@ -20,6 +18,12 @@ import { rootStore } from 'state'; import { useStore } from 'state/useStore'; import { useNavModel } from 'utils/hooks'; +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(weekday); + +// dayjs().weekday(0); + import './vars.css'; import './index.css'; diff --git a/grafana-plugin/src/components/Rotations/Rotations.helpers.ts b/grafana-plugin/src/components/Rotations/Rotations.helpers.ts index 92f470cc..f83efb50 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/components/Rotations/Rotations.helpers.ts @@ -16,7 +16,7 @@ export const getRandomTimeslots = (count = 6, layerIndex, rotationIndex) => { start, end, inactive, - users: [getRandomUser(), getRandomUser()], + users: [getRandomUser() /*, getRandomUser()*/], color: getColor(layerIndex, rotationIndex), }); } diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index eda572d3..0c47cef9 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -5,10 +5,9 @@ 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 Rotation from 'containers/Rotation/Rotation'; import { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; @@ -79,7 +78,8 @@ class Rotations extends Component {
{rotations.map((rotation, rotationIndex) => ( - = (props) => { - const { color, user, inactive, label } = props; +const ScheduleSlot: FC = observer((props) => { + const { color, userPk, inactive, label } = props; const left = Math.random() * 50; const right = 100 - (left + 20 + Math.random() * 30); - const width = Math.random() * 150 + 100; + const store = useStore(); - let title = user; - if (width < 150) { - title = title - .split(' ') - .map((word) => word.charAt(0).toUpperCase()) - .join(''); - } + const storeUser = store.userStore.items[userPk]; + + let title = storeUser + ? storeUser.username + .split(' ') + .map((word) => word.charAt(0).toUpperCase()) + .join('') + : null; return ( - }> + }>
@@ -51,13 +56,13 @@ const ScheduleSlot: FC = (props) => {
); -}; +}); export default ScheduleSlot; interface ScheduleSlotDetailsProps {} -const ScheduleSlotDetails = (props) => { +const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { const { user, currentUser } = props; const userStatus = 'success'; @@ -72,7 +77,7 @@ const ScheduleSlotDetails = (props) => { [`details-user-status__type_${userStatus}`]: true, })} /> - {user} + {user?.username} @@ -102,3 +107,9 @@ const ScheduleSlotDetails = (props) => {
); }; + +interface ScheduleGapProps {} + +export const ScheduleGap = (props: ScheduleGapProps) => { + return
; +}; diff --git a/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx b/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx deleted file mode 100644 index 45d872de..00000000 --- a/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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, slots, label } = props; - - return ( -
- {/*
*/} -
-
- {slots.map(({ users, inactive, color }, slotIndex) => { - return ( -
- {users.map((user, userIndex) => ( - - ))} -
- ); - })} -
-
-
- ); -}; - -export default ScheduleTimeline; diff --git a/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css similarity index 93% rename from grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css rename to grafana-plugin/src/containers/Rotation/Rotation.module.css index 261807fc..dfe0219a 100644 --- a/grafana-plugin/src/components/ScheduleTimeline/ScheduleTimeline.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -27,16 +27,18 @@ .slots { display: flex; - gap: 4px; - padding: 0 2px; } -.users{ +.stack { display: flex; flex-direction: column; gap:1px; } +.stack > div { + margin: 0 2px; +} + .current-time { position: absolute; left: 450px; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx new file mode 100644 index 00000000..370a91b6 --- /dev/null +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -0,0 +1,80 @@ +import React, { FC, useMemo, useState, useEffect } from 'react'; + +import { LoadingPlaceholder } from '@grafana/ui'; +import cn from 'classnames/bind'; +import * as dayjs from 'dayjs'; +import { observer } from 'mobx-react'; + +import { getColor } from 'components/Rotations/Rotations.helpers'; +import ScheduleSlot, { ScheduleGap } from 'components/ScheduleSlot/ScheduleSlot'; +import Text from 'components/Text/Text'; +import { Rotation as RotationType } from 'models/schedule/schedule.types'; +import { useStore } from 'state/useStore'; + +import styles from './Rotation.module.css'; + +const cx = cn.bind(styles); + +interface ScheduleSlotState {} + +interface RotationProps { + id: RotationType['id']; + layerIndex: number; + rotationIndex: number; + label: string; +} + +const Rotation: FC = observer((props) => { + const { id, layerIndex, rotationIndex, label } = props; + + const store = useStore(); + + useEffect(() => { + store.scheduleStore.updateRotation(id); + }, []); + + const rotation = store.scheduleStore.rotations[id]; + + if (!rotation) { + return ; + } + + const { shifts } = rotation; + + return ( +
+ {/*
*/} +
+
+ {shifts.map(({ start, duration, users }, slotIndex) => { + const inactive = false; + + const width = duration / (60 * 60 * 24 * 7); + + return ( +
+ {users.length ? ( + users.map((pk, userIndex) => { + return ( + + ); + }) + ) : ( + + )} +
+ ); + })} +
+
+
+ ); +}); + +export default Rotation; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 14d02744..adc66049 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -1,11 +1,14 @@ -import { omit } from 'lodash-es'; +import dayjs from 'dayjs'; +import { omit, reject } from 'lodash-es'; import { action, observable, toJS } from 'mobx'; import BaseStore from 'models/base_store'; import { makeRequest } from 'network'; import { RootStore } from 'state'; -import { Schedule, ScheduleEvent } from './schedule.types'; +import { Rotation, Schedule, ScheduleEvent } from './schedule.types'; + +const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; export class ScheduleStore extends BaseStore { @observable @@ -14,6 +17,9 @@ export class ScheduleStore extends BaseStore { @observable.shallow items: { [id: string]: Schedule } = {}; + @observable.shallow + rotations: { [id: string]: Rotation } = {}; + @observable scheduleToScheduleEvents: { [id: string]: ScheduleEvent[]; @@ -107,4 +113,57 @@ export class ScheduleStore extends BaseStore { method: 'DELETE', }); } + + async updateRotation(rotationId: Rotation['id'], from?: string) { + const response = await new Promise((resolve, reject) => { + function getUsers() { + const rnd = Math.random(); + + if (rnd > 0.66) { + return []; + } + + const users = [ + 'UCXTPJYKQHFW6', + 'UFYP8IJV9BZDE', + 'U122EFECQFN9Y', + 'UZ2LWBDAZE962', + 'U87ZI7PRWF7K1', + 'U2VY9ZP5A1XKL', + 'UTA6SS7RL3HC7', + 'UAYAYSDVG5MYH', + ]; + + if (rnd > 0.33) { + return [users[Math.floor(Math.random() * users.length)]]; + } + + return [users[Math.floor(Math.random() * users.length)], users[Math.floor(Math.random() * users.length)]]; + } + + setTimeout(() => { + if (!from) { + from = dayjs().startOf('week').format('YYYY-MM-DDTHH:mm:ss'); + } + + const startMoment = dayjs(`${from}.000Z`).utc(); + + const shifts = []; + for (let i = 0; i < 14; i++) { + shifts.push({ + start: dayjs(startMoment).add(3 * i, 'hour'), + duration: (Math.floor(Math.random() * 6) + 8) * 60 * 60, + users: getUsers(), + }); + } + + resolve({ id: rotationId, shifts }); + }, 500); + }); + + this.rotations = { + ...this.rotations, + [rotationId]: response as Rotation, + }; + } } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 800df6d2..0c9ffe09 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -43,3 +43,14 @@ export interface CreateScheduleExportTokenResponse { created_at: string; export_url: string; } + +export interface Shift { + start: string; + duration: number; // in seconds + users: Array; +} + +export interface Rotation { + id: string; + shifts: Shift[]; +} diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 30fa1744..5227d54e 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -18,6 +18,7 @@ import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; +import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { getRandomUsers } from './Schedule.helpers'; @@ -26,7 +27,7 @@ import styles from './Schedule.module.css'; const cx = cn.bind(styles); -interface SchedulePageProps extends AppRootProps {} +interface SchedulePageProps extends AppRootProps, WithStoreProps {} interface SchedulePageState { startMoment: dayjs.Dayjs; @@ -46,7 +47,11 @@ class SchedulePage extends React.Component tz: 'Europe/Moscow', }; - async componentDidMount() {} + async componentDidMount() { + const { store } = this.props; + + store.userStore.updateItems(); + } componentDidUpdate() {} diff --git a/grafana-plugin/src/pages/schedules/Schedules.module.css b/grafana-plugin/src/pages/schedules/Schedules.module.css index 12747b4a..855f3b38 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules/Schedules.module.css @@ -54,19 +54,15 @@ 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; color: #ff5286; font-weight: 400; } - -.gap-between-shifts-icon { - margin-left: 24px; -} diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index d07b39ea..7ae5647b 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -479,7 +479,7 @@ const Event = ({ event }: EventProps) => { return ( {!event.is_gap ? ( - <> +
{`L${event.priority_level || '0'}`}
@@ -504,10 +504,11 @@ const Event = ({ event }: EventProps) => { {dates}
- + ) : (
- Gap! Nobody On-Call... + Gap! Nobody + On-Call...
)} diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 0f2ac804..4c636202 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -7,19 +7,19 @@ import { observer } from 'mobx-react'; import Avatar from 'components/Avatar/Avatar'; import PluginLink from 'components/PluginLink/PluginLink'; -import Rotation from 'components/Rotation/Rotation'; import { getColor, getLabel } from 'components/Rotations/Rotations.helpers'; import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; -import ScheduleTimeline from 'components/ScheduleTimeline/ScheduleTimeline'; 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 GSelect from 'containers/GSelect/GSelect'; +import Rotation from 'containers/Rotation/Rotation'; import { Schedule } from 'models/schedule/schedule.types'; import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { getRandomSchedules, getRandomTimeslots } from './Schedules.helpers'; @@ -28,7 +28,7 @@ import styles from './Schedules.module.css'; const cx = cn.bind(styles); -interface SchedulesPageProps {} +interface SchedulesPageProps extends WithStoreProps {} interface SchedulesPageState { startMoment: dayjs.Dayjs; @@ -43,7 +43,11 @@ class SchedulesPage extends React.Component
- +
); From 3387c4be0fc58e164a2189c8e28250ddb5f594ed Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 5 Jul 2022 13:20:26 +0100 Subject: [PATCH 09/60] add API support for user timezone and working hours --- engine/apps/api/serializers/user.py | 50 +++++++++++++++++++ engine/apps/api/views/user.py | 7 ++- .../migrations/0002_auto_20220705_1214.py | 24 +++++++++ engine/apps/user_management/models/user.py | 30 +++++++++-- 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 engine/apps/user_management/migrations/0002_auto_20220705_1214.py diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 51418885..c71203d4 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,3 +1,6 @@ +import time + +import pytz from django.conf import settings from rest_framework import serializers @@ -9,6 +12,7 @@ from apps.base.utils import live_settings from apps.oss_installation.utils import cloud_user_identity_status from apps.twilioapp.utils import check_phone_number_is_valid from apps.user_management.models import User +from apps.user_management.models.user import default_working_hours from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin from common.constants.role import Role @@ -29,6 +33,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): organization = FastOrganizationSerializer(read_only=True) current_team = TeamPrimaryKeyRelatedField(allow_null=True, required=False) + timezone = serializers.CharField(allow_null=True, required=False) avatar = serializers.URLField(source="avatar_url", read_only=True) permissions = serializers.SerializerMethodField() @@ -47,6 +52,8 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "username", "role", "avatar", + "timezone", + "working_hours", "unverified_phone_number", "verified_phone_number", "slack_user_identity", @@ -63,6 +70,49 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "verified_phone_number", ] + def validate_timezone(self, tz): + if tz is None: + return tz + + try: + pytz.timezone(tz) + except pytz.UnknownTimeZoneError: + raise serializers.ValidationError("not a valid timezone") + + return tz + + def validate_working_hours(self, working_hours): + if not isinstance(working_hours, dict): + raise serializers.ValidationError("must be dict") + + # check that all days are present + if sorted(working_hours.keys()) != sorted(default_working_hours().keys()): + raise serializers.ValidationError("missing some days") + + for day in working_hours: + periods = working_hours[day] + + for period in periods: + if not isinstance(period, dict): + raise serializers.ValidationError("period must be dict") + + if sorted(period.keys()) != sorted(["start", "end"]): + raise serializers.ValidationError("'start' and 'end' fields must be present") + + if not isinstance(period["start"], str) or not isinstance(period["end"], str): + raise serializers.ValidationError("'start' and 'end' fields must be str") + + try: + start = time.strptime(period["start"], "%H:%M:%S") + end = time.strptime(period["end"], "%H:%M:%S") + except ValueError: + raise serializers.ValidationError("'start' and 'end' fields must be in '%H:%M:%S' format") + + if start >= end: + raise serializers.ValidationError("'start' must be less than 'end'") + + return working_hours + def validate_unverified_phone_number(self, value): if value: if check_phone_number_is_valid(value): diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index e7d20a32..b79be50f 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -1,6 +1,7 @@ import logging from urllib.parse import urljoin +import pytz from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -123,7 +124,7 @@ class UserView( "mobile_app_verification_token", "mobile_app_auth_token", ), - AnyRole: ("retrieve",), + AnyRole: ("retrieve", "timezone_options"), } action_object_permissions = { @@ -236,6 +237,10 @@ class UserView( serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk)) return Response(serializer.data) + @action(detail=False, methods=["get"]) + def timezone_options(self, request): + return Response(pytz.common_timezones) + @action(detail=True, methods=["get"]) def get_verification_code(self, request, pk): user = self.get_object() diff --git a/engine/apps/user_management/migrations/0002_auto_20220705_1214.py b/engine/apps/user_management/migrations/0002_auto_20220705_1214.py new file mode 100644 index 00000000..976bfe7a --- /dev/null +++ b/engine/apps/user_management/migrations/0002_auto_20220705_1214.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-07-05 12:14 + +import apps.user_management.models.user +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0001_squashed_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='_timezone', + field=models.CharField(default=None, max_length=50, null=True), + ), + migrations.AddField( + model_name='user', + name='working_hours', + field=models.JSONField(default=apps.user_management.models.user.default_working_hours, null=True), + ), + ] diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index ecdc46e0..ca769243 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -30,6 +30,16 @@ def generate_public_primary_key_for_user(): return new_public_primary_key +def default_working_hours(): + weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"] + weekends = ["saturday", "sunday"] + + working_hours = {day: [{"start": "09:00:00", "end": "17:00:00"}] for day in weekdays} + working_hours |= {day: [] for day in weekends} + + return working_hours + + class UserManager(models.Manager): @staticmethod def sync_for_team(team, api_members: list[dict]): @@ -128,6 +138,10 @@ class User(models.Model): role = models.PositiveSmallIntegerField(choices=Role.choices()) avatar_url = models.URLField() + # don't use "_timezone" directly, use the "timezone" property since it can be populated via slack user identity + _timezone = models.CharField(max_length=50, null=True, default=None) + working_hours = models.JSONField(null=True, default=default_working_hours) + notification = models.ManyToManyField("alerts.AlertGroup", through="alerts.UserHasNotification") unverified_phone_number = models.CharField(max_length=20, null=True, default=None) @@ -222,11 +236,17 @@ class User(models.Model): @property def timezone(self): - slack_user_identity = self.slack_user_identity - if slack_user_identity: - return slack_user_identity.timezone - else: - return None + if self._timezone: + return self._timezone + + if self.slack_user_identity: + return self.slack_user_identity.timezone + + return None + + @timezone.setter + def timezone(self, value): + self._timezone = value def short(self): return {"username": self.username, "pk": self.public_primary_key, "avatar": self.avatar_url} From 499a812425e1525871417c0b62c16d859b0b0547 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 5 Jul 2022 14:06:26 +0100 Subject: [PATCH 10/60] add tests --- engine/apps/api/serializers/user.py | 5 ++ engine/apps/api/tests/test_user.py | 107 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index c71203d4..73466515 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -92,6 +92,9 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): for day in working_hours: periods = working_hours[day] + if not isinstance(periods, list): + raise serializers.ValidationError("periods must be list") + for period in periods: if not isinstance(period, dict): raise serializers.ValidationError("period must be dict") @@ -160,6 +163,8 @@ class UserHiddenFieldsSerializer(UserSerializer): "current_team", "username", "avatar", + "timezone", + "working_hours", "notification_chain_verbal", "permissions", ] diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index dd23feb5..7c064616 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -10,6 +10,7 @@ from rest_framework.test import APIClient from apps.base.constants import ADMIN_PERMISSIONS, EDITOR_PERMISSIONS from apps.base.models import UserNotificationPolicy +from apps.user_management.models.user import default_working_hours from common.constants.role import Role @@ -67,6 +68,8 @@ def test_update_user_cant_change_email_and_username( "email": admin.email, "username": admin.username, "role": admin.role, + "timezone": None, + "working_hours": default_working_hours(), "unverified_phone_number": phone_number, "verified_phone_number": None, "telegram_configuration": None, @@ -113,6 +116,8 @@ def test_list_users( "email": admin.email, "username": admin.username, "role": admin.role, + "timezone": None, + "working_hours": default_working_hours(), "unverified_phone_number": None, "verified_phone_number": None, "telegram_configuration": None, @@ -134,6 +139,8 @@ def test_list_users( "email": editor.email, "username": editor.username, "role": editor.role, + "timezone": None, + "working_hours": default_working_hours(), "unverified_phone_number": None, "verified_phone_number": None, "telegram_configuration": None, @@ -1485,3 +1492,103 @@ def test_viewer_cant_unlink_backend_another_user( response = client.post(url, format="json", **make_user_auth_headers(second_user, token)) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_change_timezone( + make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + data = {"timezone": "Europe/London"} + + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert "timezone" in response.json() + assert response.json()["timezone"] == "Europe/London" + + +@pytest.mark.django_db +@pytest.mark.parametrize("timezone", ["", 1, "NotATimezone"]) +def test_invalid_timezone( + make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers, timezone +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + data = {"timezone": timezone} + + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_change_working_hours( + make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + periods = [{"start": "05:00:00", "end": "23:00:00"}] + working_hours = { + day: periods for day in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + } + + data = {"working_hours": working_hours} + + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert "working_hours" in response.json() + assert response.json()["working_hours"] == working_hours + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "working_hours_extra", + [ + {}, + {"sunday": 1}, + {"sunday": ""}, + {"sunday": {"start": "18:00:00"}}, + {"sunday": {"start": "", "end": ""}}, + {"sunday": {"start": "18:00:00", "end": None}}, + {"sunday": {"start": "18:00:00", "end": "18:00:00"}}, + {"sunday": {"start": "18:00:00", "end": "9:00:00"}}, + {"sunday": {"start": "18:00:00", "end": "9:00:00", "extra": 1}}, + ], +) +def test_invalid_working_hours( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + working_hours_extra, +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + periods = [{"start": "05:00:00", "end": "23:00:00"}] + working_hours = {day: periods for day in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]} + working_hours.update(working_hours_extra) + + data = {"working_hours": working_hours} + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST From f8dc3898eb47e06f99ff9762bb4bf97597d30a15 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 7 Jul 2022 10:10:36 +0100 Subject: [PATCH 11/60] refactor schedule slot --- .../components/Rotations/Rotations.helpers.ts | 61 -- .../src/components/Rotations/Rotations.tsx | 2 - .../ScheduleSlot/ScheduleSlot.helpers.ts | 75 +++ .../ScheduleSlot/ScheduleSlot.module.css | 12 + .../components/ScheduleSlot/ScheduleSlot.tsx | 110 +++- .../containers/Rotation/Rotation.module.css | 10 - .../src/containers/Rotation/Rotation.tsx | 33 +- .../src/models/schedule/schedule.ts | 14 +- .../src/models/timezone/timezone.helpers.ts | 599 ++++++++++++++++++ grafana-plugin/src/models/user/user.ts | 17 +- .../src/pages/schedules_NEW/Schedules.tsx | 2 +- 11 files changed, 800 insertions(+), 135 deletions(-) create mode 100644 grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts diff --git a/grafana-plugin/src/components/Rotations/Rotations.helpers.ts b/grafana-plugin/src/components/Rotations/Rotations.helpers.ts index f83efb50..e69de29b 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/components/Rotations/Rotations.helpers.ts @@ -1,61 +0,0 @@ -import dayjs from 'dayjs'; - -export const getRandomTimeslots = (count = 6, layerIndex, rotationIndex) => { - 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()*/], - color: getColor(layerIndex, rotationIndex), - }); - } - 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']; - -export const getOverrideColor = (index: number) => { - return OVERRIDE_COLORS[index]; -}; - -const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS, OVERRIDE_COLORS]; - -export const getColor = (layerIndex: number, rotationIndex: number) => { - 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.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index 0c47cef9..7da97723 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -82,8 +82,6 @@ class Rotations extends Component { id={`${layerIndex}-${rotationIndex}`} layerIndex={layerIndex} rotationIndex={rotationIndex} - slots={getRandomTimeslots(6, layerIndex, rotationIndex)} - label={getLabel(layerIndex, rotationIndex)} /> ))}
diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts new file mode 100644 index 00000000..f9a35fa9 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts @@ -0,0 +1,75 @@ +import dayjs from 'dayjs'; + +import { Shift } from 'models/schedule/schedule.types'; +import { User } from 'models/user/user.types'; + +export const getRandomTimeslots = (count = 6, layerIndex, rotationIndex) => { + 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()*/], + color: getColor(layerIndex, rotationIndex), + }); + } + 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']; + +export const getOverrideColor = (index: number) => { + return OVERRIDE_COLORS[index]; +}; + +const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS, OVERRIDE_COLORS]; + +export const getColor = (layerIndex: number, rotationIndex: number) => { + 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}`; +}; + +export const getTitle = (user: User) => { + return user + ? user.username + .split(' ') + .map((word) => word.charAt(0).toUpperCase()) + .join('') + : null; +}; + +export const getOuRanges = (shift: Shift, user: User) => {}; diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 7a41c20d..b83c323b 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -7,9 +7,21 @@ gap: 4px; } + +.stack { + display: flex; + flex-direction: column; + gap:1px; +} + +.stack > .root { + margin: 0 2px; +} + .root__type_gap { background: rgba(209, 14, 92, 0.2); border: 1px dashed #FF5286; + color: rgba(209, 14, 92, .5); } .root__inactive { diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 98a677e1..c2ad78eb 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -6,61 +6,85 @@ import { observer } from 'mobx-react'; import Line from 'components/ScheduleUserDetails/img/line.svg'; import Text from 'components/Text/Text'; +import { Shift } from 'models/schedule/schedule.types'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; +import { getColor, getLabel, getTitle } from './ScheduleSlot.helpers'; + import styles from './ScheduleSlot.module.css'; interface ScheduleSlotProps { - color: string; - userPk: User['pk']; - label: string; - inactive: boolean; - width: number; + index: number; + layerIndex: number; + rotationIndex: number; + shift: Shift; } const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { color, userPk, inactive, label } = props; + const { index, layerIndex, rotationIndex, shift } = props; + const { duration, users } = shift; - const left = Math.random() * 50; - const right = 100 - (left + 20 + Math.random() * 30); + const isGap = !users.length; const store = useStore(); - const storeUser = store.userStore.items[userPk]; + const width = duration / (60 * 60 * 24 * 7); - let title = storeUser - ? storeUser.username - .split(' ') - .map((word) => word.charAt(0).toUpperCase()) - .join('') - : null; + const label = index === 0 && getLabel(layerIndex, rotationIndex); return ( - }> -
-
- {label && ( -
- {label} +
+ {!isGap ? ( + users.map((pk, userIndex) => { + const left = Math.random() * 50; + const right = 100 - (left + 20 + Math.random() * 30); + + const storeUser = store.userStore.items[pk]; + + const inactive = false; + + const color = getColor(layerIndex, rotationIndex); + const title = getTitle(storeUser); + + return ( + }> +
+
+ {label && ( +
+ {label} +
+ )} +
{title}
+
+ + ); + }) + ) : ( + }> +
+ {label &&
{label}
}
- )} -
{title}
-
-
+ + )} +
); }); export default ScheduleSlot; -interface ScheduleSlotDetailsProps {} +interface ScheduleSlotDetailsProps { + user: User; + currentUser: User; +} const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { const { user, currentUser } = props; @@ -96,7 +120,7 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { - Maxim Mordasov + {currentUser?.username} 30 apr, 12:54 29 apr, 20:00 @@ -108,8 +132,24 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { ); }; -interface ScheduleGapProps {} +interface ScheduleGapDetailsProps {} -export const ScheduleGap = (props: ScheduleGapProps) => { - return
; +const ScheduleGapDetails = (props: ScheduleGapDetailsProps) => { + const {} = props; + + return ( +
+ + Gaps this week + + Number of gaps + 12 + + + Time + 23h 12m + + +
+ ); }; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index dfe0219a..46f8256c 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -29,16 +29,6 @@ display: flex; } -.stack { - display: flex; - flex-direction: column; - gap:1px; -} - -.stack > div { - margin: 0 2px; -} - .current-time { position: absolute; left: 450px; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 370a91b6..1dd719ad 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -5,8 +5,7 @@ import cn from 'classnames/bind'; import * as dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import { getColor } from 'components/Rotations/Rotations.helpers'; -import ScheduleSlot, { ScheduleGap } from 'components/ScheduleSlot/ScheduleSlot'; +import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; import Text from 'components/Text/Text'; import { Rotation as RotationType } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; @@ -46,29 +45,15 @@ const Rotation: FC = observer((props) => { {/*
*/}
- {shifts.map(({ start, duration, users }, slotIndex) => { - const inactive = false; - - const width = duration / (60 * 60 * 24 * 7); - + {shifts.map((shift, index) => { return ( -
- {users.length ? ( - users.map((pk, userIndex) => { - return ( - - ); - }) - ) : ( - - )} -
+ ); })}
diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index adc66049..f7426748 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -152,11 +152,23 @@ export class ScheduleStore extends BaseStore { for (let i = 0; i < 14; i++) { shifts.push({ start: dayjs(startMoment).add(3 * i, 'hour'), - duration: (Math.floor(Math.random() * 6) + 8) * 60 * 60, + duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, users: getUsers(), }); } + const a = { + working_hours: { + monday: [{ start: '09:00:00', end: '18:00:00' }], + tuesday: [{ start: '09:00:00', end: '18:00:00' }], + wednesday: [{ start: '09:00:00', end: '18:00:00' }], + thursday: [{ start: '09:00:00', end: '18:00:00' }], + friday: [{ start: '09:00:00', end: '18:00:00' }], + saturday: [], + sunday: [], + }, + }; + resolve({ id: rotationId, shifts }); }, 500); }); diff --git a/grafana-plugin/src/models/timezone/timezone.helpers.ts b/grafana-plugin/src/models/timezone/timezone.helpers.ts index c2627b97..688b409b 100644 --- a/grafana-plugin/src/models/timezone/timezone.helpers.ts +++ b/grafana-plugin/src/models/timezone/timezone.helpers.ts @@ -1,5 +1,604 @@ 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; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 9f98d04d..e1d66667 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -3,6 +3,7 @@ import { action, computed, observable } from 'mobx'; 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'; @@ -97,12 +98,26 @@ export class UserStore extends BaseStore { ...results.reduce( (acc: { [key: number]: User }, item: User) => ({ ...acc, - [item.pk]: item, + [item.pk]: { + ...item, + tz: getRandomTimezone(), + working_hours: { + monday: [{ start: '09:00:00', end: '18:00:00' }], + tuesday: [{ start: '09:00:00', end: '18:00:00' }], + wednesday: [{ start: '09:00:00', end: '18:00:00' }], + thursday: [{ start: '09:00:00', end: '18:00:00' }], + friday: [{ start: '09:00:00', end: '18:00:00' }], + saturday: [], + sunday: [], + }, + }, }), {} ), }; + console.log(this.items); + this.searchResult = { count, results: results.map((item: User) => item.pk), diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 4c636202..8bc0ac91 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -131,7 +131,7 @@ class SchedulesPage extends React.Component
- +
); From b2f693b8355965baefb4ec3a17c4f361f1d71e29 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 11 Jul 2022 15:09:27 +0100 Subject: [PATCH 12/60] add working hours component --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 6 ++ .../src/components/Rotations/Rotations.tsx | 6 +- .../ScheduleSlot/ScheduleSlot.module.css | 6 ++ .../components/ScheduleSlot/ScheduleSlot.tsx | 11 ++- .../WorkingHours/WorkingHours.config.ts | 9 ++ .../WorkingHours/WorkingHours.helpers.ts | 88 ++++++++++++++++++ .../WorkingHours/WorkingHours.module.css | 3 + .../components/WorkingHours/WorkingHours.tsx | 89 +++++++++++++++++++ .../src/models/schedule/schedule.ts | 25 +++--- grafana-plugin/src/models/user/user.ts | 4 +- .../src/pages/schedule/Schedule.module.css | 4 + .../src/pages/schedule/Schedule.tsx | 74 +++++++-------- 12 files changed, 272 insertions(+), 53 deletions(-) create mode 100644 grafana-plugin/src/components/WorkingHours/WorkingHours.config.ts create mode 100644 grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts create mode 100644 grafana-plugin/src/components/WorkingHours/WorkingHours.module.css create mode 100644 grafana-plugin/src/components/WorkingHours/WorkingHours.tsx diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 4863734c..dcf33cbe 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -3,6 +3,9 @@ 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 localeData from 'dayjs/plugin/localeData'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import weekday from 'dayjs/plugin/weekday'; @@ -21,6 +24,9 @@ import { useNavModel } from 'utils/hooks'; dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(weekday); +dayjs.extend(localeData); +dayjs.extend(isSameOrBefore); +dayjs.extend(isSameOrAfter); // dayjs().weekday(0); diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index 7da97723..c939298b 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -39,12 +39,12 @@ class Rotations extends Component { const layers = [ { id: 0, title: 'Layer 1' }, - { id: 1, title: 'Layer 2' }, + /* { id: 1, title: 'Layer 2' }, { id: 2, title: 'Layer 3' }, - { id: 3, title: 'Layer 4' }, + { id: 3, title: 'Layer 4' },*/ ]; - const rotations = [{}, {}]; + const rotations = [{} /*, {}*/]; return ( <> diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index b83c323b..91f29443 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -7,6 +7,11 @@ gap: 4px; } +.working-hours{ + position: absolute; + top: 0; + left: 0; +} .stack { display: flex; @@ -22,6 +27,7 @@ background: rgba(209, 14, 92, 0.2); border: 1px dashed #FF5286; color: rgba(209, 14, 92, .5); + visibility: hidden; } .root__inactive { diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index c2ad78eb..98d0715d 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -6,6 +6,7 @@ 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 { Shift } from 'models/schedule/schedule.types'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; @@ -57,7 +58,15 @@ const ScheduleSlot: FC = observer((props) => { backgroundColor: color, }} > -
+ {storeUser && ( + + )} {label && (
{label} diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.config.ts b/grafana-plugin/src/components/WorkingHours/WorkingHours.config.ts new file mode 100644 index 00000000..085666a9 --- /dev/null +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.config.ts @@ -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' }], +}; diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts b/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts new file mode 100644 index 00000000..692cea35 --- /dev/null +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts @@ -0,0 +1,88 @@ +import dayjs from 'dayjs'; + +export const getWorkingMoments = ( + startMoment, + endMoment, + workingHours, + timezone, +) => { + const weekdays = dayjs.weekdays(); + + const dayOfWeekToStartIteration = startMoment.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(startMoment) + .tz(timezone) + .add(i, 'day') + .set('hour', Number(start_HH)) + .set('minute', Number(start_mm)) + .set('second', Number(start_ss)); + + const rangeEndMoment = dayjs(startMoment) + .tz(timezone) + .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; +}; diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css b/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css new file mode 100644 index 00000000..8b98de94 --- /dev/null +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css @@ -0,0 +1,3 @@ +.root { + display: block; +} diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx new file mode 100644 index 00000000..3f729216 --- /dev/null +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -0,0 +1,89 @@ +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 + width: number; // in pixels + className: string; +} + +const cx = cn.bind(styles); + +const WorkingHours: FC = (props) => { + const { + timezone, + workingHours = default_working_hours, + startMoment = dayjs().utc().startOf('week'), + duration = 14 * 24 * 60 * 60, + className, + } = props; + + timezone = dayjs.tz.guess(); + + const endMoment = startMoment.add(duration, 'seconds'); + + const workingMoments = useMemo( + () => getWorkingMoments(startMoment, endMoment, workingHours, timezone), + [startMoment, endMoment, workingHours, timezone] + ); + + const nonWorkingMoments = getNonWorkingMoments(startMoment, endMoment, workingMoments); + + console.log(startMoment.tz(timezone).format('D MMM ddd HH:ss')); + + /*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( + nonWorkingMoments.map( + (range) => + `${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}` + ) + );*/ + + return ( + + + + + + + {nonWorkingMoments.map((moment, index) => { + const start = moment.start.diff(startMoment, 'seconds'); + const diff = moment.end.diff(moment.start, 'seconds'); + return ( + + ); + })} + + ); +}; + +export default WorkingHours; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index f7426748..79a3b86a 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -118,20 +118,24 @@ export class ScheduleStore extends BaseStore { const response = await new Promise((resolve, reject) => { function getUsers() { const rnd = Math.random(); + /* if (rnd > 0.66) { return []; } +*/ const users = [ - 'UCXTPJYKQHFW6', - 'UFYP8IJV9BZDE', - 'U122EFECQFN9Y', - 'UZ2LWBDAZE962', - 'U87ZI7PRWF7K1', - 'U2VY9ZP5A1XKL', - 'UTA6SS7RL3HC7', - 'UAYAYSDVG5MYH', + 'UQEAACAGQ5JHL', + 'UEHYTCX4AMX75', + 'U3U8343UTJ91U', + 'UTNF7TCGBPADM', + 'UWPPUTZHCC9U5', + 'UDUG977U8V8AX', + 'UNN22BHCXZ6TR', + 'UTKBFZH8HM1TF', + 'U1DJX6WMFTWY7', + 'UPZ7AJPKVJL9K', ]; if (rnd > 0.33) { @@ -151,8 +155,9 @@ export class ScheduleStore extends BaseStore { const shifts = []; for (let i = 0; i < 14; i++) { shifts.push({ - start: dayjs(startMoment).add(3 * i, 'hour'), - duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, + start: dayjs(startMoment).add(12 * i, 'hour'), + //duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, + duration: 12 * 60 * 60, users: getUsers(), }); } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index e1d66667..d9713f0b 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -100,7 +100,7 @@ export class UserStore extends BaseStore { ...acc, [item.pk]: { ...item, - tz: getRandomTimezone(), + timezone: getRandomTimezone(), working_hours: { monday: [{ start: '09:00:00', end: '18:00:00' }], tuesday: [{ start: '09:00:00', end: '18:00:00' }], @@ -116,8 +116,6 @@ export class UserStore extends BaseStore { ), }; - console.log(this.items); - this.searchResult = { count, results: results.map((item: User) => item.pk), diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css index ce2dcc89..c4a276b4 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.module.css +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -4,6 +4,10 @@ margin-top: 24px; } +.header{ + position: sticky; +} + .desc{ width: 736px; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 5227d54e..56486caf 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -62,43 +62,45 @@ class SchedulePage extends React.Component return (
- - - - - - Schedule Team {query.id} - - Grafana 1 -
- Grafana 2 -
- Grafana 3 - - } - /> - +
+ + + + + + Schedule Team {query.id} + + Grafana 1 +
+ Grafana 2 +
+ Grafana 3 + + } + /> + +
+ + + + + + + + +
- - - - - - - - - - +
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. From 0174c973e3dd29a29735e8bcc4e70cb05b0f5166 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 18 Jul 2022 11:02:06 +0100 Subject: [PATCH 13/60] render slots with tz offset --- .../src/components/Rotations/Rotations.tsx | 6 ++- .../ScheduleSlot/ScheduleSlot.module.css | 19 ++----- .../components/ScheduleSlot/ScheduleSlot.tsx | 23 +++++---- .../TimelineMarks/TimelineMarks.module.css | 12 +++++ .../TimelineMarks/TimelineMarks.tsx | 26 ++++++++-- .../UserTimezoneSelect/UserTimezoneSelect.tsx | 9 +++- .../components/WorkingHours/WorkingHours.tsx | 8 +-- .../containers/Rotation/Rotation.module.css | 5 ++ .../src/containers/Rotation/Rotation.tsx | 16 ++++-- .../src/models/schedule/schedule.ts | 49 +++++++------------ .../src/models/schedule/schedule.types.ts | 4 +- .../src/pages/schedule/Schedule.helpers.ts | 5 +- .../src/pages/schedule/Schedule.module.css | 3 +- .../src/pages/schedule/Schedule.tsx | 24 ++++----- 14 files changed, 125 insertions(+), 84 deletions(-) diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index c939298b..7593497b 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -8,6 +8,7 @@ import utc from 'dayjs/plugin/utc'; import RotationForm from 'components/RotationForm/RotationForm'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; +import { Timezone } from 'models/timezone/timezone.types'; import { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; @@ -18,6 +19,7 @@ const cx = cn.bind(styles); interface RotationsProps { title: string; startMoment: dayjs.Dayjs; + currentTimezone: Timezone; } type Layer = { @@ -34,7 +36,7 @@ class Rotations extends Component { }; render() { - const { title, startMoment } = this.props; + const { title, startMoment, currentTimezone } = this.props; const { layerIdToCreateRotation } = this.state; const layers = [ @@ -82,6 +84,8 @@ class Rotations extends Component { id={`${layerIndex}-${rotationIndex}`} layerIndex={layerIndex} rotationIndex={rotationIndex} + startMoment={startMoment} + currentTimezone={currentTimezone} /> ))}
diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 91f29443..30807864 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -17,6 +17,7 @@ display: flex; flex-direction: column; gap:1px; + transition: left 500ms ease; } .stack > .root { @@ -50,22 +51,8 @@ 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 - ); + font-size: 10px; + font-weight: bold; } .details { diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 98d0715d..8fea9680 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -1,13 +1,15 @@ import React, { FC } from 'react'; -import { HorizontalGroup, VerticalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui'; +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 { Shift } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; @@ -20,29 +22,29 @@ interface ScheduleSlotProps { layerIndex: number; rotationIndex: number; shift: Shift; + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; } const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, shift } = props; + const { index, layerIndex, rotationIndex, shift, startMoment, currentTimezone } = props; const { duration, users } = shift; const isGap = !users.length; const store = useStore(); - const width = duration / (60 * 60 * 24 * 7); + const base = 60 * 60 * 24 * 7; - const label = index === 0 && getLabel(layerIndex, rotationIndex); + const width = duration / base; return ( -
+
{!isGap ? ( users.map((pk, userIndex) => { - const left = Math.random() * 50; - const right = 100 - (left + 20 + Math.random() * 30); - + const label = index === 0 && userIndex == 0 && getLabel(layerIndex, rotationIndex); const storeUser = store.userStore.items[pk]; const inactive = false; @@ -61,8 +63,9 @@ const ScheduleSlot: FC = observer((props) => { {storeUser && ( diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css index b5742c32..0d434a32 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css @@ -12,6 +12,7 @@ pointer-events: none; } + .weekday { width: calc(100% / 7); display: flex; @@ -48,3 +49,14 @@ .weekday-time-title__hidden{ visibility: hidden; } + +/* +for debug purposes only +*/ + +.debug-scale { + position: absolute; + top: -6px; + width: 100%; + right: 0; +} diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index c89f8cb6..c237956e 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -21,12 +21,10 @@ const TimelineMarks: FC = (props) => { const jLimit = 24 / hoursToSplit; for (let i = 0; i < 7; i++) { - const d = dayjs(startMoment).utc().add(i, 'days'); + const d = dayjs(startMoment).add(i, 'days'); const obj = { moment: d, moments: [] }; for (let j = 0; j < jLimit; j++) { - const m = dayjs(d) - .utc() - .add(j * hoursToSplit, 'hour'); + const m = dayjs(d).add(j * hoursToSplit, 'hour'); obj.moments.push(m); } momentsToRender.push(obj); @@ -34,8 +32,26 @@ const TimelineMarks: FC = (props) => { return momentsToRender; }, [startMoment]); + const cuts = []; + for (let i = 0; i < 24 * 7; i++) { + cuts.push({}); + } + cuts.push({}); + return (
+ + {cuts.map((cut, index) => ( + + ))} + {momentsToRender.map((m, i) => { return (
@@ -45,7 +61,7 @@ const TimelineMarks: FC = (props) => {
{mm.format('HH:mm')} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx index 4d5a8f5d..0af1d9c8 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -2,8 +2,10 @@ 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'; @@ -25,7 +27,12 @@ const UserTimezoneSelect: FC = (props) => { let item = memo.find((item) => item.label === user.tz); if (!item) { - item = { value: user.pk, label: user.tz, imgUrl: user.avatar, description: user.name }; + item = { + value: user.pk, + label: `${user.tz} ${getTzOffsetString(dayjs().tz(user.tz))}`, + imgUrl: user.avatar, + description: user.name, + }; memo.push(item); } else { item.description += ', ' + user.name; diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index 3f729216..3c62021c 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -33,8 +33,6 @@ const WorkingHours: FC = (props) => { className, } = props; - timezone = dayjs.tz.guess(); - const endMoment = startMoment.add(duration, 'seconds'); const workingMoments = useMemo( @@ -42,9 +40,11 @@ const WorkingHours: FC = (props) => { [startMoment, endMoment, workingHours, timezone] ); - const nonWorkingMoments = getNonWorkingMoments(startMoment, endMoment, workingMoments); + /*console.log( + workingMoments.map(({ start, end }) => `${start.diff(startMoment, 'hours')} - ${end.diff(startMoment, 'hours')}`) + );*/ - console.log(startMoment.tz(timezone).format('D MMM ddd HH:ss')); + const nonWorkingMoments = getNonWorkingMoments(startMoment, endMoment, workingMoments); /*console.log( workingMoments.map( diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index 46f8256c..960ec28c 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -25,8 +25,13 @@ padding-bottom: 0; } +.timeline { + /* overflow: hidden; */ +} + .slots { display: flex; + transition: transform 500ms ease; } .current-time { diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 1dd719ad..2a8fd222 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -2,12 +2,13 @@ import React, { FC, useMemo, useState, useEffect } from 'react'; import { LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; -import * as dayjs from 'dayjs'; +import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; import Text from 'components/Text/Text'; import { Rotation as RotationType } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; import { useStore } from 'state/useStore'; import styles from './Rotation.module.css'; @@ -21,10 +22,12 @@ interface RotationProps { layerIndex: number; rotationIndex: number; label: string; + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; } const Rotation: FC = observer((props) => { - const { id, layerIndex, rotationIndex, label } = props; + const { id, layerIndex, rotationIndex, label, startMoment, currentTimezone } = props; const store = useStore(); @@ -38,13 +41,18 @@ const Rotation: FC = observer((props) => { return ; } + const base = 60 * 24 * 7; // in minutes + const utcOffset = dayjs().tz(currentTimezone).utcOffset(); + + const x = utcOffset / base; + const { shifts } = rotation; return (
{/*
*/}
-
+
{shifts.map((shift, index) => { return ( = observer((props) => { shift={shift} layerIndex={layerIndex} rotationIndex={rotationIndex} + startMoment={startMoment} + currentTimezone={currentTimezone} /> ); })} diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 79a3b86a..a0fdebfd 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -126,23 +126,22 @@ export class ScheduleStore extends BaseStore { */ const users = [ - 'UQEAACAGQ5JHL', - 'UEHYTCX4AMX75', - 'U3U8343UTJ91U', - 'UTNF7TCGBPADM', - 'UWPPUTZHCC9U5', - 'UDUG977U8V8AX', - 'UNN22BHCXZ6TR', - 'UTKBFZH8HM1TF', - 'U1DJX6WMFTWY7', - 'UPZ7AJPKVJL9K', + 'U5WE86241LNEA', + 'U9XM1G7KTE3KW', + 'UYKS64M6C59XM', + 'UFFIRDUFXA6W3', + 'UPRMSTP9LCADE', + 'UR6TVJWZYV19M', + 'UHRMQQ7KETPCS', ]; - if (rnd > 0.33) { - return [users[Math.floor(Math.random() * users.length)]]; - } + /* if (rnd > 0.33) { + return [users[Math.floor(Math.random() * users.length)], users[Math.floor(Math.random() * users.length)]]; + }*/ - return [users[Math.floor(Math.random() * users.length)], users[Math.floor(Math.random() * users.length)]]; + return ['UPRMSTP9LCADE', 'UHRMQQ7KETPCS']; + + return [users[Math.floor(Math.random() * users.length)]]; } setTimeout(() => { @@ -153,27 +152,17 @@ export class ScheduleStore extends BaseStore { const startMoment = dayjs(`${from}.000Z`).utc(); const shifts = []; - for (let i = 0; i < 14; i++) { + for (let i = 0; i < 7; i++) { shifts.push({ - start: dayjs(startMoment).add(12 * i, 'hour'), - //duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, - duration: 12 * 60 * 60, + // start: dayjs(startMoment).add(12 * i, 'hour'), + // duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, + start: dayjs(startMoment).add(24 * i, 'hour'), + // duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, + duration: 24 * 60 * 60, users: getUsers(), }); } - const a = { - working_hours: { - monday: [{ start: '09:00:00', end: '18:00:00' }], - tuesday: [{ start: '09:00:00', end: '18:00:00' }], - wednesday: [{ start: '09:00:00', end: '18:00:00' }], - thursday: [{ start: '09:00:00', end: '18:00:00' }], - friday: [{ start: '09:00:00', end: '18:00:00' }], - saturday: [], - sunday: [], - }, - }; - resolve({ id: rotationId, shifts }); }, 500); }); diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 0c9ffe09..ed252915 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; + import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { User } from 'models/user/user.types'; @@ -45,7 +47,7 @@ export interface CreateScheduleExportTokenResponse { } export interface Shift { - start: string; + start: dayjs.Dayjs; duration: number; // in seconds users: Array; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index fe745de1..9d6e8589 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -619,6 +619,7 @@ export const getRandomUsers = (count = 5) => { //name: getRandomUser(), pk: i, name: [ + 'Hypothetical UTC user', 'Matias Bordese', 'Michael Derynck', 'Yulia Shanyrova', @@ -629,6 +630,7 @@ export const getRandomUsers = (count = 5) => { ][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', @@ -638,11 +640,12 @@ export const getRandomUsers = (count = 5) => { ][i], //tz: getRandomTimezone(), tz: [ + 'UTC', 'America/Montevideo', 'America/Vancouver', 'Europe/Amsterdam', 'Europe/Moscow', - 'Europe/Moscow', + 'Europe/London', 'Asia/Yerevan', /*'Asia/Tel_Aviv',*/ ][i], diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css index c4a276b4..229d0314 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.module.css +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -5,7 +5,8 @@ } .header{ - position: sticky; + position: sticky; /* TODO check */ + width: 100%; } .desc{ diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 56486caf..f8aa5d02 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from 'react'; import { AppRootProps } from '@grafana/data'; import { Button, HorizontalGroup, VerticalGroup, RadioButtonGroup, IconButton, ToolbarButton } from '@grafana/ui'; import cn from 'classnames/bind'; -import * as dayjs from 'dayjs'; +import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import Draggable from 'react-draggable'; @@ -34,17 +34,19 @@ interface SchedulePageState { schedulePeriodType: string; renderType: string; users: User[]; - tz: Timezone; + currentTimezone: Timezone; } +const INITIAL_TIMEZONE = 'UTC'; + @observer class SchedulePage extends React.Component { state: SchedulePageState = { - startMoment: dayjs().utc().startOf('week'), + startMoment: dayjs().tz(INITIAL_TIMEZONE).startOf('week'), schedulePeriodType: 'week', renderType: 'timeline', users: getRandomUsers(), - tz: 'Europe/Moscow', + currentTimezone: INITIAL_TIMEZONE, }; async componentDidMount() { @@ -56,7 +58,7 @@ class SchedulePage extends React.Component componentDidUpdate() {} render() { - const { startMoment, schedulePeriodType, renderType, users, tz } = this.state; + const { startMoment, schedulePeriodType, renderType, users, currentTimezone } = this.state; const { query } = this.props; return ( @@ -91,7 +93,7 @@ class SchedulePage extends React.Component /> - + @@ -106,7 +108,7 @@ class SchedulePage extends React.Component Users from on-call schedule" step in escalation chains.
- +
@@ -155,7 +157,7 @@ class SchedulePage extends React.Component {/*
*/}
{/**/} - + {/* */}
@@ -164,7 +166,7 @@ class SchedulePage extends React.Component } handleTimezoneChange = (value: Timezone) => { - this.setState({ tz: value }); + this.setState({ currentTimezone: value, startMoment: dayjs().tz(value).startOf('week') }); }; handleShedulePeriodTypeChange = (value: string) => { @@ -176,9 +178,9 @@ class SchedulePage extends React.Component }; handleTodayClick = () => { - const { startMoment } = this.state; + const { startMoment, currentTimezone } = this.state; - this.setState({ startMoment: dayjs().utc().startOf('week') }); + this.setState({ startMoment: dayjs().tz(currentTimezone).startOf('week') }); }; handleLeftClick = () => { From 6da94edb3fb331093ef31c806e7ee1074b820f04 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 Jul 2022 11:22:34 +0100 Subject: [PATCH 14/60] split Rotations to overrides, rotations and final --- .../src/components/Rotations/Rotations.tsx | 14 ++-- .../components/Rotations/ScheduleFinal.tsx | 65 ++++++++++++++++++ .../Rotations/ScheduleOverrides.tsx | 67 +++++++++++++++++++ .../ScheduleOverrideForm.module.css | 3 + .../ScheduleOverrideForm.tsx | 20 ++++++ .../components/ScheduleSlot/ScheduleSlot.tsx | 10 ++- .../TimelineMarks/TimelineMarks.tsx | 41 +++++++----- .../UserGroups/UserGroups.helpers.ts | 2 - .../containers/Rotation/Rotation.module.css | 1 + .../src/containers/Rotation/Rotation.tsx | 32 ++++++--- .../src/models/schedule/schedule.ts | 65 +++++++++--------- .../src/pages/schedule/Schedule.helpers.ts | 4 +- .../src/pages/schedule/Schedule.tsx | 12 ++-- 13 files changed, 256 insertions(+), 80 deletions(-) create mode 100644 grafana-plugin/src/components/Rotations/ScheduleFinal.tsx create mode 100644 grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx create mode 100644 grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css create mode 100644 grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index 7593497b..2b99d601 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -17,7 +17,6 @@ import styles from './Rotations.module.css'; const cx = cn.bind(styles); interface RotationsProps { - title: string; startMoment: dayjs.Dayjs; currentTimezone: Timezone; } @@ -36,24 +35,25 @@ class Rotations extends Component { }; render() { - const { title, startMoment, currentTimezone } = this.props; + const { startMoment, currentTimezone } = this.props; const { layerIdToCreateRotation } = this.state; const layers = [ { id: 0, title: 'Layer 1' }, - /* { id: 1, title: 'Layer 2' }, + /*{ id: 1, title: 'Layer 2' }, { id: 2, title: 'Layer 3' }, - { id: 3, title: 'Layer 4' },*/ + { id: 3, title: 'Layer 4' }*/ + , ]; - const rotations = [{} /*, {}*/]; + const rotations = [{} /* {}*/]; return ( <>
-
{title}
+
Rotations
({ @@ -77,7 +77,7 @@ class Rotations extends Component {
- +
{rotations.map((rotation, rotationIndex) => ( { + state: ScheduleOverridesState = {}; + + render() { + const { title, startMoment, currentTimezone } = this.props; + const { showAddOverrideForm, searchTerm } = this.state; + + return ( + <> +
+
+ +
Final schedule
+ } + placeholder="Search..." + value={searchTerm} + onChange={this.onSearchTermChangeCallback} + /> +
+
+
+
+ +
+ +
+
+
+ + ); + } + + onSearchTermChangeCallback = () => {}; +} + +export default withMobXProviderContext(ScheduleOverrides); diff --git a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx new file mode 100644 index 00000000..f985bb61 --- /dev/null +++ b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; + +import { Button, HorizontalGroup, Icon, ValuePicker } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import OverrideForm from 'components/OverrideForm/OverrideForm'; +import RotationForm from 'components/RotationForm/RotationForm'; +import ScheduleOverrideForm from 'components/ScheduleOverrideForm/ScheduleOverrideForm'; +import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; +import Rotation from 'containers/Rotation/Rotation'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './Rotations.module.css'; + +const cx = cn.bind(styles); + +interface ScheduleOverridesProps extends WithStoreProps {} + +interface ScheduleOverridesState {} + +@observer +class ScheduleOverrides extends Component { + state: ScheduleOverridesState = {}; + + render() { + const { title, startMoment, currentTimezone } = this.props; + const { showAddOverrideForm } = this.state; + + return ( + <> +
+
+ +
Overrides
+ +
+
+
+
+ +
+ +
+
+
Add override +
+
+ {showAddOverrideForm && ( + { + this.setState({ showAddOverrideForm: false }); + }} + /> + )} + + ); + } + + handleAddOverride = () => { + this.setState({ showAddOverrideForm: true }); + }; +} + +export default withMobXProviderContext(ScheduleOverrides); diff --git a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css new file mode 100644 index 00000000..a61fd276 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css @@ -0,0 +1,3 @@ +.root { + +} \ No newline at end of file diff --git a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx new file mode 100644 index 00000000..efedece6 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; +import cn from 'classnames/bind'; + +import styles from './ScheduleOverrideForm.module.css'; + +interface ScheduleOverrideFormProps { + +} + +const cx = cn.bind(styles); + +const ScheduleOverrideForm: FC = props => { + const { } = props; + + return ( +
+ ); +}; + +export default ScheduleOverrideForm; diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 8fea9680..3fa21af7 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -24,12 +24,13 @@ interface ScheduleSlotProps { shift: Shift; startMoment: dayjs.Dayjs; currentTimezone: Timezone; + color?: string; } const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, shift, startMoment, currentTimezone } = props; + const { index, layerIndex, rotationIndex, shift, startMoment, currentTimezone, color: propColor } = props; const { duration, users } = shift; const isGap = !users.length; @@ -44,12 +45,15 @@ const ScheduleSlot: FC = observer((props) => {
{!isGap ? ( users.map((pk, userIndex) => { - const label = index === 0 && userIndex == 0 && getLabel(layerIndex, rotationIndex); + const label = + !isNaN(layerIndex) && !isNaN(rotationIndex) && index === 0 && userIndex === 0 + ? getLabel(layerIndex, rotationIndex) + : null; const storeUser = store.userStore.items[pk]; const inactive = false; - const color = getColor(layerIndex, rotationIndex); + const color = propColor || getColor(layerIndex, rotationIndex); const title = getTitle(storeUser); return ( diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index c237956e..60e88236 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -7,12 +7,13 @@ import styles from './TimelineMarks.module.css'; interface TimelineMarksProps { startMoment: dayjs.Dayjs; + debug?: boolean; } const cx = cn.bind(styles); const TimelineMarks: FC = (props) => { - const { startMoment } = props; + const { startMoment, debug } = props; const momentsToRender = useMemo(() => { const hoursToSplit = 12; @@ -32,26 +33,30 @@ const TimelineMarks: FC = (props) => { return momentsToRender; }, [startMoment]); - const cuts = []; - for (let i = 0; i < 24 * 7; i++) { - cuts.push({}); - } - cuts.push({}); + const cuts = useMemo(() => { + const cuts = []; + for (let i = 0; i <= 24 * 7; i++) { + cuts.push({}); + } + return cuts; + }, []); return (
- - {cuts.map((cut, index) => ( - - ))} - + {debug && ( + + {cuts.map((cut, index) => ( + + ))} + + )} {momentsToRender.map((m, i) => { return (
diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts index dafdd941..879fcf7d 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -1,5 +1,3 @@ -import { getRandomTimezone } from 'components/UsersTimezones/UsersTimezones.helpers'; - export const getRandomGroups = () => { return [ [ diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index 960ec28c..09531c09 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -1,5 +1,6 @@ .root { transition: background-color 300ms; + min-height: 28px; } .root:last-child{ diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 2a8fd222..c4588c5d 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -19,34 +19,45 @@ interface ScheduleSlotState {} interface RotationProps { id: RotationType['id']; - layerIndex: number; - rotationIndex: number; label: string; startMoment: dayjs.Dayjs; currentTimezone: Timezone; + layerIndex?: number; + rotationIndex?: number; + color?: string; } const Rotation: FC = observer((props) => { - const { id, layerIndex, rotationIndex, label, startMoment, currentTimezone } = props; + const { id, layerIndex, rotationIndex, label, startMoment, currentTimezone, color } = props; const store = useStore(); useEffect(() => { - store.scheduleStore.updateRotation(id); - }, []); + const startMomentString = startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); + + store.scheduleStore.updateRotation(id, startMomentString); + }, [startMoment]); const rotation = store.scheduleStore.rotations[id]; if (!rotation) { - return ; + return ( +
+ +
+ ); } - const base = 60 * 24 * 7; // in minutes + const { shifts } = rotation; + + const firstShift = shifts[0]; + + const firstShiftOffset = firstShift.start.diff(startMoment, 'minutes'); + + const base = 60 * 24 * 7; // in minutes only const utcOffset = dayjs().tz(currentTimezone).utcOffset(); - const x = utcOffset / base; - - const { shifts } = rotation; + const x = (firstShiftOffset + utcOffset) / base; return (
@@ -63,6 +74,7 @@ const Rotation: FC = observer((props) => { rotationIndex={rotationIndex} startMoment={startMoment} currentTimezone={currentTimezone} + color={color} /> ); })} diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index a0fdebfd..21c4515b 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -1,6 +1,7 @@ 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 { makeRequest } from 'network'; @@ -10,6 +11,34 @@ import { Rotation, Schedule, ScheduleEvent } from './schedule.types'; const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; +function getUsers() { + const rnd = Math.random(); + /* + + if (rnd > 0.66) { + return []; + } +*/ + + const users = [ + 'U5WE86241LNEA', + 'U9XM1G7KTE3KW', + 'UYKS64M6C59XM', + 'UFFIRDUFXA6W3', + 'UPRMSTP9LCADE', + 'UR6TVJWZYV19M', + 'UHRMQQ7KETPCS', + ]; + + /* if (rnd > 0.33) { + return [users[Math.floor(Math.random() * users.length)], users[Math.floor(Math.random() * users.length)]]; + }*/ + + return ['UPRMSTP9LCADE', 'UHRMQQ7KETPCS']; + + return [users[Math.floor(Math.random() * users.length)]]; +} + export class ScheduleStore extends BaseStore { @observable searchResult: { [key: string]: Array } = {}; @@ -114,42 +143,14 @@ export class ScheduleStore extends BaseStore { }); } - async updateRotation(rotationId: Rotation['id'], from?: string) { + async updateRotation(rotationId: Rotation['id'], fromString: string) { const response = await new Promise((resolve, reject) => { - function getUsers() { - const rnd = Math.random(); - /* - - if (rnd > 0.66) { - return []; - } -*/ - - const users = [ - 'U5WE86241LNEA', - 'U9XM1G7KTE3KW', - 'UYKS64M6C59XM', - 'UFFIRDUFXA6W3', - 'UPRMSTP9LCADE', - 'UR6TVJWZYV19M', - 'UHRMQQ7KETPCS', - ]; - - /* if (rnd > 0.33) { - return [users[Math.floor(Math.random() * users.length)], users[Math.floor(Math.random() * users.length)]]; - }*/ - - return ['UPRMSTP9LCADE', 'UHRMQQ7KETPCS']; - - return [users[Math.floor(Math.random() * users.length)]]; - } - setTimeout(() => { - if (!from) { - from = dayjs().startOf('week').format('YYYY-MM-DDTHH:mm:ss'); + if (!fromString) { + fromString = dayjs().startOf('week').format('YYYY-MM-DDTHH:mm:ss.000Z'); } - const startMoment = dayjs(`${from}.000Z`).utc(); + const startMoment = dayjs(fromString).utc(); const shifts = []; for (let i = 0; i < 7; i++) { diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 9d6e8589..80814585 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -619,7 +619,7 @@ export const getRandomUsers = (count = 5) => { //name: getRandomUser(), pk: i, name: [ - 'Hypothetical UTC user', + 'Some Etc/Universal user', 'Matias Bordese', 'Michael Derynck', 'Yulia Shanyrova', @@ -640,7 +640,7 @@ export const getRandomUsers = (count = 5) => { ][i], //tz: getRandomTimezone(), tz: [ - 'UTC', + 'Etc/Universal', 'America/Montevideo', 'America/Vancouver', 'Europe/Amsterdam', diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index f8aa5d02..1cc04f02 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -10,6 +10,8 @@ import Draggable from 'react-draggable'; // import Rotations from 'components/Rotations/Rotations'; import PluginLink from 'components/PluginLink/PluginLink'; import Rotations from 'components/Rotations/Rotations'; +import ScheduleFinal from 'components/Rotations/ScheduleFinal'; +import ScheduleOverrides from 'components/Rotations/ScheduleOverrides'; import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; import ScheduleQuality from 'components/ScheduleQuality/ScheduleQuality'; import Text from 'components/Text/Text'; @@ -37,7 +39,7 @@ interface SchedulePageState { currentTimezone: Timezone; } -const INITIAL_TIMEZONE = 'UTC'; +const INITIAL_TIMEZONE = 'Etc/Universal'; // todo check why doesn't work @observer class SchedulePage extends React.Component { @@ -55,8 +57,6 @@ class SchedulePage extends React.Component store.userStore.updateItems(); } - componentDidUpdate() {} - render() { const { startMoment, schedulePeriodType, renderType, users, currentTimezone } = this.state; const { query } = this.props; @@ -156,9 +156,9 @@ class SchedulePage extends React.Component
{/*
*/}
- {/**/} - - {/* */} + + +
From 48240522ba00721b7f23563549a0738981519bbc Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 19 Jul 2022 12:58:13 +0100 Subject: [PATCH 15/60] add schedule override form --- grafana-plugin/.stylelintrc | 3 +- .../src/components/Modal/Modal.module.css | 49 +++---- .../RotationForm/RotationForm.module.css | 39 +++--- .../components/Rotations/Rotations.module.css | 86 ++++++------ .../src/components/Rotations/Rotations.tsx | 2 +- .../Rotations/ScheduleOverrides.tsx | 1 - .../ScheduleCounter.module.css | 28 ++-- .../ScheduleOverrideForm.module.css | 20 ++- .../ScheduleOverrideForm.tsx | 96 ++++++++++++-- .../ScheduleQuality.module.css | 54 ++++---- .../ScheduleSlot/ScheduleSlot.module.css | 78 +++++------ .../ScheduleUserDetails.module.css | 45 ++++--- .../src/components/Table/Table.module.css | 33 +++-- .../TimelineMarks/TimelineMarks.module.css | 67 +++++----- .../UserGroups/UserGroups.module.css | 110 ++++++++-------- .../UserTimezoneSelect.module.css | 2 +- .../UsersTimezones/UsersTimezones.module.css | 124 +++++++++--------- .../WorkingHours/WorkingHours.module.css | 2 +- .../containers/Rotation/Rotation.module.css | 46 +++---- .../src/containers/Rotation/Rotation.tsx | 2 +- .../SchedulesFilters.module.css | 2 +- .../src/models/schedule/schedule.ts | 34 ++++- .../src/pages/schedule/Schedule.module.css | 37 +++--- .../pages/schedules_NEW/Schedules.module.css | 7 +- grafana-plugin/src/vars.css | 12 +- 25 files changed, 544 insertions(+), 435 deletions(-) diff --git a/grafana-plugin/.stylelintrc b/grafana-plugin/.stylelintrc index 9144c1e6..7cda5c91 100644 --- a/grafana-plugin/.stylelintrc +++ b/grafana-plugin/.stylelintrc @@ -1,6 +1,7 @@ { "extends": "stylelint-config-standard", "rules": { + "block-no-empty": [true,{ "severity": "warning"}], "selector-pseudo-class-no-unknown": [ true, { @@ -8,4 +9,4 @@ } ] } -} \ No newline at end of file +} diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css index 4c3fad93..a117b44d 100644 --- a/grafana-plugin/src/components/Modal/Modal.module.css +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -1,32 +1,33 @@ .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; + 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: 0px; - z-index: 10; - /*background-color: rgba(0, 0, 0, 0.45); - backdrop-filter: blur(1px);*/ + position: fixed; + inset: 0; + z-index: 10; + + /* background-color: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(1px); */ } .body-open { - overflow: hidden; + overflow: hidden; } diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.module.css b/grafana-plugin/src/components/RotationForm/RotationForm.module.css index fa811439..1928ede4 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/components/RotationForm/RotationForm.module.css @@ -1,44 +1,41 @@ .root { - + display: block; } .header { - width: 100%; - display: flex; - justify-content: space-between; + width: 100%; + display: flex; + justify-content: space-between; } .control { - width: 195px; + width: 195px; } - .date-time-picker { - display: block; + display: block; } .inline-switch { - height: 22px; + height: 22px; } - .days { - display: flex; - gap: 14px; - width: 100%; + 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; - + 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; + background: #3d71d9; } diff --git a/grafana-plugin/src/components/Rotations/Rotations.module.css b/grafana-plugin/src/components/Rotations/Rotations.module.css index 2c34ef90..31be75c1 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.module.css +++ b/grafana-plugin/src/components/Rotations/Rotations.module.css @@ -1,34 +1,34 @@ .root { - border: var(--border-medium); - border-radius: 2px; - background: var(--primary-background); + 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; + position: absolute; + left: 650px; + width: 1px; + background: #fff; + top: 0; + bottom: 0; + z-index: 1; } .header { - padding: 0 10px; + padding: 0 10px; } .title { - font-weight: 500; - font-size: 19px; - line-height: 24px; - color: rgba(204, 204, 220, 0.65); - margin: 16px 0; + 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; + display: flex; + flex-direction: column; } .layer { @@ -36,47 +36,47 @@ } .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); + 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); + background: rgba(204, 204, 220, 0.12); } .header-plus-content { - position: relative; + position: relative; } .layer-header { - padding: 12px; - display: flex; - justify-content: space-between; + 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); + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(204, 204, 220, 0.65); } .layer-content { - position: relative; + 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); + 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 index 2b99d601..c34f8c29 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -96,7 +96,7 @@ class Rotations extends Component {
Add rotations layer +
- {layerIdToCreateRotation && ( + {!isNaN(layerIdToCreateRotation) && ( { diff --git a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx index f985bb61..ca258436 100644 --- a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx @@ -4,7 +4,6 @@ import { Button, HorizontalGroup, Icon, ValuePicker } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; -import OverrideForm from 'components/OverrideForm/OverrideForm'; import RotationForm from 'components/RotationForm/RotationForm'; import ScheduleOverrideForm from 'components/ScheduleOverrideForm/ScheduleOverrideForm'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css index c687d23d..f0f2dc1f 100644 --- a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.module.css @@ -1,33 +1,32 @@ .root { - font-size: 12px; - line-height: 16px; + 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; - + 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; + 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); + color: var(--success-text-color); } .icon__type_warning { - color: var(--warning-text-color); + color: var(--warning-text-color); } .tooltip { - width: auto; + width: auto; } /* @@ -41,4 +40,3 @@ background: #3A301E; } */ - diff --git a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css index a61fd276..a77fc6d4 100644 --- a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css +++ b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css @@ -1,3 +1,21 @@ .root { + display: block; +} -} \ No newline at end of file +.header { + width: 100%; + display: flex; + justify-content: space-between; +} + +.control { + width: 195px; +} + +.date-time-picker { + display: block; +} + +.inline-switch { + height: 22px; +} diff --git a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx index efedece6..9b7b46b4 100644 --- a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx @@ -1,20 +1,100 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback, 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 { getTzOffsetString } from 'models/timezone/timezone.helpers'; import styles from './ScheduleOverrideForm.module.css'; -interface ScheduleOverrideFormProps { - +interface RotationFormProps { + layerId: string; + onHide: () => void; + id: number | 'new'; } const cx = cn.bind(styles); -const ScheduleOverrideForm: FC = props => { - const { } = props; +const ScheduleOverrideForm: FC = (props) => { + const { onHide } = props; - return ( -
- ); + const [shiftStart, setShiftStart] = useState(dateTime('2021-05-05 12:00:00')); + const [shiftEnd, setShiftEnd] = useState(dateTime('2021-05-05 12:00:00')); + + const moment = dayjs(); + + return ( + ( + +
{children}
+
+ )} + > + + + Rotation 1 + + + + + + + + + {/*
*/} + + + + Override start + + } + > + + + + Override end + + } + > + + + + + + Timezone: {getTzOffsetString(moment)} + + + + +
+
+ ); }; export default ScheduleOverrideForm; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css index 25453f1c..5f9d2674 100644 --- a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.module.css @@ -1,46 +1,46 @@ .root { - padding: 4px 10px; - gap: 10px; - background: var(--primary-background); - border: var(--border-medium); - border-radius: 2px; + padding: 4px 10px; + gap: 10px; + background: var(--primary-background); + border: var(--border-medium); + border-radius: 2px; } .details { - width: auto; - padding: 10px 0; + width: auto; + padding: 10px 0; } -.progress{ - width:100%; - height: 16px; - background-color: var( --secondary-background-shade); - position: relative; +.progress { + width: 100%; + height: 16px; + background-color: var(--secondary-background-shade); + position: relative; } -.progress-filler{ - height: 100%; - position: absolute; +.progress-filler { + height: 100%; + position: absolute; } -.progress-filler__type_success{ - background-color: var(--success-text-color); +.progress-filler__type_success { + background-color: var(--success-text-color); } -.progress-filler__type_warning{ - background-color: var(--warning-text-color); +.progress-filler__type_warning { + background-color: var(--warning-text-color); } -.quality-text{ - float: right; - line-height: 16px; - margin-right: 3px; +.quality-text { + float: right; + line-height: 16px; + margin-right: 3px; } -.quality-text__type_success{ - color: var(--primary-text-color); +.quality-text__type_success { + color: var(--primary-text-color); } -.quality-text__type_warning{ - color: #111217 +.quality-text__type_warning { + color: #111217; } diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 30807864..36709dad 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -1,70 +1,70 @@ .root { - height: 28px; - background: #3274D9; - border-radius: 2px; - position: relative; - display: flex; - gap: 4px; + height: 28px; + background: #3274d9; + border-radius: 2px; + position: relative; + display: flex; + gap: 4px; } -.working-hours{ - position: absolute; - top: 0; - left: 0; +.working-hours { + position: absolute; + top: 0; + left: 0; } .stack { - display: flex; - flex-direction: column; - gap:1px; - transition: left 500ms ease; + display: flex; + flex-direction: column; + gap: 1px; + transition: left 500ms ease; } .stack > .root { - margin: 0 2px; + margin: 0 2px; } .root__type_gap { - background: rgba(209, 14, 92, 0.2); - border: 1px dashed #FF5286; - color: rgba(209, 14, 92, .5); - visibility: hidden; + 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; + opacity: 0.5; } .title { - padding: 5px; - z-index: 1; - color: #ffffff; - font-sizrooe: 12px; - font-weight: 500; + padding: 5px; + z-index: 1; + color: #fff; + 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; - font-size: 10px; - font-weight: bold; + background: rgba(255, 255, 255, 0.7); + border-radius: 2px; + display: inline-block; + padding: 2px 4px; + margin: 4px; + line-height: 16px; + z-index: 1; + font-size: 10px; + font-weight: bold; } .details { - width: auto; + width: auto; } .details-user-status { - width: 10px; - height: 10px; - border-radius: 50%; + width: 10px; + height: 10px; + border-radius: 50%; } .details-user-status__type_success { - background-color: var(--success-text-color); + background-color: var(--success-text-color); } diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css index 552802a6..84906035 100644 --- a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.module.css @@ -1,39 +1,38 @@ .root { - width: 220px; - padding: 10px; + width: 220px; + padding: 10px; } .oncall-badge { - line-height: 16px; - color: var(--primary-background); - padding: 2px 7px; - border-radius: 4px; - margin-bottom: 10px; + 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_now { + background: #6ccf8e; } -.oncall-badge__type_inside{ - background: #CCCCDC; +.oncall-badge__type_inside { + background: #ccccdc; } -.oncall-badge__type_outside{ - background: rgba(204, 204, 220, 0.4); +.oncall-badge__type_outside { + background: rgba(204, 204, 220, 0.4); } -.hr{ - width: 100%; - margin:0 -11px; +.hr { + width: 100%; + margin: 0 -11px; } -.times{ - display: flex; - flex-direction: column; -} -.icon{ - color: #CCCCDC; +.times { + display: flex; + flex-direction: column; } - +.icon { + color: #ccccdc; +} diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css index 3822a863..2791d8bc 100644 --- a/grafana-plugin/src/components/Table/Table.module.css +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -1,40 +1,37 @@ .root { - width: 100%; + width: 100%; } -.root table{ - width: 100%; +.root table { + width: 100%; } - .root tr { - border-bottom: 1px solid #33363B; - height: 60px; + border-bottom: 1px solid #33363b; + height: 60px; } .root tr:hover { - background: var(--secondary-background) - + background: var(--secondary-background); } - .root td { - min-height: 60px; + min-height: 60px; } .pagination { - width: 100%; - margin-top: 20px; + width: 100%; + margin-top: 20px; } .expand-icon { - padding: 10px; - pointer-events: none; - transform: rotate(-90deg); - transform-origin: center; - transition: transform 0.2s; + padding: 10px; + pointer-events: none; + transform: rotate(-90deg); + transform-origin: center; + transition: transform 0.2s; } .expand-icon__expanded { - transform: rotate(0deg); + transform: rotate(0deg); } diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css index 0d434a32..a6b0f246 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css @@ -1,53 +1,52 @@ .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; + 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; + 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; + width: 100%; + text-align: center; + padding-top: 4px; + flex-grow: 1; } -.weekday:not(:last-child) .weekday-title{ - border-right: var(--border-medium) +.weekday:not(:last-child) .weekday-title { + border-right: var(--border-medium); } .weekday-times { - width: 100%; - display: flex; - height: 16px; + width: 100%; + display: flex; + height: 16px; } .weekday-time { - width: 50%; + width: 50%; } .weekday-time-title { - display: inline-block; - transform: translate(-50%, 0); + display: inline-block; + transform: translate(-50%, 0); } -.weekday-time-title__hidden{ - visibility: hidden; +.weekday-time-title__hidden { + visibility: hidden; } /* @@ -55,8 +54,8 @@ for debug purposes only */ .debug-scale { - position: absolute; - top: -6px; - width: 100%; - right: 0; + position: absolute; + top: -6px; + width: 100%; + right: 0; } diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index de2ad2cd..9ff63c70 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -1,89 +1,87 @@ .root { - width: 100%; + width: 100%; } .sortable-helper { - z-index: 1062; - box-shadow: var(--focused-box-shadow); - background: var(--hover-selected-hardcoded) !important; + 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; + 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__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; + 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; + 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; + 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; + background: #22252b; + border-radius: 2px; + padding: 6px 10px; + display: flex; + justify-content: space-between; } -.user:hover{ - background: var(--hover-selected-hardcoded); +.user:hover { + background: var(--hover-selected-hardcoded); } -.delete-icon{ - /*display: none;*/ - display: block; +.delete-icon { + /* display: none; */ + display: block; } -.user:hover .delete-icon{ - 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; - + width: 100%; + text-align: center; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: var(--secondary-text-color); + cursor: pointer; } -.select{ - width: 100%; +.select { + width: 100%; } diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css index 3584b59a..c6b6dd45 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css @@ -1,3 +1,3 @@ .root { - width: 300px; + width: 300px; } diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css index 4b392f16..b7a96eda 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css +++ b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css @@ -1,90 +1,94 @@ .root { - border: var(--border-medium); - display: flex; - flex-direction: column; - border-radius: 2px; - background: var(--primary-background); + border: var(--border-medium); + display: flex; + flex-direction: column; + border-radius: 2px; + background: var(--primary-background); } .header { - padding: 0 10px; + padding: 0 10px; } .title { - font-weight: 500; - font-size: 19px; - line-height: 24px; - color: rgba(204, 204, 220, 0.65); - margin: 16px 0; + 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; + 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; + position: relative; + height: 76px; } .user { - position: absolute; - top: 10px; - border: 2px solid #C65210; - transition: left 1s linear; - transform: translate(-50%,0); - z-index: 0; - border-radius: 50%; + position: absolute; + top: 10px; + border: 2px solid #c65210; + transition: left 1s linear; + transform: translate(-50%, 0); + z-index: 0; + border-radius: 50%; } .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 + 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%); + 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%; + 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 { @@ -92,18 +96,16 @@ } .time-mark-text { - display: inline-block; - padding: 0 5px; + display: inline-block; + padding: 0 5px; } .time-mark-text__translated { - transform: translate(-50%, 0); - padding: 0; + transform: translate(-50%, 0); + padding: 0; } -.time-mark:last-child{ - position: absolute; - right: 0; +.time-mark:last-child { + position: absolute; + right: 0; } - - diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css b/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css index 8b98de94..63d08ecc 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.module.css @@ -1,3 +1,3 @@ .root { - display: block; + display: block; } diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index 09531c09..e8194941 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -1,46 +1,42 @@ .root { - transition: background-color 300ms; - min-height: 28px; + transition: background-color 300ms; + min-height: 28px; } -.root:last-child{ - padding-bottom: 26px; +.root:last-child { + padding-bottom: 26px; } -.root:hover{ - background: var(--secondary-background); +.root:hover { + background: var(--secondary-background); } .timeline { - display: flex; - flex-direction: column; - gap: 5px; - padding-bottom: 8px; + display: flex; + flex-direction: column; + gap: 5px; + padding-bottom: 8px; } .root:first-child .timeline { - padding-top: 26px; + padding-top: 26px; } .root:last-child .timeline { - padding-bottom: 0; -} - -.timeline { - /* overflow: hidden; */ + padding-bottom: 0; } .slots { - display: flex; - transition: transform 500ms ease; + display: flex; + transition: transform 500ms ease; } .current-time { - position: absolute; - left: 450px; - width: 1px; - background: #fff; - top: -10px; - bottom: -10px; - z-index: 1; + position: absolute; + left: 450px; + width: 1px; + background: #fff; + top: -10px; + bottom: -10px; + z-index: 1; } diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index c4588c5d..ee42d94b 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -35,7 +35,7 @@ const Rotation: FC = observer((props) => { useEffect(() => { const startMomentString = startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); - store.scheduleStore.updateRotation(id, startMomentString); + store.scheduleStore.updateRotationMock(id, startMomentString); }, [startMoment]); const rotation = store.scheduleStore.rotations[id]; diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css index a61fd276..bedeb67b 100644 --- a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.module.css @@ -1,3 +1,3 @@ .root { -} \ No newline at end of file +} diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 21c4515b..5a946ef8 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -143,7 +143,27 @@ export class ScheduleStore extends BaseStore { }); } - async updateRotation(rotationId: Rotation['id'], fromString: string) { + // ------- NEW SCHEDULES API ENDPOINTS --------- + + async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params) { + const type = isOverride ? 3 : 2; + + const { name, shift_start, shift_end, rotation_start } = params; + + return await makeRequest(`/oncall_shifts/`, { + data: { name, type, schedule: scheduleId, shift_start, shift_end, rotation_start }, + method: 'POST', + }); + } + + async updateRotation(rotationId: Rotation['id']) { + return await makeRequest(`/oncall_shifts/`, { + params: { shift_id: rotationId }, + method: 'GET', + }); + } + + async updateRotationMock(rotationId: Rotation['id'], fromString: string) { const response = await new Promise((resolve, reject) => { setTimeout(() => { if (!fromString) { @@ -173,4 +193,16 @@ export class ScheduleStore extends BaseStore { [rotationId]: response as Rotation, }; } + + async updateFrequencyOptions(scheduleId: Schedule['id']) { + return await makeRequest(`/oncall_shifts/frequency_options/`, { + method: 'GET', + }); + } + + async updateDaysOptions(scheduleId: Schedule['id']) { + return await makeRequest(`/oncall_shifts/days_options/`, { + method: 'GET', + }); + } } diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css index 229d0314..8c24c26a 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.module.css +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -1,32 +1,31 @@ .root { - max-width: 1600px; - margin: 0 auto; - margin-top: 24px; + max-width: 1600px; + margin: 0 auto; + margin-top: 24px; } -.header{ - position: sticky; /* TODO check */ - width: 100%; +.header { + position: sticky; /* TODO check */ + width: 100%; } -.desc{ - width: 736px; +.desc { + width: 736px; } -.users-timezones{ - width: 100%; - margin-bottom: 16px; - +.users-timezones { + width: 100%; + margin-bottom: 16px; } -.controls{ - width: 100%; +.controls { + width: 100%; } .rotations { - display: flex; - flex-direction: column; - gap: 20px; - position: relative; - width: 100%; + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + width: 100%; } diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css index 0884c8f0..2eff6caa 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -2,16 +2,15 @@ margin-top: 24px; } -.quality__type_success{ +.quality__type_success { color: var(--warning-text-color); } - -.schedule{ +.schedule { position: relative; margin: 20px 0; } -.root .expanded-row{ +.root .expanded-row { background: var(--secondary-background); } diff --git a/grafana-plugin/src/vars.css b/grafana-plugin/src/vars.css index 936cd721..55917544 100644 --- a/grafana-plugin/src/vars.css +++ b/grafana-plugin/src/vars.css @@ -45,15 +45,9 @@ --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) 0px 0px 0px 2px, rgb(61 113 217) 0px 0px 0px 4px; + --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: rgba(204, 204, 220, 0.12); --hover-selected-hardcoded: #34363d; - - --secondary-background-shade: rgba(204, 204, 220, 0.2); - - + --secondary-background-shade: rgba(204, 204, 220, 0.2); } From 15d0c39a4fb4bcd06e067c4f5511d2c3c43e21fd Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 21 Jul 2022 14:22:23 +0100 Subject: [PATCH 16/60] add week sliding --- grafana-plugin/package.json | 2 + grafana-plugin/src/GrafanaPluginRootPage.tsx | 2 + .../components/Rotations/Rotations.module.css | 5 +- .../src/components/Rotations/Rotations.tsx | 11 +- .../components/Rotations/ScheduleFinal.tsx | 8 +- .../Rotations/ScheduleOverrides.tsx | 8 +- .../ScheduleSlot/ScheduleSlot.helpers.ts | 1 + .../ScheduleSlot/ScheduleSlot.module.css | 3 +- .../components/ScheduleSlot/ScheduleSlot.tsx | 14 +-- .../containers/Rotation/Rotation.module.css | 20 ++++ .../src/containers/Rotation/Rotation.tsx | 105 ++++++++++++------ .../src/models/schedule/schedule.ts | 44 ++++++-- .../src/models/schedule/schedule.types.ts | 3 + .../src/pages/schedule/Schedule.helpers.ts | 14 ++- .../src/pages/schedule/Schedule.tsx | 16 +-- .../src/pages/schedules_NEW/Schedules.tsx | 4 +- grafana-plugin/yarn.lock | 34 +++++- 17 files changed, 218 insertions(+), 76 deletions(-) diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index fffef322..7a090dd0 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@types/query-string": "^6.3.0", + "@types/react-transition-group": "1.x", "array-move": "^4.0.0", "change-case": "^4.1.1", "circular-dependency-plugin": "^5.2.2", @@ -79,6 +80,7 @@ "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", "react-string-replace": "^0.4.4", + "react-transition-group": "1.x", "stylelint": "^13.13.1", "stylelint-config-standard": "^22.0.0", "throttle-debounce": "^2.1.0" diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index dcf33cbe..aa48c65b 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -5,6 +5,7 @@ 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'; @@ -27,6 +28,7 @@ dayjs.extend(weekday); dayjs.extend(localeData); dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrAfter); +dayjs.extend(isoWeek); // dayjs().weekday(0); diff --git a/grafana-plugin/src/components/Rotations/Rotations.module.css b/grafana-plugin/src/components/Rotations/Rotations.module.css index 31be75c1..6d054716 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.module.css +++ b/grafana-plugin/src/components/Rotations/Rotations.module.css @@ -6,12 +6,13 @@ .current-time { position: absolute; - left: 650px; width: 1px; background: #fff; top: 0; bottom: 0; z-index: 1; + + /* transition: left 500ms ease; */ } .header { @@ -32,7 +33,7 @@ } .layer { - + display: block; } .layer-title { diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index c34f8c29..a413b094 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -2,7 +2,7 @@ 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 dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import RotationForm from 'components/RotationForm/RotationForm'; @@ -41,11 +41,16 @@ class Rotations extends Component { const layers = [ { id: 0, title: 'Layer 1' }, /*{ id: 1, title: 'Layer 2' }, - { id: 2, title: 'Layer 3' }, + { id: 2, title: 'Layer 3' }, { id: 3, title: 'Layer 4' }*/ , ]; + const base = 7 * 24 * 60; // in minutes + const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); + + const currentTimeX = diff / base; + const rotations = [{} /* {}*/]; return ( @@ -76,7 +81,7 @@ class Rotations extends Component {
-
+
{rotations.map((rotation, rotationIndex) => ( diff --git a/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx index eb4cab0e..d74eba7a 100644 --- a/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { Button, HorizontalGroup, Icon, Input, ValuePicker } from '@grafana/ui'; import cn from 'classnames/bind'; +import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import RotationForm from 'components/RotationForm/RotationForm'; @@ -27,6 +28,11 @@ class ScheduleOverrides extends Component
@@ -42,7 +48,7 @@ class ScheduleOverrides extends Component
-
+
@@ -39,7 +45,7 @@ class ScheduleOverrides extends Component
-
+
diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts index f9a35fa9..1d3bca98 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts @@ -64,6 +64,7 @@ export const getLabel = (layerIndex: number, rotationIndex) => { }; export const getTitle = (user: User) => { + return user ? user.username.split(' ')[0] : null; return user ? user.username .split(' ') diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 36709dad..db25c886 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -17,7 +17,7 @@ display: flex; flex-direction: column; gap: 1px; - transition: left 500ms ease; + flex-shrink: 0; } .stack > .root { @@ -28,7 +28,6 @@ background: rgba(209, 14, 92, 0.2); border: 1px dashed #ff5286; color: rgba(209, 14, 92, 0.5); - visibility: hidden; } .root__inactive { diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 3fa21af7..4249bff5 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -30,25 +30,21 @@ interface ScheduleSlotProps { const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, shift, startMoment, currentTimezone, color: propColor } = props; + const { index, layerIndex, rotationIndex, shift, startMoment, currentTimezone, color: propColor, x, basePx } = props; const { duration, users } = shift; - const isGap = !users.length; - const store = useStore(); const base = 60 * 60 * 24 * 7; const width = duration / base; + const label = !isNaN(layerIndex) && !isNaN(rotationIndex) && index === 0 ? getLabel(layerIndex, rotationIndex) : null; + return (
- {!isGap ? ( + {!shift.is_gap ? ( users.map((pk, userIndex) => { - const label = - !isNaN(layerIndex) && !isNaN(rotationIndex) && index === 0 && userIndex === 0 - ? getLabel(layerIndex, rotationIndex) - : null; const storeUser = store.userStore.items[pk]; const inactive = false; @@ -74,7 +70,7 @@ const ScheduleSlot: FC = observer((props) => { duration={shift.duration} /> )} - {label && ( + {userIndex === 0 && label && (
{label}
diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index e8194941..0b941671 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -3,6 +3,14 @@ min-height: 28px; } +.loader { + height: 28px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + .root:last-child { padding-bottom: 26px; } @@ -16,6 +24,8 @@ flex-direction: column; gap: 5px; padding-bottom: 8px; + + /* overflow: hidden; */ } .root:first-child .timeline { @@ -27,10 +37,20 @@ } .slots { + width: 100%; display: flex; + transition: opacity 500ms ease; + opacity: 1; +} + +.slots__animate { transition: transform 500ms ease; } +.slots__transparent { + opacity: 0; +} + .current-time { position: absolute; left: 450px; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index ee42d94b..a15bc6d1 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -1,15 +1,17 @@ -import React, { FC, useMemo, useState, useEffect } from 'react'; +import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react'; -import { LoadingPlaceholder } from '@grafana/ui'; +import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; +import { CSSTransitionGroup } from 'react-transition-group'; // ES6 import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; import Text from 'components/Text/Text'; import { Rotation as RotationType } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { useStore } from 'state/useStore'; +import { usePrevious } from 'utils/hooks'; import styles from './Rotation.module.css'; @@ -30,55 +32,90 @@ interface RotationProps { const Rotation: FC = observer((props) => { const { id, layerIndex, rotationIndex, label, startMoment, currentTimezone, color } = props; + const [animate, setAnimate] = useState(true); + const [width, setWidth] = useState(); + const [transparent, setTransparent] = useState(false); + const store = useStore(); + const startMomentString = useMemo(() => startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'), [startMoment]); + + const prevStartMomentString = usePrevious(startMomentString); + + const rotation = store.scheduleStore.rotations[id]?.[startMomentString]; + //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'); - store.scheduleStore.updateRotationMock(id, startMomentString); - }, [startMoment]); + console.log('CHANGE START MOMENT', startMomentString); - const rotation = store.scheduleStore.rotations[id]; + store.scheduleStore.updateRotationMock(id, startMomentString, currentTimezone); + }, [startMomentString]); - if (!rotation) { - return ( -
- -
- ); - } + const slots = useCallback((node) => { + if (node) { + setWidth(node.offsetWidth); + } + }, []); - const { shifts } = rotation; + const x = useMemo(() => { + if (!rotation) { + return 0; + } - const firstShift = shifts[0]; + const { shifts } = rotation; - const firstShiftOffset = firstShift.start.diff(startMoment, 'minutes'); + const firstShift = shifts[0]; - const base = 60 * 24 * 7; // in minutes only - const utcOffset = dayjs().tz(currentTimezone).utcOffset(); + const firstShiftOffset = firstShift.start.diff(startMoment, 'minutes'); - const x = (firstShiftOffset + utcOffset) / base; + const base = 60 * 24 * 7; // in minutes only + const utcOffset = dayjs().tz(currentTimezone).utcOffset(); + + return firstShiftOffset / base; + }, [rotation]); + + useEffect(() => {}); return (
{/*
*/}
-
- {shifts.map((shift, index) => { - return ( - - ); - })} -
+ {rotation ? ( +
+ {rotation.shifts.map((shift, index) => { + return ( + + ); + })} +
+ ) : ( + + + + )}
); diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 5a946ef8..6306fd02 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -4,6 +4,7 @@ import { action, observable, toJS } from 'mobx'; import ReactCSSTransitionGroup from 'react-transition-group'; // ES6 import BaseStore from 'models/base_store'; +import { Timezone } from 'models/timezone/timezone.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; @@ -11,6 +12,8 @@ import { Rotation, Schedule, ScheduleEvent } from './schedule.types'; const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; +let I = 0; + function getUsers() { const rnd = Math.random(); /* @@ -47,7 +50,11 @@ export class ScheduleStore extends BaseStore { items: { [id: string]: Schedule } = {}; @observable.shallow - rotations: { [id: string]: Rotation } = {}; + rotations: { + [id: string]: { + [startMoment: string]: Rotation; + }; + } = {}; @observable scheduleToScheduleEvents: { @@ -163,25 +170,41 @@ export class ScheduleStore extends BaseStore { }); } - async updateRotationMock(rotationId: Rotation['id'], fromString: string) { + async updateRotationMock(rotationId: Rotation['id'], fromString: string, currentTimezone: Timezone) { + if (this.rotations[rotationId]?.[fromString]) { + return; + } + const response = await new Promise((resolve, reject) => { setTimeout(() => { if (!fromString) { fromString = dayjs().startOf('week').format('YYYY-MM-DDTHH:mm:ss.000Z'); } - const startMoment = dayjs(fromString).utc(); + let startMoment = dayjs(fromString); + const utcOffset = dayjs().tz(currentTimezone).utcOffset(); + + startMoment = startMoment.add(utcOffset, 'minutes'); + //const startMoment = dayjs().utc().startOf('week'); const shifts = []; for (let i = 0; i < 7; i++) { + const shiftDuration = (12 + Math.floor(Math.random() * 12)) * 60 * 60; + const gapDuration = 24 * 60 * 60 - shiftDuration; + shifts.push({ - // start: dayjs(startMoment).add(12 * i, 'hour'), - // duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, - start: dayjs(startMoment).add(24 * i, 'hour'), - // duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60, - duration: 24 * 60 * 60, + pk: I++, + start: startMoment.add(24 * i, 'hour'), + duration: shiftDuration, users: getUsers(), }); + + shifts.push({ + pk: I++, + start: startMoment.add(24 * i, 'hour').add(shiftDuration, 'seconds'), + duration: gapDuration, + is_gap: true, + }); } resolve({ id: rotationId, shifts }); @@ -190,7 +213,10 @@ export class ScheduleStore extends BaseStore { this.rotations = { ...this.rotations, - [rotationId]: response as Rotation, + [rotationId]: { + ...this.rotations[rotationId], + [fromString]: response as Rotation, + }, }; } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index d5d86ba9..83b89cd9 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -45,9 +45,12 @@ export interface CreateScheduleExportTokenResponse { } export interface Shift { + pk: string; start: dayjs.Dayjs; duration: number; // in seconds users: Array; + is_gap: boolean; + is_empty: boolean; } export interface Rotation { diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 80814585..313a5345 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -1,3 +1,7 @@ +import dayjs from 'dayjs'; + +import { Timezone } from 'models/timezone/timezone.types'; + const tzs = [ 'Africa/Abidjan', 'Africa/Accra', @@ -612,14 +616,14 @@ export const getRandomTimezone = () => { return tzs[Math.floor(Math.random() * tzs.length)]; }; -export const getRandomUsers = (count = 5) => { +export const getRandomUsers = (count = 7) => { const users = []; for (let i = 0; i < count; i++) { users.push({ //name: getRandomUser(), pk: i, name: [ - 'Some Etc/Universal user', + 'Some UTC user', 'Matias Bordese', 'Michael Derynck', 'Yulia Shanyrova', @@ -640,7 +644,7 @@ export const getRandomUsers = (count = 5) => { ][i], //tz: getRandomTimezone(), tz: [ - 'Etc/Universal', + 'UTC', 'America/Montevideo', 'America/Vancouver', 'Europe/Amsterdam', @@ -654,3 +658,7 @@ export const getRandomUsers = (count = 5) => { return users; }; + +export const getStartOfWeek = (tz: Timezone) => { + return dayjs().tz(tz).utcOffset() === 0 ? dayjs().utc().startOf('isoWeek') : dayjs().tz(tz).startOf('isoWeek'); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 1cc04f02..16e08d7a 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -23,7 +23,7 @@ import { User } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; -import { getRandomUsers } from './Schedule.helpers'; +import { getRandomUsers, getStartOfWeek } from './Schedule.helpers'; import styles from './Schedule.module.css'; @@ -39,12 +39,12 @@ interface SchedulePageState { currentTimezone: Timezone; } -const INITIAL_TIMEZONE = 'Etc/Universal'; // todo check why doesn't work +const INITIAL_TIMEZONE = 'UTC'; // todo check why doesn't work @observer class SchedulePage extends React.Component { state: SchedulePageState = { - startMoment: dayjs().tz(INITIAL_TIMEZONE).startOf('week'), + startMoment: getStartOfWeek(INITIAL_TIMEZONE), schedulePeriodType: 'week', renderType: 'timeline', users: getRandomUsers(), @@ -156,9 +156,9 @@ class SchedulePage extends React.Component
{/*
*/}
- + {/**/} - + {/**/}
@@ -166,7 +166,7 @@ class SchedulePage extends React.Component } handleTimezoneChange = (value: Timezone) => { - this.setState({ currentTimezone: value, startMoment: dayjs().tz(value).startOf('week') }); + this.setState({ currentTimezone: value, startMoment: getStartOfWeek(value) }); }; handleShedulePeriodTypeChange = (value: string) => { @@ -178,9 +178,9 @@ class SchedulePage extends React.Component }; handleTodayClick = () => { - const { startMoment, currentTimezone } = this.state; + const { currentTimezone } = this.state; - this.setState({ startMoment: dayjs().tz(currentTimezone).startOf('week') }); + this.setState({ startMoment: getStartOfWeek(currentTimezone) }); }; handleLeftClick = () => { diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 8bc0ac91..0fdc1f71 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -125,13 +125,13 @@ class SchedulesPage extends React.Component { - const { startMoment } = this.props; + const { startMoment } = this.state; return (
- +
); diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 2ff15eaa..adeff3ef 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -3325,6 +3325,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@1.x": + version "1.1.8" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-1.1.8.tgz#99b01ebf3e73a50c484e5ee86a49e9289698d456" + integrity sha512-dPE733MOo56o6Uvck0lwz8383HTneJsw8SDKe3+M9qnxPqfj78s73l0tMk//Bz1CSf+7aZLF9UVYMY9agnrboA== + dependencies: + "@types/react" "*" + "@types/react@*": version "17.0.10" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.10.tgz#c8bb4e37cc642f7cc5532959cb5e2ead6c74dec9" @@ -4979,6 +4986,11 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +chain-function@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.1.tgz#c63045e5b4b663fb86f1c6e186adaf1de402a1cc" + integrity sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg== + chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -6593,7 +6605,7 @@ dom-css@^2.0.0: prefix-style "2.0.1" to-camel-case "1.0.0" -dom-helpers@^3.3.1: +dom-helpers@^3.2.0, dom-helpers@^3.3.1: version "3.4.0" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== @@ -12741,7 +12753,7 @@ prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, pr object-assign "^4.1.1" react-is "^16.8.1" -prop-types@^15.8.1: +prop-types@^15.5.6, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -13616,6 +13628,17 @@ react-table@7.8.0: resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2" integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA== +react-transition-group@1.x: + version "1.2.1" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.2.1.tgz#e11f72b257f921b213229a774df46612346c7ca6" + integrity sha512-CWaL3laCmgAFdxdKbhhps+c0HRGF4c+hdM4H23+FI1QBNUyx/AMeIJGWorehPNSaKnQNOAxL7PQmqMu78CDj3Q== + dependencies: + chain-function "^1.0.0" + dom-helpers "^3.2.0" + loose-envify "^1.3.1" + prop-types "^15.5.6" + warning "^3.0.0" + react-transition-group@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -16254,6 +16277,13 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.x" +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + integrity sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ== + dependencies: + loose-envify "^1.0.0" + warning@^4.0.1, warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" From 9197a4de9c53e2ee2e0022a4f1566b100cba6b86 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 22 Jul 2022 12:51:11 +0100 Subject: [PATCH 17/60] add NewScheduleSelector --- .../NewScheduleSelector.module.css | 7 ++ .../NewScheduleSelector.tsx | 118 ++++++++++++++++++ .../components/RotationForm/RotationForm.tsx | 29 ++++- .../RotationForm/RotationForm.types.ts | 1 + .../src/components/Rotations/Rotations.tsx | 17 ++- .../ScheduleForm/ScheduleForm.config.ts | 11 ++ .../containers/ScheduleForm/ScheduleForm.tsx | 26 ++-- .../src/models/schedule/schedule.ts | 10 ++ .../src/pages/schedule/Schedule.tsx | 23 +++- .../src/pages/schedules_NEW/Schedules.tsx | 104 ++++++++++----- 10 files changed, 296 insertions(+), 50 deletions(-) create mode 100644 grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css create mode 100644 grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx create mode 100644 grafana-plugin/src/components/RotationForm/RotationForm.types.ts diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css new file mode 100644 index 00000000..719dfe35 --- /dev/null +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.module.css @@ -0,0 +1,7 @@ +.root { + display: block; +} + +.block { + width: 100%; +} diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx new file mode 100644 index 00000000..f70df5b5 --- /dev/null +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx @@ -0,0 +1,118 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { getLocationSrv } from '@grafana/runtime'; +import { Button, Drawer, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; +import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; +import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; +import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; +import { UserAction } from 'state/userAction'; + +import styles from './NewScheduleSelector.module.css'; + +interface NewScheduleSelectorProps { + onHide: () => void; + onCreate: (data: Schedule) => void; + onUpdate: () => void; +} + +const cx = cn.bind(styles); + +const NewScheduleSelector: FC = (props) => { + const { onHide, onCreate, onUpdate } = props; + + const [showScheduleForm, setShowScheduleForm] = useState(false); + const [type, setType] = useState(); + + const getCreateScheduleClickHandler = useCallback((type: ScheduleType) => { + return () => { + setType(type); + setShowScheduleForm(true); + }; + }, []); + + return ( + <> + +
+ + {/* + Manage on-call schedules using your favourite calendar app, such as Google Calendar or Microsoft Outlook. To + schedule on-call shifts create a new calendar and use events with the teammates usernames + */} + + + + + + + Set up on-call rotation schedule + + Configure rotations and shifts directly in Grafana On-Call + + + + + + + + + + + + + + Import schedule from iCal Url + + Import rotations and shifts from your calendar app + + + + + + + + + + + + Create schedule by API + + Configure rotations and upload calendar by Terraform file + + + + + + +
+
+ {showScheduleForm && ( + { + onHide(); + onUpdate(); + }} + onCreate={onCreate} + onHide={() => { + setType(undefined); + setShowScheduleForm(false); + }} + /> + )} + + ); +}; + +export default NewScheduleSelector; diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.tsx b/grafana-plugin/src/components/RotationForm/RotationForm.tsx index f1c1f5a1..f2fc8394 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/components/RotationForm/RotationForm.tsx @@ -20,19 +20,24 @@ import Modal from 'components/Modal/Modal'; import Text from 'components/Text/Text'; import UserGroups from 'components/UserGroups/UserGroups'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { Timezone } from 'models/timezone/timezone.types'; + +import { RotationCreateData } from './RotationForm.types'; import styles from './RotationForm.module.css'; interface RotationFormProps { layerId: string; onHide: () => void; + onCreate: (date: RotationCreateData) => void; id: number | 'new'; + currentTimezone: Timezone; } const cx = cn.bind(styles); const RotationForm: FC = (props) => { - const { onHide } = props; + const { onHide, onCreate, currentTimezone } = props; const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState('days'); @@ -43,6 +48,22 @@ const RotationForm: FC = (props) => { const [endLess, setEndless] = useState(true); const [rotationEnd, setRotationEnd] = useState(dateTime('2021-05-05 12:00:00')); + const handleCreate = useCallback(() => { + /* console.log( + repeatEveryValue, + repeatEveryPeriod, + selectedDays, + shiftStart, + shiftEnd, + rotationStart, + endLess, + rotationEnd + ); + */ + + console.log(rotationEnd, dayjs(rotationEnd)); + }, [repeatEveryValue, repeatEveryPeriod, selectedDays, shiftStart, shiftEnd, rotationStart, endLess, rotationEnd]); + const handleChangeEndless = useCallback( (event: React.ChangeEvent) => { setEndless(!event.currentTarget.checked); @@ -180,10 +201,12 @@ const RotationForm: FC = (props) => { - Timezone: {getTzOffsetString(moment)} + Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} - + diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.types.ts b/grafana-plugin/src/components/RotationForm/RotationForm.types.ts new file mode 100644 index 00000000..86bf4877 --- /dev/null +++ b/grafana-plugin/src/components/RotationForm/RotationForm.types.ts @@ -0,0 +1 @@ +export interface RotationCreateData {} diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index a413b094..cca5478c 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -6,6 +6,7 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import RotationForm from 'components/RotationForm/RotationForm'; +import { RotationCreateData } from 'components/RotationForm/RotationForm.types'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; import { Timezone } from 'models/timezone/timezone.types'; @@ -19,6 +20,7 @@ const cx = cn.bind(styles); interface RotationsProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; + onCreate: (date: RotationCreateData) => void; } type Layer = { @@ -43,7 +45,6 @@ class Rotations extends Component { /*{ id: 1, title: 'Layer 2' }, { id: 2, title: 'Layer 3' }, { id: 3, title: 'Layer 4' }*/ - , ]; const base = 7 * 24 * 60; // in minutes @@ -98,21 +99,33 @@ class Rotations extends Component {
))} -
Add rotations layer +
+
+ Add rotations layer + +
{!isNaN(layerIdToCreateRotation) && ( { this.setState({ layerIdToCreateRotation: undefined }); }} + onCreate={this.onRotationCreate} /> )} ); } + onRotationCreate = (data: RotationCreateData) => { + const { onCreate } = this.props; + + onCreate(data); + }; + + handleAddLayer = () => {}; + handleAddRotation = (option) => { this.setState({ layerIdToCreateRotation: option.value }); }; diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts index 7019d0f4..62380d0d 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts @@ -139,3 +139,14 @@ export const calendarForm: { name: string; fields: FormItem[] } = { ...commonFields, ], }; + +export const apiForm: { name: string; fields: FormItem[] } = { + name: 'Schedule', + fields: [ + { + name: 'name', + type: FormItemType.Input, + validation: { required: true }, + }, + ], +}; diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 380f89ae..4f78f860 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -13,7 +13,7 @@ import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; -import { calendarForm, iCalForm } from './ScheduleForm.config'; +import { apiForm, calendarForm, iCalForm } from './ScheduleForm.config'; import { prepareForEdit } from './ScheduleForm.helpers'; import styles from './ScheduleForm.module.css'; @@ -24,19 +24,25 @@ interface ScheduleFormProps { id: Schedule['id'] | 'new'; onHide: () => void; onUpdate: () => void; + onCreate: (data: Schedule) => void; + type: ScheduleType; } +const scheduleTypeToForm = { + [ScheduleType.Calendar]: calendarForm, + [ScheduleType.Ical]: iCalForm, + [ScheduleType.API]: apiForm, +}; + const ScheduleForm = observer((props: ScheduleFormProps) => { - const { id, onUpdate, onHide } = props; + const { id, type, onUpdate, onCreate, onHide } = props; const store = useStore(); const { scheduleStore, userStore, grafanaTeamStore } = 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,10 +50,14 @@ 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] @@ -63,9 +73,7 @@ const ScheduleForm = observer((props: ScheduleFormProps) => { ); }; - const handleTeamChange = useCallback((value) => {}, []); - - const formConfig = data.type === ScheduleType.Ical ? iCalForm : calendarForm; + const formConfig = scheduleTypeToForm[data.type]; return ( const { store } = this.props; store.userStore.updateItems(); + + const { + query: { id }, + } = this.props; + + store.scheduleStore.updateItem(id); } render() { + const { store } = this.props; const { startMoment, schedulePeriodType, renderType, users, currentTimezone } = this.state; const { query } = this.props; + const { scheduleStore } = store; + + const schedule = scheduleStore.items[query.id]; + return (
@@ -70,7 +81,7 @@ class SchedulePage extends React.Component - Schedule Team {query.id} + {schedule?.name} {/*
*/}
{/**/} - + {/**/}
@@ -165,6 +180,10 @@ class SchedulePage extends React.Component ); } + handleCreateRotation = () => { + const { store } = this.props; + }; + handleTimezoneChange = (value: Timezone) => { this.setState({ currentTimezone: value, startMoment: getStartOfWeek(value) }); }; diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 0fdc1f71..108e823c 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -1,29 +1,25 @@ import React from 'react'; +import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, IconButton, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import Avatar from 'components/Avatar/Avatar'; +import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector'; import PluginLink from 'components/PluginLink/PluginLink'; -import { getColor, getLabel } from 'components/Rotations/Rotations.helpers'; 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 GSelect from 'containers/GSelect/GSelect'; import Rotation from 'containers/Rotation/Rotation'; -import { Schedule } from 'models/schedule/schedule.types'; -import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; +import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; -import { getRandomSchedules, getRandomTimeslots } from './Schedules.helpers'; - import styles from './Schedules.module.css'; const cx = cn.bind(styles); @@ -33,26 +29,34 @@ interface SchedulesPageProps extends WithStoreProps {} interface SchedulesPageState { startMoment: dayjs.Dayjs; filters: SchedulesFiltersType; + showNewScheduleSelector: boolean; } @observer class SchedulesPage extends React.Component { state: SchedulesPageState = { startMoment: dayjs().utc().startOf('week'), - schedules: getRandomSchedules(), + // schedules: getRandomSchedules(), filters: { searchTerm: '', status: 'all', type: 'all' }, + showNewScheduleSelector: false, }; async componentDidMount() { const { store } = this.props; store.userStore.updateItems(); + store.scheduleStore.updateItems(); } componentDidUpdate() {} render() { - const { schedules, filters } = this.state; + const { store } = this.props; + const { filters, showNewScheduleSelector } = this.state; + + const { scheduleStore } = store; + + const schedules = scheduleStore.getSearchResult(); const columns = [ { @@ -94,36 +98,61 @@ class SchedulesPage extends React.Component - - - - - - Timezone: - - {getTzOffsetString(moment)} ({dayjs.tz.guess()}) - + <> +
+ + + + + + Timezone: + + {getTzOffsetString(moment)} ({dayjs.tz.guess()}) + + + - - -
cx('expanded-row'), +
cx('expanded-row'), + }} + /> + + + {showNewScheduleSelector && ( + { + this.setState({ showNewScheduleSelector: false }); }} /> - - + )} + ); } + 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 } }); + } + }; + renderSchedule = () => { const { startMoment } = this.state; @@ -174,11 +203,11 @@ class SchedulesPage extends React.Component { return ( - {item.users.map((user) => ( + {/*{item.users.map((user) => ( {user.name} - ))} + ))}*/} ); }; @@ -209,6 +238,13 @@ class SchedulesPage extends React.Component {}; + + update = () => { + const { store } = this.props; + const { scheduleStore } = store; + + return scheduleStore.updateItems(); + }; } export default withMobXProviderContext(SchedulesPage); From 27c22cc98b5f086b49e91cdc8df007f66f8a869e Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 22 Jul 2022 15:25:14 +0100 Subject: [PATCH 18/60] connect endpoints #1 --- .../src/components/Rotations/Rotations.tsx | 13 ++++++-- .../RotationForm/RotationForm.module.css | 0 .../RotationForm/RotationForm.tsx | 30 +++++++++++++---- .../RotationForm/RotationForm.types.ts | 2 ++ .../src/models/schedule/schedule.ts | 33 +++++++++++++++---- .../src/pages/schedule/Schedule.helpers.ts | 4 +++ .../src/pages/schedule/Schedule.tsx | 24 ++++++++++++-- 7 files changed, 88 insertions(+), 18 deletions(-) rename grafana-plugin/src/{components => containers}/RotationForm/RotationForm.module.css (100%) rename grafana-plugin/src/{components => containers}/RotationForm/RotationForm.tsx (90%) rename grafana-plugin/src/{components => containers}/RotationForm/RotationForm.types.ts (53%) diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index cca5478c..8e10f847 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -5,10 +5,11 @@ import cn from 'classnames/bind'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; -import RotationForm from 'components/RotationForm/RotationForm'; -import { RotationCreateData } from 'components/RotationForm/RotationForm.types'; 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 { Schedule } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; @@ -21,6 +22,8 @@ interface RotationsProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; onCreate: (date: RotationCreateData) => void; + scheduleId: Schedule['id']; + onRotationUpdate: () => void; } type Layer = { @@ -37,7 +40,7 @@ class Rotations extends Component { }; render() { - const { startMoment, currentTimezone } = this.props; + const { startMoment, currentTimezone, scheduleId, onRotationUpdate } = this.props; const { layerIdToCreateRotation } = this.state; const layers = [ @@ -106,11 +109,13 @@ class Rotations extends Component { {!isNaN(layerIdToCreateRotation) && ( { this.setState({ layerIdToCreateRotation: undefined }); }} + onUpdate={onRotationUpdate} onCreate={this.onRotationCreate} /> )} @@ -118,6 +123,8 @@ class Rotations extends Component { ); } + updateEvents = () => {}; + onRotationCreate = (data: RotationCreateData) => { const { onCreate } = this.props; diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css similarity index 100% rename from grafana-plugin/src/components/RotationForm/RotationForm.module.css rename to grafana-plugin/src/containers/RotationForm/RotationForm.module.css diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx similarity index 90% rename from grafana-plugin/src/components/RotationForm/RotationForm.tsx rename to grafana-plugin/src/containers/RotationForm/RotationForm.tsx index f2fc8394..3ba83959 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -19,8 +19,11 @@ import Draggable from 'react-draggable'; import Modal from 'components/Modal/Modal'; import Text from 'components/Text/Text'; import UserGroups from 'components/UserGroups/UserGroups'; +import { Rotation, Schedule } from 'models/schedule/schedule.types'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { Timezone } from 'models/timezone/timezone.types'; +import { getUTCString } from 'pages/schedule/Schedule.helpers'; +import { useStore } from 'state/useStore'; import { RotationCreateData } from './RotationForm.types'; @@ -32,21 +35,25 @@ interface RotationFormProps { onCreate: (date: RotationCreateData) => void; id: number | 'new'; currentTimezone: Timezone; + scheduleId: Schedule['id']; + onUpdate: (data: Rotation) => void; } const cx = cn.bind(styles); const RotationForm: FC = (props) => { - const { onHide, onCreate, currentTimezone } = props; + const { onHide, onCreate, currentTimezone, scheduleId, onUpdate } = props; const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState('days'); const [selectedDays, setSelectedDays] = useState(['Tuesday']); - const [shiftStart, setShiftStart] = useState(dateTime('2021-05-05 12:00:00')); - const [shiftEnd, setShiftEnd] = useState(dateTime('2021-05-05 12:00:00')); - const [rotationStart, setRotationStart] = useState(dateTime('2021-05-05 12:00:00')); + const [shiftStart, setShiftStart] = useState(dateTime('2022-07-22 17:00:00')); + const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-22 19:00:00')); + const [rotationStart, setRotationStart] = useState(dateTime('2022-07-22 17:00:00')); const [endLess, setEndless] = useState(true); - const [rotationEnd, setRotationEnd] = useState(dateTime('2021-05-05 12:00:00')); + const [rotationEnd, setRotationEnd] = useState(dateTime('2022-08-22 12:00:00')); + + const store = useStore(); const handleCreate = useCallback(() => { /* console.log( @@ -61,7 +68,18 @@ const RotationForm: FC = (props) => { ); */ - console.log(rotationEnd, dayjs(rotationEnd)); + store.scheduleStore + .createRotation(scheduleId, true, { + name: 'Rotation' + Math.floor(Math.random() * 100), + rotation_start: getUTCString(rotationStart), + shift_start: getUTCString(shiftStart), + shift_end: getUTCString(shiftEnd), + rolling_users: [['UYKS64M6C59XM']], + frequency: 0, + }) + .then((data) => { + onUpdate(data); + }); }, [repeatEveryValue, repeatEveryPeriod, selectedDays, shiftStart, shiftEnd, rotationStart, endLess, rotationEnd]); const handleChangeEndless = useCallback( diff --git a/grafana-plugin/src/components/RotationForm/RotationForm.types.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts similarity index 53% rename from grafana-plugin/src/components/RotationForm/RotationForm.types.ts rename to grafana-plugin/src/containers/RotationForm/RotationForm.types.ts index 86bf4877..91c76468 100644 --- a/grafana-plugin/src/components/RotationForm/RotationForm.types.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts @@ -1 +1,3 @@ export interface RotationCreateData {} + +export interface RotationData {} diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 22ac2d24..7ecd8586 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -152,13 +152,13 @@ export class ScheduleStore extends BaseStore { // ------- NEW SCHEDULES API ENDPOINTS --------- - async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params) { + async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params: any) { const type = isOverride ? 3 : 2; - const { name, shift_start, shift_end, rotation_start } = params; + const { name, shift_start, shift_end, rotation_start, rolling_users, frequency } = params; return await makeRequest(`/oncall_shifts/`, { - data: { name, type, schedule: scheduleId, shift_start, shift_end, rotation_start }, + data: { name, type, schedule: scheduleId, shift_start, shift_end, rotation_start, rolling_users, frequency }, method: 'POST', }); } @@ -220,23 +220,42 @@ export class ScheduleStore extends BaseStore { }; } - async updateEvents(scheduleId: Schedule['id'], fromString: string, days = 7) { - return await makeRequest(`/schedules/${scheduleId}/filter_events`, { + async updateOncallShifts(scheduleId: Schedule['id']) { + return await makeRequest(`/oncall_shifts/`, { params: { + schedule: scheduleId, + }, + method: 'GET', + }); + } + async updateEvents(scheduleId: Schedule['id'], fromString: string, type = 'rotation', days = 7) { + const events = await makeRequest(`/schedules/${scheduleId}/filter_events/`, { + params: { + type, date: fromString, days, }, method: 'GET', }); + + /*this.rotations = { + ...this.rotations, + [rotationId]: { + ...this.rotations[rotationId], + [level]: { + [fromString]: response as Rotation, + }, + }, + };*/ } - async updateFrequencyOptions(scheduleId: Schedule['id']) { + async updateFrequencyOptions() { return await makeRequest(`/oncall_shifts/frequency_options/`, { method: 'GET', }); } - async updateDaysOptions(scheduleId: Schedule['id']) { + async updateDaysOptions() { return await makeRequest(`/oncall_shifts/days_options/`, { method: 'GET', }); diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 313a5345..d34668c3 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -662,3 +662,7 @@ export const getRandomUsers = (count = 7) => { 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) => { + return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 29d87bb7..3fc5c893 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -23,7 +23,7 @@ import { User } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; -import { getRandomUsers, getStartOfWeek } from './Schedule.helpers'; +import { getRandomUsers, getStartOfWeek, getUTCString } from './Schedule.helpers'; import styles from './Schedule.module.css'; @@ -53,6 +53,7 @@ class SchedulePage extends React.Component async componentDidMount() { const { store } = this.props; + const { startMoment } = this.state; store.userStore.updateItems(); @@ -61,16 +62,22 @@ class SchedulePage extends React.Component } = this.props; store.scheduleStore.updateItem(id); + store.scheduleStore.updateFrequencyOptions(); + store.scheduleStore.updateDaysOptions(); + store.scheduleStore.updateOncallShifts(id); + + this.updateEvents(); } render() { const { store } = this.props; const { startMoment, schedulePeriodType, renderType, users, currentTimezone } = this.state; const { query } = this.props; + const { id: scheduleId } = query; const { scheduleStore } = store; - const schedule = scheduleStore.items[query.id]; + const schedule = scheduleStore.items[scheduleId]; return (
@@ -169,9 +176,11 @@ class SchedulePage extends React.Component
{/**/} {/**/}
@@ -180,6 +189,17 @@ class SchedulePage extends React.Component ); } + updateEvents = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; + + const { startMoment } = this.state; + + store.scheduleStore.updateEvents(scheduleId, startMoment.format('YYYY-MM-DD')); + }; + handleCreateRotation = () => { const { store } = this.props; }; From 87994ae23d62cf7f585826891e59154f5f77a58b Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 26 Jul 2022 16:50:00 +0300 Subject: [PATCH 19/60] connect UserSelect to real data --- .../Rotations/ScheduleOverrides.tsx | 3 +- .../ScheduleOverrideForm.module.css | 21 --- .../UserGroups/UserGroups.helpers.ts | 40 ++--- .../src/components/UserGroups/UserGroups.tsx | 164 ++++++++++-------- .../components/UserGroups/UserGroups.types.ts | 11 ++ .../src/containers/GSelect/GSelect.tsx | 2 +- .../containers/RotationForm/RotationForm.tsx | 34 +++- .../RotationForm}/ScheduleOverrideForm.tsx | 21 ++- grafana-plugin/src/models/user/user.types.ts | 2 +- .../src/pages/schedule/Schedule.helpers.ts | 3 +- .../src/pages/schedule/Schedule.tsx | 2 +- 11 files changed, 164 insertions(+), 139 deletions(-) delete mode 100644 grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css create mode 100644 grafana-plugin/src/components/UserGroups/UserGroups.types.ts rename grafana-plugin/src/{components/ScheduleOverrideForm => containers/RotationForm}/ScheduleOverrideForm.tsx (84%) diff --git a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx index ca7adc87..f25d6ea9 100644 --- a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx @@ -5,10 +5,9 @@ import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import RotationForm from 'components/RotationForm/RotationForm'; -import ScheduleOverrideForm from 'components/ScheduleOverrideForm/ScheduleOverrideForm'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; +import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; diff --git a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css b/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css deleted file mode 100644 index a77fc6d4..00000000 --- a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.module.css +++ /dev/null @@ -1,21 +0,0 @@ -.root { - display: block; -} - -.header { - width: 100%; - display: flex; - justify-content: space-between; -} - -.control { - width: 195px; -} - -.date-time-picker { - display: block; -} - -.inline-switch { - height: 22px; -} diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts index 879fcf7d..2a8b53ce 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -1,38 +1,22 @@ -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' }, - ], - ]; -}; +import { Item, ItemData } from './UserGroups.types'; -export const toPlainArray = (groups) => { +export const toPlainArray = (groups: string[][], getItemData: (item: Item['item']) => ItemData) => { let i = 0; - const items = []; - groups.forEach((group, groupIndex) => { + 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, itemIndex) => { + groups[groupIndex].forEach((item: string, itemIndex: number) => { items.push({ key: `item-${groupIndex}-${itemIndex}`, type: 'item', - data: item, + item, + data: getItemData(item), }); }); }); @@ -40,25 +24,23 @@ export const toPlainArray = (groups) => { return items; }; -export const fromPlainArray = (items, createNewGroup = false, deleteEmptyGroups = true) => { +export const fromPlainArray = (items: Item[], createNewGroup = false, deleteEmptyGroups = true) => { const groups = []; return items - .reduce((memo, item, currentIndex) => { + .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.data); + lastGroup.push(item.item); } else { memo.push([]); } return memo; }, []) - .filter((group) => !deleteEmptyGroups || group.length); + .filter((group: string[][]) => !deleteEmptyGroups || group.length); }; - -export const deleteItemFromGroupByIndex = (groups) => {}; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index 645983e2..d9d996ba 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -10,13 +10,18 @@ import Text from 'components/Text/Text'; import GSelect from 'containers/GSelect/GSelect'; import UserTooltip from 'containers/UserTooltip/UserTooltip'; import { User } from 'models/user/user.types'; -import { getRandomTimezone } from 'pages/schedule/Schedule.helpers'; -import { fromPlainArray, getRandomGroups, toPlainArray } from './UserGroups.helpers'; +import { fromPlainArray, toPlainArray } from './UserGroups.helpers'; +import { Item, ItemData } from './UserGroups.types'; import styles from './UserGroups.module.css'; -interface UserGroupsProps {} +interface UserGroupsProps { + value: Array>; + onChange: (value: Array>) => void; + isMultipleGroups: boolean; + getItemData: (id: string) => ItemData; +} const cx = cn.bind(styles); @@ -24,101 +29,59 @@ const DragHandle = () => ; const SortableHandleHoc = SortableHandle(DragHandle); -const SortableItem = SortableElement(({ children }) => children); - -const SortableList = SortableContainer(({ items, handleAddGroup, handleDeleteItem }) => { - const getDeleteItemHandler = (index) => { - return () => { - handleDeleteItem(index); - }; - }; - - return ( -
    - {items.map((item, index) => - item.type === 'item' ? ( - -
  • -
    - {item.data.name} ({item.data.tz}) -
    -
    - - - - -
    -
  • -
    - ) : ( - -
  • {item.data.name}
  • -
    - ) - )} - {items[items.length - 1]?.type === 'item' && ( - -
  • - Add user group + -
  • -
    - )} -
- ); -}); - -const UserGroups = () => { - const [groups, setGroups] = useState([[]]); +const UserGroups = (props: UserGroupsProps) => { + const { value, onChange, isMultipleGroups, getItemData } = props; const handleAddUserGroup = useCallback(() => { - setGroups((oldGroups) => [...oldGroups, []]); - }, [groups]); + onChange([...value, []]); + }, [value]); const handleDeleteUser = (index: number) => { - const newGroups = [...groups]; + const newGroups = [...value]; let k = -1; - for (let i = 0; i < groups.length; i++) { + for (let i = 0; i < value.length; i++) { k++; - const users = groups[i]; + 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); - setGroups(newGroups.filter((group, index) => index === 0 || group.length)); + onChange(newGroups.filter((group, index) => index === newGroups.length - 1 || group.length)); return; } } } }; - const handleUserAdd = useCallback((pk: User['pk'], user: User) => { - if (!pk) { - return; - } + const handleUserAdd = useCallback( + (pk: User['pk'], user: User) => { + if (!pk) { + return; + } - setGroups((groups) => { - const newGroups = [...groups]; - const lastGroup = newGroups[groups.length - 1]; + const newGroups = [...value]; + const lastGroup = newGroups[newGroups.length - 1]; - lastGroup.push({ pk, name: user.username, tz: getRandomTimezone() }); + lastGroup.push(pk); - return newGroups; - }); - }, []); - - const filterUsers = useCallback( - ({ value }) => !groups.some((group) => group.some((user) => user.pk === value)), - [groups] + onChange(newGroups); + }, + [value] ); - const items = useMemo(() => toPlainArray(groups), [groups]); + const filterUsers = useCallback( + ({ value: itemValue }) => !value.some((group: Array) => group.some((pk) => pk === itemValue)), + [value] + ); + + const items = useMemo(() => toPlainArray(value, getItemData), [value]); const onSortEnd = useCallback( - ({ oldIndex, newIndex, collection, isKeySorting }) => { + ({ oldIndex, newIndex }) => { const newPlainArray = arrayMoveImmutable(items, oldIndex, newIndex); - setGroups(fromPlainArray(newPlainArray, newIndex > items.length)); + onChange(fromPlainArray(newPlainArray, newIndex > items.length)); }, [items] ); @@ -134,6 +97,7 @@ const UserGroups = () => { onSortEnd={onSortEnd} handleAddGroup={handleAddUserGroup} handleDeleteItem={handleDeleteUser} + isMultipleGroups={isMultipleGroups} useDragHandle /> { ); }; +interface SortableItemProps { + children: React.ReactElement; +} + +const SortableItem = SortableElement(({ children }: SortableItemProps) => children); + +interface SortableListProps { + items: Item[]; + handleAddGroup: () => void; + handleDeleteItem: (index: number) => void; + isMultipleGroups: boolean; +} + +const SortableList = SortableContainer( + ({ items, handleAddGroup, handleDeleteItem, isMultipleGroups }: SortableListProps) => { + const getDeleteItemHandler = (index: number) => { + return () => { + handleDeleteItem(index); + }; + }; + + return ( +
    + {items.map((item, index) => + item.type === 'item' ? ( + +
  • +
    + {item.data.name} ({item.data.desc}) +
    +
    + + + + +
    +
  • +
    + ) : isMultipleGroups ? ( + +
  • {item.data.name}
  • +
    + ) : null + )} + {isMultipleGroups && items[items.length - 1]?.type === 'item' && ( + +
  • + Add user group + +
  • +
    + )} +
+ ); + } +); + export default UserGroups; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.types.ts b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts new file mode 100644 index 00000000..0a4bd62c --- /dev/null +++ b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts @@ -0,0 +1,11 @@ +export interface Item { + key: string; + type: string; + data: ItemData; + item?: string; +} + +export interface ItemData { + name: string; + desc?: string; +} diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx index ae7ae736..9200725c 100644 --- a/grafana-plugin/src/containers/GSelect/GSelect.tsx +++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx @@ -35,7 +35,7 @@ interface GSelectProps { dropdownRender?: (menu: ReactElement) => ReactElement; getOptionLabel?: (item: SelectableValue) => React.ReactNode; getDescription?: (item: any) => React.ReactNode; - filterOptions?: () => boolean; + filterOptions?: (item: SelectableValue) => boolean; } const GSelect = observer((props: GSelectProps) => { diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 3ba83959..23df9341 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -22,6 +22,7 @@ import UserGroups from 'components/UserGroups/UserGroups'; import { Rotation, Schedule } 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 { getUTCString } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; @@ -47,11 +48,20 @@ const RotationForm: FC = (props) => { const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState('days'); const [selectedDays, setSelectedDays] = useState(['Tuesday']); - const [shiftStart, setShiftStart] = useState(dateTime('2022-07-22 17:00:00')); - const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-22 19:00:00')); - const [rotationStart, setRotationStart] = useState(dateTime('2022-07-22 17:00:00')); + const [shiftStart, setShiftStart] = useState(dateTime('2022-07-26 17:00:00')); + const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-26 19:00:00')); + const [rotationStart, setRotationStart] = useState(dateTime('2022-07-26 17:00:00')); const [endLess, setEndless] = useState(true); - const [rotationEnd, setRotationEnd] = useState(dateTime('2022-08-22 12:00:00')); + const [rotationEnd, setRotationEnd] = useState(dateTime('2022-08-26 12:00:00')); + + const [userGroups, setUserGroups] = useState([['U9XM1G7KTE3KW'], ['UYKS64M6C59XM']]); + + const getUser = (pk: User['pk']) => { + return { + name: store.userStore.items[pk]?.username, + desc: store.userStore.items[pk]?.timezone, + }; + }; const store = useStore(); @@ -74,13 +84,23 @@ const RotationForm: FC = (props) => { rotation_start: getUTCString(rotationStart), shift_start: getUTCString(shiftStart), shift_end: getUTCString(shiftEnd), - rolling_users: [['UYKS64M6C59XM']], + rolling_users: userGroups, frequency: 0, }) .then((data) => { onUpdate(data); }); - }, [repeatEveryValue, repeatEveryPeriod, selectedDays, shiftStart, shiftEnd, rotationStart, endLess, rotationEnd]); + }, [ + repeatEveryValue, + repeatEveryPeriod, + selectedDays, + shiftStart, + shiftEnd, + rotationStart, + endLess, + rotationEnd, + userGroups, + ]); const handleChangeEndless = useCallback( (event: React.ChangeEvent) => { @@ -120,7 +140,7 @@ const RotationForm: FC = (props) => { - + {/*
*/} diff --git a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx similarity index 84% rename from grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx rename to grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 9b7b46b4..4477c1aa 100644 --- a/grafana-plugin/src/components/ScheduleOverrideForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -20,8 +20,10 @@ import Modal from 'components/Modal/Modal'; import Text from 'components/Text/Text'; import UserGroups from 'components/UserGroups/UserGroups'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; -import styles from './ScheduleOverrideForm.module.css'; +import styles from './RotationForm.module.css'; interface RotationFormProps { layerId: string; @@ -34,8 +36,19 @@ const cx = cn.bind(styles); const ScheduleOverrideForm: FC = (props) => { const { onHide } = props; - const [shiftStart, setShiftStart] = useState(dateTime('2021-05-05 12:00:00')); - const [shiftEnd, setShiftEnd] = useState(dateTime('2021-05-05 12:00:00')); + const store = useStore(); + + const [shiftStart, setShiftStart] = useState(dateTime('2022-08-26 12:00:00')); + const [shiftEnd, setShiftEnd] = useState(dateTime('2022-08-26 12:00:00')); + + const [userGroups, setUserGroups] = useState([[]]); + + const getUser = (pk: User['pk']) => { + return { + name: store.userStore.items[pk]?.username, + desc: store.userStore.items[pk]?.timezone, + }; + }; const moment = dayjs(); @@ -60,7 +73,7 @@ const ScheduleOverrideForm: FC = (props) => { - + {/*
*/} diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index abb74575..14e613f3 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -55,5 +55,5 @@ export interface User { link?: string; cloud_connection_status?: number; hidden_fields?: boolean; - tz: Timezone; + timezone: Timezone; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index d34668c3..1a789742 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -1,3 +1,4 @@ +import { DateTime } from '@grafana/data'; import dayjs from 'dayjs'; import { Timezone } from 'models/timezone/timezone.types'; @@ -663,6 +664,6 @@ 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) => { +export const getUTCString = (moment: dayjs.Dayjs | DateTime) => { return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); }; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 3fc5c893..fccfd65f 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -182,7 +182,7 @@ class SchedulePage extends React.Component onCreate={this.handleCreateRotation} onRotationUpdate={this.updateEvents} /> - {/**/} +
From 9ca33114d27ca252522faf14588df4fb3aa099d9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 26 Jul 2022 19:37:31 +0300 Subject: [PATCH 20/60] render rotation with real data --- .../src/components/Rotations/Rotations.tsx | 16 ++---- .../components/Rotations/ScheduleFinal.tsx | 25 +++++++-- .../Rotations/ScheduleOverrides.tsx | 17 +++++- .../components/ScheduleSlot/ScheduleSlot.tsx | 25 +++++---- .../components/WorkingHours/WorkingHours.tsx | 1 - .../src/containers/Rotation/Rotation.tsx | 36 +++++++------ .../containers/RotationForm/RotationForm.tsx | 52 +++++++++++-------- .../RotationForm/ScheduleOverrideForm.tsx | 29 +++++++++-- .../src/models/schedule/schedule.ts | 37 ++++++++++--- .../src/models/schedule/schedule.types.ts | 21 ++++++++ grafana-plugin/src/models/user/user.ts | 12 ++++- .../src/pages/schedule/Schedule.tsx | 26 ++++++++-- .../src/pages/schedules_NEW/Schedules.tsx | 14 ++++- 13 files changed, 227 insertions(+), 84 deletions(-) diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index 8e10f847..42601630 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -21,9 +21,9 @@ const cx = cn.bind(styles); interface RotationsProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; - onCreate: (date: RotationCreateData) => void; scheduleId: Schedule['id']; - onRotationUpdate: () => void; + onCreate: () => void; + onUpdate: () => void; } type Layer = { @@ -40,7 +40,7 @@ class Rotations extends Component { }; render() { - const { startMoment, currentTimezone, scheduleId, onRotationUpdate } = this.props; + const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate } = this.props; const { layerIdToCreateRotation } = this.state; const layers = [ @@ -115,8 +115,8 @@ class Rotations extends Component { onHide={() => { this.setState({ layerIdToCreateRotation: undefined }); }} - onUpdate={onRotationUpdate} - onCreate={this.onRotationCreate} + onUpdate={onCreate} + onCreate={onUpdate} /> )} @@ -125,12 +125,6 @@ class Rotations extends Component { updateEvents = () => {}; - onRotationCreate = (data: RotationCreateData) => { - const { onCreate } = this.props; - - onCreate(data); - }; - handleAddLayer = () => {}; handleAddRotation = (option) => { diff --git a/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx index d74eba7a..46268a6c 100644 --- a/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/components/Rotations/ScheduleFinal.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component, useEffect } from 'react'; import { Button, HorizontalGroup, Icon, Input, ValuePicker } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -9,6 +9,8 @@ import RotationForm from 'components/RotationForm/RotationForm'; import ScheduleOverrideForm from 'components/ScheduleOverrideForm/ScheduleOverrideForm'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; +import { Schedule } from 'models/schedule/schedule.types'; +import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; @@ -16,7 +18,11 @@ import styles from './Rotations.module.css'; const cx = cn.bind(styles); -interface ScheduleOverridesProps extends WithStoreProps {} +interface ScheduleOverridesProps extends WithStoreProps { + startMoment: dayjs.Dayjs; + currentTimezone: Timezone; + scheduleId: Schedule['id']; +} interface ScheduleOverridesState {} @@ -24,8 +30,18 @@ interface ScheduleOverridesState {} class ScheduleOverrides extends Component { state: ScheduleOverridesState = {}; + componentDidMount() { + const { store, scheduleId, startMoment, currentTimezone } = this.props; + + const {} = this.props; + + const startMomentString = startMoment.utc().format('YYYY-MM-DD'); + + store.scheduleStore.updateEvents(scheduleId, startMomentString, 'final'); + } + render() { - const { title, startMoment, currentTimezone } = this.props; + const { scheduleId, startMoment, currentTimezone, store } = this.props; const { showAddOverrideForm, searchTerm } = this.state; const base = 7 * 24 * 60; // in minutes @@ -52,7 +68,8 @@ class ScheduleOverrides extends Component
void; + onUpdate: () => void; +} interface ScheduleOverridesState {} @@ -24,7 +33,7 @@ class ScheduleOverrides extends Component {showAddOverrideForm && ( { this.setState({ showAddOverrideForm: false }); }} + onUpdate={onCreate} + onCreate={onUpdate} /> )} diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 4249bff5..10ecb1bb 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -8,7 +8,7 @@ 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 { Shift } from 'models/schedule/schedule.types'; +import { Event } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; @@ -21,7 +21,7 @@ interface ScheduleSlotProps { index: number; layerIndex: number; rotationIndex: number; - shift: Shift; + event: Event; startMoment: dayjs.Dayjs; currentTimezone: Timezone; color?: string; @@ -30,8 +30,13 @@ interface ScheduleSlotProps { const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, shift, startMoment, currentTimezone, color: propColor, x, basePx } = props; - const { duration, users } = shift; + const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color: propColor } = props; + const { users } = event; + + const start = dayjs(event.start); + const end = dayjs(event.end); + + const duration = end.diff(start, 'seconds'); const store = useStore(); @@ -43,9 +48,9 @@ const ScheduleSlot: FC = observer((props) => { return (
- {!shift.is_gap ? ( - users.map((pk, userIndex) => { - const storeUser = store.userStore.items[pk]; + {!event.is_gap ? ( + users.map(({ pk: userPk }, userIndex) => { + const storeUser = store.userStore.items[userPk]; const inactive = false; @@ -64,10 +69,10 @@ const ScheduleSlot: FC = observer((props) => { )} {userIndex === 0 && label && ( diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index 3c62021c..fe891e33 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -18,7 +18,6 @@ interface WorkingHoursProps { workingHours: any; startMoment: dayjs.Dayjs; duration: number; // in seconds - width: number; // in pixels className: string; } diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index a15bc6d1..b582daa8 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -8,7 +8,7 @@ import { CSSTransitionGroup } from 'react-transition-group'; // ES6 import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; import Text from 'components/Text/Text'; -import { Rotation as RotationType } from 'models/schedule/schedule.types'; +import { Rotation as RotationType, Schedule } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { useStore } from 'state/useStore'; import { usePrevious } from 'utils/hooks'; @@ -20,7 +20,8 @@ const cx = cn.bind(styles); interface ScheduleSlotState {} interface RotationProps { - id: RotationType['id']; + type: 'final' | 'rotation' | 'override'; + scheduleId: Schedule['id']; label: string; startMoment: dayjs.Dayjs; currentTimezone: Timezone; @@ -30,7 +31,7 @@ interface RotationProps { } const Rotation: FC = observer((props) => { - const { id, layerIndex, rotationIndex, label, startMoment, currentTimezone, color } = props; + const { type, scheduleId, layerIndex, rotationIndex, label, startMoment, currentTimezone, color } = props; const [animate, setAnimate] = useState(true); const [width, setWidth] = useState(); @@ -38,12 +39,15 @@ const Rotation: FC = observer((props) => { const store = useStore(); - const startMomentString = useMemo(() => startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'), [startMoment]); + const startMomentString = useMemo(() => startMoment.utc().format('YYYY-MM-DD'), [startMoment]); const prevStartMomentString = usePrevious(startMomentString); - const rotation = store.scheduleStore.rotations[id]?.[startMomentString]; - //const rotation = store.scheduleStore.rotations[id]?.[prevStartMomentString]; + const events = store.scheduleStore.events[scheduleId]?.[type]?.[startMomentString]; + + console.log(events); + + // const rotation = store.scheduleStore.rotations[id]?.[prevStartMomentString]; /* useEffect(() => { setTransparent(false); @@ -58,7 +62,7 @@ const Rotation: FC = observer((props) => { console.log('CHANGE START MOMENT', startMomentString); - store.scheduleStore.updateRotationMock(id, startMomentString, currentTimezone); + // store.scheduleStore.updateEvents(scheduleId, startMomentString, currentTimezone); }, [startMomentString]); const slots = useCallback((node) => { @@ -68,21 +72,19 @@ const Rotation: FC = observer((props) => { }, []); const x = useMemo(() => { - if (!rotation) { + if (!events) { return 0; } - const { shifts } = rotation; + const firstShift = events[0]; - const firstShift = shifts[0]; - - const firstShiftOffset = firstShift.start.diff(startMoment, 'minutes'); + const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'minutes'); const base = 60 * 24 * 7; // in minutes only const utcOffset = dayjs().tz(currentTimezone).utcOffset(); return firstShiftOffset / base; - }, [rotation]); + }, [events]); useEffect(() => {}); @@ -90,18 +92,18 @@ const Rotation: FC = observer((props) => {
{/*
*/}
- {rotation ? ( + {events ? (
- {rotation.shifts.map((shift, index) => { + {events.map((event, index) => { return ( void; - onCreate: (date: RotationCreateData) => void; id: number | 'new'; currentTimezone: Timezone; scheduleId: Schedule['id']; - onUpdate: (data: Rotation) => void; + onCreate: () => void; + onUpdate: () => void; } const cx = cn.bind(styles); @@ -46,8 +48,8 @@ const RotationForm: FC = (props) => { const { onHide, onCreate, currentTimezone, scheduleId, onUpdate } = props; const [repeatEveryValue, setRepeatEveryValue] = useState(1); - const [repeatEveryPeriod, setRepeatEveryPeriod] = useState('days'); - const [selectedDays, setSelectedDays] = useState(['Tuesday']); + const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); + const [selectedDays, setSelectedDays] = useState([]); const [shiftStart, setShiftStart] = useState(dateTime('2022-07-26 17:00:00')); const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-26 19:00:00')); const [rotationStart, setRotationStart] = useState(dateTime('2022-07-26 17:00:00')); @@ -79,17 +81,17 @@ const RotationForm: FC = (props) => { */ store.scheduleStore - .createRotation(scheduleId, true, { - name: 'Rotation' + Math.floor(Math.random() * 100), + .createRotation(scheduleId, false, { + name: 'Rotation ' + Math.floor(Math.random() * 100), rotation_start: getUTCString(rotationStart), + until: endLess ? null : getUTCString(rotationEnd), shift_start: getUTCString(shiftStart), shift_end: getUTCString(shiftEnd), rolling_users: userGroups, - frequency: 0, + frequency: repeatEveryValue, + by_day: selectedDays, }) - .then((data) => { - onUpdate(data); - }); + .then(onUpdate); }, [ repeatEveryValue, repeatEveryPeriod, @@ -159,21 +161,21 @@ const RotationForm: FC = (props) => { /> - } - placeholder="Search..." - value={searchTerm} - onChange={this.onSearchTermChangeCallback} - /> - -
+ {!hideHeader && ( +
+ +
Final schedule
+ } + placeholder="Search..." + value={searchTerm} + onChange={this.onSearchTermChangeCallback} + /> +
+
+ )}
@@ -85,4 +100,4 @@ class ScheduleOverrides extends Component {}; } -export default withMobXProviderContext(ScheduleOverrides); +export default withMobXProviderContext(ScheduleFinal); diff --git a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx index 62aafdb5..a468062d 100644 --- a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx @@ -68,8 +68,8 @@ class ScheduleOverrides extends Component { this.setState({ showAddOverrideForm: false }); }} - onUpdate={onCreate} - onCreate={onUpdate} + onUpdate={onUpdate} + onCreate={onCreate} /> )} diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx index 0af1d9c8..70c05f3e 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -24,14 +24,14 @@ const UserTimezoneSelect: FC = (props) => { const options = useMemo(() => { return users.reduce((memo, user) => { - let item = memo.find((item) => item.label === user.tz); + let item = memo.find((item) => item.label === user.timezone); if (!item) { item = { value: user.pk, - label: `${user.tz} ${getTzOffsetString(dayjs().tz(user.tz))}`, + label: `${user.timezone} ${getTzOffsetString(dayjs().tz(user.timezone))}`, imgUrl: user.avatar, - description: user.name, + description: user.username, }; memo.push(item); } else { @@ -44,7 +44,7 @@ const UserTimezoneSelect: FC = (props) => { }, [users]); const selectValue = useMemo(() => { - const user = users.find((user) => user.tz === value); + const user = users.find((user) => user.timezone === value); return user.pk; }, [value, users]); @@ -52,7 +52,7 @@ const UserTimezoneSelect: FC = (props) => { ({ value }) => { const user = users.find((user) => user.pk === value); - onChange(user.tz); + onChange(user.timezone); }, [users] ); diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index b582daa8..c6bff43d 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -72,7 +72,7 @@ const Rotation: FC = observer((props) => { }, []); const x = useMemo(() => { - if (!events) { + if (!events || !events.length) { return 0; } @@ -93,26 +93,28 @@ const Rotation: FC = observer((props) => { {/*
*/}
{events ? ( -
- {events.map((event, index) => { - return ( - - ); - })} -
+ events.length ? ( +
+ {events.map((event, index) => { + return ( + + ); + })} +
+ ) : null ) : ( diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 82304eea..4e530fbc 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -89,9 +89,12 @@ const RotationForm: FC = (props) => { shift_end: getUTCString(shiftEnd), rolling_users: userGroups, frequency: repeatEveryValue, - by_day: selectedDays, + by_day: repeatEveryPeriod === 1 ? selectedDays : null, }) - .then(onUpdate); + .then(() => { + onHide(); + onCreate(); + }); }, [ repeatEveryValue, repeatEveryPeriod, @@ -168,7 +171,7 @@ const RotationForm: FC = (props) => { /> - {repeatEveryPeriod === 0 && ( + {repeatEveryPeriod === 1 && ( /**/ = (props) => { rotation_start: getUTCString(shiftStart), shift_start: getUTCString(shiftStart), shift_end: getUTCString(shiftEnd), - rolling_users: userGroups[0], + rolling_users: userGroups, frequency: null, }) - .then(onUpdate); + .then(() => { + onHide(); + onCreate(); + }); }, [shiftStart, shiftEnd, userGroups]); const moment = dayjs(); diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index ebb4d8ac..98af9535 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -54,7 +54,7 @@ export class UserStore extends BaseStore { this.items = { ...this.items, - [user.pk]: user, + [user.pk]: { ...user, timezone: this.rootStore.currentTimezone }, }; this.currentUserPk = user.pk; @@ -108,6 +108,7 @@ export class UserStore extends BaseStore { 'Maxim Mordasov': 'Europe/Moscow', 'Vadim Stepanov': 'Europe/London', 'Ildar Iskhakov': 'Asia/Yerevan', + 'Raphael Batyrbaev': 'Europe/Rome', 'Innokentii Konstantinov': 'Asia/Singapore', /* 'Matvey Kukuy',*/ }[item.username], diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 727ba875..c97a8a4b 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -35,21 +35,22 @@ interface SchedulePageState { startMoment: dayjs.Dayjs; schedulePeriodType: string; renderType: string; - users: User[]; - currentTimezone: Timezone; } const INITIAL_TIMEZONE = 'UTC'; // todo check why doesn't work @observer class SchedulePage extends React.Component { - state: SchedulePageState = { - startMoment: getStartOfWeek(INITIAL_TIMEZONE), - schedulePeriodType: 'week', - renderType: 'timeline', - users: getRandomUsers(), - currentTimezone: INITIAL_TIMEZONE, - }; + 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; @@ -71,11 +72,13 @@ class SchedulePage extends React.Component render() { const { store } = this.props; - const { startMoment, schedulePeriodType, renderType, users, currentTimezone } = this.state; + const { startMoment, schedulePeriodType, renderType } = this.state; const { query } = this.props; const { id: scheduleId } = query; - const { scheduleStore } = store; + const users = store.userStore.getSearchResult().results; + + const { scheduleStore, currentTimezone } = store; const schedule = scheduleStore.items[scheduleId]; @@ -111,7 +114,9 @@ class SchedulePage extends React.Component /> - + {users && ( + + )} @@ -203,7 +208,7 @@ class SchedulePage extends React.Component const { startMoment } = this.state; - //debugger; + // debugger; store.scheduleStore.updateEvents(scheduleId, startMoment.format('YYYY-MM-DD')); }; @@ -221,7 +226,11 @@ class SchedulePage extends React.Component }; handleTimezoneChange = (value: Timezone) => { - this.setState({ currentTimezone: value, startMoment: getStartOfWeek(value) }); + const { store } = this.props; + + store.currentTimezone = value; + + this.setState({ startMoment: getStartOfWeek(value) }); }; handleShedulePeriodTypeChange = (value: string) => { @@ -233,9 +242,9 @@ class SchedulePage extends React.Component }; handleTodayClick = () => { - const { currentTimezone } = this.state; + const { store } = this.props; - this.setState({ startMoment: getStartOfWeek(currentTimezone) }); + this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }); }; handleLeftClick = () => { diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 07172ef3..5429d608 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -8,16 +8,20 @@ import { observer } from 'mobx-react'; import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector'; import PluginLink from 'components/PluginLink/PluginLink'; +import ScheduleFinal from 'components/Rotations/ScheduleFinal'; 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 Rotation from 'containers/Rotation/Rotation'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { Timezone } from 'models/timezone/timezone.types'; +import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; @@ -35,12 +39,16 @@ interface SchedulesPageState { @observer class SchedulesPage extends React.Component { - state: SchedulesPageState = { - startMoment: dayjs().utc().startOf('week'), - // schedules: getRandomSchedules(), - filters: { searchTerm: '', status: 'all', type: 'all' }, - showNewScheduleSelector: false, - }; + constructor(props: SchedulesPageProps) { + super(props); + + const { store } = this.props; + this.state = { + startMoment: getStartOfWeek(store.currentTimezone), + filters: { searchTerm: '', status: 'all', type: 'all' }, + showNewScheduleSelector: false, + }; + } async componentDidMount() { const { store } = this.props; @@ -96,7 +104,9 @@ class SchedulesPage extends React.Component @@ -105,13 +115,14 @@ class SchedulesPage extends React.Component - - Timezone: - - {getTzOffsetString(moment)} ({dayjs.tz.guess()}) - - - @@ -142,6 +153,14 @@ class SchedulesPage extends React.Component { + const { store } = this.props; + + store.currentTimezone = value; + + this.setState({ startMoment: getStartOfWeek(value) }); + }; + handleCreateScheduleClick = () => { this.setState({ showNewScheduleSelector: true }); }; @@ -154,14 +173,20 @@ class SchedulesPage extends React.Component { + renderSchedule = (data: Schedule) => { const { startMoment } = this.state; + const { store } = this.props; return (
- +
); diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index 3746d9eb..e68a701b 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -24,6 +24,7 @@ import { SlackStore } from 'models/slack/slack'; import { SlackChannelStore } from 'models/slack_channel/slack_channel'; import { TeamStore } from 'models/team/team'; import { TelegramChannelStore } from 'models/telegram_channel/telegram_channel'; +import { Timezone } from 'models/timezone/timezone.types'; import { UserStore } from 'models/user/user'; import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; @@ -38,6 +39,9 @@ export class RootBaseStore { @observable appLoading = true; + @observable + currentTimezone: Timezone = 'UTC'; + @observable backendVersion = ''; From f9d907d17df86fea4669b8ab2d1f3a7d5c852815 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 28 Jul 2022 11:29:44 +0300 Subject: [PATCH 23/60] connect API pt.2 --- .../src/components/Rotations/Rotations.tsx | 8 ++-- .../components/Rotations/ScheduleFinal.tsx | 24 +---------- .../Rotations/ScheduleOverrides.tsx | 10 ++++- .../ScheduleSlot/ScheduleSlot.module.css | 2 + .../ScheduleUserDetails.tsx | 22 ++++------ .../UsersTimezones/UsersTimezones.tsx | 4 +- .../containers/Rotation/Rotation.module.css | 8 ++++ .../src/containers/Rotation/Rotation.tsx | 14 ++++--- .../containers/RotationForm/RotationForm.tsx | 40 ++++++++++--------- .../RotationForm/ScheduleOverrideForm.tsx | 4 +- .../src/models/schedule/schedule.helpers.ts | 23 +++++++++++ .../src/models/schedule/schedule.ts | 9 +++-- .../src/models/schedule/schedule.types.ts | 2 + grafana-plugin/src/models/user/user.ts | 2 +- .../src/pages/schedule/Schedule.tsx | 32 ++++++++------- .../src/pages/schedules_NEW/Schedules.tsx | 8 ++++ 16 files changed, 124 insertions(+), 88 deletions(-) create mode 100644 grafana-plugin/src/models/schedule/schedule.helpers.ts diff --git a/grafana-plugin/src/components/Rotations/Rotations.tsx b/grafana-plugin/src/components/Rotations/Rotations.tsx index 181390c0..39a8eb78 100644 --- a/grafana-plugin/src/components/Rotations/Rotations.tsx +++ b/grafana-plugin/src/components/Rotations/Rotations.tsx @@ -11,6 +11,7 @@ import RotationForm from 'containers/RotationForm/RotationForm'; import { RotationCreateData } from 'containers/RotationForm/RotationForm.types'; import { Schedule } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; +import { withMobXProviderContext } from 'state/withStore'; import { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; @@ -36,7 +37,7 @@ interface RotationsState { class Rotations extends Component { state: RotationsState = { - //layerIdToCreateRotation: '12', + layerIdToCreateRotation: undefined, }; render() { @@ -44,7 +45,7 @@ class Rotations extends Component { const { layerIdToCreateRotation } = this.state; const layers = [ - { id: 0, title: 'Layer 1' }, + { id: 1, title: 'Layer 1' }, /*{ id: 1, title: 'Layer 2' }, { id: 2, title: 'Layer 3' }, { id: 3, title: 'Layer 4' }*/ @@ -90,7 +91,8 @@ class Rotations extends Component {
{rotations.map((rotation, rotationIndex) => ( { state: ScheduleOverridesState = {}; - componentDidMount() { - this.updateEvents(); - } - - componentDidUpdate( - prevProps: Readonly, - prevState: Readonly, - snapshot?: any - ) { - if (this.props.startMoment !== prevProps.startMoment) { - this.updateEvents(); - } - } - - updateEvents() { - const { store, scheduleId, startMoment, currentTimezone } = this.props; - - const startMomentString = startMoment.utc().format('YYYY-MM-DD'); - - store.scheduleStore.updateEvents(scheduleId, startMomentString, 'final'); - } - render() { const { scheduleId, startMoment, currentTimezone, store, hideHeader } = this.props; const { showAddOverrideForm, searchTerm } = this.state; @@ -100,4 +78,4 @@ class ScheduleFinal extends Component {}; } -export default withMobXProviderContext(ScheduleFinal); +export default ScheduleFinal; diff --git a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx index a468062d..e18195c1 100644 --- a/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/components/Rotations/ScheduleOverrides.tsx @@ -56,7 +56,13 @@ class ScheduleOverrides extends Component
- +
Add override +
@@ -81,4 +87,4 @@ class ScheduleOverrides extends Component = (props) => { ? 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)`; + const userMoment = currentMoment.tz(user.timezone); + + const userOffsetHoursStr = getTzOffsetString(userMoment); return (
@@ -65,9 +59,9 @@ const ScheduleUserDetails: FC = (props) => { - {user.name} + {user.username} - {`${userMoment.format('DD MMM, HH:mm')}`} {userOffsetHoursStr} + {`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} {userOffsetHoursStr}
= (props) => { const getAvatarClickHandler = useCallback((user) => { return () => { - onTzChange(user.tz); + onTzChange(user.timezone); }; }, []); @@ -83,7 +83,7 @@ const UsersTimezones: FC = (props) => {
{users.map((user, index) => { - const userCurrentMoment = dayjs(currentMoment).tz(user.tz); + const userCurrentMoment = dayjs(currentMoment).tz(user.timezone); const diff = userCurrentMoment.diff(userCurrentMoment.startOf('day'), 'minutes'); const userHour = userCurrentMoment.hour(); diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index 0b941671..33af60ba 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -60,3 +60,11 @@ bottom: -10px; z-index: 1; } + +.empty { + height: 28px; + background: #5f505633; + border: 1px dashed #5c474d; + color: rgba(209, 14, 92, 0.5); + margin: 0 2px; +} diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index c6bff43d..4410d813 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -8,6 +8,7 @@ import { CSSTransitionGroup } from 'react-transition-group'; // ES6 import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; import Text from 'components/Text/Text'; +import { getFromString } from 'models/schedule/schedule.helpers'; import { Rotation as RotationType, Schedule } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { useStore } from 'state/useStore'; @@ -22,7 +23,6 @@ interface ScheduleSlotState {} interface RotationProps { type: 'final' | 'rotation' | 'override'; scheduleId: Schedule['id']; - label: string; startMoment: dayjs.Dayjs; currentTimezone: Timezone; layerIndex?: number; @@ -31,7 +31,7 @@ interface RotationProps { } const Rotation: FC = observer((props) => { - const { type, scheduleId, layerIndex, rotationIndex, label, startMoment, currentTimezone, color } = props; + const { type, scheduleId, layerIndex, rotationIndex, startMoment, currentTimezone, color } = props; const [animate, setAnimate] = useState(true); const [width, setWidth] = useState(); @@ -39,13 +39,13 @@ const Rotation: FC = observer((props) => { const store = useStore(); - const startMomentString = useMemo(() => startMoment.utc().format('YYYY-MM-DD'), [startMoment]); + const startMomentString = useMemo(() => getFromString(startMoment), [startMoment]); const prevStartMomentString = usePrevious(startMomentString); - const events = store.scheduleStore.events[scheduleId]?.[type]?.[startMomentString]; + const events = store.scheduleStore.events[scheduleId]?.[type]?.[getFromString(startMoment)]; - console.log(events); + // console.log(events); // const rotation = store.scheduleStore.rotations[id]?.[prevStartMomentString]; @@ -114,7 +114,9 @@ const Rotation: FC = observer((props) => { ); })}
- ) : null + ) : ( +
+ ) ) : ( diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 4e530fbc..dfd34b43 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -45,14 +45,14 @@ interface RotationFormProps { const cx = cn.bind(styles); const RotationForm: FC = (props) => { - const { onHide, onCreate, currentTimezone, scheduleId, onUpdate } = props; + const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, layerId } = props; const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); const [selectedDays, setSelectedDays] = useState([]); - const [shiftStart, setShiftStart] = useState(dateTime('2022-07-26 17:00:00')); + const [shiftStart, setShiftStart] = useState(dateTime('2022-07-26 12:00:00')); const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-26 19:00:00')); - const [rotationStart, setRotationStart] = useState(dateTime('2022-07-26 17:00:00')); + const [rotationStart, setRotationStart] = useState(dateTime('2022-07-26 12:00:00')); const [endLess, setEndless] = useState(true); const [rotationEnd, setRotationEnd] = useState(dateTime('2022-08-26 12:00:00')); @@ -80,21 +80,24 @@ const RotationForm: FC = (props) => { ); */ - store.scheduleStore - .createRotation(scheduleId, false, { - name: 'Rotation ' + Math.floor(Math.random() * 100), - rotation_start: getUTCString(rotationStart), - until: endLess ? null : getUTCString(rotationEnd), - shift_start: getUTCString(shiftStart), - shift_end: getUTCString(shiftEnd), - rolling_users: userGroups, - frequency: repeatEveryValue, - by_day: repeatEveryPeriod === 1 ? selectedDays : null, - }) - .then(() => { - onHide(); - onCreate(); - }); + const params = { + name: 'Rotation ' + Math.floor(Math.random() * 100), + rotation_start: getUTCString(rotationStart), + until: endLess ? null : getUTCString(rotationEnd), + shift_start: getUTCString(shiftStart), + shift_end: getUTCString(shiftEnd), + rolling_users: userGroups.filter((group) => group.length), + frequency: repeatEveryPeriod, + by_day: repeatEveryPeriod === 1 ? selectedDays : null, + priority_level: layerId, + }; + + console.log('params', params); + + store.scheduleStore.createRotation(scheduleId, false, params).then(() => { + onHide(); + onCreate(); + }); }, [ repeatEveryValue, repeatEveryPeriod, @@ -105,6 +108,7 @@ const RotationForm: FC = (props) => { endLess, rotationEnd, userGroups, + layerId, ]); const handleChangeEndless = useCallback( diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 5e07b15b..ac97c379 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -46,8 +46,8 @@ const ScheduleOverrideForm: FC = (props) => { const store = useStore(); - const [shiftStart, setShiftStart] = useState(dateTime('2022-08-26 12:00:00')); - const [shiftEnd, setShiftEnd] = useState(dateTime('2022-08-26 12:00:00')); + const [shiftStart, setShiftStart] = useState(dateTime('2022-07-26 12:00:00')); + const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-26 20:00:00')); const [userGroups, setUserGroups] = useState([[]]); diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts new file mode 100644 index 00000000..721fdd08 --- /dev/null +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -0,0 +1,23 @@ +import dayjs from 'dayjs'; + +import { Event } 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) { + newEvents.push({ start: event.end, end: nextEvent.start, is_gap: true }); + } + } + + return newEvents; +}; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 9fba0e02..5e031e0f 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -10,7 +10,8 @@ import { makeRequest } from 'network'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { Events, Rotation, Schedule, ScheduleEvent } from './schedule.types'; +import { fillGaps } from './schedule.helpers'; +import { Events, Rotation, RotationType, Schedule, ScheduleEvent } from './schedule.types'; const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; @@ -240,7 +241,7 @@ export class ScheduleStore extends BaseStore { method: 'GET', }); } - async updateEvents(scheduleId: Schedule['id'], fromString: string, type = 'rotation', days = 7) { + async updateEvents(scheduleId: Schedule['id'], fromString: string, type: RotationType = 'rotation', days = 7) { const response = await makeRequest(`/schedules/${scheduleId}/filter_events/`, { params: { type, @@ -250,13 +251,15 @@ export class ScheduleStore extends BaseStore { method: 'GET', }); + const events = type !== 'final' ? fillGaps(response.events) : response.events; + this.events = { ...this.events, [scheduleId]: { ...this.events[scheduleId], [type]: { ...this.events[scheduleId]?.[type], - [fromString]: response.events, + [fromString]: events, }, }, }; diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index d05860d1..8e566aae 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -59,6 +59,8 @@ export interface Rotation { shifts: Shift[]; } +export type RotationType = 'final' | 'rotation' | 'override'; + export interface Event { all_day: boolean; calendar_type: 0; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 98af9535..a86f3068 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -102,7 +102,7 @@ export class UserStore extends BaseStore { ...item, timezone: { 'Hello Oncall': 'UTC', - 'Matias Bordese': 'America/Montevideo', + 'Matías Bordese': 'America/Montevideo', 'Michael Derynck': 'America/Vancouver', 'Yulia Shanyrova': 'Europe/Amsterdam', 'Maxim Mordasov': 'Europe/Moscow', diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index c97a8a4b..01309e71 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -18,6 +18,7 @@ import Text from 'components/Text/Text'; // import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; +import { getFromString } from 'models/schedule/schedule.helpers'; import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; @@ -131,7 +132,7 @@ class SchedulePage extends React.Component Users from on-call schedule" step in escalation chains.
- +
@@ -200,7 +201,7 @@ class SchedulePage extends React.Component ); } - updateEvents = (...rest) => { + updateEvents = () => { const { store, query: { id: scheduleId }, @@ -208,21 +209,24 @@ class SchedulePage extends React.Component const { startMoment } = this.state; - // debugger; - - store.scheduleStore.updateEvents(scheduleId, startMoment.format('YYYY-MM-DD')); + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'rotation'); + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'override'); + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final'); }; - handleCreateRotation = (...rest) => { - const { store } = this.props; + handleCreateRotation = () => { + const { + store, + query: { id: scheduleId }, + } = this.props; - // debugger; + this.updateEvents(); }; - handleCreateOverride = (...rest) => { + handleCreateOverride = () => { const { store } = this.props; - // debugger; + this.updateEvents(); }; handleTimezoneChange = (value: Timezone) => { @@ -230,7 +234,7 @@ class SchedulePage extends React.Component store.currentTimezone = value; - this.setState({ startMoment: getStartOfWeek(value) }); + this.setState({ startMoment: getStartOfWeek(value) }, this.updateEvents); }; handleShedulePeriodTypeChange = (value: string) => { @@ -244,19 +248,19 @@ class SchedulePage extends React.Component handleTodayClick = () => { const { store } = this.props; - this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }); + this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }, this.updateEvents); }; handleLeftClick = () => { const { startMoment } = this.state; - this.setState({ startMoment: startMoment.add(-7, 'day') }); + this.setState({ startMoment: startMoment.add(-7, 'day') }, this.updateEvents); }; handleRightClick = () => { const { startMoment } = this.state; - this.setState({ startMoment: startMoment.add(7, 'day') }); + this.setState({ startMoment: startMoment.add(7, 'day') }, this.updateEvents); }; } diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 5429d608..78fdc39f 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -18,6 +18,7 @@ import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import Rotation from 'containers/Rotation/Rotation'; +import { getFromString } from 'models/schedule/schedule.helpers'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { Timezone } from 'models/timezone/timezone.types'; @@ -133,6 +134,7 @@ class SchedulesPage extends React.Component cx('expanded-row'), @@ -173,6 +175,12 @@ class SchedulesPage extends React.Component { + const { store } = this.props; + const { startMoment } = this.state; + store.scheduleStore.updateEvents(data.id, getFromString(startMoment), 'final'); + }; + renderSchedule = (data: Schedule) => { const { startMoment } = this.state; const { store } = this.props; From fa25d351bf789b3a5f73e736aaab099392746d87 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 28 Jul 2022 16:42:45 +0300 Subject: [PATCH 24/60] add layers for rotations --- .../ScheduleSlot/ScheduleSlot.helpers.ts | 2 +- .../ScheduleSlot/ScheduleSlot.module.css | 16 ++--- .../UsersTimezones/UsersTimezones.tsx | 10 ++- .../src/containers/Rotation/Rotation.tsx | 19 ++---- .../containers/RotationForm/RotationForm.tsx | 16 +++-- .../RotationForm/ScheduleOverrideForm.tsx | 18 ++--- .../Rotations/Rotations.helpers.ts | 0 .../Rotations/Rotations.module.css | 0 .../Rotations/Rotations.tsx | 68 +++++++++++++------ .../Rotations/ScheduleFinal.tsx | 45 ++++++++---- .../Rotations/ScheduleOverrides.tsx | 27 +++++--- .../src/models/schedule/schedule.ts | 19 +++++- .../src/pages/schedule/Schedule.helpers.ts | 15 +++- .../src/pages/schedule/Schedule.tsx | 6 +- .../src/pages/schedules_NEW/Schedules.tsx | 2 +- 15 files changed, 175 insertions(+), 88 deletions(-) rename grafana-plugin/src/{components => containers}/Rotations/Rotations.helpers.ts (100%) rename grafana-plugin/src/{components => containers}/Rotations/Rotations.module.css (100%) rename grafana-plugin/src/{components => containers}/Rotations/Rotations.tsx (66%) rename grafana-plugin/src/{components => containers}/Rotations/ScheduleFinal.tsx (66%) rename grafana-plugin/src/{components => containers}/Rotations/ScheduleOverrides.tsx (77%) diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts index 1d3bca98..3cc508f3 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts @@ -41,7 +41,7 @@ export const getOverrideColor = (index: number) => { const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS, OVERRIDE_COLORS]; export const getColor = (layerIndex: number, rotationIndex: number) => { - return COLORS[layerIndex][rotationIndex]; + return COLORS[layerIndex]?.[rotationIndex]; }; const USERS = [ diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 28c3e0dc..3c154a6a 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -4,7 +4,10 @@ border-radius: 2px; position: relative; display: flex; - gap: 4px; + overflow: hidden; + margin: 0 2px; + padding: 4px; + align-items: center; } .working-hours { @@ -20,16 +23,11 @@ flex-shrink: 0; } -.stack > .root { - margin: 0 2px; -} - .root__type_gap { background: rgba(209, 14, 92, 0.2); border: 1px dashed #ff5286; color: rgba(209, 14, 92, 0.5); - - /* visibility: hidden; */ + visibility: hidden; } .root__inactive { @@ -37,7 +35,6 @@ } .title { - padding: 5px; z-index: 1; color: #fff; font-size: 12px; @@ -49,11 +46,12 @@ border-radius: 2px; display: inline-block; padding: 2px 4px; - margin: 4px; line-height: 16px; z-index: 1; font-size: 10px; font-weight: bold; + margin-right: 5px; + flex-shrink: 0; } .details { diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx index fb984800..fd6957fd 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx @@ -1,6 +1,6 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { HorizontalGroup, Tooltip } from '@grafana/ui'; +import { HorizontalGroup, InlineSwitch, Tooltip } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; @@ -72,7 +72,13 @@ const UsersTimezones: FC = (props) => {
-
Daily team timezones
+ +
Team timezones
+ + + Current schedule users only + +
Current timezone: {tz}, local time: {currentMoment.format('HH:mm')} diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 4410d813..8b36edd3 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -9,7 +9,7 @@ import { CSSTransitionGroup } from 'react-transition-group'; // ES6 import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; import Text from 'components/Text/Text'; import { getFromString } from 'models/schedule/schedule.helpers'; -import { Rotation as RotationType, Schedule } from 'models/schedule/schedule.types'; +import { Rotation as RotationType, Schedule, Event } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { useStore } from 'state/useStore'; import { usePrevious } from 'utils/hooks'; @@ -21,17 +21,16 @@ const cx = cn.bind(styles); interface ScheduleSlotState {} interface RotationProps { - type: 'final' | 'rotation' | 'override'; - scheduleId: Schedule['id']; startMoment: dayjs.Dayjs; currentTimezone: Timezone; layerIndex?: number; rotationIndex?: number; color?: string; + events: Event[]; } const Rotation: FC = observer((props) => { - const { type, scheduleId, layerIndex, rotationIndex, startMoment, currentTimezone, color } = props; + const { events, layerIndex, rotationIndex, startMoment, currentTimezone, color } = props; const [animate, setAnimate] = useState(true); const [width, setWidth] = useState(); @@ -43,8 +42,6 @@ const Rotation: FC = observer((props) => { const prevStartMomentString = usePrevious(startMomentString); - const events = store.scheduleStore.events[scheduleId]?.[type]?.[getFromString(startMoment)]; - // console.log(events); // const rotation = store.scheduleStore.rotations[id]?.[prevStartMomentString]; @@ -60,7 +57,7 @@ const Rotation: FC = observer((props) => { useEffect(() => { const startMomentString = startMoment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); - console.log('CHANGE START MOMENT', startMomentString); + // console.log('CHANGE START MOMENT', startMomentString); // store.scheduleStore.updateEvents(scheduleId, startMomentString, currentTimezone); }, [startMomentString]); @@ -78,16 +75,14 @@ const Rotation: FC = observer((props) => { const firstShift = events[0]; - const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'minutes'); + const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'seconds'); - const base = 60 * 24 * 7; // in minutes only - const utcOffset = dayjs().tz(currentTimezone).utcOffset(); + const base = 60 * 60 * 24 * 7; // in minutes only + // const utcOffset = dayjs().tz(currentTimezone).utcOffset(); return firstShiftOffset / base; }, [events]); - useEffect(() => {}); - return (
{/*
*/} diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index dfd34b43..be05cb38 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -45,7 +45,7 @@ interface RotationFormProps { const cx = cn.bind(styles); const RotationForm: FC = (props) => { - const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, layerId } = props; + const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, layerIndex } = props; const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); @@ -82,18 +82,20 @@ const RotationForm: FC = (props) => { const params = { name: 'Rotation ' + Math.floor(Math.random() * 100), - rotation_start: getUTCString(rotationStart), - until: endLess ? null : getUTCString(rotationEnd), - shift_start: getUTCString(shiftStart), - shift_end: getUTCString(shiftEnd), + rotation_start: getUTCString(rotationStart, currentTimezone), + until: endLess ? null : getUTCString(rotationEnd, currentTimezone), + shift_start: getUTCString(shiftStart, currentTimezone), + shift_end: getUTCString(shiftEnd, currentTimezone), rolling_users: userGroups.filter((group) => group.length), frequency: repeatEveryPeriod, by_day: repeatEveryPeriod === 1 ? selectedDays : null, - priority_level: layerId, + priority_level: layerIndex + 1, }; console.log('params', params); + return; + store.scheduleStore.createRotation(scheduleId, false, params).then(() => { onHide(); onCreate(); @@ -108,7 +110,7 @@ const RotationForm: FC = (props) => { endLess, rotationEnd, userGroups, - layerId, + layerIndex, ]); const handleChangeEndless = useCallback( diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index ac97c379..fc9c1c10 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -46,8 +46,12 @@ const ScheduleOverrideForm: FC = (props) => { const store = useStore(); - const [shiftStart, setShiftStart] = useState(dateTime('2022-07-26 12:00:00')); - const [shiftEnd, setShiftEnd] = useState(dateTime('2022-07-26 20:00:00')); + const startOfDay = dayjs().startOf('day'); + + const [shiftStart, setShiftStart] = useState(dateTime(startOfDay.format('YYYY-MM-DD HH:mm:ss'))); + const [shiftEnd, setShiftEnd] = useState( + dateTime(startOfDay.add(12, 'hours').format('YYYY-MM-DD HH:mm:ss')) + ); const [userGroups, setUserGroups] = useState([[]]); @@ -62,9 +66,9 @@ const ScheduleOverrideForm: FC = (props) => { store.scheduleStore .createRotation(scheduleId, true, { name: 'Rotation ' + Math.floor(Math.random() * 100), - rotation_start: getUTCString(shiftStart), - shift_start: getUTCString(shiftStart), - shift_end: getUTCString(shiftEnd), + rotation_start: getUTCString(shiftStart, currentTimezone), + shift_start: getUTCString(shiftStart, currentTimezone), + shift_end: getUTCString(shiftEnd, currentTimezone), rolling_users: userGroups, frequency: null, }) @@ -74,8 +78,6 @@ const ScheduleOverrideForm: FC = (props) => { }); }, [shiftStart, shiftEnd, userGroups]); - const moment = dayjs(); - return ( = (props) => { - Timezone: {getTzOffsetString(moment)} + Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} ); -}; +}); interface DaysSelectorProps { value: string[]; diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index c1a94914..f0803672 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; import { dateTime, DateTime } from '@grafana/data'; import { @@ -24,7 +24,7 @@ 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 { getUTCString } from 'pages/schedule/Schedule.helpers'; +import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; import { RotationCreateData } from './RotationForm.types'; @@ -42,13 +42,13 @@ interface RotationFormProps { const cx = cn.bind(styles); +const startOfDay = dayjs().startOf('day'); + const ScheduleOverrideForm: FC = (props) => { const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, shiftId } = props; const store = useStore(); - const startOfDay = dayjs().startOf('day'); - const [shiftStart, setShiftStart] = useState(dateTime(startOfDay.format('YYYY-MM-DD HH:mm:ss'))); const [shiftEnd, setShiftEnd] = useState( dateTime(startOfDay.add(12, 'hours').format('YYYY-MM-DD HH:mm:ss')) @@ -65,6 +65,21 @@ const ScheduleOverrideForm: FC = (props) => { const shift = store.scheduleStore.shifts[shiftId]; + useEffect(() => { + if (shiftId !== 'new') { + store.scheduleStore.updateOncallShift(shiftId); + } + }, [shiftId]); + + useEffect(() => { + if (shift) { + setShiftStart(getDateTime(shift.shift_start)); + setShiftEnd(getDateTime(shift.shift_end)); + + setUserGroups(shift.rolling_users); + } + }, [shift]); + const handleDeleteClick = useCallback(() => { store.scheduleStore.deleteOncallShift(shiftId).then(() => { onHide(); @@ -73,19 +88,26 @@ const ScheduleOverrideForm: FC = (props) => { }, []); const handleCreate = useCallback(() => { - store.scheduleStore - .createRotation(scheduleId, true, { - title: 'Override ' + Math.floor(Math.random() * 100), - rotation_start: getUTCString(shiftStart, currentTimezone), - shift_start: getUTCString(shiftStart, currentTimezone), - shift_end: getUTCString(shiftEnd, currentTimezone), - rolling_users: userGroups, - frequency: null, - }) - .then(() => { + const params = { + title: 'Override ' + Math.floor(Math.random() * 100), + rotation_start: getUTCString(shiftStart, currentTimezone), + shift_start: getUTCString(shiftStart, currentTimezone), + shift_end: getUTCString(shiftEnd, currentTimezone), + rolling_users: userGroups, + frequency: null, + }; + + if (shiftId === 'new') { + store.scheduleStore.createRotation(scheduleId, true, params).then(() => { onHide(); onCreate(); }); + } else { + store.scheduleStore.updateRotation(shiftId, params).then(() => { + onHide(); + onUpdate(); + }); + } }, [shiftStart, shiftEnd, userGroups]); return ( @@ -142,7 +164,7 @@ const ScheduleOverrideForm: FC = (props) => { Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.module.css b/grafana-plugin/src/containers/Rotations/Rotations.module.css index 6d054716..e9dd92b6 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.module.css +++ b/grafana-plugin/src/containers/Rotations/Rotations.module.css @@ -11,8 +11,7 @@ top: 0; bottom: 0; z-index: 1; - - /* transition: left 500ms ease; */ + transition: left 500ms ease; } .header { @@ -36,6 +35,10 @@ display: block; } +.rotations { + position: relative; +} + .layer-title { text-align: center; font-weight: 500; @@ -80,4 +83,9 @@ text-align: center; padding: 12px; color: rgba(204, 204, 220, 0.65); + cursor: pointer; +} + +.add-rotations-layer:hover { + background: var(--secondary-background); } diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index b8c18344..8f67e9f3 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -11,9 +11,9 @@ import Rotation from 'containers/Rotation/Rotation'; import RotationForm from 'containers/RotationForm/RotationForm'; import { RotationCreateData } from 'containers/RotationForm/RotationForm.types'; import { getFromString } from 'models/schedule/schedule.helpers'; -import { Schedule, Shift } from 'models/schedule/schedule.types'; +import { Event, Schedule, Shift } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; -import { WithStoreProps } from 'state/types'; +import { SelectOption, WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; @@ -31,12 +31,14 @@ interface RotationsProps extends WithStoreProps { onUpdate: () => void; } -type Layer = { - id: string; -}; - interface RotationsState { shiftIdToShowRotationForm?: Shift['id']; + layerIndexToShowRotationForm?: number; +} + +interface Layer { + priority: Shift['priority_level']; + shifts: Array<{ shiftId: Shift['id']; events: Event[] }>; } @observer @@ -47,24 +49,51 @@ class Rotations extends Component { render() { const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, store, onClick } = this.props; - const { shiftIdToShowRotationForm } = this.state; - - const layers = [ - { id: 1, title: 'Layer 1' }, - /*{ id: 1, title: 'Layer 2' }, - { id: 2, title: 'Layer 3' }, - { id: 3, title: 'Layer 4' }*/ - ]; + const { shiftIdToShowRotationForm, layerIndexToShowRotationForm } = this.state; const base = 7 * 24 * 60; // in minutes const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); const currentTimeX = diff / base; - const rotations = [{} /* {}*/]; + const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; const shifts = store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)]; + const layers: Layer[] | undefined = shifts + ? shifts + .reduce((memo, shift) => { + const storeShift = store.scheduleStore.shifts[shift.shiftId]; + let layer = memo.find((level) => level.priority === storeShift.priority_level); + if (!layer) { + layer = { priority: storeShift.priority_level, 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; + }) + : undefined; + + const options = layers + ? layers.map((layer) => ({ + label: `Layer ${layer.priority}`, + value: layer.priority - 1, + })) + : []; + + options.push({ label: 'New Layer', value: layers?.length || 0 }); + return ( <>
@@ -73,10 +102,7 @@ class Rotations extends Component {
Rotations
({ - label: title, - value: id, - }))} + options={options} onChange={this.handleAddRotation} variant="secondary" size="md" @@ -84,30 +110,36 @@ class Rotations extends Component {
- {shifts && shifts.length ? ( - shifts.map(({ shiftId, events }, layerIndex) => ( -
+ {layers && layers.length ? ( + layers.map((layer) => ( +
- Layer {layerIndex + 1} + Layer {layer.priority}
-
-
- -
- { - this.onRotationClick(shiftId); - }} - events={events} - layerIndex={layerIndex} - rotationIndex={0} - startMoment={startMoment} - currentTimezone={currentTimezone} - /> -
+
+ + {layer.shifts.map(({ shiftId, events }, rotationIndex) => ( +
+ {!currentTimeHidden && ( +
+ )} +
+ { + this.onRotationClick(shiftId); + }} + events={events} + layerIndex={layer.priority - 1} + rotationIndex={rotationIndex} + startMoment={startMoment} + currentTimezone={currentTimezone} + /> +
+
+ ))}
@@ -140,7 +172,12 @@ class Rotations extends Component {
)} -
+
{ + this.handleAddLayer(layers ? layers.length : 0); + }} + > Add rotations layer +
@@ -149,7 +186,7 @@ class Rotations extends Component { { this.setState({ shiftIdToShowRotationForm: undefined }); @@ -168,10 +205,12 @@ class Rotations extends Component { updateEvents = () => {}; - handleAddLayer = () => {}; + handleAddLayer = (layerIndex: number) => { + this.setState({ shiftIdToShowRotationForm: 'new', layerIndexToShowRotationForm: layerIndex }); + }; - handleAddRotation = (option) => { - this.setState({ shiftIdToShowRotationForm: option.value }); + handleAddRotation = (option: SelectOption) => { + this.setState({ shiftIdToShowRotationForm: 'new', layerIndexToShowRotationForm: option.value }); }; } diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 99f4cb90..1bfcab67 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -45,6 +45,8 @@ class ScheduleFinal extends Component 1; + return ( <>
@@ -52,17 +54,17 @@ class ScheduleFinal extends Component
Final schedule
- } placeholder="Search..." value={searchTerm} onChange={this.onSearchTermChangeCallback} - /> + />*/}
)}
-
+ {!currentTimeHidden &&
}
{shifts && shifts.length ? ( diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 7b68e126..37ce474c 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -48,6 +48,8 @@ class ScheduleOverrides extends Component 1; + return ( <>
@@ -60,7 +62,7 @@ class ScheduleOverrides extends Component
-
+ {!currentTimeHidden &&
}
{shifts && shifts.length ? ( @@ -89,7 +91,9 @@ class ScheduleOverrides extends Component
-
Add override +
+
+ Add override + +
{shiftIdToShowOverrideForm && ( ) { const type = isOverride ? 3 : 2; - return await makeRequest(`/oncall_shifts/`, { + 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 updateRotation(rotationId: Rotation['id']) { - return await makeRequest(`/oncall_shifts/`, { - params: { shift_id: rotationId }, - method: 'GET', - }); + async updateRotation(shiftId: Shift['id'], params: Partial) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, { + data: { ...params }, + method: 'PUT', + }).catch(this.onApiError); + + this.shifts = { + ...this.shifts, + [response.id]: response, + }; + + return response; } async updateRotationMock(rotationId: Rotation['id'], fromString: string, currentTimezone: Timezone) { @@ -256,6 +271,16 @@ export class ScheduleStore extends BaseStore { }; } + @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', @@ -287,6 +312,21 @@ export class ScheduleStore extends BaseStore { } } + 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); + } + }); + shifts.forEach((shift) => { shift.events = fillGaps(shift.events); }); @@ -305,16 +345,6 @@ export class ScheduleStore extends BaseStore { }; console.log(toJS(this.events)); - - /*this.rotations = { - ...this.rotations, - [rotationId]: { - ...this.rotations[rotationId], - [level]: { - [fromString]: response as Rotation, - }, - }, - };*/ } async updateFrequencyOptions() { diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 336b934f..6739ada9 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -46,10 +46,10 @@ export interface CreateScheduleExportTokenResponse { } export interface Shift { - by_day: null; - frequency: number; + by_day: string[]; + frequency: number | null; id: string; - interval: null; + interval: number; priority_level: number; rolling_users: Array>; rotation_start: string; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index bbee6eac..b1b0122f 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { Tooltip } from '@grafana/ui'; +import dayjs from 'dayjs'; import { pick } from 'lodash-es'; +import { Timezone } from 'models/timezone/timezone.types'; + import { User, UserRole } from './user.types'; export const getIconType = (role: UserRole) => { @@ -31,6 +34,23 @@ export const getRole = (role: UserRole) => { } }; +export const getTimezone = (user: User) => { + const tzByName = { + 'Hello Oncall': 'UTC', + 'Matías Bordese': 'America/Montevideo', + 'Michael Derynck': 'America/Vancouver', + 'Yulia Shanyrova': 'Europe/Amsterdam', + 'Maxim Mordasov': 'Europe/Moscow', + 'Vadim Stepanov': 'Europe/London', + 'Ildar Iskhakov': 'Asia/Yerevan', + 'Raphael Batyrbaev': 'Europe/Rome', + 'Innokentii Konstantinov': 'Asia/Singapore', + 'Matvey Kukuy': 'Asia/Tel_Aviv', + }; + + return user.timezone || tzByName[user.username] || dayjs.tz.guess(); +}; + export const getUserNotificationsSummary = (user: User) => { if (!user) { return null; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index a86f3068..5c000c8d 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -1,5 +1,7 @@ +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'; @@ -9,7 +11,7 @@ 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 { @@ -54,7 +56,7 @@ export class UserStore extends BaseStore { this.items = { ...this.items, - [user.pk]: { ...user, timezone: this.rootStore.currentTimezone }, + [user.pk]: { ...user, timezone: getTimezone(user) }, }; this.currentUserPk = user.pk; @@ -100,27 +102,7 @@ export class UserStore extends BaseStore { ...acc, [item.pk]: { ...item, - timezone: { - 'Hello Oncall': 'UTC', - 'Matías Bordese': 'America/Montevideo', - 'Michael Derynck': 'America/Vancouver', - 'Yulia Shanyrova': 'Europe/Amsterdam', - 'Maxim Mordasov': 'Europe/Moscow', - 'Vadim Stepanov': 'Europe/London', - 'Ildar Iskhakov': 'Asia/Yerevan', - 'Raphael Batyrbaev': 'Europe/Rome', - 'Innokentii Konstantinov': 'Asia/Singapore', - /* 'Matvey Kukuy',*/ - }[item.username], - working_hours: { - monday: [{ start: '09:00:00', end: '18:00:00' }], - tuesday: [{ start: '09:00:00', end: '18:00:00' }], - wednesday: [{ start: '09:00:00', end: '18:00:00' }], - thursday: [{ start: '09:00:00', end: '18:00:00' }], - friday: [{ start: '09:00:00', end: '18:00:00' }], - saturday: [], - sunday: [], - }, + timezone: getTimezone(item), }, }), {} diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 258f8cae..3d0f9fa6 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -1,4 +1,4 @@ -import { DateTime } from '@grafana/data'; +import { dateTime, DateTime } from '@grafana/data'; import dayjs from 'dayjs'; import { subtract } from 'lodash-es'; @@ -678,3 +678,11 @@ export const getUTCString = (moment: dayjs.Dayjs | DateTime, timezone: Timezone) .subtract(timezoneOffset, 'minutes') .format('YYYY-MM-DDTHH:mm:ss.000Z'); }; + +export const getDateTime = (date: string) => { + const browserTimezone = dayjs.tz.guess(); + + const browserTimezoneOffset = dayjs().tz(browserTimezone).utcOffset(); + + return dateTime(dayjs(date).subtract(browserTimezoneOffset, 'minutes').format('YYYY-MM-DDTHH:mm:ss.000Z')); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 38ed4218..e9d017b3 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { AppRootProps } from '@grafana/data'; +import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, VerticalGroup, RadioButtonGroup, IconButton, ToolbarButton } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; @@ -15,6 +16,7 @@ import Text from 'components/Text/Text'; // import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; import UsersTimezones from 'components/UsersTimezones/UsersTimezones'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; import Rotations from 'containers/Rotations/Rotations'; import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides'; @@ -66,7 +68,7 @@ class SchedulePage extends React.Component store.scheduleStore.updateItem(id); store.scheduleStore.updateFrequencyOptions(); store.scheduleStore.updateDaysOptions(); - store.scheduleStore.updateOncallShifts(id); + await store.scheduleStore.updateOncallShifts(id); // TODO we should know shifts to render Rotations this.updateEvents(); } @@ -93,7 +95,7 @@ class SchedulePage extends React.Component {schedule?.name} - count={2} tooltipTitle="Warnings" tooltipContent="Schedule has unassigned time periods during next 7 days" - /> + />*/} {users && ( )} - + {/* - - + */} + + +
@@ -150,7 +154,7 @@ class SchedulePage extends React.Component {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
- + {/* value={renderType} onChange={this.handleRenderTypeChange} /> - + */}
{/*
*/} @@ -257,6 +261,17 @@ class SchedulePage extends React.Component 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' } }); + }); + }; + handleRightClick = () => { const { startMoment } = this.state; diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css index 2eff6caa..19fd4532 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -11,6 +11,8 @@ margin: 20px 0; } +/* .root .expanded-row { background: var(--secondary-background); } +*/ diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 088d5c35..f502a664 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -6,6 +6,7 @@ import cn from 'classnames/bind'; import dayjs from 'dayjs'; 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'; @@ -69,25 +70,25 @@ class SchedulesPage extends React.Component{item.name}; }; - renderUsers = (item: Schedule) => { - return ( - - {/*{item.users.map((user) => ( - - {user.name} - - ))}*/} - - ); + renderOncallNow = (item: Schedule, index: number) => { + if (item.on_call_now?.length > 0) { + return item.on_call_now.map((user, index) => { + return ( + +
+ + {user.username} +
+
+ ); + }); + } + return null; }; renderChatOps = (item: Schedule) => { @@ -259,9 +265,9 @@ class SchedulesPage extends React.Component { return ( - + {/* - + */} From 337af8918d7f5ee4be3cede8bdd31c4eacf173a1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 30 Jul 2022 23:01:06 +0300 Subject: [PATCH 27/60] some bug fixes --- .../containers/ScheduleForm/ScheduleForm.tsx | 2 +- .../src/pages/schedules/Schedules.tsx | 10 +++---- .../src/pages/schedules_NEW/Schedules.tsx | 28 +++++++++++++++---- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 4f78f860..e2078035 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -25,7 +25,7 @@ interface ScheduleFormProps { onHide: () => void; onUpdate: () => void; onCreate: (data: Schedule) => void; - type: ScheduleType; + type?: ScheduleType; } const scheduleTypeToForm = { diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index d538a81d..9b2e5e3a 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -5,11 +5,10 @@ import { getLocationSrv } from '@grafana/runtime'; import { Button, ConfirmModal, - Modal, - DatePickerWithInput, HorizontalGroup, Icon, LoadingPlaceholder, + Modal, PENDING_COLOR, Tooltip, VerticalGroup, @@ -17,7 +16,7 @@ import { import cn from 'classnames/bind'; import { omit } from 'lodash-es'; import { observer } from 'mobx-react'; -import moment, { Moment } from 'moment-timezone'; +import moment from 'moment-timezone'; import instructionsImage from 'assets/img/events_instructions.png'; import Avatar from 'components/Avatar/Avatar'; @@ -28,12 +27,10 @@ import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilte import Text from 'components/Text/Text'; import Tutorial from 'components/Tutorial/Tutorial'; import { TutorialStep } from 'components/Tutorial/Tutorial.types'; -import GSelect from 'containers/GSelect/GSelect'; import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { Schedule, ScheduleEvent } from 'models/schedule/schedule.types'; -import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; +import { Schedule, ScheduleEvent, ScheduleType } from 'models/schedule/schedule.types'; import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers'; import { WithStoreProps } from 'state/types'; import { UserAction } from 'state/userAction'; @@ -217,6 +214,7 @@ class SchedulesPage extends React.Component { this.setState({ scheduleIdToEdit: undefined }); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index f502a664..283cd2a2 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -37,6 +37,7 @@ interface SchedulesPageState { startMoment: dayjs.Dayjs; filters: SchedulesFiltersType; showNewScheduleSelector: boolean; + expandedRowKeys: Array; } @observer @@ -49,6 +50,7 @@ class SchedulesPage extends React.Component )} - @@ -136,6 +136,7 @@ class SchedulesPage extends React.Component { @@ -179,8 +180,23 @@ class SchedulesPage extends React.Component { const { store } = this.props; + const { expandedRowKeys } = this.state; const { startMoment } = this.state; - store.scheduleStore.updateEvents(data.id, getFromString(startMoment), 'final'); + 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); + this.setState({ expandedRowKeys: [...expandedRowKeys.splice(index, 1)] }, this.updateEvents); + } + }; + + updateEvents = () => { + const { store } = this.props; + const { expandedRowKeys, startMoment } = this.state; + + expandedRowKeys.forEach((scheduleId) => { + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final'); + }); }; renderSchedule = (data: Schedule) => { From b833b430fbe614c1067106555db0e28d696f1a00 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 30 Jul 2022 23:24:09 +0300 Subject: [PATCH 28/60] hide fake ScheduleUserDetails --- .../ScheduleUserDetails/ScheduleUserDetails.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx index 3bf1ccde..423fb074 100644 --- a/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx +++ b/grafana-plugin/src/components/ScheduleUserDetails/ScheduleUserDetails.tsx @@ -51,19 +51,19 @@ const ScheduleUserDetails: FC = (props) => { - + */} {user.username} {`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`} {userOffsetHoursStr} -
= (props) => { +39 555 449 00 00 - + */}
From ec40e28064b4f56832526c4ebd61a782b8b24cc4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 12 Aug 2022 17:50:07 +0300 Subject: [PATCH 29/60] fix WH, add cursor tracking --- .../ScheduleSlot/ScheduleSlot.module.css | 12 ++++++++++++ .../components/ScheduleSlot/ScheduleSlot.tsx | 17 ++++++++++++++--- .../WorkingHours/WorkingHours.helpers.ts | 17 ++++++----------- .../components/WorkingHours/WorkingHours.tsx | 17 ++++++++++++----- .../src/containers/Rotations/Rotations.tsx | 4 ++-- grafana-plugin/src/models/schedule/schedule.ts | 4 ++-- 6 files changed, 48 insertions(+), 23 deletions(-) diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 3c154a6a..779b263f 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -14,6 +14,7 @@ position: absolute; top: 0; left: 0; + pointer-events: none; } .stack { @@ -39,6 +40,7 @@ color: #fff; font-size: 12px; font-weight: 500; + pointer-events: none; } .label { @@ -52,6 +54,7 @@ font-weight: bold; margin-right: 5px; flex-shrink: 0; + pointer-events: none; } .details { @@ -67,3 +70,12 @@ .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; +} diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 66d8920c..3c596f61 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -33,6 +33,10 @@ const ScheduleSlot: FC = observer((props) => { const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color: propColor } = props; const { users } = event; + const trackMouse = true; + + const [mouseX, setMouseX] = useState(0); + const start = dayjs(event.start); const end = dayjs(event.end); @@ -46,6 +50,10 @@ const ScheduleSlot: FC = observer((props) => { const label = !isNaN(layerIndex) && !isNaN(rotationIndex) && index === 0 ? getLabel(layerIndex, rotationIndex) : null; + const handleMouseMove = useCallback((event) => { + setMouseX(event.nativeEvent.offsetX); + }, []); + return (
{!event.is_gap ? ( @@ -64,13 +72,15 @@ const ScheduleSlot: FC = observer((props) => { style={{ backgroundColor: color, }} + onMouseMove={trackMouse ? handleMouseMove : undefined} + onMouseLeave={trackMouse ? () => setMouseX(0) : undefined} > + {trackMouse && mouseX > 0 &&
} {storeUser && ( @@ -88,6 +98,7 @@ const ScheduleSlot: FC = observer((props) => { ) : ( }>
+ {trackMouse && mouseX > 0 &&
} {label &&
{label}
}
diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts b/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts index 692cea35..bd703b79 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts @@ -1,14 +1,11 @@ import dayjs from 'dayjs'; -export const getWorkingMoments = ( - startMoment, - endMoment, - workingHours, - timezone, -) => { +export const getWorkingMoments = (startMoment, endMoment, workingHours, timezone) => { const weekdays = dayjs.weekdays(); - const dayOfWeekToStartIteration = startMoment.format('dddd'); + const momentToStartIteration = startMoment.tz(timezone); + const dayOfWeekToStartIteration = momentToStartIteration.format('dddd'); + const weekDaysToIterateChunk = [ dayOfWeekToStartIteration, ...weekdays.slice(weekdays.indexOf(dayOfWeekToStartIteration) + 1), @@ -30,15 +27,13 @@ export const getWorkingMoments = ( const [start_HH, start_mm, start_ss] = rangeStartData.split(':'); const [end_HH, end_mm, end_ss] = rangeEndData.split(':'); - const rangeStartMoment = dayjs(startMoment) - .tz(timezone) + 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(startMoment) - .tz(timezone) + const rangeEndMoment = dayjs(momentToStartIteration) .add(i, 'day') .set('hour', Number(end_HH)) .set('minute', Number(end_mm)) diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index fe891e33..3bc04739 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -26,7 +26,7 @@ const cx = cn.bind(styles); const WorkingHours: FC = (props) => { const { timezone, - workingHours = default_working_hours, + workingHours, startMoment = dayjs().utc().startOf('week'), duration = 14 * 24 * 60 * 60, className, @@ -43,16 +43,23 @@ const WorkingHours: FC = (props) => { workingMoments.map(({ start, end }) => `${start.diff(startMoment, 'hours')} - ${end.diff(startMoment, 'hours')}`) );*/ - const nonWorkingMoments = getNonWorkingMoments(startMoment, endMoment, workingMoments); + const nonWorkingMoments = useMemo( + () => getNonWorkingMoments(startMoment, endMoment, workingMoments), + [startMoment, endMoment, workingMoments] + ); - /*console.log( + // 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( + // 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')}` diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 8f67e9f3..595b81ab 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -120,7 +120,7 @@ class Rotations extends Component {
- + {layer.shifts.map(({ shiftId, events }, rotationIndex) => (
{!currentTimeHidden && ( @@ -158,7 +158,7 @@ class Rotations extends Component {
{ - this.onRotationClick('new'); + this.handleAddLayer(layers ? layers.length : 0); }} events={[]} layerIndex={0} diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 2f53eed3..40cf3da2 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -331,7 +331,7 @@ export class ScheduleStore extends BaseStore { shift.events = fillGaps(shift.events); }); - console.log(type, shifts); + //console.log(type, shifts); this.events = { ...this.events, @@ -344,7 +344,7 @@ export class ScheduleStore extends BaseStore { }, }; - console.log(toJS(this.events)); + // console.log(toJS(this.events)); } async updateFrequencyOptions() { From bd0334f8d3144dc876913debe12ede5e708475a1 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 15 Aug 2022 10:24:22 -0300 Subject: [PATCH 30/60] Add shift preview endpoint for web schedule --- engine/apps/api/tests/test_oncall_shift.py | 173 +++++++++++++++- engine/apps/api/views/on_call_shifts.py | 24 ++- engine/apps/api/views/schedule.py | 18 +- .../apps/schedules/models/on_call_schedule.py | 45 ++++- .../schedules/tests/test_on_call_schedule.py | 190 ++++++++++++++++++ engine/common/api_helpers/utils.py | 40 ++++ 6 files changed, 470 insertions(+), 20 deletions(-) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index a40fbd46..fe9f77cf 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -7,7 +7,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb from common.constants.role import Role @@ -1140,3 +1140,174 @@ def test_on_call_shift_days_options_permissions( response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (Role.ADMIN, status.HTTP_200_OK), + (Role.EDITOR, status.HTTP_403_FORBIDDEN), + (Role.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_on_call_shift_preview_permissions( + make_organization_and_user_with_plugin_token, + make_schedule, + make_user_auth_headers, + role, + expected_status, +): + organization, user, token = make_organization_and_user_with_plugin_token(role) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + start_date = timezone.now() + client = APIClient() + + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + + url = reverse("api-internal:oncall_shifts-preview") + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +def test_on_call_shift_preview_missing_data( + make_organization_and_user_with_plugin_token, + make_schedule, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + client = APIClient() + + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rolling_users": [[user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + + url = reverse("api-internal:oncall_shifts-preview") + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_on_call_shift_preview( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + request_date = start_date + + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + data = { + "start": start_date + timezone.timedelta(hours=9), + "rotation_start": start_date + timezone.timedelta(hours=9), + "duration": timezone.timedelta(hours=9), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + url = "{}?date={}&days={}".format( + reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 + ) + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[other_user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + # check rotation events + rotation_events = response.json()["rotation"] + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 2, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "source": "web", + } + ] + # there isn't a saved shift, we don't care/know the temp pk + _ = [r.pop("shift") for r in rotation_events] + assert rotation_events == expected_rotation_events + + # check final schedule events + final_events = response.json()["final"] + expected = ( + # start (h), duration (H), user, priority + (9, 3, user.username, 1), # 9-12 user + (12, 1, other_user.username, 2), # 12-13 other_user + (13, 5, user.username, 1), # 13-18 C + ) + expected_events = [ + { + "end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "priority_level": priority, + "start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index a12e5c0b..ad9fe688 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -1,5 +1,6 @@ from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -12,6 +13,7 @@ from apps.schedules.models import CustomOnCallShift from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator +from common.api_helpers.utils import get_date_range_from_request class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): @@ -19,7 +21,7 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet permission_classes = (IsAuthenticated, ActionPermission) action_permissions = { - IsAdmin: MODIFY_ACTIONS, + IsAdmin: (*MODIFY_ACTIONS, "preview"), AnyRole: (*READ_ACTIONS, "details", "frequency_options", "days_options"), } @@ -77,6 +79,26 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet create_organization_log(organization, user, OrganizationLogType.TYPE_ON_CALL_SHIFT_DELETED, description) instance.delete() + @action(detail=False, methods=["post"]) + def preview(self, request): + user_tz, starting_date, days = get_date_range_from_request(self.request) + + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + validated_data = serializer._correct_validated_data( + serializer.validated_data["type"], serializer.validated_data + ) + shift = CustomOnCallShift(**validated_data) + schedule = shift.schedule + shift_events, final_events = schedule.preview_shift(shift, user_tz, starting_date, days) + data = { + "rotation": shift_events, + "final": final_events, + } + return Response(data=data, status=status.HTTP_200_OK) + @action(detail=False, methods=["get"]) def frequency_options(self, request): return Response( diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 4af90abf..19b65010 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -1,5 +1,3 @@ -import datetime - import pytz from django.core.exceptions import ObjectDoesNotExist from django.db.models import OuterRef, Subquery @@ -35,7 +33,7 @@ from common.api_helpers.mixins import ( ShortSerializerMixin, UpdateSerializerMixin, ) -from common.api_helpers.utils import create_engine_url +from common.api_helpers.utils import create_engine_url, get_date_range_from_request EVENTS_FILTER_BY_ROTATION = "rotation" EVENTS_FILTER_BY_OVERRIDE = "override" @@ -224,24 +222,14 @@ class ScheduleView( @action(detail=True, methods=["get"]) def filter_events(self, request, pk): - user_tz, date = self.get_request_timezone() - filter_by = self.request.query_params.get("type") + user_tz, starting_date, days = get_date_range_from_request(self.request) + filter_by = self.request.query_params.get("type") valid_filters = (EVENTS_FILTER_BY_ROTATION, EVENTS_FILTER_BY_OVERRIDE, EVENTS_FILTER_BY_FINAL) if filter_by is not None and filter_by not in valid_filters: raise BadRequest(detail="Invalid type value") resolve_schedule = filter_by is None or filter_by == EVENTS_FILTER_BY_FINAL - starting_date = date if self.request.query_params.get("date") else None - if starting_date is None: - # default to current week start - starting_date = date - datetime.timedelta(days=date.weekday()) - - try: - days = int(self.request.query_params.get("days", 7)) # fallback to a week - except ValueError: - raise BadRequest(detail="Invalid days format") - schedule = self.original_get_object() if filter_by is not None: diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 1589757b..70fdd01f 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -1,4 +1,5 @@ import datetime +import itertools import icalendar from django.apps import apps @@ -509,10 +510,12 @@ class OnCallScheduleCalendar(OnCallSchedule): class OnCallScheduleWeb(OnCallSchedule): time_zone = models.CharField(max_length=100, default="UTC") - def _generate_ical_file_from_shifts(self, qs): + def _generate_ical_file_from_shifts(self, qs, extra_shifts=None): """Generate iCal events file from custom on-call shifts.""" ical = None - if qs.exists(): + if qs.exists() or extra_shifts is not None: + if extra_shifts is None: + extra_shifts = [] end_line = "END:VCALENDAR" calendar = Calendar() calendar.add("prodid", "-//web schedule//oncall//") @@ -521,7 +524,7 @@ class OnCallScheduleWeb(OnCallSchedule): ical_file = calendar.to_ical().decode() ical = ical_file.replace(end_line, "").strip() ical = f"{ical}\r\n" - for event in qs.all(): + for event in itertools.chain(qs.all(), extra_shifts): ical += event.convert_to_ical(self.time_zone) ical += f"{end_line}\r\n" return ical @@ -559,3 +562,39 @@ class OnCallScheduleWeb(OnCallSchedule): self.prev_ical_file_overrides = self.cached_ical_file_overrides self.cached_ical_file_overrides = self._generate_ical_file_overrides() self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) + + def preview_shift(self, custom_shift, user_tz, starting_date, days): + """Return unsaved rotation and final schedule preview events.""" + if custom_shift.type == CustomOnCallShift.TYPE_OVERRIDE: + qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) + ical_attr = "cached_ical_file_overrides" + ical_property = "_ical_file_overrides" + elif custom_shift.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: + qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE) + ical_attr = "cached_ical_file_primary" + ical_property = "_ical_file_primary" + else: + raise ValueError("Invalid shift type") + + def _invalidate_cache(schedule, prop_name): + """Invalidate cached property cache""" + try: + delattr(schedule, prop_name) + except AttributeError: + pass + + ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=[custom_shift]) + + original_value = getattr(self, ical_attr) + _invalidate_cache(self, ical_property) + setattr(self, ical_attr, ical_file) + + # filter events using a temporal overriden calendar including the not-yet-saved shift + events = self.filter_events(user_tz, starting_date, days=days, with_empty=True, with_gap=True) + shift_events = [e for e in events if e["shift"]["pk"] == custom_shift.public_primary_key] + final_events = self._resolve_schedule(events) + + _invalidate_cache(self, ical_property) + setattr(self, ical_attr, original_value) + + return shift_events, final_events diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 11f4be13..3752e1f2 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -320,3 +320,193 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma for e in returned_events ] assert returned_events == expected_events + + +@pytest.mark.django_db +def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + data = { + "start": start_date + timezone.timedelta(hours=9), + "rotation_start": start_date + timezone.timedelta(hours=9), + "duration": timezone.timedelta(hours=9), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + schedule_primary_ical = schedule._ical_file_primary + + # proposed shift + new_shift = CustomOnCallShift( + type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + organization=organization, + schedule=schedule, + name="testing", + start=start_date + timezone.timedelta(hours=12), + rotation_start=start_date + timezone.timedelta(hours=12), + duration=timezone.timedelta(seconds=3600), + frequency=CustomOnCallShift.FREQUENCY_DAILY, + priority_level=2, + rolling_users=[{other_user.pk: other_user.public_primary_key}], + ) + + rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + + # check rotation events + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": new_shift.start, + "end": new_shift.start + new_shift.duration, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": new_shift.priority_level, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "shift": {"pk": new_shift.public_primary_key}, + "source": "api", + } + ] + assert rotation_events == expected_rotation_events + + # check final schedule events + expected = ( + # start (h), duration (H), user, priority + (9, 3, user.username, 1), # 9-12 user + (12, 1, other_user.username, 2), # 12-13 other_user + (13, 5, user.username, 1), # 13-18 C + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events + + # final ical schedule didn't change + assert schedule._ical_file_primary == schedule_primary_ical + + +@pytest.mark.django_db +def test_preview_override_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + data = { + "start": start_date + timezone.timedelta(hours=9), + "rotation_start": start_date + timezone.timedelta(hours=9), + "duration": timezone.timedelta(hours=9), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + schedule_overrides_ical = schedule._ical_file_overrides + + # proposed override + new_shift = CustomOnCallShift( + type=CustomOnCallShift.TYPE_OVERRIDE, + organization=organization, + schedule=schedule, + name="testing", + start=start_date + timezone.timedelta(hours=12), + rotation_start=start_date + timezone.timedelta(hours=12), + duration=timezone.timedelta(seconds=3600), + rolling_users=[{other_user.pk: other_user.public_primary_key}], + ) + + rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1) + + # check rotation events + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_OVERRIDES, + "start": new_shift.start, + "end": new_shift.start + new_shift.duration, + "all_day": False, + "is_override": True, + "is_empty": False, + "is_gap": False, + "priority_level": None, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "shift": {"pk": new_shift.public_primary_key}, + "source": "api", + } + ] + assert rotation_events == expected_rotation_events + + # check final schedule events + expected = ( + # start (h), duration (H), user, priority, is_override + (9, 3, user.username, 1, False), # 9-12 user + (12, 1, other_user.username, None, True), # 12-13 other_user + (13, 5, user.username, 1, False), # 13-18 C + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "user": user, + "is_override": is_override, + } + for start, duration, user, priority, is_override in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + "is_override": e["is_override"], + } + for e in final_events + if not e["is_gap"] + ] + assert returned_events == expected_events + + # final ical schedule didn't change + assert schedule._ical_file_overrides == schedule_overrides_ical diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 7ecd5d47..5ccc93b1 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -1,10 +1,15 @@ +import datetime from urllib.parse import urljoin +import pytz import requests from django.conf import settings +from django.utils import dateparse, timezone from icalendar import Calendar from rest_framework import serializers +from common.api_helpers.exceptions import BadRequest + class CurrentOrganizationDefault: """ @@ -71,3 +76,38 @@ def create_engine_url(path, override_base=None): base += "/" trimmed_path = path.lstrip("/") return urljoin(base, trimmed_path) + + +def get_date_range_from_request(request): + """Extract timezone, starting date and number of days params from request. + + Used mainly for schedules and shifts API. + """ + user_tz = request.query_params.get("user_tz", "UTC") + try: + pytz.timezone(user_tz) + except pytz.exceptions.UnknownTimeZoneError: + raise BadRequest(detail="Invalid tz format") + + date = timezone.now().date() + date_param = request.query_params.get("date") + if date_param is not None: + try: + date = dateparse.parse_date(date_param) + except ValueError: + raise BadRequest(detail="Invalid date format") + else: + if date is None: + raise BadRequest(detail="Invalid date format") + + starting_date = date if request.query_params.get("date") else None + if starting_date is None: + # default to current week start + starting_date = date - datetime.timedelta(days=date.weekday()) + + try: + days = int(request.query_params.get("days", 7)) # fallback to a week + except ValueError: + raise BadRequest(detail="Invalid days format") + + return user_tz, starting_date, days From 2ddbf866a09ebe374c8a3c9f4635ec1feef0e5e8 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 15 Aug 2022 11:44:26 -0300 Subject: [PATCH 31/60] Fix to check for final type in schedule filter_events --- engine/apps/api/views/schedule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 19b65010..e2c1ea51 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -232,7 +232,7 @@ class ScheduleView( schedule = self.original_get_object() - if filter_by is not None: + if filter_by is not None and filter_by != EVENTS_FILTER_BY_FINAL: filter_by = OnCallSchedule.PRIMARY if filter_by == EVENTS_FILTER_BY_ROTATION else OnCallSchedule.OVERRIDES events = schedule.filter_events( user_tz, starting_date, days=days, with_empty=True, with_gap=resolve_schedule, filter_by=filter_by From 1ba742d99e3c33766ece5a64ef2776f73d1fc8a6 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 16 Aug 2022 18:13:31 -0300 Subject: [PATCH 32/60] Fix for final event calculation when splitting events --- .../apps/schedules/models/on_call_schedule.py | 21 ++++-- .../schedules/tests/test_on_call_schedule.py | 67 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 98d605f3..d05cc0f4 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -276,14 +276,18 @@ class OnCallSchedule(PolymorphicModel): if not events: return [] - # sort schedule events by (type desc, priority desc, start timestamp asc) - events.sort( - key=lambda e: ( - -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None - -e["priority_level"] if e["priority_level"] else 0, - e["start"], + def apply_sorting(eventlist): + """Sort events keeping the events priority criteria.""" + eventlist.sort( + key=lambda e: ( + -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None + -e["priority_level"] if e["priority_level"] else 0, + e["start"], + ) ) - ) + + # sort schedule events by (type desc, priority desc, start timestamp asc) + apply_sorting(events) def _merge_intervals(evs): """Keep track of scheduled intervals.""" @@ -345,6 +349,9 @@ class OnCallSchedule(PolymorphicModel): # event ends after current interval, update event start timestamp to match the interval end # and process the updated event as any other event ev["start"] = intervals[current_interval_idx][1] + # reorder pending events after updating current event start date + # (ie. insert the event where it should be to keep the order criteria) + apply_sorting(pending) else: # done, go to next event current_event_idx += 1 diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index a6e875ba..9fae6517 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -322,6 +322,73 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma assert returned_events == expected_events +@pytest.mark.django_db +def test_final_schedule_splitting_events( + make_organization, make_user_for_organization, make_on_call_shift, make_schedule +): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC") + + shifts = ( + # user, priority, start time (h), duration (hs) + (user_a, 1, 10, 10), # r1-1: 10-20 / A + (user_b, 1, 12, 4), # r1-2: 12-16 / B + (user_c, 2, 15, 3), # r2-1: 15-18 / C + ) + for user, priority, start_h, duration in shifts: + data = { + "start": start_date + timezone.timedelta(hours=start_h), + "rotation_start": start_date + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(hours=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + returned_events = schedule.final_events("UTC", start_date, days=1) + + expected = ( + # start (h), duration (H), user, priority + (10, 5, "A", 1), # 10-15 A + (12, 3, "B", 1), # 12-15 B + (15, 3, "C", 2), # 15-18 C + (18, 2, "A", 1), # 18-20 A + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in returned_events + if not e["is_gap"] + ] + assert returned_events == expected_events + + @pytest.mark.django_db def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): organization = make_organization() From afbfdc5ab5113ea4c82e3feef2efa2a3d7f28f34 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 17 Aug 2022 09:22:53 -0300 Subject: [PATCH 33/60] Refactoring schedules _resolve_schedule method --- .../apps/schedules/models/on_call_schedule.py | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index d05cc0f4..9b7cbb38 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -276,18 +276,23 @@ class OnCallSchedule(PolymorphicModel): if not events: return [] - def apply_sorting(eventlist): - """Sort events keeping the events priority criteria.""" - eventlist.sort( - key=lambda e: ( - -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None - -e["priority_level"] if e["priority_level"] else 0, - e["start"], - ) + def event_cmp_key(e): + """Sorting key criteria for events.""" + return ( + -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None + -e["priority_level"] if e["priority_level"] else 0, + e["start"], ) - # sort schedule events by (type desc, priority desc, start timestamp asc) - apply_sorting(events) + def insort_event(eventlist, e): + """Insert event keeping ordering criteria into already sorted event list.""" + idx = 0 + for i in eventlist: + if event_cmp_key(e) > event_cmp_key(i): + idx += 1 + else: + break + eventlist.insert(idx, e) def _merge_intervals(evs): """Keep track of scheduled intervals.""" @@ -303,24 +308,25 @@ class OnCallSchedule(PolymorphicModel): result.append(interval) return result + # sort schedule events by (type desc, priority desc, start timestamp asc) + events.sort(key=event_cmp_key) + # iterate over events, reserving schedule slots based on their priority # if the expected slot was already scheduled for a higher priority event, # split the event, or fix start/end timestamps accordingly - # include overrides from start - resolved = [e for e in events if e["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES] - intervals = _merge_intervals(resolved) - - pending = events[len(resolved) :] - if not pending: - return resolved - - current_event_idx = 0 # current event to resolve + resolved = [] + pending = events current_interval_idx = 0 # current scheduled interval being checked - current_priority = pending[0]["priority_level"] # current priority level being resolved + current_priority = None # current priority level being resolved - while current_event_idx < len(pending): - ev = pending[current_event_idx] + while pending: + ev = pending.pop(0) + + if ev["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES: + # include overrides from start + resolved.append(ev) + continue if ev["priority_level"] != current_priority: # update scheduled intervals on priority change @@ -333,11 +339,11 @@ class OnCallSchedule(PolymorphicModel): if current_interval_idx >= len(intervals): # event outside scheduled intervals, add to resolved resolved.append(ev) - current_event_idx += 1 + elif ev["start"] < intervals[current_interval_idx][0] and ev["end"] <= intervals[current_interval_idx][0]: # event starts and ends outside an already scheduled interval, add to resolved resolved.append(ev) - current_event_idx += 1 + elif ev["start"] < intervals[current_interval_idx][0] and ev["end"] > intervals[current_interval_idx][0]: # event starts outside interval but overlaps with an already scheduled interval # 1. add a split event copy to schedule the time before the already scheduled interval @@ -351,13 +357,16 @@ class OnCallSchedule(PolymorphicModel): ev["start"] = intervals[current_interval_idx][1] # reorder pending events after updating current event start date # (ie. insert the event where it should be to keep the order criteria) - apply_sorting(pending) + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) else: # done, go to next event - current_event_idx += 1 + continue + elif ev["start"] >= intervals[current_interval_idx][0] and ev["end"] <= intervals[current_interval_idx][1]: # event inside an already scheduled interval, ignore (go to next) - current_event_idx += 1 + continue + elif ( ev["start"] >= intervals[current_interval_idx][0] and ev["start"] < intervals[current_interval_idx][1] @@ -366,11 +375,18 @@ class OnCallSchedule(PolymorphicModel): # event starts inside a scheduled interval but ends out of it # update the event start timestamp to match the interval end ev["start"] = intervals[current_interval_idx][1] + # unresolved, re-add to pending + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) # move to next interval and process the updated event as any other event current_interval_idx += 1 + elif ev["start"] >= intervals[current_interval_idx][1]: # event starts after the current interval, move to next interval and go through it current_interval_idx += 1 + # unresolved, re-add to pending + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"])) return resolved From d48454f040268be4cc8c657d5ebac4af5d5dfb56 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 17 Aug 2022 10:20:33 -0300 Subject: [PATCH 34/60] Fix higher priority overlapping multiple same-start events --- .../apps/schedules/models/on_call_schedule.py | 6 +- .../schedules/tests/test_on_call_schedule.py | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 9b7cbb38..31269432 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -359,9 +359,7 @@ class OnCallSchedule(PolymorphicModel): # (ie. insert the event where it should be to keep the order criteria) # TODO: switch to bisect insert on python 3.10 (or consider heapq) insort_event(pending, ev) - else: - # done, go to next event - continue + # done, go to next event elif ev["start"] >= intervals[current_interval_idx][0] and ev["end"] <= intervals[current_interval_idx][1]: # event inside an already scheduled interval, ignore (go to next) @@ -378,8 +376,6 @@ class OnCallSchedule(PolymorphicModel): # unresolved, re-add to pending # TODO: switch to bisect insert on python 3.10 (or consider heapq) insort_event(pending, ev) - # move to next interval and process the updated event as any other event - current_interval_idx += 1 elif ev["start"] >= intervals[current_interval_idx][1]: # event starts after the current interval, move to next interval and go through it diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 9fae6517..48e1e244 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -389,6 +389,74 @@ def test_final_schedule_splitting_events( assert returned_events == expected_events +@pytest.mark.django_db +def test_final_schedule_splitting_same_time_events( + make_organization, make_user_for_organization, make_on_call_shift, make_schedule +): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC") + + shifts = ( + # user, priority, start time (h), duration (hs) + (user_a, 1, 10, 10), # r1-1: 10-20 / A + (user_b, 1, 10, 10), # r1-2: 10-20 / B + (user_c, 2, 10, 3), # r2-1: 10-13 / C + ) + for user, priority, start_h, duration in shifts: + data = { + "start": start_date + timezone.timedelta(hours=start_h), + "rotation_start": start_date + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(hours=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + returned_events = schedule.final_events("UTC", start_date, days=1) + + expected = ( + # start (h), duration (H), user, priority + (10, 3, "C", 2), # 10-13 C + (13, 7, "A", 1), # 13-20 A + (13, 7, "B", 1), # 13-20 B + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in sorted( + returned_events, key=lambda e: (e["start"], e["users"][0]["display_name"] if e["users"] else None) + ) + if not e["is_gap"] + ] + assert returned_events == expected_events + + @pytest.mark.django_db def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): organization = make_organization() From fa6f1600aba24876438096cc52233473f0518b7b Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 18 Aug 2022 11:04:06 -0300 Subject: [PATCH 35/60] Combine same-shift schedule events early --- engine/apps/api/tests/test_oncall_shift.py | 95 +++++++++++++++++++ engine/apps/api/views/schedule.py | 22 ----- .../apps/schedules/models/on_call_schedule.py | 22 +++++ 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index fe9f77cf..7d5ef169 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1311,3 +1311,98 @@ def test_on_call_shift_preview( if not e["is_override"] and not e["is_gap"] ] assert returned_events == expected_events + + +@pytest.mark.django_db +def test_on_call_shift_preview_merge_events( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + request_date = start_date + + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + url = "{}?date={}&days={}".format( + reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 + ) + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[user.public_primary_key, other_user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + # check rotation events + rotation_events = response.json()["rotation"] + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 2, + "missing_users": [], + "source": "web", + } + ] + expected_users = sorted([user.username, other_user.username]) + returned_event = rotation_events[0] + # there isn't a saved shift, we don't care/know the temp pk + returned_event.pop("shift") + returned_users = sorted(u["display_name"] for u in returned_event.pop("users")) + assert sorted(returned_users) == expected_users + assert rotation_events == expected_rotation_events + + # check final schedule events + final_events = response.json()["final"] + expected = ( + # start (h), duration (H), users, priority + (12, 1, expected_users, 2), # 12-13 other_user + ) + expected_events = [ + { + "end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "priority_level": priority, + "start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "users": users, + } + for start, duration, users, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "users": sorted(u["display_name"] for u in e["users"]), + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 8a066cee..e2c1ea51 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -240,9 +240,6 @@ class ScheduleView( else: # return final schedule events = schedule.final_events(user_tz, starting_date, days) - # combine multiple-users same-shift events into one - events = self._merge_events(events) - result = { "id": schedule.public_primary_key, "name": schedule.name, @@ -251,25 +248,6 @@ class ScheduleView( } return Response(result, status=status.HTTP_200_OK) - def _merge_events(self, events): - """Merge user groups same-shift events.""" - if events: - merged = [events[0]] - current = merged[0] - for next_event in events[1:]: - if ( - current["start"] == next_event["start"] - and current["shift"]["pk"] is not None - and current["shift"]["pk"] == next_event["shift"]["pk"] - ): - current["users"] += next_event["users"] - current["missing_users"] += next_event["missing_users"] - else: - merged.append(next_event) - current = next_event - events = merged - return events - @action(detail=True, methods=["get"]) def next_shifts_per_user(self, request, pk): """Return next shift for users in schedule.""" diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 31269432..5841b198 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -263,6 +263,9 @@ class OnCallSchedule(PolymorphicModel): } events.append(shift_json) + # combine multiple-users same-shift events into one + events = self._merge_events(events) + return events def final_events(self, user_tz, starting_date, days): @@ -387,6 +390,25 @@ class OnCallSchedule(PolymorphicModel): resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"])) return resolved + def _merge_events(self, events): + """Merge user groups same-shift events.""" + if events: + merged = [events[0]] + current = merged[0] + for next_event in events[1:]: + if ( + current["start"] == next_event["start"] + and current["shift"]["pk"] is not None + and current["shift"]["pk"] == next_event["shift"]["pk"] + ): + current["users"] += next_event["users"] + current["missing_users"] += next_event["missing_users"] + else: + merged.append(next_event) + current = next_event + events = merged + return events + class OnCallScheduleICal(OnCallSchedule): # For the ical schedule both primary and overrides icals are imported via ical url From 4551481c0f37dc508d00fd9b161a65d64fbc91fc Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 18 Aug 2022 18:13:54 +0300 Subject: [PATCH 36/60] add rotation preview --- .../components/ScheduleSlot/ScheduleSlot.tsx | 2 +- .../src/containers/Rotation/Rotation.tsx | 16 ++-- .../containers/RotationForm/RotationForm.tsx | 58 ++++++++------ .../src/containers/Rotations/Rotations.tsx | 72 +++++++---------- .../containers/Rotations/ScheduleFinal.tsx | 17 +--- .../src/models/schedule/schedule.helpers.ts | 23 +++++- .../src/models/schedule/schedule.ts | 79 +++++++++++++------ .../src/models/schedule/schedule.types.ts | 5 ++ 8 files changed, 157 insertions(+), 115 deletions(-) diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 3c596f61..787d7fa5 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -33,7 +33,7 @@ const ScheduleSlot: FC = observer((props) => { const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color: propColor } = props; const { users } = event; - const trackMouse = true; + const trackMouse = false; const [mouseX, setMouseX] = useState(0); diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index b6105e1b..b1aa6e0c 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -3,15 +3,12 @@ import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 're import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; -import { observer } from 'mobx-react'; import { CSSTransitionGroup } from 'react-transition-group'; // ES6 import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; -import Text from 'components/Text/Text'; 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 { useStore } from 'state/useStore'; import { usePrevious } from 'utils/hooks'; import styles from './Rotation.module.css'; @@ -30,15 +27,13 @@ interface RotationProps { onClick: () => void; } -const Rotation: FC = observer((props) => { +const Rotation: FC = (props) => { const { events, layerIndex, rotationIndex, startMoment, currentTimezone, color, onClick } = props; const [animate, setAnimate] = useState(true); const [width, setWidth] = useState(); const [transparent, setTransparent] = useState(false); - const store = useStore(); - const startMomentString = useMemo(() => getFromString(startMoment), [startMoment]); const prevStartMomentString = usePrevious(startMomentString); @@ -92,7 +87,6 @@ const Rotation: FC = observer((props) => {
{events.map((event, index) => { return ( @@ -110,7 +104,7 @@ const Rotation: FC = observer((props) => { })}
) : ( -
+ ) ) : ( @@ -120,6 +114,10 @@ const Rotation: FC = observer((props) => {
); -}); +}; + +const Empty = () => { + return
; +}; export default Rotation; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index f6638fce..36a18cf8 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { dateTime, DateTime } from '@grafana/data'; import { @@ -22,21 +22,25 @@ import Text from 'components/Text/Text'; import UserGroups from 'components/UserGroups/UserGroups'; import WithConfirm from 'components/WithConfirm/WithConfirm'; 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 { useDebouncedCallback } from 'utils/hooks'; import { RotationCreateData } from './RotationForm.types'; import styles from './RotationForm.module.css'; interface RotationFormProps { - layerIndex: number; + layerPriority: number; onHide: () => void; + startMoment: dayjs.Dayjs; currentTimezone: Timezone; scheduleId: Schedule['id']; shiftId: Shift['id'] | 'new'; @@ -46,10 +50,10 @@ interface RotationFormProps { const cx = cn.bind(styles); -const startOfDay = dayjs().startOf('day'); +const startOfDay = dayjs().startOf('day').add(1, 'day'); const RotationForm: FC = observer((props) => { - const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, layerIndex, shiftId } = props; + const { onHide, onCreate, startMoment, currentTimezone, scheduleId, onUpdate, layerPriority, shiftId } = props; const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); @@ -88,9 +92,8 @@ const RotationForm: FC = observer((props) => { } }, [shiftId]); - const handleCreate = useCallback(() => { - const params = { - title: 'Rotation ' + Math.floor(Math.random() * 100), + const params = useMemo( + () => ({ rotation_start: getUTCString(rotationStart, currentTimezone), until: endLess ? null : getUTCString(rotationEnd, currentTimezone), shift_start: getUTCString(shiftStart, currentTimezone), @@ -99,9 +102,25 @@ const RotationForm: FC = observer((props) => { interval: repeatEveryValue, frequency: repeatEveryPeriod, by_day: repeatEveryPeriod === 1 ? selectedDays : null, - priority_level: shiftId === 'new' ? layerIndex + 1 : shift?.priority_level, - }; + priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level, + }), + [ + rotationStart, + currentTimezone, + rotationEnd, + shiftStart, + shiftEnd, + userGroups, + repeatEveryValue, + repeatEveryPeriod, + selectedDays, + shiftId, + layerPriority, + shift, + ] + ); + const handleCreate = useCallback(() => { if (shiftId === 'new') { store.scheduleStore.createRotation(scheduleId, false, params).then(() => { onHide(); @@ -113,18 +132,13 @@ const RotationForm: FC = observer((props) => { onUpdate(); }); } - }, [ - repeatEveryValue, - repeatEveryPeriod, - selectedDays, - shiftStart, - shiftEnd, - rotationStart, - endLess, - rotationEnd, - userGroups, - layerIndex, - ]); + }, [shiftId, params]); + + const handleChange = useDebouncedCallback(() => { + store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params); + }, 1000); + + useEffect(handleChange, [params]); useEffect(() => { if (shift) { @@ -173,7 +187,7 @@ const RotationForm: FC = observer((props) => { - [L{shiftId === 'new' ? layerIndex + 1 : shift?.priority_level}] + [L{shiftId === 'new' ? layerPriority : shift?.priority_level}] {shiftId === 'new' ? 'New Rotation' : shift?.title} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 595b81ab..02ea59ec 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -4,6 +4,7 @@ import { ValuePicker, IconButton, Icon, HorizontalGroup, Button, LoadingPlacehol 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 TimelineMarks from 'components/TimelineMarks/TimelineMarks'; @@ -11,7 +12,7 @@ import Rotation from 'containers/Rotation/Rotation'; import RotationForm from 'containers/RotationForm/RotationForm'; import { RotationCreateData } from 'containers/RotationForm/RotationForm.types'; import { getFromString } from 'models/schedule/schedule.helpers'; -import { Event, Schedule, Shift } from 'models/schedule/schedule.types'; +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'; @@ -33,12 +34,7 @@ interface RotationsProps extends WithStoreProps { interface RotationsState { shiftIdToShowRotationForm?: Shift['id']; - layerIndexToShowRotationForm?: number; -} - -interface Layer { - priority: Shift['priority_level']; - shifts: Array<{ shiftId: Shift['id']; events: Event[] }>; + layerPriority?: Layer['priority']; } @observer @@ -49,7 +45,7 @@ class Rotations extends Component { render() { const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, store, onClick } = this.props; - const { shiftIdToShowRotationForm, layerIndexToShowRotationForm } = this.state; + const { shiftIdToShowRotationForm, layerPriority } = this.state; const base = 7 * 24 * 60; // in minutes const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); @@ -58,32 +54,12 @@ class Rotations extends Component { const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; - const shifts = store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)]; + const storeLayers = store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]; - const layers: Layer[] | undefined = shifts - ? shifts - .reduce((memo, shift) => { - const storeShift = store.scheduleStore.shifts[shift.shiftId]; - let layer = memo.find((level) => level.priority === storeShift.priority_level); - if (!layer) { - layer = { priority: storeShift.priority_level, 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; - }) - : undefined; + let layers = storeLayers; + if (store.scheduleStore.rotationPreview) { + layers = [...layers, { priority: 2, shifts: [store.scheduleStore.rotationPreview] }]; + } const options = layers ? layers.map((layer) => ({ @@ -92,7 +68,9 @@ class Rotations extends Component { })) : []; - options.push({ label: 'New Layer', value: layers?.length || 0 }); + const nextPriority = layers && layers.length ? layers[layers.length - 1].priority + 1 : 1; + + options.push({ label: 'New Layer', value: nextPriority }); return ( <> @@ -158,7 +136,7 @@ class Rotations extends Component {
{ - this.handleAddLayer(layers ? layers.length : 0); + this.handleAddLayer(nextPriority); }} events={[]} layerIndex={0} @@ -175,7 +153,7 @@ class Rotations extends Component {
{ - this.handleAddLayer(layers ? layers.length : 0); + this.handleAddLayer(nextPriority); }} > Add rotations layer + @@ -186,11 +164,10 @@ class Rotations extends Component { { - this.setState({ shiftIdToShowRotationForm: undefined }); - }} + onHide={this.handleRotationFormHide} onUpdate={onUpdate} onCreate={onCreate} /> @@ -199,18 +176,23 @@ class Rotations extends Component { ); } + handleRotationFormHide = () => { + const { store } = this.props; + + store.scheduleStore.rotationPreview = undefined; + this.setState({ shiftIdToShowRotationForm: undefined, layerPriority: undefined }); + }; + onRotationClick = (shiftId: Shift['id']) => { this.setState({ shiftIdToShowRotationForm: shiftId }); }; - updateEvents = () => {}; - - handleAddLayer = (layerIndex: number) => { - this.setState({ shiftIdToShowRotationForm: 'new', layerIndexToShowRotationForm: layerIndex }); + handleAddLayer = (layerPriority: number) => { + this.setState({ shiftIdToShowRotationForm: 'new', layerPriority }); }; handleAddRotation = (option: SelectOption) => { - this.setState({ shiftIdToShowRotationForm: 'new', layerIndexToShowRotationForm: option.value }); + this.setState({ shiftIdToShowRotationForm: 'new', layerPriority: option.value }); }; } diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 1bfcab67..752f4123 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -69,23 +69,10 @@ class ScheduleFinal extends Component {shifts && shifts.length ? ( shifts.map(({ shiftId, events }, index) => ( - + )) ) : ( - + )}
diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 8e85beb3..963a4057 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; -import { Event } from './schedule.types'; +import { Event, Shift } from './schedule.types'; export const getFromString = (moment: dayjs.Dayjs) => { return moment.format('YYYY-MM-DD'); @@ -23,3 +23,24 @@ export const fillGaps = (events: Event[]) => { return newEvents; }; + +export const splitToShiftsAndFillGaps = (events: Event[]) => { + const shifts: Array<{ shiftId: Shift['id']; 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, events: [] }; + shifts.push(shift); + } + shift.events.push(event); + } + } + + shifts.forEach((shift) => { + shift.events = fillGaps(shift.events); + }); + + return shifts; +}; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index e4db3881..f268b4a7 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -11,8 +11,8 @@ import { makeRequest } from 'network'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { fillGaps } from './schedule.helpers'; -import { Events, Rotation, RotationType, Schedule, ScheduleEvent, Shift, Event } from './schedule.types'; +import { fillGaps, splitToShiftsAndFillGaps } from './schedule.helpers'; +import { Events, Rotation, RotationType, Schedule, ScheduleEvent, Shift, Event, Layer } from './schedule.types'; const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; @@ -67,11 +67,17 @@ export class ScheduleStore extends BaseStore { events: { [scheduleId: string]: { [type: string]: { - [startMoment: string]: Array<{ shiftId: string; events: Event[] }>; + [startMoment: string]: Array<{ shiftId: string; events: Event[] }> | Layer[]; }; }; } = {}; + @observable.shallow + rotationPreview?: { shiftId: Shift['id']; events: Event[] }; + + @observable.shallow + finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; + @observable scheduleToScheduleEvents: { [id: string]: ScheduleEvent[]; @@ -187,6 +193,27 @@ export class ScheduleStore extends BaseStore { return response; } + async updateRotationPreview( + scheduleId: Schedule['id'], + shiftId: Shift['id'] | 'new', + fromString: string, + isOverride: boolean, + params: Partial + ) { + const type = isOverride ? 3 : 2; + + const typeString = isOverride ? 'override' : 'rotation'; + + const response = await makeRequest(`/oncall_shifts/preview/`, { + params: { date: fromString }, + data: { type, schedule: scheduleId, ...params }, + method: 'POST', + }).catch(this.onApiError); + + this.rotationPreview = { shiftId: shiftId, events: fillGaps(response.rotation.filter((event) => !event.is_gap)) }; + this.finalPreview = splitToShiftsAndFillGaps(response.final); + } + async updateRotation(shiftId: Shift['id'], params: Partial) { const response = await makeRequest(`/oncall_shifts/${shiftId}`, { data: { ...params }, @@ -297,21 +324,9 @@ export class ScheduleStore extends BaseStore { method: 'GET', }); - const events = type !== 'final' ? fillGaps(response.events) : response.events; - - const shifts: Array<{ shiftId: Shift['id']; events: Event[] }> = []; - - for (const [i, event] of response.events.entries()) { - if (event.shift?.pk) { - let shift = shifts.find((shift) => shift.shiftId === event.shift?.pk); - if (!shift) { - shift = { shiftId: event.shift.pk, events: [] }; - shifts.push(shift); - } - shift.events.push(event); - } - } + 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]; @@ -327,11 +342,31 @@ export class ScheduleStore extends BaseStore { } });*/ - shifts.forEach((shift) => { - shift.events = fillGaps(shift.events); - }); + const layers: Layer[] | undefined = + type === 'rotation' + ? shifts + .reduce((memo, shift) => { + const storeShift = this.shifts[shift.shiftId]; + let layer = memo.find((level) => level.priority === storeShift.priority_level); + if (!layer) { + layer = { priority: storeShift.priority_level, shifts: [] }; + memo.push(layer); + } + layer.shifts.push(shift); - //console.log(type, shifts); + return memo; + }, []) + .sort((a, b) => { + if (a.priority > b.priority) { + return 1; + } + if (a.priority < b.priority) { + return -1; + } + + return 0; + }) + : undefined; this.events = { ...this.events, @@ -339,7 +374,7 @@ export class ScheduleStore extends BaseStore { ...this.events[scheduleId], [type]: { ...this.events[scheduleId]?.[type], - [fromString]: shifts, + [fromString]: layers ? layers : shifts, }, }, }; diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 6739ada9..6bb2af11 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -89,3 +89,8 @@ export interface Events { name: string; type: number; //? } + +export interface Layer { + priority: Shift['priority_level']; + shifts: Array<{ shiftId: Shift['id']; events: Event[] }>; +} From 2365506b96dcb05dd415c1a843358b75667cef05 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 18 Aug 2022 17:41:13 -0300 Subject: [PATCH 37/60] Handle shift previews for rotation updates --- engine/apps/api/tests/test_oncall_shift.py | 109 ++++++++++++++++++ engine/apps/api/views/on_call_shifts.py | 5 +- .../apps/schedules/models/on_call_schedule.py | 15 ++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index 7d5ef169..b31aa598 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1406,3 +1406,112 @@ def test_on_call_shift_preview_merge_events( if not e["is_override"] and not e["is_gap"] ] assert returned_events == expected_events + + +@pytest.mark.django_db +def test_on_call_shift_preview_update( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + request_date = start_date + + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + data = { + "start": start_date + timezone.timedelta(hours=8), + "rotation_start": start_date + timezone.timedelta(hours=8), + "duration": timezone.timedelta(hours=1), + "priority_level": 1, + "interval": 4, + "frequency": CustomOnCallShift.FREQUENCY_HOURLY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + url = "{}?date={}&days={}".format( + reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 + ) + shift_start = (start_date + timezone.timedelta(hours=10)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=18)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "shift_pk": on_call_shift.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[other_user.public_primary_key]], + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + # check rotation events + rotation_events = response.json()["rotation"] + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 1, + "missing_users": [], + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "source": "web", + } + ] + # there isn't a saved shift, we don't care/know the temp pk + _ = [r.pop("shift") for r in rotation_events] + assert rotation_events == expected_rotation_events + + # check final schedule events + final_events = response.json()["final"] + expected = ( + # start (h), duration (H), user, priority + (8, 1, user.username, 1), # 8-9 user + (10, 8, other_user.username, 1), # 10-18 other_user + ) + expected_events = [ + { + "end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "priority_level": priority, + "start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index ad9fe688..42c5eda2 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -90,9 +90,12 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet validated_data = serializer._correct_validated_data( serializer.validated_data["type"], serializer.validated_data ) + updated_shift_pk = self.request.data.get("shift_pk") shift = CustomOnCallShift(**validated_data) schedule = shift.schedule - shift_events, final_events = schedule.preview_shift(shift, user_tz, starting_date, days) + shift_events, final_events = schedule.preview_shift( + shift, user_tz, starting_date, days, updated_shift_pk=updated_shift_pk + ) data = { "rotation": shift_events, "final": final_events, diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 5841b198..b89a8815 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -604,7 +604,7 @@ class OnCallScheduleWeb(OnCallSchedule): self.cached_ical_file_overrides = self._generate_ical_file_overrides() self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) - def preview_shift(self, custom_shift, user_tz, starting_date, days): + def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None): """Return unsaved rotation and final schedule preview events.""" if custom_shift.type == CustomOnCallShift.TYPE_OVERRIDE: qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) @@ -624,7 +624,18 @@ class OnCallScheduleWeb(OnCallSchedule): except AttributeError: pass - ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=[custom_shift]) + extra_shifts = [custom_shift] + if updated_shift_pk is not None: + try: + update_shift = qs.get(public_primary_key=updated_shift_pk) + except CustomOnCallShift.DoesNotExist: + pass + else: + update_shift.until = custom_shift.rotation_start + qs = qs.exclude(public_primary_key=updated_shift_pk) + extra_shifts.append(update_shift) + + ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts) original_value = getattr(self, ical_attr) _invalidate_cache(self, ical_property) From c822fbd2b5adbe2ae82b0e91c893973e5d9d3a09 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 19 Aug 2022 10:35:34 +0300 Subject: [PATCH 38/60] add rotations live preview --- grafana-plugin/src/components/Modal/Modal.tsx | 8 ++-- .../containers/RotationForm/RotationForm.tsx | 9 +++- .../src/containers/Rotations/Rotations.tsx | 44 ++++++++++++++++++- .../containers/Rotations/ScheduleFinal.tsx | 4 +- .../src/models/schedule/schedule.ts | 13 +++--- 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx index 5f6352ce..b26cafaa 100644 --- a/grafana-plugin/src/components/Modal/Modal.tsx +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -1,6 +1,7 @@ import React, { FC, PropsWithChildren } from 'react'; -import ReactModal from 'react-modal'; + import cn from 'classnames/bind'; +import ReactModal from 'react-modal'; import styles from './Modal.module.css'; @@ -13,12 +14,13 @@ export interface ModalProps { onDismiss?: () => void; width: string; contentElement?: (props, children: React.ReactNode) => React.ReactNode; + isOpen: boolean; } const cx = cn.bind(styles); const Modal: FC> = (props) => { - const { title, children, onDismiss, width = '600px', contentElement } = props; + const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props; return ( > = (props) => { width, }, }} - isOpen + isOpen={isOpen} onAfterOpen={() => {}} onRequestClose={onDismiss} contentLabel={title} diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 36a18cf8..df652fe0 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -55,6 +55,8 @@ const startOfDay = dayjs().startOf('day').add(1, 'day'); const RotationForm: FC = observer((props) => { const { onHide, onCreate, startMoment, currentTimezone, scheduleId, onUpdate, layerPriority, shiftId } = props; + const [isOpen, setIsOpen] = useState(true); + const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); const [selectedDays, setSelectedDays] = useState([]); @@ -135,7 +137,11 @@ const RotationForm: FC = observer((props) => { }, [shiftId, params]); const handleChange = useDebouncedCallback(() => { - store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params); + store.scheduleStore + .updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params) + .finally(() => { + setIsOpen(true); + }); }, 1000); useEffect(handleChange, [params]); @@ -175,6 +181,7 @@ const RotationForm: FC = observer((props) => { return ( ( diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 02ea59ec..bae5e108 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -56,15 +56,53 @@ class Rotations extends Component { const storeLayers = store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]; + console.log('store.scheduleStore.rotationPreview', store.scheduleStore.rotationPreview); + let layers = storeLayers; if (store.scheduleStore.rotationPreview) { - layers = [...layers, { priority: 2, shifts: [store.scheduleStore.rotationPreview] }]; + layers = [...layers]; + + const isNew = store.scheduleStore.rotationPreview.shifts[0].shiftId === 'new'; + const priority = store.scheduleStore.rotationPreview.priority; + + let added = false; + layers = layers.reduce((memo, layer, index) => { + if (isNew) { + if (layer.priority === priority) { + const newLayer = { ...layer }; + newLayer.shifts = [...layer.shifts, ...store.scheduleStore.rotationPreview.shifts]; + + memo[index] = newLayer; + + added = true; + } + } else { + const oldShiftIndex = layer.shifts.findIndex( + (shift) => shift.shiftId === store.scheduleStore.rotationPreview.shifts[0].shiftId + ); + if (oldShiftIndex > -1) { + const newLayer = { ...layer }; + newLayer.shifts = [...layer.shifts]; + newLayer.shifts[oldShiftIndex] = store.scheduleStore.rotationPreview.shifts[0]; + + memo[index] = newLayer; + + added = true; + } + } + + return layers; + }, layers); + + if (!added) { + layers.push(store.scheduleStore.rotationPreview); + } } const options = layers ? layers.map((layer) => ({ label: `Layer ${layer.priority}`, - value: layer.priority - 1, + value: layer.priority, })) : []; @@ -180,6 +218,8 @@ class Rotations extends Component { const { store } = this.props; store.scheduleStore.rotationPreview = undefined; + store.scheduleStore.finalPreview = undefined; + this.setState({ shiftIdToShowRotationForm: undefined, layerPriority: undefined }); }; diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 752f4123..b680c2eb 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -43,7 +43,9 @@ class ScheduleFinal extends Component 1; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index f268b4a7..c3b251dd 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -72,10 +72,10 @@ export class ScheduleStore extends BaseStore { }; } = {}; - @observable.shallow - rotationPreview?: { shiftId: Shift['id']; events: Event[] }; + @observable + rotationPreview?: Layer; - @observable.shallow + @observable finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; @observable @@ -210,8 +210,11 @@ export class ScheduleStore extends BaseStore { method: 'POST', }).catch(this.onApiError); - this.rotationPreview = { shiftId: shiftId, events: fillGaps(response.rotation.filter((event) => !event.is_gap)) }; - this.finalPreview = splitToShiftsAndFillGaps(response.final); + this.rotationPreview = { + priority: params.priority_level, + shifts: [{ shiftId: shiftId, events: fillGaps(response.rotation.filter((event) => !event.is_gap)) }], + }; + this.finalPreview = splitToShiftsAndFillGaps(response.final).filter((shift) => shift.shiftId !== shiftId); } async updateRotation(shiftId: Shift['id'], params: Partial) { From af7128c5915ea07054e8c04586e2616169430552 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 19 Aug 2022 13:35:25 +0300 Subject: [PATCH 39/60] fix markup --- .../src/containers/Rotations/Rotations.tsx | 36 +++++++++---------- .../src/models/schedule/schedule.ts | 2 +- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index bae5e108..131ea98e 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -137,25 +137,23 @@ class Rotations extends Component {
- {layer.shifts.map(({ shiftId, events }, rotationIndex) => ( -
- {!currentTimeHidden && ( -
- )} -
- { - this.onRotationClick(shiftId); - }} - events={events} - layerIndex={layer.priority - 1} - rotationIndex={rotationIndex} - startMoment={startMoment} - currentTimezone={currentTimezone} - /> -
-
- ))} + {!currentTimeHidden && ( +
+ )} +
+ {layer.shifts.map(({ shiftId, events }, rotationIndex) => ( + { + this.onRotationClick(shiftId); + }} + events={events} + layerIndex={layer.priority - 1} + rotationIndex={rotationIndex} + startMoment={startMoment} + currentTimezone={currentTimezone} + /> + ))} +
diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index c3b251dd..e73359ab 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -206,7 +206,7 @@ export class ScheduleStore extends BaseStore { const response = await makeRequest(`/oncall_shifts/preview/`, { params: { date: fromString }, - data: { type, schedule: scheduleId, ...params }, + data: { type, schedule: scheduleId, shift_pk: shiftId === 'new' ? undefined : shiftId, ...params }, method: 'POST', }).catch(this.onApiError); From 874be7fc9008593808fe40a2413bfeba034f81ff Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 19 Aug 2022 15:12:22 +0300 Subject: [PATCH 40/60] add color coding --- .../ScheduleSlot/ScheduleSlot.helpers.ts | 41 ------- .../ScheduleSlot/ScheduleSlot.module.css | 2 +- .../components/ScheduleSlot/ScheduleSlot.tsx | 3 +- .../containers/RotationForm/RotationForm.tsx | 2 +- .../src/containers/Rotations/Rotations.tsx | 56 ++------- .../containers/Rotations/ScheduleFinal.tsx | 35 +++++- .../Rotations/ScheduleOverrides.tsx | 9 +- .../src/models/schedule/schedule.helpers.ts | 115 +++++++++++++++++- .../src/models/schedule/schedule.ts | 50 +++----- .../src/models/schedule/schedule.types.ts | 8 +- 10 files changed, 175 insertions(+), 146 deletions(-) diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts index 3cc508f3..5afee5c7 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts @@ -3,47 +3,6 @@ import dayjs from 'dayjs'; import { Shift } from 'models/schedule/schedule.types'; import { User } from 'models/user/user.types'; -export const getRandomTimeslots = (count = 6, layerIndex, rotationIndex) => { - 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()*/], - color: getColor(layerIndex, rotationIndex), - }); - } - 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']; - -export const getOverrideColor = (index: number) => { - return OVERRIDE_COLORS[index]; -}; - -const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS, OVERRIDE_COLORS]; - -export const getColor = (layerIndex: number, rotationIndex: number) => { - return COLORS[layerIndex]?.[rotationIndex]; -}; - const USERS = [ 'Innokentii Konstantinov', 'Ildar Iskhakov', diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 779b263f..99f592d1 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -1,6 +1,6 @@ .root { height: 28px; - background: #3274d9; + background: #595959; border-radius: 2px; position: relative; display: flex; diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index 787d7fa5..d799c53e 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -30,7 +30,7 @@ interface ScheduleSlotProps { const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color: propColor } = props; + const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color } = props; const { users } = event; const trackMouse = false; @@ -62,7 +62,6 @@ const ScheduleSlot: FC = observer((props) => { const inactive = false; - const color = propColor || getColor(layerIndex, rotationIndex); const title = getTitle(storeUser); return ( diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index df652fe0..daa8d552 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -195,7 +195,7 @@ const RotationForm: FC = observer((props) => { [L{shiftId === 'new' ? layerPriority : shift?.priority_level}] - {shiftId === 'new' ? 'New Rotation' : shift?.title} + {shiftId === 'new' ? 'New Rotation' : shift?.id} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 131ea98e..6fafbf52 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -11,14 +11,12 @@ 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 { getFromString } from 'models/schedule/schedule.helpers'; +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 { getColor, getLabel, getRandomTimeslots, getRandomUser } from './Rotations.helpers'; - import styles from './Rotations.module.css'; const cx = cn.bind(styles); @@ -54,50 +52,9 @@ class Rotations extends Component { const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; - const storeLayers = store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]; - - console.log('store.scheduleStore.rotationPreview', store.scheduleStore.rotationPreview); - - let layers = storeLayers; - if (store.scheduleStore.rotationPreview) { - layers = [...layers]; - - const isNew = store.scheduleStore.rotationPreview.shifts[0].shiftId === 'new'; - const priority = store.scheduleStore.rotationPreview.priority; - - let added = false; - layers = layers.reduce((memo, layer, index) => { - if (isNew) { - if (layer.priority === priority) { - const newLayer = { ...layer }; - newLayer.shifts = [...layer.shifts, ...store.scheduleStore.rotationPreview.shifts]; - - memo[index] = newLayer; - - added = true; - } - } else { - const oldShiftIndex = layer.shifts.findIndex( - (shift) => shift.shiftId === store.scheduleStore.rotationPreview.shifts[0].shiftId - ); - if (oldShiftIndex > -1) { - const newLayer = { ...layer }; - newLayer.shifts = [...layer.shifts]; - newLayer.shifts[oldShiftIndex] = store.scheduleStore.rotationPreview.shifts[0]; - - memo[index] = newLayer; - - added = true; - } - } - - return layers; - }, layers); - - if (!added) { - layers.push(store.scheduleStore.rotationPreview); - } - } + const layers = store.scheduleStore.rotationPreview + ? store.scheduleStore.rotationPreview + : (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]); const options = layers ? layers.map((layer) => ({ @@ -127,7 +84,7 @@ class Rotations extends Component {
{layers && layers.length ? ( - layers.map((layer) => ( + layers.map((layer, layerIndex) => (
@@ -146,8 +103,9 @@ class Rotations extends Component { onClick={() => { this.onRotationClick(shiftId); }} + color={getColor(layerIndex, rotationIndex)} events={events} - layerIndex={layer.priority - 1} + layerIndex={layerIndex} rotationIndex={rotationIndex} startMoment={startMoment} currentTimezone={currentTimezone} diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index b680c2eb..1df5c54f 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -3,12 +3,13 @@ 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 TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; -import { getFromString } from 'models/schedule/schedule.helpers'; -import { Schedule } from 'models/schedule/schedule.types'; +import { getColor, getFromString } 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'; @@ -47,8 +48,15 @@ class ScheduleFinal extends Component 1; + console.log(toJS(shifts)); + console.log(toJS(layers)); + return ( <>
@@ -70,9 +78,26 @@ class ScheduleFinal extends Component
{shifts && shifts.length ? ( - shifts.map(({ shiftId, events }, index) => ( - - )) + shifts.map(({ shiftId, events }, index) => { + const layerIndex = layers + ? layers.findIndex((layer) => layer.shifts.some((shift) => shift.shiftId === shiftId)) + : -1; + + const rotationIndex = + layerIndex > -1 ? layers[layerIndex].shifts.findIndex((shift) => shift.shiftId === shiftId) : -1; + + console.log(layerIndex, rotationIndex); + + return ( + + ); + }) ) : ( )} diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 37ce474c..16456048 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -9,7 +9,7 @@ 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 } from 'models/schedule/schedule.helpers'; +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'; @@ -66,11 +66,11 @@ class ScheduleOverrides extends Component
{shifts && shifts.length ? ( - shifts.map(({ shiftId, events }, index) => ( + shifts.map(({ shiftId, events }, rotationIndex) => ( { @@ -81,7 +81,6 @@ class ScheduleOverrides extends Component { diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 963a4057..54df9f54 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -1,6 +1,6 @@ import dayjs from 'dayjs'; -import { Event, Shift } from './schedule.types'; +import { Event, Layer, ScheduleType, Shift } from './schedule.types'; export const getFromString = (moment: dayjs.Dayjs) => { return moment.format('YYYY-MM-DD'); @@ -16,7 +16,19 @@ export const fillGaps = (events: Event[]) => { if (nextEvent) { if (nextEvent.start !== event.end) { - newEvents.push({ start: event.end, end: nextEvent.start, is_gap: true }); + 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', + }); } } } @@ -25,13 +37,13 @@ export const fillGaps = (events: Event[]) => { }; export const splitToShiftsAndFillGaps = (events: Event[]) => { - const shifts: Array<{ shiftId: Shift['id']; 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, events: [] }; + shift = { shiftId: event.shift.pk, priority: event.priority_level, events: [] }; shifts.push(shift); } shift.events.push(event); @@ -44,3 +56,98 @@ export const splitToShiftsAndFillGaps = (events: Event[]) => { 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'] +) => { + const updatingLayer = { + priority, + shifts: [{ shiftId: shiftId, events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)) }], + }; + + const isNew = updatingLayer.shifts[0].shiftId === 'new'; + + let added = false; + layers = layers.reduce((memo, layer, index) => { + if (isNew) { + 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; +}; + +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, OVERRIDE_COLORS]; + +export const getColor = (layerIndex: number, rotationIndex: number) => { + const normalizedLayerIndex = layerIndex % COLORS.length; + const normalizedRotationIndex = rotationIndex % COLORS[normalizedLayerIndex]?.length; + + return COLORS[normalizedLayerIndex]?.[normalizedRotationIndex]; +}; + +export const getOverrideColor = (rotationIndex: number) => { + const normalizedRotationIndex = rotationIndex % OVERRIDE_COLORS.length; + return OVERRIDE_COLORS[normalizedRotationIndex]; +}; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index e73359ab..b625f5cd 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -11,7 +11,7 @@ import { makeRequest } from 'network'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { fillGaps, splitToShiftsAndFillGaps } from './schedule.helpers'; +import { enrichLayers, 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'; @@ -73,7 +73,7 @@ export class ScheduleStore extends BaseStore { } = {}; @observable - rotationPreview?: Layer; + rotationPreview?: Layer[]; @observable finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; @@ -202,19 +202,25 @@ export class ScheduleStore extends BaseStore { ) { const type = isOverride ? 3 : 2; - const typeString = isOverride ? 'override' : 'rotation'; - 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); - this.rotationPreview = { - priority: params.priority_level, - shifts: [{ shiftId: shiftId, events: fillGaps(response.rotation.filter((event) => !event.is_gap)) }], - }; - this.finalPreview = splitToShiftsAndFillGaps(response.final).filter((shift) => shift.shiftId !== shiftId); + if (isOverride) { + } 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);*/ } async updateRotation(shiftId: Shift['id'], params: Partial) { @@ -345,31 +351,7 @@ export class ScheduleStore extends BaseStore { } });*/ - const layers: Layer[] | undefined = - type === 'rotation' - ? shifts - .reduce((memo, shift) => { - const storeShift = this.shifts[shift.shiftId]; - let layer = memo.find((level) => level.priority === storeShift.priority_level); - if (!layer) { - layer = { priority: storeShift.priority_level, 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; - }) - : undefined; + const layers = type === 'rotation' ? splitToLayers(shifts) : undefined; this.events = { ...this.events, diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 6bb2af11..572a8097 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -71,16 +71,16 @@ export type RotationType = 'final' | 'rotation' | 'override'; export interface Event { all_day: boolean; - calendar_type: 0; + calendar_type: ScheduleType; end: string; is_empty: boolean; is_gap: boolean; - missing_users: []; + missing_users: Array<{ display_name: User['username']; pk: User['pk'] }>; priority_level: number; - shift: { pk: string }; + shift: { pk: Shift['id'] | null }; source: string; start: string; - users: [{ display_name: User['username']; pk: User['pk'] }]; + users: Array<{ display_name: User['username']; pk: User['pk'] }>; } export interface Events { From 18fd3a4f71e292a3da4def7cc5565d20e3fadb2f Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 19 Aug 2022 15:45:52 -0300 Subject: [PATCH 41/60] Update shift preview to reuse shift PK when previewing update --- engine/apps/api/tests/test_oncall_shift.py | 20 ++++++++++++++++--- engine/apps/api/views/on_call_shifts.py | 3 +++ .../apps/schedules/models/on_call_schedule.py | 6 ++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index b31aa598..ff752555 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1467,9 +1467,25 @@ def test_on_call_shift_preview_update( # check rotation events rotation_events = response.json()["rotation"] + # previewing an update reuses shift PK, so rotation keeps original event too expected_rotation_events = [ { "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "shift": {"pk": on_call_shift.public_primary_key}, + "start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%SZ"), + "end": (on_call_shift.start + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 1, + "missing_users": [], + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "source": "api", + }, + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "shift": {"pk": on_call_shift.public_primary_key}, "start": shift_start, "end": shift_end, "all_day": False, @@ -1480,10 +1496,8 @@ def test_on_call_shift_preview_update( "missing_users": [], "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], "source": "web", - } + }, ] - # there isn't a saved shift, we don't care/know the temp pk - _ = [r.pop("shift") for r in rotation_events] assert rotation_events == expected_rotation_events # check final schedule events diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index 42c5eda2..9ca87d2f 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -90,6 +90,9 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet validated_data = serializer._correct_validated_data( serializer.validated_data["type"], serializer.validated_data ) + if not validated_data.get("rolling_users"): + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) + updated_shift_pk = self.request.data.get("shift_pk") shift = CustomOnCallShift(**validated_data) schedule = shift.schedule diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index b89a8815..264b351d 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -631,9 +631,11 @@ class OnCallScheduleWeb(OnCallSchedule): except CustomOnCallShift.DoesNotExist: pass else: - update_shift.until = custom_shift.rotation_start + if update_shift.event_is_started: + update_shift.until = custom_shift.rotation_start + extra_shifts.append(update_shift) + custom_shift.public_primary_key = updated_shift_pk qs = qs.exclude(public_primary_key=updated_shift_pk) - extra_shifts.append(update_shift) ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts) From 97f135b69d712483ac146e13f1ee8fe9e56029c7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 22 Aug 2022 12:57:54 +0300 Subject: [PATCH 42/60] minor changes --- grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx | 4 ++-- grafana-plugin/src/models/schedule/schedule.helpers.ts | 5 +++++ grafana-plugin/src/models/schedule/schedule.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 1df5c54f..3b4373b4 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -54,8 +54,8 @@ class ScheduleFinal extends Component 1; - console.log(toJS(shifts)); - console.log(toJS(layers)); + /* console.log('shifts', toJS(shifts)); + console.log('layers', toJS(layers)); */ return ( <> diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 54df9f54..fda67137 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -89,6 +89,11 @@ export const enrichLayers = ( shiftId: Shift['id'] | 'new', priority: Shift['priority_level'] ) => { + /*const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftId = event.shift.pk; + }*/ + const updatingLayer = { priority, shifts: [{ shiftId: shiftId, events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)) }], diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index b625f5cd..87e6fcb4 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -220,7 +220,7 @@ export class ScheduleStore extends BaseStore { this.rotationPreview = layers; } - this.finalPreview = splitToShiftsAndFillGaps(response.final); /*.filter((shift) => shift.shiftId !== shiftId);*/ + this.finalPreview = splitToShiftsAndFillGaps(response.final).filter((shift) => shift.shiftId !== shiftId); } async updateRotation(shiftId: Shift['id'], params: Partial) { From 08f2cade46f85daf8832c1149f4126ab9c5eb6f0 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 22 Aug 2022 08:53:30 -0300 Subject: [PATCH 43/60] Fix issue post-refactoring --- engine/apps/schedules/models/on_call_schedule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 264b351d..de3394e1 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -318,6 +318,7 @@ class OnCallSchedule(PolymorphicModel): # if the expected slot was already scheduled for a higher priority event, # split the event, or fix start/end timestamps accordingly + intervals = [] resolved = [] pending = events current_interval_idx = 0 # current scheduled interval being checked From 9858054841a013db20dd3be534c60582b129d141 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Aug 2022 12:21:54 +0300 Subject: [PATCH 44/60] add override preview --- .../containers/RotationForm/RotationForm.tsx | 23 +++----- .../RotationForm/ScheduleOverrideForm.tsx | 45 ++++++++++------ .../src/containers/Rotations/Rotations.tsx | 31 ++++++++--- .../containers/Rotations/ScheduleFinal.tsx | 19 +++++-- .../Rotations/ScheduleOverrides.tsx | 35 ++++++++++-- .../src/models/schedule/schedule.helpers.ts | 35 ++++++++++-- .../src/models/schedule/schedule.ts | 28 ++++++++-- .../src/pages/schedule/Schedule.tsx | 54 ++++++++++++++++--- 8 files changed, 207 insertions(+), 63 deletions(-) diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index daa8d552..15aa59ea 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -46,6 +46,7 @@ interface RotationFormProps { shiftId: Shift['id'] | 'new'; onCreate: () => void; onUpdate: () => void; + onDelete: () => void; } const cx = cn.bind(styles); @@ -53,7 +54,8 @@ const cx = cn.bind(styles); const startOfDay = dayjs().startOf('day').add(1, 'day'); const RotationForm: FC = observer((props) => { - const { onHide, onCreate, startMoment, currentTimezone, scheduleId, onUpdate, layerPriority, shiftId } = props; + const { onHide, onCreate, startMoment, currentTimezone, scheduleId, onUpdate, onDelete, layerPriority, shiftId } = + props; const [isOpen, setIsOpen] = useState(true); @@ -79,8 +81,7 @@ const RotationForm: FC = observer((props) => { const handleDeleteClick = useCallback(() => { store.scheduleStore.deleteOncallShift(shiftId).then(() => { - onHide(); - onUpdate(); + onDelete(); }); }, []); @@ -100,7 +101,7 @@ const RotationForm: FC = observer((props) => { until: endLess ? null : getUTCString(rotationEnd, currentTimezone), shift_start: getUTCString(shiftStart, currentTimezone), shift_end: getUTCString(shiftEnd, currentTimezone), - rolling_users: userGroups.filter((group) => group.length), + rolling_users: userGroups, interval: repeatEveryValue, frequency: repeatEveryPeriod, by_day: repeatEveryPeriod === 1 ? selectedDays : null, @@ -125,23 +126,17 @@ const RotationForm: FC = observer((props) => { const handleCreate = useCallback(() => { if (shiftId === 'new') { store.scheduleStore.createRotation(scheduleId, false, params).then(() => { - onHide(); onCreate(); }); } else { store.scheduleStore.updateRotation(shiftId, params).then(() => { - onHide(); onUpdate(); }); } - }, [shiftId, params]); + }, [scheduleId, shiftId, params]); const handleChange = useDebouncedCallback(() => { - store.scheduleStore - .updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params) - .finally(() => { - setIsOpen(true); - }); + store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params); }, 1000); useEffect(handleChange, [params]); @@ -173,10 +168,6 @@ const RotationForm: FC = observer((props) => { setRepeatEveryValue(option.value); }, []); - const handleRepeatEveryPeriodChange = useCallback((option) => { - setRepeatEveryPeriod(option.value); - }, []); - const moment = dayjs(); return ( diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index f0803672..62204844 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { dateTime, DateTime } from '@grafana/data'; import { @@ -20,12 +20,14 @@ 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 { 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 { useDebouncedCallback } from 'utils/hooks'; import { RotationCreateData } from './RotationForm.types'; @@ -34,18 +36,20 @@ import styles from './RotationForm.module.css'; interface RotationFormProps { onHide: () => void; shiftId: Shift['id'] | 'new'; + startMoment: dayjs.Dayjs; currentTimezone: Timezone; scheduleId: Schedule['id']; onCreate: () => void; onUpdate: () => void; + onDelete: () => void; } const cx = cn.bind(styles); -const startOfDay = dayjs().startOf('day'); +const startOfDay = dayjs().startOf('day').add(1, 'day'); const ScheduleOverrideForm: FC = (props) => { - const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, shiftId } = props; + const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, onDelete, shiftId, startMoment } = props; const store = useStore(); @@ -71,6 +75,17 @@ const ScheduleOverrideForm: FC = (props) => { } }, [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)); @@ -83,32 +98,28 @@ const ScheduleOverrideForm: FC = (props) => { const handleDeleteClick = useCallback(() => { store.scheduleStore.deleteOncallShift(shiftId).then(() => { onHide(); - onUpdate(); + + onDelete(); }); }, []); const handleCreate = useCallback(() => { - const params = { - title: 'Override ' + Math.floor(Math.random() * 100), - rotation_start: getUTCString(shiftStart, currentTimezone), - shift_start: getUTCString(shiftStart, currentTimezone), - shift_end: getUTCString(shiftEnd, currentTimezone), - rolling_users: userGroups, - frequency: null, - }; - if (shiftId === 'new') { store.scheduleStore.createRotation(scheduleId, true, params).then(() => { - onHide(); onCreate(); }); } else { store.scheduleStore.updateRotation(shiftId, params).then(() => { - onHide(); onUpdate(); }); } - }, [shiftStart, shiftEnd, userGroups]); + }, [scheduleId, shiftId, params]); + + const handleChange = useDebouncedCallback(() => { + store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params); + }, 1000); + + useEffect(handleChange, [params]); return ( = (props) => { > - {shiftId === 'new' ? 'New Override' : shift?.title} + {shiftId === 'new' ? 'New Override' : shift?.id} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 6fafbf52..d0cb61c5 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -28,6 +28,7 @@ interface RotationsProps extends WithStoreProps { onClick: (id: Shift['id'] | 'new') => void; onCreate: () => void; onUpdate: () => void; + onDelete: () => void; } interface RotationsState { @@ -42,7 +43,7 @@ class Rotations extends Component { }; render() { - const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, store, onClick } = this.props; + const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, onClick } = this.props; const { shiftIdToShowRotationForm, layerPriority } = this.state; const base = 7 * 24 * 60; // in minutes @@ -161,21 +162,35 @@ class Rotations extends Component { layerPriority={layerPriority} startMoment={startMoment} currentTimezone={currentTimezone} - onHide={this.handleRotationFormHide} - onUpdate={onUpdate} - onCreate={onCreate} + onHide={() => { + this.hideRotationForm(); + + store.scheduleStore.clearPreview(); + }} + onUpdate={() => { + this.hideRotationForm(); + + onUpdate(); + }} + onCreate={() => { + this.hideRotationForm(); + + onCreate(); + }} + onDelete={() => { + this.hideRotationForm(); + + onDelete(); + }} /> )} ); } - handleRotationFormHide = () => { + hideRotationForm = () => { const { store } = this.props; - store.scheduleStore.rotationPreview = undefined; - store.scheduleStore.finalPreview = undefined; - this.setState({ shiftIdToShowRotationForm: undefined, layerPriority: undefined }); }; diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 3b4373b4..5a07e1b8 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -8,7 +8,7 @@ import { observer } from 'mobx-react'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; -import { getColor, getFromString } from 'models/schedule/schedule.helpers'; +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'; @@ -52,6 +52,9 @@ class ScheduleFinal extends Component 1; /* console.log('shifts', toJS(shifts)); @@ -79,6 +82,8 @@ class ScheduleFinal extends Component {shifts && shifts.length ? ( shifts.map(({ shiftId, events }, index) => { + let color = undefined; + const layerIndex = layers ? layers.findIndex((layer) => layer.shifts.some((shift) => shift.shiftId === shiftId)) : -1; @@ -86,7 +91,15 @@ class ScheduleFinal extends Component -1 ? layers[layerIndex].shifts.findIndex((shift) => shift.shiftId === shiftId) : -1; - console.log(layerIndex, rotationIndex); + if (layerIndex > -1 && rotationIndex > -1) { + color = getColor(layerIndex, rotationIndex); + } else { + const overrideIndex = overrides ? overrides.findIndex((shift) => shift.shiftId === shiftId) : -1; + + if (overrideIndex > -1) { + color = getOverrideColor(overrideIndex); + } + } return ( ); }) diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 16456048..4eb8aa33 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -25,6 +25,7 @@ interface ScheduleOverridesProps extends WithStoreProps { scheduleId: Schedule['id']; onCreate: () => void; onUpdate: () => void; + onDelete: () => void; } interface ScheduleOverridesState { @@ -38,10 +39,12 @@ class ScheduleOverrides extends Component { - this.setState({ shiftIdToShowOverrideForm: undefined }); + this.handleHide(); + + store.scheduleStore.clearPreview(); + }} + onUpdate={() => { + this.handleHide(); + + onUpdate(); + }} + onCreate={() => { + this.handleHide(); + + onCreate(); + }} + onDelete={() => { + this.handleHide(); + + onDelete(); }} - onUpdate={onUpdate} - onCreate={onCreate} /> )} @@ -117,6 +136,12 @@ class ScheduleOverrides extends Component { this.setState({ shiftIdToShowOverrideForm: 'new' }); }; + + handleHide = () => { + const { store } = this.props; + + this.setState({ shiftIdToShowOverrideForm: undefined }); + }; } export default withMobXProviderContext(ScheduleOverrides); diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index fda67137..9d2ec980 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -89,10 +89,12 @@ export const enrichLayers = ( shiftId: Shift['id'] | 'new', priority: Shift['priority_level'] ) => { - /*const event = newEvents.find((event) => !event.is_gap); - if (event) { - shiftId = event.shift.pk; - }*/ + if (shiftId === 'new') { + const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftId = event.shift.pk; + } + } const updatingLayer = { priority, @@ -135,6 +137,31 @@ export const enrichLayers = ( return layers; }; +export const enrichOverrides = ( + overrides: Array<{ shiftId: Shift['id']; events: Event[] }>, + newEvents: Event[], + shiftId: Shift['id'] +) => { + if (shiftId === 'new') { + const event = newEvents.find((event) => !event.is_gap); + if (event) { + shiftId = event.shift.pk; + } + } + + const newShift = { shiftId, 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']; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 87e6fcb4..0b032e28 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -11,7 +11,14 @@ import { makeRequest } from 'network'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { enrichLayers, fillGaps, getFromString, splitToLayers, splitToShiftsAndFillGaps } from './schedule.helpers'; +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'; @@ -72,11 +79,14 @@ export class ScheduleStore extends BaseStore { }; } = {}; + @observable + finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; + @observable rotationPreview?: Layer[]; @observable - finalPreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; + overridePreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; @observable scheduleToScheduleEvents: { @@ -209,6 +219,11 @@ export class ScheduleStore extends BaseStore { }).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[])], @@ -220,7 +235,14 @@ export class ScheduleStore extends BaseStore { this.rotationPreview = layers; } - this.finalPreview = splitToShiftsAndFillGaps(response.final).filter((shift) => shift.shiftId !== shiftId); + 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) { diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index e9d017b3..5541feff 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -190,14 +190,16 @@ class SchedulePage extends React.Component currentTimezone={currentTimezone} startMoment={startMoment} onCreate={this.handleCreateRotation} - onUpdate={this.updateEvents} + onUpdate={this.handleUpdateRotation} + onDelete={this.handleDeleteRotation} />
@@ -213,9 +215,11 @@ class SchedulePage extends React.Component const { startMoment } = this.state; - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'rotation'); - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'override'); - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final'); + return Promise.all([ + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'rotation'), + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'override'), + store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final'), + ]); }; handleCreateRotation = () => { @@ -224,13 +228,49 @@ class SchedulePage extends React.Component query: { id: scheduleId }, } = this.props; - this.updateEvents(); + this.updateEvents().then(() => { + store.scheduleStore.clearPreview(); + }); }; handleCreateOverride = () => { const { store } = this.props; - this.updateEvents(); + 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) => { From 751ee980e04e1bdab1351f40ad46ca23e5127e08 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Aug 2022 16:36:20 +0300 Subject: [PATCH 45/60] add rotation form positionning --- grafana-plugin/src/components/Modal/Modal.tsx | 2 + .../containers/Rotation/Rotation.module.css | 5 ++- .../RotationForm/RotationForm.module.css | 5 +++ .../containers/RotationForm/RotationForm.tsx | 31 ++++++++++----- .../RotationForm/ScheduleOverrideForm.tsx | 18 ++++++++- .../src/containers/Rotations/Rotations.tsx | 12 +++--- .../Rotations/ScheduleOverrides.tsx | 8 ++-- grafana-plugin/src/utils/DOM.ts | 38 +++++++++++++++++++ 8 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 grafana-plugin/src/utils/DOM.ts diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx index b26cafaa..4cff1595 100644 --- a/grafana-plugin/src/components/Modal/Modal.tsx +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -3,6 +3,8 @@ 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 { diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index a8edd3bf..be9dadd3 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -62,8 +62,9 @@ .empty { height: 28px; - background: #5f505633; + + /* background: #5f505633; border: 1px dashed #5c474d; - color: rgba(209, 14, 92, 0.5); + color: rgba(209, 14, 92, 0.5); */ margin: 0 2px; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css index 1928ede4..719e0b69 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css @@ -2,6 +2,11 @@ display: block; } +.draggable { + top: 0; + transition: transform 500ms ease; +} + .header { width: 100%; display: flex; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 15aa59ea..853c8f4e 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -31,6 +31,7 @@ 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'; @@ -57,8 +58,6 @@ const RotationForm: FC = observer((props) => { const { onHide, onCreate, startMoment, currentTimezone, scheduleId, onUpdate, onDelete, layerPriority, shiftId } = props; - const [isOpen, setIsOpen] = useState(true); - const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); const [selectedDays, setSelectedDays] = useState([]); @@ -70,6 +69,24 @@ const RotationForm: FC = observer((props) => { dateTime(startOfDay.add(1, 'month').format('YYYY-MM-DD HH:mm:ss')) ); + const store = useStore(); + + const shift = store.scheduleStore.shifts[shiftId]; + + const [offsetTop, setOffsetTop] = useState(0); + + useEffect(() => { + waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`).then((elm) => { + const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; + + const coords = getCoords(elm); + + // setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0)); + + setOffsetTop(coords.top - modal?.offsetHeight - 70); + }); + }, []); + const [userGroups, setUserGroups] = useState([[]]); const getUser = (pk: User['pk']) => { @@ -85,10 +102,6 @@ const RotationForm: FC = observer((props) => { }); }, []); - const store = useStore(); - - const shift = store.scheduleStore.shifts[shiftId]; - useEffect(() => { if (shiftId !== 'new') { store.scheduleStore.updateOncallShift(shiftId); @@ -137,7 +150,7 @@ const RotationForm: FC = observer((props) => { const handleChange = useDebouncedCallback(() => { store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params); - }, 1000); + }, 500); useEffect(handleChange, [params]); @@ -172,11 +185,11 @@ const RotationForm: FC = observer((props) => { return ( ( - +
{children}
)} diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 62204844..6c83d0b4 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -27,6 +27,7 @@ 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'; @@ -53,6 +54,18 @@ const ScheduleOverrideForm: FC = (props) => { const store = useStore(); + const [offsetTop, setOffsetTop] = useState(0); + + useEffect(() => { + waitForElement('#overrides-list').then((elm) => { + const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; + + const coords = getCoords(elm); + + setOffsetTop(coords.top - modal?.offsetHeight - 10); + }); + }, []); + const [shiftStart, setShiftStart] = useState(dateTime(startOfDay.format('YYYY-MM-DD HH:mm:ss'))); const [shiftEnd, setShiftEnd] = useState( dateTime(startOfDay.add(12, 'hours').format('YYYY-MM-DD HH:mm:ss')) @@ -117,16 +130,17 @@ const ScheduleOverrideForm: FC = (props) => { const handleChange = useDebouncedCallback(() => { store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params); - }, 1000); + }, 500); useEffect(handleChange, [params]); return ( ( - +
{children}
)} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index d0cb61c5..dce39f50 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -87,10 +87,11 @@ class Rotations extends Component { {layers && layers.length ? ( layers.map((layer, layerIndex) => (
-
+
- Layer {layer.priority} + Layer {layer.priority} +
@@ -119,10 +120,11 @@ class Rotations extends Component { )) ) : (
-
+
- Layer 1 + Layer 1 +
@@ -151,7 +153,7 @@ class Rotations extends Component { this.handleAddLayer(nextPriority); }} > - Add rotations layer + + + Add rotations layer
diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 4eb8aa33..bfbf9df9 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -55,7 +55,7 @@ class ScheduleOverrides extends Component -
+
Overrides
@@ -93,9 +93,9 @@ class ScheduleOverrides extends Component
-
- Add override + -
+ {/*
+ + Add override +
*/}
{shiftIdToShowOverrideForm && ( { + 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) }; +}; From 4f6b69b89ba1a5a0f80f688277819b9d6c94a6f8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 24 Aug 2022 13:19:33 +0300 Subject: [PATCH 46/60] add preview transparency --- .../containers/Rotation/Rotation.module.css | 2 +- .../src/containers/Rotation/Rotation.tsx | 36 ++++++++-- .../containers/RotationForm/RotationForm.tsx | 65 +++++++++++++------ .../RotationForm/ScheduleOverrideForm.tsx | 56 +++++++++++----- .../containers/Rotations/Rotations.helpers.ts | 22 +++++++ .../src/containers/Rotations/Rotations.tsx | 59 ++++++++++------- .../containers/Rotations/ScheduleFinal.tsx | 23 +------ .../Rotations/ScheduleOverrides.tsx | 25 +++---- .../src/models/schedule/schedule.helpers.ts | 6 +- .../src/models/schedule/schedule.ts | 2 +- .../src/models/schedule/schedule.types.ts | 2 +- 11 files changed, 196 insertions(+), 102 deletions(-) diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index be9dadd3..b7690181 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -47,7 +47,7 @@ } .slots__transparent { - opacity: 0; + opacity: 0.5; } .current-time { diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index b1aa6e0c..e4c2b1a5 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -24,15 +24,26 @@ interface RotationProps { rotationIndex?: number; color?: string; events: Event[]; - onClick: () => void; + onClick: (moment: dayjs.Dayjs) => void; + days?: number; + transparent?: boolean; } const Rotation: FC = (props) => { - const { events, layerIndex, rotationIndex, startMoment, currentTimezone, color, onClick } = props; + const { + events, + layerIndex, + rotationIndex, + startMoment, + currentTimezone, + color, + onClick, + days = 7, + transparent = false, + } = props; const [animate, setAnimate] = useState(true); const [width, setWidth] = useState(); - const [transparent, setTransparent] = useState(false); const startMomentString = useMemo(() => getFromString(startMoment), [startMoment]); @@ -64,6 +75,21 @@ const Rotation: FC = (props) => { } }, []); + 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); + + console.log('event.offsetX', event.offsetX); + console.log('event.nativeEvent', event.nativeEvent); + console.log('event.currentTarget', event.currentTarget); + console.log('dayOffset', dayOffset); + + onClick(startMoment.add(dayOffset, 'day')); + }; + const x = useMemo(() => { if (!events || !events.length) { return 0; @@ -73,14 +99,14 @@ const Rotation: FC = (props) => { const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'seconds'); - const base = 60 * 60 * 24 * 7; // in minutes only + const base = 60 * 60 * 24 * days; // const utcOffset = dayjs().tz(currentTimezone).utcOffset(); return firstShiftOffset / base; }, [events]); return ( -
+
{events ? ( events.length ? ( diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 853c8f4e..5fe37915 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -45,6 +45,7 @@ interface RotationFormProps { currentTimezone: Timezone; scheduleId: Schedule['id']; shiftId: Shift['id'] | 'new'; + shiftMoment?: dayjs.Dayjs; onCreate: () => void; onUpdate: () => void; onDelete: () => void; @@ -52,21 +53,31 @@ interface RotationFormProps { const cx = cn.bind(styles); -const startOfDay = dayjs().startOf('day').add(1, 'day'); - const RotationForm: FC = observer((props) => { - const { onHide, onCreate, startMoment, currentTimezone, scheduleId, onUpdate, onDelete, layerPriority, shiftId } = - props; + const { + onHide, + onCreate, + startMoment, + currentTimezone, + scheduleId, + onUpdate, + onDelete, + layerPriority, + shiftId, + shiftMoment = dayjs().startOf('day').add(1, 'day'), + } = props; + + const [isOpen, setIsOpen] = useState(false); const [repeatEveryValue, setRepeatEveryValue] = useState(1); const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); const [selectedDays, setSelectedDays] = useState([]); - const [shiftStart, setShiftStart] = useState(dateTime(startOfDay.format('YYYY-MM-DD HH:mm:ss'))); - const [shiftEnd, setShiftEnd] = useState(dateTime(startOfDay.add(1, 'day').format('YYYY-MM-DD HH:mm:ss'))); - const [rotationStart, setRotationStart] = useState(dateTime(startOfDay.format('YYYY-MM-DD HH:mm:ss'))); + const [shiftStart, setShiftStart] = useState(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss'))); + const [shiftEnd, setShiftEnd] = useState(dateTime(shiftMoment.add(1, 'day').format('YYYY-MM-DD HH:mm:ss'))); + const [rotationStart, setRotationStart] = useState(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss'))); const [endLess, setEndless] = useState(true); const [rotationEnd, setRotationEnd] = useState( - dateTime(startOfDay.add(1, 'month').format('YYYY-MM-DD HH:mm:ss')) + dateTime(shiftMoment.add(1, 'month').format('YYYY-MM-DD HH:mm:ss')) ); const store = useStore(); @@ -76,18 +87,20 @@ const RotationForm: FC = observer((props) => { const [offsetTop, setOffsetTop] = useState(0); useEffect(() => { - waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`).then((elm) => { - const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; + if (isOpen) { + waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`).then((elm) => { + const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; - const coords = getCoords(elm); + const coords = getCoords(elm); - // setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0)); + // setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0)); - setOffsetTop(coords.top - modal?.offsetHeight - 70); - }); - }, []); + setOffsetTop(coords.top - modal?.offsetHeight - 10); + }); + } + }, [isOpen]); - const [userGroups, setUserGroups] = useState([[]]); + const [userGroups, setUserGroups] = useState(shiftId === 'new' ? [[store.userStore.currentUserPk]] : [[]]); const getUser = (pk: User['pk']) => { return { @@ -148,9 +161,21 @@ const RotationForm: FC = observer((props) => { } }, [scheduleId, shiftId, params]); - const handleChange = useDebouncedCallback(() => { - store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params); - }, 500); + 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]); @@ -185,7 +210,7 @@ const RotationForm: FC = observer((props) => { return ( ( diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 6c83d0b4..5d4b87c5 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -47,31 +47,43 @@ interface RotationFormProps { const cx = cn.bind(styles); -const startOfDay = dayjs().startOf('day').add(1, 'day'); - const ScheduleOverrideForm: FC = (props) => { - const { onHide, onCreate, currentTimezone, scheduleId, onUpdate, onDelete, shiftId, startMoment } = props; + const { + onHide, + onCreate, + currentTimezone, + scheduleId, + onUpdate, + onDelete, + shiftId, + startMoment, + shiftMoment = dayjs().startOf('day').add(1, 'day'), + } = props; const store = useStore(); const [offsetTop, setOffsetTop] = useState(0); + const [isOpen, setIsOpen] = useState(false); + useEffect(() => { - waitForElement('#overrides-list').then((elm) => { - const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; + if (isOpen) { + waitForElement('#overrides-list').then((elm) => { + const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; - const coords = getCoords(elm); + const coords = getCoords(elm); - setOffsetTop(coords.top - modal?.offsetHeight - 10); - }); - }, []); + setOffsetTop(coords.top - modal?.offsetHeight - 10); + }); + } + }, [isOpen]); - const [shiftStart, setShiftStart] = useState(dateTime(startOfDay.format('YYYY-MM-DD HH:mm:ss'))); + const [shiftStart, setShiftStart] = useState(dateTime(shiftMoment.format('YYYY-MM-DD HH:mm:ss'))); const [shiftEnd, setShiftEnd] = useState( - dateTime(startOfDay.add(12, 'hours').format('YYYY-MM-DD HH:mm:ss')) + dateTime(shiftMoment.add(24, 'hours').format('YYYY-MM-DD HH:mm:ss')) ); - const [userGroups, setUserGroups] = useState([[]]); + const [userGroups, setUserGroups] = useState(shiftId === 'new' ? [[store.userStore.currentUserPk]] : [[]]); const getUser = (pk: User['pk']) => { return { @@ -128,15 +140,27 @@ const ScheduleOverrideForm: FC = (props) => { } }, [scheduleId, shiftId, params]); - const handleChange = useDebouncedCallback(() => { - store.scheduleStore.updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params); - }, 500); + 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 ( ( diff --git a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts index e69de29b..9326fe3b 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts @@ -0,0 +1,22 @@ +import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers'; + +export const findColor = (shiftId, layers, overrides) => { + let color = undefined; + + const layerIndex = layers ? layers.findIndex((layer) => layer.shifts.some((shift) => shift.shiftId === shiftId)) : -1; + + const rotationIndex = + layerIndex > -1 ? layers[layerIndex].shifts.findIndex((shift) => shift.shiftId === shiftId) : -1; + + if (layerIndex > -1 && rotationIndex > -1) { + color = getColor(layerIndex, rotationIndex); + } else { + const overrideIndex = overrides ? overrides.findIndex((shift) => shift.shiftId === shiftId) : -1; + + if (overrideIndex > -1) { + color = getOverrideColor(overrideIndex); + } + } + + return color; +}; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index dce39f50..53e87569 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -34,17 +34,19 @@ interface RotationsProps extends WithStoreProps { interface RotationsState { shiftIdToShowRotationForm?: Shift['id']; layerPriority?: Layer['priority']; + shiftMomentToShowRotationForm?: dayjs.Dayjs; } @observer class Rotations extends Component { state: RotationsState = { shiftIdToShowRotationForm: undefined, + shiftMomentToShowRotationForm: undefined, }; render() { const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, onClick } = this.props; - const { shiftIdToShowRotationForm, layerPriority } = this.state; + const { shiftIdToShowRotationForm, layerPriority, shiftMomentToShowRotationForm } = this.state; const base = 7 * 24 * 60; // in minutes const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); @@ -100,10 +102,10 @@ class Rotations extends Component {
)}
- {layer.shifts.map(({ shiftId, events }, rotationIndex) => ( + {layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( { - this.onRotationClick(shiftId); + onClick={(moment) => { + this.onRotationClick(shiftId, moment); }} color={getColor(layerIndex, rotationIndex)} events={events} @@ -111,6 +113,7 @@ class Rotations extends Component { rotationIndex={rotationIndex} startMoment={startMoment} currentTimezone={currentTimezone} + transparent={isPreview} /> ))}
@@ -132,8 +135,8 @@ class Rotations extends Component {
{ - this.handleAddLayer(nextPriority); + onClick={(moment) => { + this.handleAddLayer(nextPriority, moment); }} events={[]} layerIndex={0} @@ -146,15 +149,16 @@ class Rotations extends Component {
)} - -
{ - this.handleAddLayer(nextPriority); - }} - > - + Add rotations layer -
+ {nextPriority > 1 && ( +
{ + this.handleAddLayer(nextPriority); + }} + > + + Add rotations layer +
+ )}
{shiftIdToShowRotationForm && ( @@ -164,6 +168,7 @@ class Rotations extends Component { layerPriority={layerPriority} startMoment={startMoment} currentTimezone={currentTimezone} + shiftMoment={shiftMomentToShowRotationForm} onHide={() => { this.hideRotationForm(); @@ -190,23 +195,27 @@ class Rotations extends Component { ); } - hideRotationForm = () => { - const { store } = this.props; - - this.setState({ shiftIdToShowRotationForm: undefined, layerPriority: undefined }); + onRotationClick = (shiftId: Shift['id'], moment?: dayjs.Dayjs) => { + this.setState({ shiftIdToShowRotationForm: shiftId, shiftMomentToShowRotationForm: moment }); }; - onRotationClick = (shiftId: Shift['id']) => { - this.setState({ shiftIdToShowRotationForm: shiftId }); - }; - - handleAddLayer = (layerPriority: number) => { - this.setState({ shiftIdToShowRotationForm: 'new', layerPriority }); + handleAddLayer = (layerPriority: number, moment?: dayjs.Dayjs) => { + this.setState({ shiftIdToShowRotationForm: 'new', layerPriority, shiftMomentToShowRotationForm: moment }); }; handleAddRotation = (option: SelectOption) => { this.setState({ shiftIdToShowRotationForm: 'new', layerPriority: option.value }); }; + + hideRotationForm = () => { + const { store } = this.props; + + this.setState({ + shiftIdToShowRotationForm: undefined, + layerPriority: undefined, + shiftMomentToShowRotationForm: undefined, + }); + }; } export default withMobXProviderContext(Rotations); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 5a07e1b8..37ad678c 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -14,6 +14,8 @@ import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; +import { findColor } from './Rotations.helpers'; + import styles from './Rotations.module.css'; const cx = cn.bind(styles); @@ -82,32 +84,13 @@ class ScheduleFinal extends Component {shifts && shifts.length ? ( shifts.map(({ shiftId, events }, index) => { - let color = undefined; - - const layerIndex = layers - ? layers.findIndex((layer) => layer.shifts.some((shift) => shift.shiftId === shiftId)) - : -1; - - const rotationIndex = - layerIndex > -1 ? layers[layerIndex].shifts.findIndex((shift) => shift.shiftId === shiftId) : -1; - - if (layerIndex > -1 && rotationIndex > -1) { - color = getColor(layerIndex, rotationIndex); - } else { - const overrideIndex = overrides ? overrides.findIndex((shift) => shift.shiftId === shiftId) : -1; - - if (overrideIndex > -1) { - color = getOverrideColor(overrideIndex); - } - } - return ( ); }) diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index bfbf9df9..8aedbc31 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -4,6 +4,7 @@ import { Button, HorizontalGroup, Icon, ValuePicker } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; +import moment from 'moment'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; @@ -30,17 +31,19 @@ interface ScheduleOverridesProps extends WithStoreProps { interface ScheduleOverridesState { shiftIdToShowOverrideForm?: Shift['id'] | 'new'; + shiftMomentToShowOverrideForm?: dayjs.Dayjs; } @observer class ScheduleOverrides extends Component { state: ScheduleOverridesState = { shiftIdToShowOverrideForm: undefined, + shiftMomentToShowOverrideForm: undefined, }; render() { const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store } = this.props; - const { shiftIdToShowOverrideForm } = this.state; + const { shiftIdToShowOverrideForm, shiftMomentToShowOverrideForm } = this.state; const shifts = store.scheduleStore.overridePreview ? store.scheduleStore.overridePreview @@ -69,16 +72,17 @@ class ScheduleOverrides extends Component
{shifts && shifts.length ? ( - shifts.map(({ shiftId, events }, rotationIndex) => ( + shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( { - this.onRotationClick(shiftId); + onClick={(moment) => { + this.onRotationClick(shiftId, moment); }} + transparent={isPreview} /> )) ) : ( @@ -86,8 +90,8 @@ class ScheduleOverrides extends Component { - this.onRotationClick('new'); + onClick={(moment) => { + this.onRotationClick('new', moment); }} /> )} @@ -103,6 +107,7 @@ class ScheduleOverrides extends Component { this.handleHide(); @@ -129,8 +134,8 @@ class ScheduleOverrides extends Component { - this.setState({ shiftIdToShowOverrideForm: shiftId }); + onRotationClick = (shiftId: Shift['id'], moment: dayjs.Dayjs) => { + this.setState({ shiftIdToShowOverrideForm: shiftId, shiftMomentToShowOverrideForm: moment }); }; handleAddOverride = () => { @@ -138,9 +143,7 @@ class ScheduleOverrides extends Component { - const { store } = this.props; - - this.setState({ shiftIdToShowOverrideForm: undefined }); + this.setState({ shiftIdToShowOverrideForm: undefined, shiftMomentToShowOverrideForm: undefined }); }; } diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 9d2ec980..f6285217 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -98,7 +98,9 @@ export const enrichLayers = ( const updatingLayer = { priority, - shifts: [{ shiftId: shiftId, events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)) }], + shifts: [ + { shiftId: shiftId, isPreview: true, events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)) }, + ], }; const isNew = updatingLayer.shifts[0].shiftId === 'new'; @@ -149,7 +151,7 @@ export const enrichOverrides = ( } } - const newShift = { shiftId, events: fillGaps(newEvents) }; + const newShift = { shiftId, isPreview: true, events: fillGaps(newEvents) }; const index = overrides.findIndex((shift) => shift.shiftId === shiftId); diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 0b032e28..b8f374f8 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -86,7 +86,7 @@ export class ScheduleStore extends BaseStore { rotationPreview?: Layer[]; @observable - overridePreview?: Array<{ shiftId: Shift['id']; events: Event[] }>; + overridePreview?: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>; @observable scheduleToScheduleEvents: { diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 572a8097..877cb8bc 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -92,5 +92,5 @@ export interface Events { export interface Layer { priority: Shift['priority_level']; - shifts: Array<{ shiftId: Shift['id']; events: Event[] }>; + shifts: Array<{ shiftId: Shift['id']; isPreview?: boolean; events: Event[] }>; } From d5750a495db7e3406812db880520ff9678bd7b79 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 24 Aug 2022 13:37:33 +0300 Subject: [PATCH 47/60] wh minor fix --- .../src/components/WorkingHours/WorkingHours.helpers.ts | 3 ++- .../src/components/WorkingHours/WorkingHours.tsx | 8 +------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts b/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts index bd703b79..52c7579f 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.helpers.ts @@ -3,7 +3,8 @@ import dayjs from 'dayjs'; export const getWorkingMoments = (startMoment, endMoment, workingHours, timezone) => { const weekdays = dayjs.weekdays(); - const momentToStartIteration = startMoment.tz(timezone); + const momentToStartIteration = dayjs().tz(timezone).utcOffset() === 0 ? startMoment : startMoment.tz(timezone); + const dayOfWeekToStartIteration = momentToStartIteration.format('dddd'); const weekDaysToIterateChunk = [ diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index 3bc04739..200c895a 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -24,13 +24,7 @@ interface WorkingHoursProps { const cx = cn.bind(styles); const WorkingHours: FC = (props) => { - const { - timezone, - workingHours, - startMoment = dayjs().utc().startOf('week'), - duration = 14 * 24 * 60 * 60, - className, - } = props; + const { timezone, workingHours, startMoment, duration, className } = props; const endMoment = startMoment.add(duration, 'seconds'); From cb33155287192530904e92877b8575eb7af83d76 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 25 Aug 2022 15:49:35 +0300 Subject: [PATCH 48/60] paint users in user groups --- .../UserGroups/UserGroups.module.css | 10 ++++- .../src/components/UserGroups/UserGroups.tsx | 44 +++++++++++-------- .../components/UserGroups/UserGroups.types.ts | 7 +-- .../components/WorkingHours/WorkingHours.tsx | 12 ++++- .../containers/Rotation/Rotation.module.css | 5 +-- .../RotationForm/RotationForm.module.css | 16 ++++++- .../containers/RotationForm/RotationForm.tsx | 38 +++++++++++++++- .../RotationForm/ScheduleOverrideForm.tsx | 35 ++++++++++++++- .../containers/Rotations/Rotations.helpers.ts | 4 +- .../src/containers/Rotations/Rotations.tsx | 11 +++-- .../Rotations/ScheduleOverrides.tsx | 3 ++ grafana-plugin/src/models/user/user.types.ts | 1 + 12 files changed, 145 insertions(+), 41 deletions(-) diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index 9ff63c70..178e3412 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -54,9 +54,15 @@ .user { background: #22252b; border-radius: 2px; - padding: 6px 10px; display: flex; - justify-content: space-between; + position: relative; + overflow: hidden; +} + +.user-buttons { + position: absolute; + top: 8px; + right: 5px; } .user:hover { diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index d9d996ba..37ab4780 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -7,6 +7,7 @@ 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'; @@ -21,6 +22,7 @@ interface UserGroupsProps { onChange: (value: Array>) => void; isMultipleGroups: boolean; getItemData: (id: string) => ItemData; + renderUser: (id: string) => React.ReactElement; } const cx = cn.bind(styles); @@ -30,7 +32,7 @@ const DragHandle = () => ; const SortableHandleHoc = SortableHandle(DragHandle); const UserGroups = (props: UserGroupsProps) => { - const { value, onChange, isMultipleGroups, getItemData } = props; + const { value, onChange, isMultipleGroups, getItemData, renderUser } = props; const handleAddUserGroup = useCallback(() => { onChange([...value, []]); @@ -86,10 +88,29 @@ const UserGroups = (props: UserGroupsProps) => { [items] ); + const getDeleteItemHandler = (index: number) => { + return () => { + handleDeleteUser(index); + }; + }; + + const renderItem = (item: Item, index: number) => ( +
  • + {renderUser(item.item)} +
    + + + + +
    +
  • + ); + return (
    void; handleDeleteItem: (index: number) => void; isMultipleGroups: boolean; + renderItem: (item: Item, index: number) => React.ReactElement; } const SortableList = SortableContainer( - ({ items, handleAddGroup, handleDeleteItem, isMultipleGroups }: SortableListProps) => { - const getDeleteItemHandler = (index: number) => { - return () => { - handleDeleteItem(index); - }; - }; - + ({ items, handleAddGroup, handleDeleteItem, isMultipleGroups, renderItem }: SortableListProps) => { return (
      {items.map((item, index) => item.type === 'item' ? ( -
    • -
      - {item.data.name} ({item.data.desc}) -
      -
      - - - - -
      -
    • + {renderItem(item, index)}
      ) : isMultipleGroups ? ( diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.types.ts b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts index 0a4bd62c..b3b85942 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.types.ts +++ b/grafana-plugin/src/components/UserGroups/UserGroups.types.ts @@ -1,11 +1,6 @@ export interface Item { key: string; type: string; - data: ItemData; + data: any; item?: string; } - -export interface ItemData { - name: string; - desc?: string; -} diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index 200c895a..cf25e5dd 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -19,12 +19,13 @@ interface WorkingHoursProps { startMoment: dayjs.Dayjs; duration: number; // in seconds className: string; + style?: React.CSSProperties; } const cx = cn.bind(styles); const WorkingHours: FC = (props) => { - const { timezone, workingHours, startMoment, duration, className } = props; + const { timezone, workingHours, startMoment, duration, className, style } = props; const endMoment = startMoment.add(duration, 'seconds'); @@ -61,7 +62,14 @@ const WorkingHours: FC = (props) => { );*/ return ( - + diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index b7690181..1d39055b 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -62,9 +62,8 @@ .empty { height: 28px; - - /* background: #5f505633; + background: #5f505633; border: 1px dashed #5c474d; - color: rgba(209, 14, 92, 0.5); */ + color: rgba(209, 14, 92, 0.5); margin: 0 2px; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css index 719e0b69..d603e058 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css @@ -4,7 +4,7 @@ .draggable { top: 0; - transition: transform 500ms ease; + transition: transform 300ms ease; } .header { @@ -17,6 +17,20 @@ 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; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 5fe37915..84fe1c81 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -18,9 +18,12 @@ 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'; @@ -49,6 +52,7 @@ interface RotationFormProps { onCreate: () => void; onUpdate: () => void; onDelete: () => void; + shiftColor?: string; } const cx = cn.bind(styles); @@ -65,8 +69,11 @@ const RotationForm: FC = observer((props) => { layerPriority, shiftId, shiftMoment = dayjs().startOf('day').add(1, 'day'), + shiftColor = '#3D71D9', } = props; + console.log('shiftColor', shiftColor); + const [isOpen, setIsOpen] = useState(false); const [repeatEveryValue, setRepeatEveryValue] = useState(1); @@ -109,6 +116,29 @@ const RotationForm: FC = observer((props) => { }; }; + const renderUser = (userPk: User['pk']) => { + const name = store.userStore.items[userPk]?.username; + const desc = store.userStore.items[userPk]?.timezone; + const workingHours = store.userStore.items[userPk]?.working_hours; + const timezone = store.userStore.items[userPk]?.timezone; + + return ( + <> +
      + {name} ({desc}) +
      + + + ); + }; + const handleDeleteClick = useCallback(() => { store.scheduleStore.deleteOncallShift(shiftId).then(() => { onDelete(); @@ -238,7 +268,13 @@ const RotationForm: FC = observer((props) => { - + {/*
      */} diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 5d4b87c5..31fd7c51 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -20,6 +20,7 @@ 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'; @@ -40,6 +41,8 @@ interface RotationFormProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; scheduleId: Schedule['id']; + shiftMoment: dayjs.Dayjs; + shiftColor?: string; onCreate: () => void; onUpdate: () => void; onDelete: () => void; @@ -58,6 +61,7 @@ const ScheduleOverrideForm: FC = (props) => { shiftId, startMoment, shiftMoment = dayjs().startOf('day').add(1, 'day'), + shiftColor = '#C69B06', } = props; const store = useStore(); @@ -92,6 +96,29 @@ const ScheduleOverrideForm: FC = (props) => { }; }; + const renderUser = (userPk: User['pk']) => { + const name = store.userStore.items[userPk]?.username; + const desc = store.userStore.items[userPk]?.timezone; + const workingHours = store.userStore.items[userPk]?.working_hours; + const timezone = store.userStore.items[userPk]?.timezone; + + return ( + <> +
      + {name} ({desc}) +
      + + + ); + }; + const shift = store.scheduleStore.shifts[shiftId]; useEffect(() => { @@ -183,7 +210,13 @@ const ScheduleOverrideForm: FC = (props) => {
      - + {/*
      */} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts index 9326fe3b..b6b69c5e 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts @@ -1,6 +1,6 @@ import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers'; -export const findColor = (shiftId, layers, overrides) => { +export const findColor = (shiftId, layers, overrides?) => { let color = undefined; const layerIndex = layers ? layers.findIndex((layer) => layer.shifts.some((shift) => shift.shiftId === shiftId)) : -1; @@ -10,7 +10,7 @@ export const findColor = (shiftId, layers, overrides) => { if (layerIndex > -1 && rotationIndex > -1) { color = getColor(layerIndex, rotationIndex); - } else { + } else if (overrides) { const overrideIndex = overrides ? overrides.findIndex((shift) => shift.shiftId === shiftId) : -1; if (overrideIndex > -1) { diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 53e87569..86671f8d 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -17,6 +17,8 @@ import { Timezone } from 'models/timezone/timezone.types'; import { SelectOption, WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; +import { findColor } from './Rotations.helpers'; + import styles from './Rotations.module.css'; const cx = cn.bind(styles); @@ -93,11 +95,11 @@ class Rotations extends Component {
      Layer {layer.priority} - + {/**/}
      - + {!currentTimeHidden && (
      )} @@ -127,12 +129,12 @@ class Rotations extends Component {
      Layer 1 - + {/* */}
      - +
      { @@ -164,6 +166,7 @@ class Rotations extends Component { {shiftIdToShowRotationForm && ( Date: Fri, 26 Aug 2022 11:19:58 +0300 Subject: [PATCH 49/60] minor fixes --- grafana-plugin/src/containers/Rotation/Rotation.module.css | 6 ++++-- grafana-plugin/src/containers/RotationForm/RotationForm.tsx | 2 +- grafana-plugin/src/models/user/user.helpers.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index 1d39055b..edc41ce3 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -62,8 +62,10 @@ .empty { height: 28px; - background: #5f505633; + cursor: pointer; + + /* background: #5f505633; border: 1px dashed #5c474d; - color: rgba(209, 14, 92, 0.5); + color: rgba(209, 14, 92, 0.5); */ margin: 0 2px; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 84fe1c81..2181b83c 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -68,7 +68,7 @@ const RotationForm: FC = observer((props) => { onDelete, layerPriority, shiftId, - shiftMoment = dayjs().startOf('day').add(1, 'day'), + shiftMoment = dayjs().startOf('isoWeek'), shiftColor = '#3D71D9', } = props; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index b1b0122f..ec729ce0 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -36,7 +36,7 @@ export const getRole = (role: UserRole) => { export const getTimezone = (user: User) => { const tzByName = { - 'Hello Oncall': 'UTC', + Maxim: 'UTC', 'Matías Bordese': 'America/Montevideo', 'Michael Derynck': 'America/Vancouver', 'Yulia Shanyrova': 'Europe/Amsterdam', From e1f36ce9dff4975d22ac8c80d05a4c2d0fe24bd5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 26 Aug 2022 15:26:02 +0300 Subject: [PATCH 50/60] fix paint layers --- .../containers/RotationForm/RotationForm.tsx | 6 ++- .../RotationForm/ScheduleOverrideForm.tsx | 2 +- .../containers/Rotations/Rotations.helpers.ts | 37 ++++++++++++++----- .../src/models/schedule/schedule.helpers.ts | 20 ++++++---- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 2181b83c..3c19b25d 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -72,7 +72,7 @@ const RotationForm: FC = observer((props) => { shiftColor = '#3D71D9', } = props; - console.log('shiftColor', shiftColor); + // console.log('shiftColor', shiftColor); const [isOpen, setIsOpen] = useState(false); @@ -100,9 +100,10 @@ const RotationForm: FC = observer((props) => { const coords = getCoords(elm); + // elm.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); // setOffsetTop(Math.max(coords.top + elm.offsetHeight, 0)); - setOffsetTop(coords.top - modal?.offsetHeight - 10); + setOffsetTop(Math.max(coords.top - modal?.offsetHeight - 10, 10)); }); } }, [isOpen]); @@ -176,6 +177,7 @@ const RotationForm: FC = observer((props) => { shiftId, layerPriority, shift, + endLess, ] ); diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 31fd7c51..bb4d5b81 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -77,7 +77,7 @@ const ScheduleOverrideForm: FC = (props) => { const coords = getCoords(elm); - setOffsetTop(coords.top - modal?.offsetHeight - 10); + setOffsetTop(Math.max(coords.top - modal?.offsetHeight - 10, 10)); }); } }, [isOpen]); diff --git a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts index b6b69c5e..91225e8b 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts @@ -1,21 +1,38 @@ import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers'; +import { Layer, Shift } from 'models/schedule/schedule.types'; -export const findColor = (shiftId, layers, overrides?) => { +export const findColor = (shiftId: Shift['id'], layers: Layer[], overrides?) => { let color = undefined; - const layerIndex = layers ? layers.findIndex((layer) => layer.shifts.some((shift) => shift.shiftId === shiftId)) : -1; + 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; + } + } + } + } - const rotationIndex = - layerIndex > -1 ? layers[layerIndex].shifts.findIndex((shift) => shift.shiftId === shiftId) : -1; + 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 (overrides) { - const overrideIndex = overrides ? overrides.findIndex((shift) => shift.shiftId === shiftId) : -1; - - if (overrideIndex > -1) { - color = getOverrideColor(overrideIndex); - } + } else if (overrideIndex > -1) { + color = getOverrideColor(overrideIndex); } return color; diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index f6285217..d626d32e 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -89,25 +89,28 @@ export const enrichLayers = ( shiftId: Shift['id'] | 'new', priority: Shift['priority_level'] ) => { + let shiftIdFromEvent = shiftId; if (shiftId === 'new') { const event = newEvents.find((event) => !event.is_gap); if (event) { - shiftId = event.shift.pk; + shiftIdFromEvent = event.shift.pk; } } const updatingLayer = { priority, shifts: [ - { shiftId: shiftId, isPreview: true, events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)) }, + { + shiftId: shiftIdFromEvent, + isPreview: true, + events: fillGaps(newEvents.filter((event: Event) => !event.is_gap)), + }, ], }; - const isNew = updatingLayer.shifts[0].shiftId === 'new'; - let added = false; layers = layers.reduce((memo, layer, index) => { - if (isNew) { + if (shiftId === 'new') { if (layer.priority === priority) { const newLayer = { ...layer }; newLayer.shifts = [...layer.shifts, ...updatingLayer.shifts]; @@ -144,14 +147,15 @@ export const enrichOverrides = ( newEvents: Event[], shiftId: Shift['id'] ) => { + let shiftIdFromEvent = shiftId; if (shiftId === 'new') { const event = newEvents.find((event) => !event.is_gap); if (event) { - shiftId = event.shift.pk; + shiftIdFromEvent = event.shift.pk; } } - const newShift = { shiftId, isPreview: true, events: fillGaps(newEvents) }; + const newShift = { shiftId: shiftIdFromEvent, isPreview: true, events: fillGaps(newEvents) }; const index = overrides.findIndex((shift) => shift.shiftId === shiftId); @@ -172,7 +176,7 @@ const L3_COLORS = ['#377277', '#638282', '#364E4E', '#423220']; const OVERRIDE_COLORS = ['#C69B06', '#C2C837']; -const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS, OVERRIDE_COLORS]; +const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS]; export const getColor = (layerIndex: number, rotationIndex: number) => { const normalizedLayerIndex = layerIndex % COLORS.length; From 47045aaaf9509256d25bfe66eb08324ff67b4378 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 29 Aug 2022 13:19:56 +0300 Subject: [PATCH 51/60] add rotations transitions --- grafana-plugin/package.json | 2 +- .../containers/Rotations/Rotations.config.ts | 4 ++ .../containers/Rotations/Rotations.module.css | 22 ++++++ .../src/containers/Rotations/Rotations.tsx | 72 +++++++++++-------- .../Rotations/ScheduleOverrides.tsx | 40 ++++++----- grafana-plugin/yarn.lock | 37 ++++------ 6 files changed, 103 insertions(+), 74 deletions(-) create mode 100644 grafana-plugin/src/containers/Rotations/Rotations.config.ts diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 7a090dd0..cbfc46e5 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -80,7 +80,7 @@ "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", "react-string-replace": "^0.4.4", - "react-transition-group": "1.x", + "react-transition-group": "^4.4.5", "stylelint": "^13.13.1", "stylelint-config-standard": "^22.0.0", "throttle-debounce": "^2.1.0" diff --git a/grafana-plugin/src/containers/Rotations/Rotations.config.ts b/grafana-plugin/src/containers/Rotations/Rotations.config.ts new file mode 100644 index 00000000..2fe0b109 --- /dev/null +++ b/grafana-plugin/src/containers/Rotations/Rotations.config.ts @@ -0,0 +1,4 @@ +export const DEFAULT_TRANSITION_TIMEOUT = { + enter: 500, + exit: 0, +}; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.module.css b/grafana-plugin/src/containers/Rotations/Rotations.module.css index e9dd92b6..ce2935d5 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.module.css +++ b/grafana-plugin/src/containers/Rotations/Rotations.module.css @@ -89,3 +89,25 @@ .add-rotations-layer:hover { background: var(--secondary-background); } + +/* +animation +*/ + +.enter { + opacity: 0; +} + +.enterActive { + opacity: 1; + transition: opacity 500ms ease-in; +} + +.exit { + opacity: 1; +} + +.exitActive { + opacity: 0; + transition: opacity 500ms ease-in; +} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 86671f8d..49fb3c48 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -6,6 +6,7 @@ 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'; @@ -17,6 +18,7 @@ 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'; @@ -89,40 +91,48 @@ class Rotations extends Component {
      {layers && layers.length ? ( - layers.map((layer, layerIndex) => ( -
      -
      -
      - - Layer {layer.priority} - {/**/} - -
      -
      - - {!currentTimeHidden && ( -
      - )} + + {layers.map((layer, layerIndex) => ( + +
      +
      + + Layer {layer.priority} + {/**/} + +
      - {layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( - { - this.onRotationClick(shiftId, moment); - }} - color={getColor(layerIndex, rotationIndex)} - events={events} - layerIndex={layerIndex} - rotationIndex={rotationIndex} - startMoment={startMoment} - currentTimezone={currentTimezone} - transparent={isPreview} - /> - ))} + + {!currentTimeHidden && ( +
      + )} + + {layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( + + { + this.onRotationClick(shiftId, moment); + }} + color={getColor(layerIndex, rotationIndex)} + events={events} + layerIndex={layerIndex} + rotationIndex={rotationIndex} + startMoment={startMoment} + currentTimezone={currentTimezone} + transparent={isPreview} + /> + + ))} +
      -
      -
      - )) + + ))} + ) : (
      diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index e28384ec..3986a516 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -5,6 +5,7 @@ import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import moment from 'moment'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; @@ -16,6 +17,7 @@ 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'; @@ -72,32 +74,36 @@ class ScheduleOverrides extends Component {!currentTimeHidden &&
      } -
      + {shifts && shifts.length ? ( shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( + + { + this.onRotationClick(shiftId, moment); + }} + transparent={isPreview} + /> + + )) + ) : ( + { - this.onRotationClick(shiftId, moment); + this.onRotationClick('new', moment); }} - transparent={isPreview} /> - )) - ) : ( - { - this.onRotationClick('new', moment); - }} - /> + )} -
      +
      {/*
      + Add override diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index adeff3ef..9ae88054 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -4986,11 +4986,6 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chain-function@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.1.tgz#c63045e5b4b663fb86f1c6e186adaf1de402a1cc" - integrity sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg== - chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -6605,7 +6600,7 @@ dom-css@^2.0.0: prefix-style "2.0.1" to-camel-case "1.0.0" -dom-helpers@^3.2.0, dom-helpers@^3.3.1: +dom-helpers@^3.3.1: version "3.4.0" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== @@ -12753,7 +12748,7 @@ prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, pr object-assign "^4.1.1" react-is "^16.8.1" -prop-types@^15.5.6, prop-types@^15.8.1: +prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -13628,17 +13623,6 @@ react-table@7.8.0: resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2" integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA== -react-transition-group@1.x: - version "1.2.1" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.2.1.tgz#e11f72b257f921b213229a774df46612346c7ca6" - integrity sha512-CWaL3laCmgAFdxdKbhhps+c0HRGF4c+hdM4H23+FI1QBNUyx/AMeIJGWorehPNSaKnQNOAxL7PQmqMu78CDj3Q== - dependencies: - chain-function "^1.0.0" - dom-helpers "^3.2.0" - loose-envify "^1.3.1" - prop-types "^15.5.6" - warning "^3.0.0" - react-transition-group@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -13659,6 +13643,16 @@ react-transition-group@4.4.2, react-transition-group@^4.3.0, react-transition-gr loose-envify "^1.4.0" prop-types "^15.6.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== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-universal-interface@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b" @@ -16277,13 +16271,6 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.x" -warning@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" - integrity sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ== - dependencies: - loose-envify "^1.0.0" - warning@^4.0.1, warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" From 25dc2eb3e54c9bc6dd06be55f078fd1311489f8c Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 29 Aug 2022 14:11:44 +0300 Subject: [PATCH 52/60] fix rotation/override updating --- grafana-plugin/src/containers/Rotation/Rotation.tsx | 4 ++-- .../src/containers/RotationForm/RotationForm.tsx | 8 ++++---- .../containers/RotationForm/ScheduleOverrideForm.tsx | 4 ++-- grafana-plugin/src/pages/schedule/Schedule.helpers.ts | 10 ++++++++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index e4c2b1a5..241c357d 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -82,11 +82,11 @@ const Rotation: FC = (props) => { const dayOffset = Math.floor((x / width) * 7); - console.log('event.offsetX', event.offsetX); + /* console.log('event.offsetX', event.offsetX); console.log('event.nativeEvent', event.nativeEvent); console.log('event.currentTarget', event.currentTarget); console.log('dayOffset', dayOffset); - +*/ onClick(startMoment.add(dayOffset, 'day')); }; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 3c19b25d..8a314524 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -213,10 +213,10 @@ const RotationForm: FC = observer((props) => { useEffect(() => { if (shift) { - setRotationStart(getDateTime(shift.rotation_start)); - setRotationEnd(getDateTime(shift.until)); - setShiftStart(getDateTime(shift.shift_start)); - setShiftEnd(getDateTime(shift.shift_end)); + 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); diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index bb4d5b81..5070ff95 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -140,8 +140,8 @@ const ScheduleOverrideForm: FC = (props) => { useEffect(() => { if (shift) { - setShiftStart(getDateTime(shift.shift_start)); - setShiftEnd(getDateTime(shift.shift_end)); + setShiftStart(getDateTime(shift.shift_start, currentTimezone)); + setShiftEnd(getDateTime(shift.shift_end, currentTimezone)); setUserGroups(shift.rolling_users); } diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 3d0f9fa6..3779a86f 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -679,10 +679,16 @@ export const getUTCString = (moment: dayjs.Dayjs | DateTime, timezone: Timezone) .format('YYYY-MM-DDTHH:mm:ss.000Z'); }; -export const getDateTime = (date: string) => { +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').format('YYYY-MM-DDTHH:mm:ss.000Z')); + return dateTime( + dayjs(date) + .subtract(browserTimezoneOffset, 'minutes') + .add(timezoneOffset, 'minutes') + .format('YYYY-MM-DDTHH:mm:ss.000Z') + ); }; From 1ce970aac2b7d6c2349015cc4d116bc7c0c579ac Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 2 Sep 2022 14:18:41 +0300 Subject: [PATCH 53/60] visual fixes --- .../ScheduleSlot/ScheduleSlot.helpers.ts | 4 --- .../ScheduleSlot/ScheduleSlot.module.css | 2 +- .../components/ScheduleSlot/ScheduleSlot.tsx | 7 ++--- .../TimelineMarks/TimelineMarks.module.css | 3 +- .../TimelineMarks/TimelineMarks.tsx | 2 +- .../containers/Rotation/Rotation.helpers.ts | 3 ++ .../containers/Rotation/Rotation.module.css | 2 +- .../src/containers/Rotation/Rotation.tsx | 10 +++++- .../src/models/schedule/schedule.ts | 8 +++-- .../src/pages/schedule/Schedule.tsx | 31 +++++++++++-------- 10 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 grafana-plugin/src/containers/Rotation/Rotation.helpers.ts diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts index 5afee5c7..0d6049fb 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts @@ -18,10 +18,6 @@ export const getRandomUser = () => { return USERS[Math.floor(Math.random() * USERS.length)]; }; -export const getLabel = (layerIndex: number, rotationIndex) => { - return `L ${layerIndex + 1}-${rotationIndex + 1}`; -}; - export const getTitle = (user: User) => { return user ? user.username.split(' ')[0] : null; return user diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css index 99f592d1..922fb4e6 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css @@ -5,7 +5,7 @@ position: relative; display: flex; overflow: hidden; - margin: 0 2px; + margin: 0 1px; padding: 4px; align-items: center; } diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx index d799c53e..f169b8d5 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx @@ -13,7 +13,7 @@ import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; -import { getColor, getLabel, getTitle } from './ScheduleSlot.helpers'; +import { getTitle } from './ScheduleSlot.helpers'; import styles from './ScheduleSlot.module.css'; @@ -25,12 +25,13 @@ interface ScheduleSlotProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; color?: string; + label?: string; } const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color } = props; + const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color, label } = props; const { users } = event; const trackMouse = false; @@ -48,8 +49,6 @@ const ScheduleSlot: FC = observer((props) => { const width = duration / base; - const label = !isNaN(layerIndex) && !isNaN(rotationIndex) && index === 0 ? getLabel(layerIndex, rotationIndex) : null; - const handleMouseMove = useCallback((event) => { setMouseX(event.nativeEvent.offsetX); }, []); diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css index a6b0f246..e4cb9d63 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css @@ -33,7 +33,8 @@ .weekday-times { width: 100%; display: flex; - height: 16px; + height: 20px; + align-items: center; } .weekday-time { diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index 60e88236..a419ba40 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -60,7 +60,7 @@ const TimelineMarks: FC = (props) => { {momentsToRender.map((m, i) => { return (
      -
      {m.moment.format('DD MMM')}
      +
      {m.moment.format('D MMM')}
      {m.moments.map((mm, j) => (
      diff --git a/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts b/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts new file mode 100644 index 00000000..04369117 --- /dev/null +++ b/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts @@ -0,0 +1,3 @@ +export const getLabel = (layerIndex: number, rotationIndex) => { + return `L ${layerIndex + 1}-${rotationIndex + 1}`; +}; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index edc41ce3..b721e17b 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -23,7 +23,7 @@ display: flex; flex-direction: column; gap: 5px; - padding-bottom: 8px; + padding-bottom: 4px; overflow: hidden; } diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 241c357d..d4761f6a 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -11,6 +11,8 @@ import { Rotation as RotationType, Schedule, Event } from 'models/schedule/sched 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); @@ -24,7 +26,7 @@ interface RotationProps { rotationIndex?: number; color?: string; events: Event[]; - onClick: (moment: dayjs.Dayjs) => void; + onClick?: (moment: dayjs.Dayjs) => void; days?: number; transparent?: boolean; } @@ -105,6 +107,11 @@ const Rotation: FC = (props) => { return firstShiftOffset / base; }, [events]); + let eventIndexToShowLabel = -1; + if (!isNaN(layerIndex) && !isNaN(rotationIndex)) { + eventIndexToShowLabel = events.findIndex((event) => dayjs(event.start).isSameOrAfter(startMoment)); + } + return (
      @@ -125,6 +132,7 @@ const Rotation: FC = (props) => { startMoment={startMoment} currentTimezone={currentTimezone} color={color} + label={index === eventIndexToShowLabel && getLabel(layerIndex, rotationIndex)} /> ); })} diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index b8f374f8..834ec036 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -345,16 +345,20 @@ export class ScheduleStore extends BaseStore { }); } - async updateEvents(scheduleId: Schedule['id'], fromString: string, type: RotationType = 'rotation', days = 7) { + 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: fromString, + 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 diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 5541feff..9fa919d2 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; -import { Button, HorizontalGroup, VerticalGroup, RadioButtonGroup, IconButton, ToolbarButton } from '@grafana/ui'; +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'; @@ -20,7 +20,6 @@ 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 { getFromString } from 'models/schedule/schedule.helpers'; import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; @@ -144,12 +143,14 @@ class SchedulePage extends React.Component - - + + + +
      {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
      @@ -216,9 +217,9 @@ class SchedulePage extends React.Component const { startMoment } = this.state; return Promise.all([ - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'rotation'), - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'override'), - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'), + store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'), ]); }; @@ -276,9 +277,13 @@ class SchedulePage extends React.Component handleTimezoneChange = (value: Timezone) => { const { store } = this.props; - store.currentTimezone = value; + this.setState((oldState) => { + const wDiff = oldState.startMoment.diff(getStartOfWeek(store.currentTimezone), 'weeks'); - this.setState({ startMoment: getStartOfWeek(value) }, this.updateEvents); + return { ...oldState, startMoment: getStartOfWeek(value).add(wDiff, 'weeks') }; + }, this.updateEvents); + + store.currentTimezone = value; }; handleShedulePeriodTypeChange = (value: string) => { From 99877b99d23571cf59a2b7204b31c75cd0e3afd9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 6 Sep 2022 16:23:06 +0300 Subject: [PATCH 54/60] animate user timezones --- grafana-plugin/.eslintrc.js | 1 + .../src/components/Table/Table.module.css | 7 +- .../UsersTimezones/UsersTimezones.module.css | 41 +++- .../UsersTimezones/UsersTimezones.tsx | 205 +++++++++++++++--- .../src/containers/Rotation/Rotation.tsx | 11 +- .../src/containers/Rotations/Rotations.tsx | 2 + .../containers/Rotations/ScheduleFinal.tsx | 32 ++- .../Rotations/ScheduleOverrides.tsx | 4 +- .../ScheduleSlot/ScheduleSlot.helpers.ts | 0 .../ScheduleSlot/ScheduleSlot.module.css | 5 + .../ScheduleSlot/ScheduleSlot.tsx | 39 ++-- grafana-plugin/src/icons/index.tsx | 25 +++ .../src/pages/schedule/Schedule.tsx | 7 +- .../src/pages/schedules_NEW/Schedules.tsx | 38 ++-- grafana-plugin/src/vars.css | 2 + 15 files changed, 323 insertions(+), 96 deletions(-) rename grafana-plugin/src/{components => containers}/ScheduleSlot/ScheduleSlot.helpers.ts (100%) rename grafana-plugin/src/{components => containers}/ScheduleSlot/ScheduleSlot.module.css (93%) rename grafana-plugin/src/{components => containers}/ScheduleSlot/ScheduleSlot.tsx (86%) diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index 52f8c4a8..f364c6a1 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { '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', 'no-restricted-imports': 'warn', diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css index 2791d8bc..c2d7d959 100644 --- a/grafana-plugin/src/components/Table/Table.module.css +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -4,19 +4,22 @@ .root table { width: 100%; + background: #22252b; } .root tr { - border-bottom: 1px solid #33363b; + border-bottom: 1px solid #181b1f; height: 60px; } .root tr:hover { - background: var(--secondary-background); + /* background: var(--secondary-background); */ + background: rgba(63, 62, 62, 0.45); } .root td { min-height: 60px; + padding: 10px 0; } .pagination { diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css index b7a96eda..1cbe655f 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css +++ b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css @@ -43,16 +43,43 @@ height: 76px; } -.user { +.avatar-group { position: absolute; top: 10px; - border: 2px solid #c65210; - transition: left 1s linear; - transform: translate(-50%, 0); - z-index: 0; + 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; +} + +.avatar-group_inactive { + opacity: 0.2; + transition: opacity 0.5s ease; +} + .time-stripe { position: relative; height: 4px; @@ -91,10 +118,6 @@ width: 100%; } -.time-mark { - -} - .time-mark-text { display: inline-block; padding: 0 5px; diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx index 1cf500fd..6e1c37e9 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx @@ -7,6 +7,7 @@ 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'; @@ -16,6 +17,7 @@ interface UsersTimezonesProps { users: User[]; tz: Timezone; onTzChange: (tz: Timezone) => void; + onCallNow: Array>; } const cx = cn.bind(styles); @@ -25,17 +27,11 @@ const hoursToSplit = 3; const jLimit = 24 / hoursToSplit; const UsersTimezones: FC = (props) => { - const { users, tz, onTzChange } = props; + const { users, tz, onTzChange, onCallNow } = props; const [count, setCount] = useState(0); const [currentMoment, setCurrentMoment] = useState(dayjs().tz(tz)); - const getAvatarClickHandler = useCallback((user) => { - return () => { - onTzChange(user.timezone); - }; - }, []); - useEffect(() => { setCurrentMoment(currentMoment.tz(tz).startOf('minute')); }, [tz]); @@ -73,7 +69,7 @@ const UsersTimezones: FC = (props) => {
      -
      Team timezones
      +
      Schedule team and timezones
      {/* Current schedule users only @@ -88,32 +84,7 @@ const UsersTimezones: FC = (props) => {
      - {users.map((user, index) => { - const userCurrentMoment = dayjs(currentMoment).tz(user.timezone); - const diff = userCurrentMoment.diff(userCurrentMoment.startOf('day'), 'minutes'); - - const userHour = userCurrentMoment.hour(); - - const x = (diff / 1440) * 100; - return ( - } - > -
      = 9 && userHour < 18 ? 1 : 0.5, - }} - > - -
      -
      - ); - })} +
      @@ -138,4 +109,170 @@ const UsersTimezones: FC = (props) => { ); }; +interface UserAvatarsProps { + users: User[]; + currentMoment: dayjs.Dayjs; + onTzChange: (timezone: Timezone) => void; + onCallNow: Array>; +} + +const UserAvatars = (props: UserAvatarsProps) => { + const { users, currentMoment, onTzChange, onCallNow } = props; + const userGroups = useMemo(() => { + return users + .reduce((memo, user) => { + let group = memo.find((group) => group.timezone === user.timezone); + if (!group) { + group = { timezone: user.timezone, users: [] }; + memo.push(group); + } + group.users.push(user); + + return memo; + }, []) + .sort((a, b) => { + const aOffset = dayjs().tz(a.timezone).utcOffset(); + const bOffset = dayjs().tz(b.timezone).utcOffset(); + + if (aOffset > bOffset) { + return 1; + } + if (aOffset < bOffset) { + return -1; + } + + return 0; + }); + }, [users]); + + const getAvatarClickHandler = useCallback((timezone: Timezone) => { + return () => { + onTzChange(timezone); + }; + }, []); + + const [activeTimezone, setActiveTimezone] = useState(undefined); + + return ( +
      + {userGroups.map((group) => { + const userCurrentMoment = dayjs(currentMoment).tz(group.timezone); + const diff = userCurrentMoment.diff(userCurrentMoment.startOf('day'), 'minutes'); + + const xPos = (diff / (60 * 24)) * 100; + + return ( + + ); + })} +
      + ); +}; + +interface AvatarGroupProps { + users: User[]; + xPos: number; + currentMoment: dayjs.Dayjs; + onClick: () => void; + timezone: Timezone; + onSetActiveTimezone: (timezone: Timezone) => void; + activeTimezone: Timezone; + onCallNow: Array>; +} + +const LIMIT = 3; +const AVATAR_WIDTH = 32; +const AVATAR_GAP = 5; + +const AvatarGroup = (props: AvatarGroupProps) => { + const { + users: propsUsers, + currentMoment, + xPos, + onClick, + timezone, + onSetActiveTimezone, + activeTimezone, + onCallNow, + } = props; + + const active = activeTimezone && activeTimezone === timezone; + + 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 width = active ? users.length * AVATAR_WIDTH + (users.length - 1) * AVATAR_GAP : AVATAR_WIDTH; + + return ( +
      onSetActiveTimezone(timezone)} + onMouseLeave={() => onSetActiveTimezone(undefined)} + > + {users.map((user, index, array) => { + const isOncall = onCallNow.some((onCallUser) => user.pk === onCallUser.pk); + + return ( + } + > +
      = LIMIT ? 'hidden' : 'visible', + zIndex: array.length - index - 1, + /* opacity: userHour >= 9 && userHour < 18 ? 1 : 0.5,*/ + }} + > + + {isOncall && } +
      +
      + ); + })} +
      LIMIT ? '1' : '0', + zIndex: users.length, + left: active ? `${users.length * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${users.length * 10}px`, + }} + className={cx('user-more')} + > + +{users.length - LIMIT} +
      +
      + ); +}; + export default UsersTimezones; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index d4761f6a..7abcdc90 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -3,9 +3,8 @@ import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 're import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; -import { CSSTransitionGroup } from 'react-transition-group'; // ES6 -import ScheduleSlot from 'components/ScheduleSlot/ScheduleSlot'; +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'; @@ -20,6 +19,7 @@ const cx = cn.bind(styles); interface ScheduleSlotState {} interface RotationProps { + scheduleId: Schedule['id']; startMoment: dayjs.Dayjs; currentTimezone: Timezone; layerIndex?: number; @@ -34,6 +34,7 @@ interface RotationProps { const Rotation: FC = (props) => { const { events, + scheduleId, layerIndex, rotationIndex, startMoment, @@ -84,11 +85,6 @@ const Rotation: FC = (props) => { const dayOffset = Math.floor((x / width) * 7); - /* console.log('event.offsetX', event.offsetX); - console.log('event.nativeEvent', event.nativeEvent); - console.log('event.currentTarget', event.currentTarget); - console.log('dayOffset', dayOffset); -*/ onClick(startMoment.add(dayOffset, 'day')); }; @@ -125,6 +121,7 @@ const Rotation: FC = (props) => { return ( { classNames={{ ...styles }} > { this.onRotationClick(shiftId, moment); }} @@ -147,6 +148,7 @@ class Rotations extends Component {
      { this.handleAddLayer(nextPriority, moment); }} diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 37ad678c..a3f367c9 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -5,6 +5,7 @@ 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'; @@ -14,6 +15,7 @@ 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'; @@ -81,23 +83,33 @@ class ScheduleFinal extends Component {!currentTimeHidden &&
      } -
      + {shifts && shifts.length ? ( shifts.map(({ shiftId, events }, index) => { return ( - + + + ); }) ) : ( - + + + )} -
      +
      diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 3986a516..d908f06b 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -80,6 +80,7 @@ class ScheduleOverrides extends Component )) ) : ( - + { diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.helpers.ts similarity index 100% rename from grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.helpers.ts rename to grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.helpers.ts diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css similarity index 93% rename from grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css rename to grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css index 922fb4e6..ce26a72d 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css @@ -79,3 +79,8 @@ background-color: white; z-index: 2; } + +.is-oncall-icon { + color: var(--oncall-icon-stroke-color); + margin-left: -2px; +} diff --git a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx similarity index 86% rename from grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx rename to grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index f169b8d5..bac0dbda 100644 --- a/grafana-plugin/src/components/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -8,7 +8,8 @@ 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 { Event } from 'models/schedule/schedule.types'; +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'; @@ -18,10 +19,8 @@ import { getTitle } from './ScheduleSlot.helpers'; import styles from './ScheduleSlot.module.css'; interface ScheduleSlotProps { - index: number; - layerIndex: number; - rotationIndex: number; event: Event; + scheduleId: Schedule['id']; startMoment: dayjs.Dayjs; currentTimezone: Timezone; color?: string; @@ -31,7 +30,7 @@ interface ScheduleSlotProps { const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { index, layerIndex, rotationIndex, event, startMoment, currentTimezone, color, label } = props; + const { event, scheduleId, startMoment, currentTimezone, color, label } = props; const { users } = event; const trackMouse = false; @@ -53,6 +52,8 @@ const ScheduleSlot: FC = observer((props) => { setMouseX(event.nativeEvent.offsetX); }, []); + const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now; + return (
      {!event.is_gap ? ( @@ -63,8 +64,21 @@ const ScheduleSlot: FC = observer((props) => { const title = getTitle(storeUser); + const isOncall = Boolean( + storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk) + ); + return ( - }> + + } + >
      { - const { user, currentTimezone, event } = props; - - const userStatus = 'success'; + const { user, currentTimezone, event, isOncall } = props; return (
      - -
      + + {isOncall && } {user?.username} diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index f979ffb1..0880c117 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -238,3 +238,28 @@ export const ExpandIcon = (props: IconProps) => { ); }; + +interface IsOncallIconProps { + className: string; +} + +export const IsOncallIcon = (props: IsOncallIconProps) => { + const { className } = props; + + return ( + + + + + ); +}; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 9fa919d2..65ab23fc 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -135,7 +135,12 @@ class SchedulePage extends React.Component Users from on-call schedule" step in escalation chains.
      - +
      diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 283cd2a2..16a4dce6 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -70,14 +70,14 @@ class SchedulesPage extends React.Component { if (item.on_call_now?.length > 0) { - return item.on_call_now.map((user, index) => { - return ( - -
      - - {user.username} -
      -
      - ); - }); + return ( + + {item.on_call_now.map((user, index) => { + return ( + +
      + + {user.username} +
      +
      + ); + })} +
      + ); } return null; }; @@ -275,7 +279,7 @@ class SchedulesPage extends React.Component { const type = item.quality > 70 ? 'primary' : 'warning'; - return {item.quality}%; + return {item.quality || 70}%; }; renderButtons = (item: Schedule) => { diff --git a/grafana-plugin/src/vars.css b/grafana-plugin/src/vars.css index 55917544..3f7d9278 100644 --- a/grafana-plugin/src/vars.css +++ b/grafana-plugin/src/vars.css @@ -27,6 +27,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 { @@ -50,4 +51,5 @@ --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; } From a3ebf5267002847e8a12063a8a27235f568e1081 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 7 Sep 2022 16:05:19 +0300 Subject: [PATCH 55/60] render empty bricks --- grafana-plugin/package.json | 6 +- grafana-plugin/src/components/Text/Text.tsx | 6 +- .../src/components/UserGroups/UserGroups.tsx | 6 +- .../UsersTimezones/UsersTimezones.module.css | 1 + .../containers/RotationForm/RotationForm.tsx | 3 +- .../RotationForm/ScheduleOverrideForm.tsx | 3 +- .../src/containers/Rotations/Rotations.tsx | 10 +- .../Rotations/ScheduleOverrides.tsx | 5 +- .../containers/ScheduleSlot/ScheduleSlot.tsx | 29 +- .../src/pages/schedule/Schedule.tsx | 22 +- .../src/pages/schedules_NEW/Schedules.tsx | 10 +- grafana-plugin/yarn.lock | 261 +++++++++++++++++- 12 files changed, 323 insertions(+), 39 deletions(-) diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 09fc4d86..0093af7d 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -78,13 +78,15 @@ }, "dependencies": { "@types/query-string": "^6.3.0", - "@types/react-transition-group": "1.x", + "@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", @@ -94,8 +96,8 @@ "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", "react-string-replace": "^0.4.4", - "sass-loader": "^13.0.2", "react-transition-group": "^4.4.5", + "sass-loader": "^13.0.2", "stylelint": "^13.13.1", "stylelint-config-standard": "^22.0.0", "throttle-debounce": "^2.1.0" diff --git a/grafana-plugin/src/components/Text/Text.tsx b/grafana-plugin/src/components/Text/Text.tsx index 7d76b937..71357fdb 100644 --- a/grafana-plugin/src/components/Text/Text.tsx +++ b/grafana-plugin/src/components/Text/Text.tsx @@ -21,6 +21,7 @@ interface TextProps extends HTMLAttributes { onTextChange?: (value: string) => void; clearBeforeEdit?: boolean; hidden?: boolean; + editModalTitle?: string; } interface TextType extends React.FC { @@ -47,6 +48,7 @@ const Text: TextType = (props) => { onTextChange, clearBeforeEdit = false, hidden = false, + editModalTitle = 'New value', } = props; const [isEditMode, setIsEditMode] = useState(false); @@ -81,7 +83,7 @@ const Text: TextType = (props) => { 'text--strong': strong, 'text--underline': underline, 'no-wrap': !wrap, - keyboard + keyboard, })} > {hidden ? PLACEHOLDER : children} @@ -112,7 +114,7 @@ const Text: TextType = (props) => { )} {isEditMode && ( - + ItemData; renderUser: (id: string) => React.ReactElement; + showError?: boolean; } const cx = cn.bind(styles); @@ -32,7 +33,7 @@ const DragHandle = () => ; const SortableHandleHoc = SortableHandle(DragHandle); const UserGroups = (props: UserGroupsProps) => { - const { value, onChange, isMultipleGroups, getItemData, renderUser } = props; + const { value, onChange, isMultipleGroups, getItemData, renderUser, showError } = props; const handleAddUserGroup = useCallback(() => { onChange([...value, []]); @@ -49,7 +50,7 @@ const UserGroups = (props: UserGroupsProps) => { if (k === index) { newGroups[i] = newGroups[i].filter((item, itemIndex) => itemIndex !== j); - onChange(newGroups.filter((group, index) => index === newGroups.length - 1 || group.length)); + onChange(newGroups.filter((group) => group.length)); return; } } @@ -134,6 +135,7 @@ const UserGroups = (props: UserGroupsProps) => { onChange={handleUserAdd} getOptionLabel={({ label, value }: SelectableValue) => } filterOptions={filterUsers} + showError={showError} />
      diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css index 1cbe655f..891b27e7 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css +++ b/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css @@ -76,6 +76,7 @@ } .avatar-group_inactive { + pointer-events: none; opacity: 0.2; transition: opacity 0.5s ease; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 8a314524..bbb2aeda 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -108,7 +108,7 @@ const RotationForm: FC = observer((props) => { } }, [isOpen]); - const [userGroups, setUserGroups] = useState(shiftId === 'new' ? [[store.userStore.currentUserPk]] : [[]]); + const [userGroups, setUserGroups] = useState([[]]); const getUser = (pk: User['pk']) => { return { @@ -276,6 +276,7 @@ const RotationForm: FC = observer((props) => { isMultipleGroups={true} getItemData={getUser} renderUser={renderUser} + showError={!userGroups.some((group) => group.length)} /> {/*
      */} diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 5070ff95..a34b117e 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -87,7 +87,7 @@ const ScheduleOverrideForm: FC = (props) => { dateTime(shiftMoment.add(24, 'hours').format('YYYY-MM-DD HH:mm:ss')) ); - const [userGroups, setUserGroups] = useState(shiftId === 'new' ? [[store.userStore.currentUserPk]] : [[]]); + const [userGroups, setUserGroups] = useState([[]]); const getUser = (pk: User['pk']) => { return { @@ -216,6 +216,7 @@ const ScheduleOverrideForm: FC = (props) => { isMultipleGroups={false} getItemData={getUser} renderUser={renderUser} + showError={!userGroups.some((group) => group.length)} /> {/*
      */} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 5f8f582b..080dacab 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -167,7 +167,7 @@ class Rotations extends Component {
      { - this.handleAddLayer(nextPriority); + this.handleAddLayer(nextPriority, startMoment); }} > + Add rotations layer @@ -219,7 +219,13 @@ class Rotations extends Component { }; handleAddRotation = (option: SelectOption) => { - this.setState({ shiftIdToShowRotationForm: 'new', layerPriority: option.value }); + const { startMoment } = this.props; + + this.setState({ + shiftIdToShowRotationForm: 'new', + layerPriority: option.value, + shiftMomentToShowRotationForm: startMoment, + }); }; hideRotationForm = () => { diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index d908f06b..2e884c17 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -4,7 +4,6 @@ import { Button, HorizontalGroup, Icon, ValuePicker } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import moment from 'moment'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; @@ -150,7 +149,9 @@ class ScheduleOverrides extends Component { - this.setState({ shiftIdToShowOverrideForm: 'new' }); + const { startMoment } = this.props; + + this.setState({ shiftIdToShowOverrideForm: 'new', shiftMomentToShowOverrideForm: startMoment }); }; handleHide = () => { diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index bac0dbda..bd233ab9 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -56,7 +56,27 @@ const ScheduleSlot: FC = observer((props) => { return (
      - {!event.is_gap ? ( + {event.is_empty ? ( +
      + {label && ( +
      + {label} +
      + )} +
      + ) : event.is_gap ? ( + }> +
      + {trackMouse && mouseX > 0 &&
      } + {label &&
      {label}
      } +
      + + ) : ( users.map(({ pk: userPk }, userIndex) => { const storeUser = store.userStore.items[userPk]; @@ -107,13 +127,6 @@ const ScheduleSlot: FC = observer((props) => { ); }) - ) : ( - }> -
      - {trackMouse && mouseX > 0 &&
      } - {label &&
      {label}
      } -
      - )}
      ); diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 65ab23fc..296e7a54 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -64,7 +64,6 @@ class SchedulePage extends React.Component query: { id }, } = this.props; - store.scheduleStore.updateItem(id); store.scheduleStore.updateFrequencyOptions(); store.scheduleStore.updateDaysOptions(); await store.scheduleStore.updateOncallShifts(id); // TODO we should know shifts to render Rotations @@ -93,7 +92,9 @@ class SchedulePage extends React.Component - {schedule?.name} + + {schedule?.name} + {/* ); } + 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, @@ -221,6 +233,8 @@ class SchedulePage extends React.Component const { startMoment } = this.state; + store.scheduleStore.updateItem(scheduleId); // to refresh current oncall users + return Promise.all([ store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'), store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'), @@ -282,8 +296,10 @@ class SchedulePage extends React.Component handleTimezoneChange = (value: Timezone) => { const { store } = this.props; + const oldTimezone = store.currentTimezone; + this.setState((oldState) => { - const wDiff = oldState.startMoment.diff(getStartOfWeek(store.currentTimezone), 'weeks'); + const wDiff = oldState.startMoment.diff(getStartOfWeek(oldTimezone), 'weeks'); return { ...oldState, startMoment: getStartOfWeek(value).add(wDiff, 'weeks') }; }, this.updateEvents); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 16a4dce6..25b2ec76 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -77,7 +77,7 @@ class SchedulesPage extends React.Component } /> - + />*/} ); }; diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 6dda27d4..a4440abf 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1381,6 +1381,15 @@ esquery "^1.4.0" jsdoc-type-pratt-parser "~2.2.5" +"@es-joy/jsdoccomment@~0.31.0": + version "0.31.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.31.0.tgz#dbc342cc38eb6878c12727985e693eaef34302bc" + integrity sha512-tc1/iuQcnaiSIUVad72PBierDFpsxdUHtEF/OrfqvM1CBAsIoMP51j52jTMb3dXriwhieTo289InzZj72jL3EQ== + dependencies: + comment-parser "1.3.1" + esquery "^1.4.0" + jsdoc-type-pratt-parser "~3.1.0" + "@eslint/eslintrc@^1.2.1", "@eslint/eslintrc@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" @@ -1396,6 +1405,21 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.4.0" + globals "^13.15.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + "@formatjs/ecma402-abstract@1.11.10": version "1.11.10" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.10.tgz#1b61909ce069d1fa62bafb163aaff59d524c094d" @@ -1714,6 +1738,15 @@ uplot "1.6.22" uuid "8.3.2" +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.4" + "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -1723,6 +1756,16 @@ debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -3012,7 +3055,7 @@ "@types/history" "*" "@types/react" "*" -"@types/react-transition-group@^4.4.0": +"@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA== @@ -3167,6 +3210,21 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/eslint-plugin@^5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" + integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== + dependencies: + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/type-utils" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + functional-red-black-tree "^1.0.1" + ignore "^5.2.0" + regexpp "^3.2.0" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/parser@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.16.0.tgz#e4de1bde4b4dad5b6124d3da227347616ed55508" @@ -3185,6 +3243,14 @@ "@typescript-eslint/types" "5.16.0" "@typescript-eslint/visitor-keys" "5.16.0" +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + "@typescript-eslint/type-utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.16.0.tgz#b482bdde1d7d7c0c7080f7f2f67ea9580b9e0692" @@ -3194,11 +3260,26 @@ debug "^4.3.2" tsutils "^3.21.0" +"@typescript-eslint/type-utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" + integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" + tsutils "^3.21.0" + "@typescript-eslint/types@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.16.0.tgz#5827b011982950ed350f075eaecb7f47d3c643ee" integrity sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g== +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== + "@typescript-eslint/typescript-estree@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.16.0.tgz#32259459ec62f5feddca66adc695342f30101f61" @@ -3212,6 +3293,19 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/utils@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.16.0.tgz#42218b459d6d66418a4eb199a382bdc261650679" @@ -3224,6 +3318,18 @@ eslint-scope "^5.1.1" eslint-utils "^3.0.0" +"@typescript-eslint/utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" + integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== + dependencies: + "@types/json-schema" "^7.0.9" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + "@typescript-eslint/visitor-keys@5.16.0": version "5.16.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.16.0.tgz#f27dc3b943e6317264c7492e390c6844cd4efbbb" @@ -3232,6 +3338,14 @@ "@typescript-eslint/types" "5.16.0" eslint-visitor-keys "^3.0.0" +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== + dependencies: + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -3620,6 +3734,11 @@ array-includes@^3.1.5: get-intrinsic "^1.1.1" is-string "^1.0.7" +array-move@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/array-move/-/array-move-4.0.0.tgz#2c3730f056cc926f62a59769a5a8cda2fb6d8c55" + integrity sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ== + array-slice@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz" @@ -3649,7 +3768,7 @@ array.prototype.flat@^1.2.5: define-properties "^1.1.3" es-abstract "^1.19.0" -array.prototype.flatmap@^1.2.5: +array.prototype.flatmap@^1.2.5, array.prototype.flatmap@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz#a7e8ed4225f4788a70cd910abcf0791e76a5534f" integrity sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg== @@ -5059,6 +5178,11 @@ date-fns@2.29.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.1.tgz#9667c2615525e552b5135a3116b95b1961456e60" integrity sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw== +dayjs@^1.11.5: + version "1.11.5" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93" + integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA== + debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -5609,12 +5733,25 @@ eslint-plugin-jsdoc@38.0.6: semver "^7.3.5" spdx-expression-parse "^3.0.1" +eslint-plugin-jsdoc@^39.3.6: + version "39.3.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.3.6.tgz#6ba29f32368d72a51335a3dc9ccd22ad0437665d" + integrity sha512-R6dZ4t83qPdMhIOGr7g2QII2pwCjYyKP+z0tPOfO1bbAbQyKC20Y2Rd6z1te86Lq3T7uM8bNo+VD9YFpE8HU/g== + dependencies: + "@es-joy/jsdoccomment" "~0.31.0" + comment-parser "1.3.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.4.0" + semver "^7.3.7" + spdx-expression-parse "^3.0.1" + eslint-plugin-react-hooks@4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.3.0.tgz#318dbf312e06fab1c835a4abef00121751ac1172" integrity sha512-XslZy0LnMn+84NEG9jSGR6eGqaZB3133L8xewQo3fQagbQuGt7a63gf+P1NGKZavEYEC3UXaWEAA/AqDkuN6xA== -eslint-plugin-react-hooks@4.6.0: +eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== @@ -5639,6 +5776,26 @@ eslint-plugin-react@7.29.4: semver "^6.3.0" string.prototype.matchall "^4.0.6" +eslint-plugin-react@^7.31.7: + version "7.31.7" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.31.7.tgz#36fb1c611a7db5f757fce09cbbcc01682f8b0fbb" + integrity sha512-8NldBTeYp/kQoTV1uT0XF6HcmDqbgZ0lNPkN0wlRw8DJKXEnaWu+oh/6gt3xIhzvQ35wB2Y545fJhIbJSZ2NNw== + dependencies: + array-includes "^3.1.5" + array.prototype.flatmap "^1.3.0" + doctrine "^2.1.0" + estraverse "^5.3.0" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.5" + object.fromentries "^2.0.5" + object.hasown "^1.1.1" + object.values "^1.1.5" + prop-types "^15.8.1" + resolve "^2.0.0-next.3" + semver "^6.3.0" + string.prototype.matchall "^4.0.7" + eslint-plugin-rulesdir@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.1.tgz" @@ -5759,6 +5916,51 @@ eslint@8.20.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== + dependencies: + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + functional-red-black-tree "^1.0.1" + glob-parent "^6.0.1" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.1" + regexpp "^3.2.0" + strip-ansi "^6.0.1" + strip-json-comments "^3.1.0" + text-table "^0.2.0" + espree@^9.3.1, espree@^9.3.2: version "9.3.3" resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" @@ -5768,6 +5970,15 @@ espree@^9.3.1, espree@^9.3.2: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -5854,7 +6065,7 @@ execall@^2.0.0: dependencies: clone-regexp "^2.1.0" -exenv@^1.2.2: +exenv@^1.2.0, exenv@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== @@ -6449,7 +6660,7 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.3, globby@^11.0.4: +globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -6489,6 +6700,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz" @@ -7868,6 +8084,11 @@ jsdoc-type-pratt-parser@~2.2.5: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-2.2.5.tgz#c9f93afac7ee4b5ed4432fe3f09f7d36b05ed0ff" integrity sha512-2a6eRxSxp1BW040hFvaJxhsCMI9lT8QB8t14t+NY5tC5rckIR0U9cr2tjOeaFirmEOy6MHvmJnY7zTBHq431Lw== +jsdoc-type-pratt-parser@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e" + integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw== + jsdom@^16.6.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -8898,7 +9119,7 @@ object.fromentries@^2.0.5: define-properties "^1.1.3" es-abstract "^1.19.1" -object.hasown@^1.1.0: +object.hasown@^1.1.0, object.hasown@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.1.tgz#ad1eecc60d03f49460600430d97f23882cf592a3" integrity sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A== @@ -9946,7 +10167,7 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@2.7.1: +prettier@2.7.1, prettier@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== @@ -10381,6 +10602,14 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-draggable@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.5.tgz#9e37fe7ce1a4cf843030f521a0a4cc41886d7e7c" + integrity sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g== + dependencies: + clsx "^1.1.1" + prop-types "^15.8.1" + react-dropzone@14.2.2: version "14.2.2" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.2.tgz#a75a0676055fe9e2cb78578df4dedb4c42b54f98" @@ -10455,11 +10684,21 @@ react-is@^17.0.1, react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-modal@^3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.15.1.tgz#950ce67bfef80971182dd0ed38f2d9b1a681288b" + integrity sha512-duB9bxOaYg7Zt6TMFldIFxQRtSP+Dg3F1ZX3FXxSUn+3tZZ/9JCgeAQKDg7rhZSAqopq8TFRw3yIbnx77gyFTw== + dependencies: + exenv "^1.2.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.0" + warning "^4.0.3" + react-popper-tooltip@^4.3.1: version "4.4.2" resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-4.4.2.tgz#0dc4894b8e00ba731f89bd2d30584f6032ec6163" @@ -10579,7 +10818,7 @@ react-transition-group@4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" -react-transition-group@^4.3.0, react-transition-group@^4.4.2: +react-transition-group@^4.3.0, react-transition-group@^4.4.2, react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== @@ -11580,7 +11819,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2 is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string.prototype.matchall@^4.0.6: +string.prototype.matchall@^4.0.6, string.prototype.matchall@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz#8e6ecb0d8a1fb1fda470d81acecb2dba057a481d" integrity sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg== @@ -12486,7 +12725,7 @@ walker@^1.0.7: dependencies: makeerror "1.0.12" -warning@^4.0.2: +warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== From 23d706cc7f0afafc613aa44d422b2b5665263087 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 8 Sep 2022 12:42:16 +0300 Subject: [PATCH 56/60] add schedules filtering, add checing web schedules feature flag --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 3 +- .../ScheduleCounter/ScheduleCounter.tsx | 5 +- .../SchedulesFilters_NEW/SchedulesFilters.tsx | 5 +- .../src/components/Table/Table.module.css | 4 + .../src/components/UserGroups/UserGroups.tsx | 12 +- .../containers/RotationForm/RotationForm.tsx | 2 +- .../RotationForm/ScheduleOverrideForm.tsx | 2 +- .../containers/Rotations/Rotations.module.css | 4 +- .../containers/ScheduleSlot/ScheduleSlot.tsx | 16 +-- .../escalation_chain.types.ts | 1 + .../src/models/schedule/schedule.ts | 21 +++- .../src/models/schedule/schedule.types.ts | 1 + grafana-plugin/src/pages/index.ts | 8 +- .../src/pages/schedule/Schedule.module.css | 4 + .../src/pages/schedule/Schedule.tsx | 9 +- .../pages/schedules_NEW/Schedules.module.css | 4 + .../src/pages/schedules_NEW/Schedules.tsx | 103 ++++++++++++++---- grafana-plugin/src/state/features.ts | 1 + grafana-plugin/src/utils/hooks.tsx | 22 +++- 19 files changed, 166 insertions(+), 61 deletions(-) diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 3cd4d4a7..286bc509 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -139,9 +139,10 @@ export const Root = observer((props: AppRootProps) => { grafanaUser: window.grafanaBootData.user, enableLiveSettings: store.hasFeature(AppFeature.LiveSettings), enableCloudPage: store.hasFeature(AppFeature.CloudConnection), + enableNewSchedulesPage: store.hasFeature(AppFeature.WebSchedules), backendLicense, }), - [meta, pathWithoutLeadingSlash, page, store.features] + [meta, pathWithoutLeadingSlash, page, store.features, backendLicense] ) ); useEffect(() => { diff --git a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx index f436395b..d88013c6 100644 --- a/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx +++ b/grafana-plugin/src/components/ScheduleCounter/ScheduleCounter.tsx @@ -12,6 +12,7 @@ interface ScheduleCounterProps { count: number; tooltipTitle: string; tooltipContent: React.ReactNode; + onHover: () => void; } const typeToIcon = { @@ -37,7 +38,7 @@ const typeToBackgroundColor = { const cx = cn.bind(styles); const ScheduleCounter: FC = (props) => { - const { type, count, tooltipTitle, tooltipContent } = props; + const { type, count, tooltipTitle, tooltipContent, onHover } = props; return ( = (props) => {
      } > -
      +
      {count} diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx index e893c2cf..4edc283f 100644 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx @@ -69,11 +69,12 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => { { }, { label: 'API', - value: ScheduleType.API, + value: ScheduleType.Calendar, }, ]} value={value.type} diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css index c2d7d959..df6caa08 100644 --- a/grafana-plugin/src/components/Table/Table.module.css +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -17,6 +17,10 @@ background: rgba(63, 62, 62, 0.45); } +.root th:first-child { + padding-left: 20px; +} + .root td { min-height: 60px; padding: 10px 0; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index 086aefc1..b726e6e4 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -64,7 +64,11 @@ const UserGroups = (props: UserGroupsProps) => { } const newGroups = [...value]; - const lastGroup = newGroups[newGroups.length - 1]; + let lastGroup = newGroups[newGroups.length - 1]; + if (!lastGroup) { + lastGroup = []; + newGroups.push(lastGroup); + } lastGroup.push(pk); @@ -73,11 +77,6 @@ const UserGroups = (props: UserGroupsProps) => { [value] ); - const filterUsers = useCallback( - ({ value: itemValue }) => !value.some((group: Array) => group.some((pk) => pk === itemValue)), - [value] - ); - const items = useMemo(() => toPlainArray(value, getItemData), [value]); const onSortEnd = useCallback( @@ -134,7 +133,6 @@ const UserGroups = (props: UserGroupsProps) => { value={null} onChange={handleUserAdd} getOptionLabel={({ label, value }: SelectableValue) => } - filterOptions={filterUsers} showError={showError} /> diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index bbb2aeda..e101e150 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -256,7 +256,7 @@ const RotationForm: FC = observer((props) => { [L{shiftId === 'new' ? layerPriority : shift?.priority_level}] - {shiftId === 'new' ? 'New Rotation' : shift?.id} + {shiftId === 'new' ? 'New Rotation' : 'Update Rotation'} diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index a34b117e..b73373f3 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -198,7 +198,7 @@ const ScheduleOverrideForm: FC = (props) => { > - {shiftId === 'new' ? 'New Override' : shift?.id} + {shiftId === 'new' ? 'New Override' : 'Update Override'} diff --git a/grafana-plugin/src/containers/Rotations/Rotations.module.css b/grafana-plugin/src/containers/Rotations/Rotations.module.css index ce2935d5..e79d9f24 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.module.css +++ b/grafana-plugin/src/containers/Rotations/Rotations.module.css @@ -1,7 +1,7 @@ .root { - border: var(--border-medium); + border: var(--rotations-border); border-radius: 2px; - background: var(--primary-background); + background: var(--rotations-background); } .current-time { diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index bd233ab9..55043203 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -56,7 +56,14 @@ const ScheduleSlot: FC = observer((props) => { return (
      - {event.is_empty ? ( + {event.is_gap ? ( + }> +
      + {trackMouse && mouseX > 0 &&
      } + {label &&
      {label}
      } +
      + + ) : event.is_empty ? (
      = observer((props) => {
      )}
      - ) : event.is_gap ? ( - }> -
      - {trackMouse && mouseX > 0 &&
      } - {label &&
      {label}
      } -
      - ) : ( users.map(({ pk: userPk }, userIndex) => { const storeUser = store.userStore.items[userPk]; diff --git a/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts b/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts index 2ab202a8..55842020 100644 --- a/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts +++ b/grafana-plugin/src/models/escalation_chain/escalation_chain.types.ts @@ -1,5 +1,6 @@ export interface EscalationChain { id: string; + pk: string; //? because GET related_escalation_chains returns {name,pk}[] is_default: boolean; name: string; number_of_integrations: number; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 834ec036..41dc1b23 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -5,6 +5,7 @@ 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 { makeRequest } from 'network'; @@ -63,6 +64,9 @@ export class ScheduleStore extends BaseStore { @observable.shallow shifts: { [id: string]: Shift } = {}; + @observable.shallow + relatedEscalationChains: { [id: string]: EscalationChain[] } = {}; + @observable.shallow rotations: { [id: string]: { @@ -122,7 +126,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, @@ -259,6 +263,19 @@ export class ScheduleStore extends BaseStore { 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; + }; + async updateRotationMock(rotationId: Rotation['id'], fromString: string, currentTimezone: Timezone) { if (this.rotations[rotationId]?.[fromString]) { return; @@ -342,7 +359,7 @@ export class ScheduleStore extends BaseStore { 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) { diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 877cb8bc..c5a02e4a 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -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 { diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index 30f83e48..15561d51 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -64,14 +64,14 @@ export const pages: PageDefinition[] = [ { component: SchedulesPage2, icon: 'calendar-alt', - id: 'schedules-old', - text: 'Schedules OLD', + id: 'schedules', + text: 'Schedules', }, { component: SchedulesPage, icon: 'calendar-alt', - id: 'schedules', - text: 'Schedules', + id: 'schedules-new', + text: 'Schedules α', }, { component: SchedulePage, diff --git a/grafana-plugin/src/pages/schedule/Schedule.module.css b/grafana-plugin/src/pages/schedule/Schedule.module.css index 8c24c26a..2e5746bd 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.module.css +++ b/grafana-plugin/src/pages/schedule/Schedule.module.css @@ -1,7 +1,11 @@ + .root { max-width: 1600px; margin: 0 auto; margin-top: 24px; + + --rotations-border: var(--border-medium); + --rotations-background: var(--primary-background); } .header { diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 296e7a54..c82210d0 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -22,6 +22,7 @@ import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides'; 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'; @@ -58,6 +59,10 @@ class SchedulePage extends React.Component const { store } = this.props; const { startMoment } = this.state; + if (!store.hasFeature(AppFeature.WebSchedules)) { + getLocationSrv().update({ query: { page: 'schedules' } }); + } + store.userStore.updateItems(); const { @@ -89,7 +94,7 @@ class SchedulePage extends React.Component
      - + @@ -334,7 +339,7 @@ class SchedulePage extends React.Component } = this.props; store.scheduleStore.delete(scheduleId).then(() => { - getLocationSrv().update({ query: { page: 'schedules' } }); + getLocationSrv().update({ query: { page: 'schedules-new' } }); }); }; diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css index 19fd4532..4857e7cc 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -11,6 +11,10 @@ margin: 20px 0; } +.loader { + padding-left: 20px; +} + /* .root .expanded-row { background: var(--secondary-background); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 25b2ec76..2bceb135 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { getLocationSrv } from '@grafana/runtime'; -import { Button, HorizontalGroup, IconButton, VerticalGroup } from '@grafana/ui'; +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'; @@ -17,13 +18,12 @@ 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 Rotation from 'containers/Rotation/Rotation'; import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; import { getFromString } from 'models/schedule/schedule.helpers'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; -import { getTzOffsetString } from 'models/timezone/timezone.helpers'; 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'; @@ -48,7 +48,7 @@ class SchedulesPage extends React.Component 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 ( <>
      @@ -132,7 +145,7 @@ class SchedulesPage extends React.Component
    cx('expanded-row'), }} + emptyText={ +
    + {data ? Not found : Loading schedules...} +
    + } /> @@ -186,7 +204,9 @@ class SchedulesPage extends React.Component { - store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'); + store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'); }); }; @@ -218,25 +240,37 @@ class SchedulesPage extends React.Component { - const escalationsCount = Math.floor(Math.random() * 10) + 1; - const warningsCount = Math.floor(Math.random() * 10) + 1; + renderStatus = (item: Schedule) => { + const { + store: { scheduleStore }, + } = this.props; + + const relatedEscalationChains = scheduleStore.relatedEscalationChains[item.id]; return ( - Grafana 1 -
    - Grafana 2 -
    - Grafana 3 - + + {relatedEscalationChains ? ( + relatedEscalationChains.length ? ( + relatedEscalationChains.map((escalationChain) => ( + + {escalationChain.name} + + )) + ) : ( + 'Not used yet' + ) + ) : ( + Loading related escalation chains.... + )} + } + onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)} /> {/* { - this.setState({ filters }); + 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 = () => { @@ -316,6 +360,17 @@ class SchedulesPage extends React.Component { + const { store } = this.props; + const { scheduleStore } = store; + + return () => { + scheduleStore.updateRelatedEscalationChains(scheduleId).then(() => { + this.forceUpdate(); + }); + }; + }; } export default withMobXProviderContext(SchedulesPage); diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index bf915f19..856d26d0 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -5,4 +5,5 @@ export enum AppFeature { MobileApp = 'mobile_app', CloudNotifications = 'grafana_cloud_notifications', CloudConnection = 'grafana_cloud_connection', + WebSchedules = 'web_schedules', } diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index 93052831..7c4adc74 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useMemo } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { AppRootProps, NavModelItem } from '@grafana/data'; @@ -18,11 +17,12 @@ type Args = { }; enableLiveSettings: boolean; enableCloudPage: boolean; + enableNewSchedulesPage: boolean; backendLicense: string; }; export function useForceUpdate() { - const [value, setValue] = useState(0); + const [, setValue] = useState(0); return () => setValue((value) => value + 1); } @@ -34,6 +34,7 @@ export function useNavModel({ grafanaUser, enableLiveSettings, enableCloudPage, + enableNewSchedulesPage, backendLicense, }: Args) { return useMemo(() => { @@ -49,7 +50,8 @@ export function useNavModel({ hideFromTabs || (role === 'Admin' && grafanaUser.orgRole !== role) || (id === 'live-settings' && !enableLiveSettings) || - (id === 'cloud' && !enableCloudPage), + (id === 'cloud' && !enableCloudPage) || + (id === 'schedules-new' && !enableNewSchedulesPage), }); if (page === id) { @@ -74,7 +76,17 @@ export function useNavModel({ node, main: node, }; - }, [meta.info.logos.large, pages, path, page, enableLiveSettings, enableCloudPage]); + }, [ + meta.info.logos.large, + pages, + path, + page, + enableLiveSettings, + enableCloudPage, + backendLicense, + enableNewSchedulesPage, + grafanaUser.orgRole, + ]); } export function usePrevious(value: any) { From c3d5bcc2d88d9f018fca38ae76b415e61cc19c52 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 8 Sep 2022 15:42:12 +0300 Subject: [PATCH 57/60] use utc offset to group users --- .../UsersTimezones/UsersTimezones.module.css | 1 + .../UsersTimezones/UsersTimezones.tsx | 86 ++++++++++-------- .../src/models/schedule/schedule.ts | 87 +++---------------- .../src/models/user/user.helpers.tsx | 2 +- grafana-plugin/src/models/user/user.ts | 12 ++- .../src/pages/schedule/Schedule.tsx | 11 ++- .../src/pages/schedules_NEW/Schedules.tsx | 4 +- 7 files changed, 86 insertions(+), 117 deletions(-) rename grafana-plugin/src/{components => containers}/UsersTimezones/UsersTimezones.module.css (98%) rename grafana-plugin/src/{components => containers}/UsersTimezones/UsersTimezones.tsx (79%) diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css similarity index 98% rename from grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css rename to grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css index 891b27e7..63d1081b 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.module.css +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css @@ -73,6 +73,7 @@ border-radius: 8px; text-align: center; transition: opacity 200ms ease, left 200ms ease; + pointer-events: none; } .avatar-group_inactive { diff --git a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx similarity index 79% rename from grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx rename to grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx index 6e1c37e9..22080b8d 100644 --- a/grafana-plugin/src/components/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -10,11 +10,12 @@ 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 { - users: User[]; + userIds: Array; tz: Timezone; onTzChange: (tz: Timezone) => void; onCallNow: Array>; @@ -27,11 +28,26 @@ const hoursToSplit = 3; const jLimit = 24 / hoursToSplit; const UsersTimezones: FC = (props) => { - const { users, tz, onTzChange, onCallNow } = props; + const { userIds, tz, onTzChange, onCallNow } = props; + + const store = useStore(); const [count, setCount] = useState(0); const [currentMoment, setCurrentMoment] = useState(dayjs().tz(tz)); + useEffect(() => { + userIds.forEach((userId) => { + if (!store.userStore.items[userId]) { + store.userStore.updateItem(userId); + } + }); + }, [userIds]); + + const users = useMemo( + () => userIds.map((userId) => store.userStore.items[userId]).filter(Boolean), + [userIds, store.userStore.items] + ); + useEffect(() => { setCurrentMoment(currentMoment.tz(tz).startOf('minute')); }, [tz]); @@ -121,9 +137,10 @@ const UserAvatars = (props: UserAvatarsProps) => { const userGroups = useMemo(() => { return users .reduce((memo, user) => { - let group = memo.find((group) => group.timezone === user.timezone); + const userUtcOffset = dayjs().tz(user.timezone).utcOffset(); + let group = memo.find((group) => group.utcOffset === userUtcOffset); if (!group) { - group = { timezone: user.timezone, users: [] }; + group = { utcOffset: userUtcOffset, users: [] }; memo.push(group); } group.users.push(user); @@ -131,13 +148,10 @@ const UserAvatars = (props: UserAvatarsProps) => { return memo; }, []) .sort((a, b) => { - const aOffset = dayjs().tz(a.timezone).utcOffset(); - const bOffset = dayjs().tz(b.timezone).utcOffset(); - - if (aOffset > bOffset) { + if (a.utcOffset > b.utcOffset) { return 1; } - if (aOffset < bOffset) { + if (a.utcOffset < b.utcOffset) { return -1; } @@ -145,28 +159,22 @@ const UserAvatars = (props: UserAvatarsProps) => { }); }, [users]); - const getAvatarClickHandler = useCallback((timezone: Timezone) => { - return () => { - onTzChange(timezone); - }; - }, []); - - const [activeTimezone, setActiveTimezone] = useState(undefined); + const [activeUtcOffset, setActiveUtcOffset] = useState(undefined); return (
    {userGroups.map((group) => { - const userCurrentMoment = dayjs(currentMoment).tz(group.timezone); + 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 ( void; - timezone: Timezone; - onSetActiveTimezone: (timezone: Timezone) => void; - activeTimezone: Timezone; + utcOffset: number; + onSetActiveUtcOffset: (utcOffset: number | undefined) => void; + activeUtcOffset: number; + onTzChange: (timezone: Timezone) => void; onCallNow: Array>; } @@ -198,14 +206,14 @@ const AvatarGroup = (props: AvatarGroupProps) => { users: propsUsers, currentMoment, xPos, - onClick, - timezone, - onSetActiveTimezone, - activeTimezone, + onTzChange, + utcOffset, + onSetActiveUtcOffset, + activeUtcOffset, onCallNow, } = props; - const active = activeTimezone && activeTimezone === timezone; + const active = !isNaN(activeUtcOffset) && activeUtcOffset === utcOffset; const translateLeft = -AVATAR_WIDTH / 2; @@ -225,15 +233,22 @@ const AvatarGroup = (props: AvatarGroupProps) => { }); }, [propsUsers]); + const getAvatarClickHandler = useCallback((timezone: Timezone) => { + return () => { + onTzChange(timezone); + }; + }, []); + const width = active ? users.length * AVATAR_WIDTH + (users.length - 1) * AVATAR_GAP : AVATAR_WIDTH; return (
    onSetActiveTimezone(timezone)} - onMouseLeave={() => onSetActiveTimezone(undefined)} + 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); @@ -254,6 +269,7 @@ const AvatarGroup = (props: AvatarGroupProps) => { zIndex: array.length - index - 1, /* opacity: userHour >= 9 && userHour < 18 ? 1 : 0.5,*/ }} + onClick={getAvatarClickHandler(user.timezone)} > {isOncall && } diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index 41dc1b23..9d127b91 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -8,6 +8,7 @@ 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'; @@ -26,34 +27,6 @@ const DEFAULT_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; let I = 0; -function getUsers() { - const rnd = Math.random(); - /* - - if (rnd > 0.66) { - return []; - } -*/ - - const users = [ - 'U5WE86241LNEA', - 'U9XM1G7KTE3KW', - 'UYKS64M6C59XM', - 'UFFIRDUFXA6W3', - 'UPRMSTP9LCADE', - 'UR6TVJWZYV19M', - 'UHRMQQ7KETPCS', - ]; - - /* if (rnd > 0.33) { - return [users[Math.floor(Math.random() * users.length)], users[Math.floor(Math.random() * users.length)]]; - }*/ - - return ['UPRMSTP9LCADE', 'UHRMQQ7KETPCS']; - - return [users[Math.floor(Math.random() * users.length)]]; -} - export class ScheduleStore extends BaseStore { @observable searchResult: { [key: string]: Array } = {}; @@ -67,6 +40,9 @@ export class ScheduleStore extends BaseStore { @observable.shallow relatedEscalationChains: { [id: string]: EscalationChain[] } = {}; + @observable.shallow + relatedUsers: { [id: string]: { [key: string]: Event } } = {}; + @observable.shallow rotations: { [id: string]: { @@ -276,55 +252,18 @@ export class ScheduleStore extends BaseStore { return response; }; - async updateRotationMock(rotationId: Rotation['id'], fromString: string, currentTimezone: Timezone) { - if (this.rotations[rotationId]?.[fromString]) { - return; - } - - const response = await new Promise((resolve, reject) => { - setTimeout(() => { - if (!fromString) { - fromString = dayjs().startOf('week').format('YYYY-MM-DDTHH:mm:ss.000Z'); - } - - let startMoment = dayjs(fromString); - const utcOffset = dayjs().tz(currentTimezone).utcOffset(); - - startMoment = startMoment.add(utcOffset, 'minutes'); - //const startMoment = dayjs().utc().startOf('week'); - - const shifts = []; - for (let i = 0; i < 7; i++) { - const shiftDuration = (12 + Math.floor(Math.random() * 12)) * 60 * 60; - const gapDuration = 24 * 60 * 60 - shiftDuration; - - shifts.push({ - pk: I++, - start: startMoment.add(24 * i, 'hour'), - duration: shiftDuration, - users: getUsers(), - }); - - shifts.push({ - pk: I++, - start: startMoment.add(24 * i, 'hour').add(shiftDuration, 'seconds'), - duration: gapDuration, - is_gap: true, - }); - } - - resolve({ id: rotationId, shifts }); - }, 500); + updateRelatedUsers = async (id: Schedule['id']) => { + const { users } = await makeRequest(`/schedules/${id}/next_shifts_per_user`, { + method: 'GET', }); - this.rotations = { - ...this.rotations, - [rotationId]: { - ...this.rotations[rotationId], - [fromString]: response as Rotation, - }, + this.relatedUsers = { + ...this.relatedUsers, + [id]: users, }; - } + + return users; + }; async updateOncallShifts(scheduleId: Schedule['id']) { const { results } = await makeRequest(`/oncall_shifts/`, { diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index ec729ce0..bc633165 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -48,7 +48,7 @@ export const getTimezone = (user: User) => { 'Matvey Kukuy': 'Asia/Tel_Aviv', }; - return user.timezone || tzByName[user.username] || dayjs.tz.guess(); + return user.timezone || tzByName[user.username] || 'UTC'; }; export const getUserNotificationsSummary = (user: User) => { diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 35293dc9..b837c62d 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -59,6 +59,16 @@ export class UserStore extends BaseStore { [user.pk]: { ...user, timezone: getTimezone(user) }, }; + // TODO comment + if (user.timezone) { + this.update(user.pk, { timezone: 'UTC' }); + } + + // TODO uncomment + /*if (!user.timezone) { + this.update(user.pk, { timezone: dayjs.tz.guess() }); + }*/ + this.currentUserPk = user.pk; } @@ -68,7 +78,7 @@ export class UserStore extends BaseStore { this.items = { ...this.items, - [user.pk]: user, + [user.pk]: { ...user, timezone: getTimezone(user) }, }; } diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index c82210d0..0cdbc4da 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -15,11 +15,11 @@ 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 UsersTimezones from 'components/UsersTimezones/UsersTimezones'; 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'; @@ -59,9 +59,9 @@ class SchedulePage extends React.Component const { store } = this.props; const { startMoment } = this.state; - if (!store.hasFeature(AppFeature.WebSchedules)) { + /*if (!store.hasFeature(AppFeature.WebSchedules)) { getLocationSrv().update({ query: { page: 'schedules' } }); - } + }*/ store.userStore.updateItems(); @@ -143,7 +143,9 @@ class SchedulePage extends React.Component
    @@ -239,6 +241,7 @@ class SchedulePage extends React.Component 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'), diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 2bceb135..f9a76c89 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -57,9 +57,9 @@ class SchedulesPage extends React.Component Date: Fri, 9 Sep 2022 13:16:36 +0300 Subject: [PATCH 58/60] save browser timezone in user profile --- .../SchedulesFilters_NEW/SchedulesFilters.tsx | 2 +- .../containers/ScheduleSlot/ScheduleSlot.tsx | 5 +++++ .../SchedulesFilters/SchedulesFilters.tsx | 11 +++++----- grafana-plugin/src/dummy/dummy.ts | 1 + .../src/models/alertgroup/alertgroup.ts | 3 +-- .../src/models/user/user.helpers.tsx | 15 +------------ grafana-plugin/src/models/user/user.ts | 22 ++++++++----------- grafana-plugin/tsconfig.json | 4 ++-- 8 files changed, 25 insertions(+), 38 deletions(-) create mode 100644 grafana-plugin/src/dummy/dummy.ts diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx index 4edc283f..dbcbd942 100644 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx @@ -1,6 +1,6 @@ import React, { ChangeEvent, useCallback, useMemo, useState } from 'react'; -import { DatePickerWithInput, Field, HorizontalGroup, Icon, Input, RadioButtonGroup, Field } from '@grafana/ui'; +import { DatePickerWithInput, Field, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { ScheduleType } from 'models/schedule/schedule.types'; diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 55043203..406bef15 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -80,6 +80,11 @@ const ScheduleSlot: FC = observer((props) => { 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); diff --git a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx index fb629a9b..d6358951 100644 --- a/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx +++ b/grafana-plugin/src/containers/SchedulesFilters/SchedulesFilters.tsx @@ -1,4 +1,5 @@ import React from 'react'; + import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -11,15 +12,13 @@ const cx = cn.bind(styles); interface SchedulesFiltersProps {} const SchedulesFilters = observer((props: SchedulesFiltersProps) => { - const { } = props; + const {} = props; - const store = useStore(); + const store = useStore(); - const { } = store; + const {} = store; - return ( -
    - ); + return
    ; }); export default SchedulesFilters; diff --git a/grafana-plugin/src/dummy/dummy.ts b/grafana-plugin/src/dummy/dummy.ts new file mode 100644 index 00000000..e2e7845b --- /dev/null +++ b/grafana-plugin/src/dummy/dummy.ts @@ -0,0 +1 @@ +interface Dummy {} diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 8bc67fca..45344f60 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -6,8 +6,7 @@ import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { showApiError, refreshPageError } from 'utils'; -import { openErrorNotification } from 'utils'; +import { showApiError, refreshPageError, openErrorNotification } from 'utils'; import { Alert, AlertAction, IncidentStatus } from './alertgroup.types'; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index bc633165..7af6f17c 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -35,20 +35,7 @@ export const getRole = (role: UserRole) => { }; export const getTimezone = (user: User) => { - const tzByName = { - Maxim: 'UTC', - 'Matías Bordese': 'America/Montevideo', - 'Michael Derynck': 'America/Vancouver', - 'Yulia Shanyrova': 'Europe/Amsterdam', - 'Maxim Mordasov': 'Europe/Moscow', - 'Vadim Stepanov': 'Europe/London', - 'Ildar Iskhakov': 'Asia/Yerevan', - 'Raphael Batyrbaev': 'Europe/Rome', - 'Innokentii Konstantinov': 'Asia/Singapore', - 'Matvey Kukuy': 'Asia/Tel_Aviv', - }; - - return user.timezone || tzByName[user.username] || 'UTC'; + return user.timezone || 'UTC'; }; export const getUserNotificationsSummary = (user: User) => { diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index b837c62d..4bf99254 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -52,24 +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, timezone: getTimezone(user) }, + [response.pk]: { ...response, timezone: timezone || getTimezone(response) }, }; - // TODO comment - if (user.timezone) { - this.update(user.pk, { timezone: 'UTC' }); - } - - // TODO uncomment - /*if (!user.timezone) { - this.update(user.pk, { timezone: dayjs.tz.guess() }); - }*/ - - this.currentUserPk = user.pk; + this.currentUserPk = response.pk; } @action diff --git a/grafana-plugin/tsconfig.json b/grafana-plugin/tsconfig.json index eb27527f..931d63e8 100644 --- a/grafana-plugin/tsconfig.json +++ b/grafana-plugin/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@grafana/toolkit/src/config/tsconfig.plugin.json", - "include": ["src", "frontend_enterprise/src"], + "include": ["src/dummy", "frontend_enterprise/src"], "types": ["node", "@emotion/core"], "compilerOptions": { "rootDirs": ["src", "frontend_enterprise/src"], @@ -9,6 +9,6 @@ "noUnusedLocals": false, "strict": false, "resolveJsonModule": true, - "noImplicitAny": false + "noImplicitAny": false, } } From 3ab5a6e51af273b353306d313ba8438a1be65e25 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 9 Sep 2022 13:27:38 +0300 Subject: [PATCH 59/60] minor fix --- grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx index 22080b8d..07103b78 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -281,7 +281,7 @@ const AvatarGroup = (props: AvatarGroupProps) => { style={{ opacity: !active && users.length > LIMIT ? '1' : '0', zIndex: users.length, - left: active ? `${users.length * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${users.length * 10}px`, + left: active ? `${users.length * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${LIMIT * 10}px`, }} className={cx('user-more')} > From fb6eb8d622a75b5240cbd394fc564fcb6f12a9a9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 9 Sep 2022 13:35:46 +0300 Subject: [PATCH 60/60] minor fix 2 --- .../src/components/UserTimezoneSelect/UserTimezoneSelect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx index 00bce0cd..453c578b 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -59,7 +59,7 @@ const UserTimezoneSelect: FC = (props) => { return (
    -
    ); };