From 99877b99d23571cf59a2b7204b31c75cd0e3afd9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 6 Sep 2022 16:23:06 +0300 Subject: [PATCH] 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; }