From 82b5a877d9f79b258ae3bde977bdc469fa431389 Mon Sep 17 00:00:00 2001 From: Dominik Broj Date: Mon, 8 Jan 2024 14:57:01 +0100 Subject: [PATCH] Brojd/fix timezone issues (#3618) # What this PR does - fix multiple issues on schedule related to handling timezones ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/3576 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 4 + .../e2e-tests/schedules/timezones.test.ts | 55 ++++ grafana-plugin/jest.setup.ts | 2 + .../ScheduleQuality/ScheduleQuality.tsx | 44 ++- .../src/containers/Rotation/Rotation.tsx | 22 +- .../containers/Rotation/RotationTutorial.tsx | 16 +- .../containers/RotationForm/RotationForm.tsx | 55 ++-- .../RotationForm/ScheduleOverrideForm.tsx | 23 +- .../containers/RotationForm/ShiftSwapForm.tsx | 16 +- .../RotationForm/parts/DateTimePicker.tsx | 125 ++++---- .../src/containers/Rotations/Rotations.tsx | 36 +-- .../containers/Rotations/ScheduleFinal.tsx | 127 ++++---- .../Rotations/ScheduleOverrides.tsx | 43 ++- .../containers/Rotations/SchedulePersonal.tsx | 276 +++++++----------- .../containers/ScheduleSlot/ScheduleSlot.tsx | 62 ++-- .../TimelineMarks/TimelineMarks.module.scss | 0 .../TimelineMarks/TimelineMarks.tsx | 23 +- .../UserTimezoneSelect.module.css | 0 .../UserTimezoneSelect/UserTimezoneSelect.tsx | 93 ++---- .../ScheduleUserDetails.module.css | 2 +- .../ScheduleUserDetails.tsx | 35 ++- .../ScheduleUserDetails/img/line.svg | 0 .../UsersTimezones/UsersTimezones.helpers.ts | 6 + .../UsersTimezones/UsersTimezones.tsx | 164 +++++------ .../src/models/loader/action-keys.ts | 1 + .../src/models/schedule/schedule.ts | 49 +++- .../src/models/timezone/timezone.helpers.ts | 27 +- .../src/models/timezone/timezone.ts | 58 ++++ grafana-plugin/src/models/user/user.ts | 2 +- .../src/pages/incident/Incident.tsx | 15 +- .../src/pages/schedule/Schedule.helpers.ts | 8 +- .../src/pages/schedule/Schedule.tsx | 163 +++-------- .../src/pages/schedules/Schedules.tsx | 89 ++---- .../src/plugin/GrafanaPluginRootPage.tsx | 32 +- grafana-plugin/src/plugin/dayjs.ts | 20 ++ .../src/state/rootBaseStore/index.ts | 15 +- grafana-plugin/tsconfig.json | 4 +- 37 files changed, 824 insertions(+), 888 deletions(-) create mode 100644 grafana-plugin/e2e-tests/schedules/timezones.test.ts rename grafana-plugin/src/{components => containers}/TimelineMarks/TimelineMarks.module.scss (100%) rename grafana-plugin/src/{components => containers}/TimelineMarks/TimelineMarks.tsx (83%) rename grafana-plugin/src/{components => containers}/UserTimezoneSelect/UserTimezoneSelect.module.css (100%) rename grafana-plugin/src/{components => containers}/UserTimezoneSelect/UserTimezoneSelect.tsx (58%) rename grafana-plugin/src/{components => containers/UsersTimezones}/ScheduleUserDetails/ScheduleUserDetails.module.css (98%) rename grafana-plugin/src/{components => containers/UsersTimezones}/ScheduleUserDetails/ScheduleUserDetails.tsx (83%) rename grafana-plugin/src/{components => containers/UsersTimezones}/ScheduleUserDetails/img/line.svg (100%) create mode 100644 grafana-plugin/src/containers/UsersTimezones/UsersTimezones.helpers.ts create mode 100644 grafana-plugin/src/models/timezone/timezone.ts create mode 100644 grafana-plugin/src/plugin/dayjs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d63d5d9..dd3fd2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Move Insights to OnCall as a separate page ([#2382](https://github.com/grafana/oncall-private/issues/2382)) - Allow mobile app to access paging endpoint @imtoori ([#3619](https://github.com/grafana/oncall/pull/3619)) +### Fixed + +- Fixed schedule timezone issues ([#3576](https://github.com/grafana/oncall/issues/3576)) + ## v1.3.82 (2024-01-04) ### Added diff --git a/grafana-plugin/e2e-tests/schedules/timezones.test.ts b/grafana-plugin/e2e-tests/schedules/timezones.test.ts new file mode 100644 index 00000000..e569712b --- /dev/null +++ b/grafana-plugin/e2e-tests/schedules/timezones.test.ts @@ -0,0 +1,55 @@ +import { expect } from '@playwright/test'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +import { test } from '../fixtures'; +import { clickButton, generateRandomValue } from '../utils/forms'; +import { createOnCallSchedule } from '../utils/schedule'; + +dayjs.extend(utc); + +test.use({ timezoneId: 'Europe/Moscow' }); // GMT+3 the whole year +const currentUtcTime = dayjs().utc().format('HH:mm'); +const currentUtcDate = dayjs().utc().format('DD MMM'); +const currentMoscowTime = dayjs().utcOffset(180).format('HH:mm'); +const currentMoscowDate = dayjs().utcOffset(180).format('DD MMM'); + +test('default dates in override creation modal are correct', async ({ adminRolePage }) => { + const { page, userName } = adminRolePage; + + const onCallScheduleName = generateRandomValue(); + await createOnCallSchedule(page, onCallScheduleName, userName); + + // Current timezone is selected by default to currently logged in user timezone + await expect(page.getByTestId('timezone-select')).toHaveText('GMT+3'); + + // Change timezone to GMT + await page.getByTestId('timezone-select').getByRole('img').click(); + await page.getByText('GMT', { exact: true }).click(); + + // Selected timezone and local time is correctly displayed + await expect(page.getByText(`Current timezone: GMT, local time: ${currentUtcTime}`)).toBeVisible(); + + // // User avatar tooltip shows correct time and timezones + await page.getByTestId('user-avatar-in-schedule').hover(); + await expect(page.getByTestId('schedule-user-details_your-current-time')).toHaveText(/GMT\+3/); + await expect(page.getByTestId('schedule-user-details_your-current-time')).toHaveText(new RegExp(currentMoscowTime)); + await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(/GMT\+3/); + await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(new RegExp(currentMoscowTime)); + + // Schedule slot shows correct times and timezones + await page.getByTestId('schedule-slot').first().hover(); + await expect(page.getByText(`User's local time${currentMoscowDate}, ${currentMoscowTime}(GMT+3)`)).toBeVisible(); + await expect(page.getByText(`Current timezone${currentUtcDate}, ${currentUtcTime}(GMT)`)).toBeVisible(); + + // Rotation form has correct start date and current timezone information + await clickButton({ page, buttonText: 'Add rotation' }); + await page.getByText('Layer 1 rotation').click(); + await expect(page.getByTestId('rotation-form').getByText('Current timezone: GMT')).toBeVisible(); + await expect(page.getByTestId('rotation-form').getByPlaceholder('Date')).toHaveValue( + dayjs().utcOffset(0).format('MM/DD/YYYY') + ); + await expect(page.getByTestId('rotation-form').getByTestId('date-time-picker').getByRole('textbox')).toHaveValue( + '00:00' + ); +}); diff --git a/grafana-plugin/jest.setup.ts b/grafana-plugin/jest.setup.ts index 4ae785da..f12e5eaa 100644 --- a/grafana-plugin/jest.setup.ts +++ b/grafana-plugin/jest.setup.ts @@ -4,6 +4,8 @@ */ import '@testing-library/jest-dom'; +import 'plugin/dayjs'; + // https://stackoverflow.com/a/66055672 // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom Object.defineProperty(window, 'matchMedia', { diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx index 2c0c1040..a6b11076 100644 --- a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -1,14 +1,15 @@ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect } from 'react'; import { Tooltip, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import PluginLink from 'components/PluginLink/PluginLink'; import { ScheduleQualityDetails } from 'components/ScheduleQualityDetails/ScheduleQualityDetails'; import Tag from 'components/Tag/Tag'; import Text from 'components/Text/Text'; import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; -import { Schedule, ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types'; +import { Schedule, ScheduleScoreQualityResult } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; import styles from './ScheduleQuality.module.scss'; @@ -17,29 +18,29 @@ const cx = cn.bind(styles); interface ScheduleQualityProps { schedule: Schedule; - lastUpdated: number; } -const ScheduleQuality: FC = ({ schedule, lastUpdated }) => { - const { scheduleStore } = useStore(); - const [qualityResponse, setQualityResponse] = useState(undefined); +const ScheduleQuality: FC = observer(({ schedule }) => { + const { + scheduleStore: { getScoreQuality, relatedEscalationChains, quality }, + } = useStore(); useEffect(() => { if (schedule.id) { - fetchScoreQuality(); + getScoreQuality(schedule.id); } - }, [schedule.id, lastUpdated]); + }, [schedule.id]); - if (!qualityResponse) { + if (!quality) { return null; } - const relatedEscalationChains = scheduleStore.relatedEscalationChains[schedule.id]; + const relatedScheduleEscalationChains = relatedEscalationChains[schedule.id]; return ( <>
- {relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && ( + {relatedScheduleEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && ( = ({ schedule, lastUpdated }) => tooltipTitle="Used in escalations" tooltipContent={ - {relatedEscalationChains.map((escalationChain) => ( + {relatedScheduleEscalationChains.map((escalationChain) => (
{escalationChain.name} @@ -82,13 +83,11 @@ const ScheduleQuality: FC = ({ schedule, lastUpdated }) => - } + content={} >
- Quality: {getScheduleQualityString(qualityResponse.total_score)} + Quality: {getScheduleQualityString(quality.total_score)}
@@ -112,22 +111,15 @@ const ScheduleQuality: FC = ({ schedule, lastUpdated }) => return ScheduleScoreQualityResult.Great; } - async function fetchScoreQuality() { - await Promise.all([ - scheduleStore.getScoreQuality(schedule.id).then((qualityResponse) => setQualityResponse(qualityResponse)), - scheduleStore.updateRelatedEscalationChains(schedule.id), - ]); - } - function getTagClass() { - if (qualityResponse?.total_score < 20) { + if (quality?.total_score < 20) { return 'tag--danger'; } - if (qualityResponse?.total_score < 60) { + if (quality?.total_score < 60) { return 'tag--warning'; } return 'tag--primary'; } -}; +}); export default ScheduleQuality; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 29c798a3..a82b283e 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -3,13 +3,14 @@ import React, { FC, useMemo, useState } from 'react'; import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; import hash from 'object-hash'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import Text from 'components/Text/Text'; import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot'; import { Event, RotationFormLiveParams, ShiftSwap } from 'models/schedule/schedule.types'; -import { Timezone } from 'models/timezone/timezone.types'; +import { useStore } from 'state/useStore'; import RotationTutorial from './RotationTutorial'; @@ -18,8 +19,6 @@ import styles from './Rotation.module.css'; const cx = cn.bind(styles); interface RotationProps { - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; layerIndex?: number; rotationIndex?: number; color?: string; @@ -40,11 +39,12 @@ interface RotationProps { showScheduleNameAsSlotTitle?: boolean; } -const Rotation: FC = (props) => { +const Rotation: FC = observer((props) => { + const { + timezoneStore: { calendarStartDate }, + } = useStore(); const { events, - startMoment, - currentTimezone, color: propsColor, days = 7, transparent = false, @@ -71,7 +71,7 @@ const Rotation: FC = (props) => { const dayOffset = Math.floor((x / width) * 7); - const shiftStart = startMoment.add(dayOffset, 'day'); + const shiftStart = calendarStartDate.add(dayOffset, 'day'); const shiftEnd = shiftStart.add(1, 'day'); onClick(shiftStart, shiftEnd); @@ -133,7 +133,7 @@ const Rotation: FC = (props) => { } const firstShift = events[0]; - const firstShiftOffset = dayjs(firstShift.start).diff(startMoment, 'seconds'); + const firstShiftOffset = dayjs(firstShift.start).diff(calendarStartDate, 'seconds'); const base = 60 * 60 * 24 * days; return firstShiftOffset / base; @@ -142,7 +142,7 @@ const Rotation: FC = (props) => { return (
- {tutorialParams && } + {tutorialParams && } {events ? ( events.length ? (
= (props) => { = (props) => {
); -}; +}); const Empty = ({ text }: { text: string }) => { return ( diff --git a/grafana-plugin/src/containers/Rotation/RotationTutorial.tsx b/grafana-plugin/src/containers/Rotation/RotationTutorial.tsx index e9042ec9..4af0c1aa 100644 --- a/grafana-plugin/src/containers/Rotation/RotationTutorial.tsx +++ b/grafana-plugin/src/containers/Rotation/RotationTutorial.tsx @@ -2,20 +2,24 @@ import React, { FC, useMemo } from 'react'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; import { RotationFormLiveParams } from 'models/schedule/schedule.types'; +import { useStore } from 'state/useStore'; import styles from './Rotation.module.css'; const cx = cn.bind(styles); interface RotationProps extends RotationFormLiveParams { - startMoment: dayjs.Dayjs; days?: number; } -const RotationTutorial: FC = (props) => { - const { startMoment, days = 7, shiftStart, shiftEnd, rotationStart, focusElementName } = props; +const RotationTutorial: FC = observer((props) => { + const { + timezoneStore: { calendarStartDate }, + } = useStore(); + const { days = 7, shiftStart, shiftEnd, rotationStart, focusElementName } = props; const duration = shiftEnd.diff(shiftStart, 'seconds'); @@ -48,11 +52,11 @@ const RotationTutorial: FC = (props) => { } const firstEvent = events[0]; - const firstShiftOffset = dayjs(firstEvent.start).diff(startMoment, 'seconds'); + const firstShiftOffset = dayjs(firstEvent.start).diff(calendarStartDate, 'seconds'); const base = 60 * 60 * 24 * days; return firstShiftOffset / base; - }, [events, startMoment]); + }, [events, calendarStartDate]); return (
@@ -73,7 +77,7 @@ const RotationTutorial: FC = (props) => { })}
); -}; +}); const TutorialSlot = (props: { style: React.CSSProperties; active: boolean }) => { const { style, active } = props; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 6746fdd4..bcad0a8b 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -47,13 +47,11 @@ import TimeUnitSelector from 'containers/RotationForm/parts/TimeUnitSelector'; import UserItem from 'containers/RotationForm/parts/UserItem'; import { getShiftName } from 'models/schedule/schedule.helpers'; import { 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, getSelectedDays, - getStartOfWeek, + getStartOfWeekBasedOnCurrentDate, getUTCByDay, getUTCString, getUTCWeekStart, @@ -71,8 +69,6 @@ const cx = cn.bind(styles); interface RotationFormProps { layerPriority: number; onHide: () => void; - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; scheduleId: Schedule['id']; shiftId: Shift['id'] | 'new'; shiftStart?: dayjs.Dayjs; @@ -85,23 +81,21 @@ interface RotationFormProps { } const RotationForm = observer((props: RotationFormProps) => { + const store = useStore(); const { onHide, onCreate, - startMoment, - currentTimezone, scheduleId, onUpdate, onDelete, layerPriority, shiftId, - shiftStart: propsShiftStart = getStartOfWeek(currentTimezone), + shiftStart: propsShiftStart = getStartOfWeekBasedOnCurrentDate(store.timezoneStore.currentDateInSelectedTimezone), shiftEnd: propsShiftEnd, shiftColor = '#3D71D9', onShowRotationForm, } = props; - const store = useStore(); const shift = store.scheduleStore.shifts[shiftId]; const [errors, setErrors] = useState<{ [key: string]: string[] }>({}); @@ -192,7 +186,7 @@ const RotationForm = observer((props: RotationFormProps) => { setErrors({}); store.scheduleStore - .updateRotationPreview(scheduleId, shiftId, startMoment, false, params) + .updateRotationPreview(scheduleId, shiftId, store.timezoneStore.calendarStartDate, false, params) .catch(onError) .finally(() => { setIsOpen(true); @@ -214,14 +208,20 @@ const RotationForm = observer((props: RotationFormProps) => { rolling_users: userGroups, interval: repeatEveryValue, frequency: repeatEveryPeriod, - by_day: getUTCByDay(store.scheduleStore.byDayOptions, selectedDays, shiftStart.tz(currentTimezone)), - week_start: getUTCWeekStart(store.scheduleStore.byDayOptions, shiftStart.tz(currentTimezone)), + by_day: getUTCByDay( + store.scheduleStore.byDayOptions, + selectedDays, + store.timezoneStore.getDateInSelectedTimezone(shiftStart) + ), + week_start: getUTCWeekStart( + store.scheduleStore.byDayOptions, + store.timezoneStore.getDateInSelectedTimezone(shiftStart) + ), priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level, name: rotationName, }), [ rotationStart, - currentTimezone, rotationEnd, shiftStart, shiftEnd, @@ -237,7 +237,7 @@ const RotationForm = observer((props: RotationFormProps) => { ] ); - useEffect(handleChange, [params, startMoment]); + useEffect(handleChange, [params, store.timezoneStore.calendarStartDate]); const create = useCallback(() => { store.scheduleStore @@ -383,7 +383,13 @@ const RotationForm = observer((props: RotationFormProps) => { setRepeatEveryValue(shift.interval); setRepeatEveryPeriod(shift.frequency); - setSelectedDays(getSelectedDays(store.scheduleStore.byDayOptions, shift.by_day, shiftStart.tz(currentTimezone))); + setSelectedDays( + getSelectedDays( + store.scheduleStore.byDayOptions, + shift.by_day, + store.timezoneStore.getDateInSelectedTimezone(shiftStart) + ) + ); setShowActiveOnSelectedDays(Boolean(shift.by_day?.length)); @@ -405,9 +411,15 @@ const RotationForm = observer((props: RotationFormProps) => { useEffect(() => { if (shift) { - setSelectedDays(getSelectedDays(store.scheduleStore.byDayOptions, shift.by_day, shiftStart.tz(currentTimezone))); + setSelectedDays( + getSelectedDays( + store.scheduleStore.byDayOptions, + shift.by_day, + store.timezoneStore.getDateInSelectedTimezone(shiftStart) + ) + ); } - }, [currentTimezone]); + }, [store.timezoneStore.selectedTimezoneOffset]); const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]); @@ -435,7 +447,7 @@ const RotationForm = observer((props: RotationFormProps) => { )} > -
+
@@ -501,10 +513,8 @@ const RotationForm = observer((props: RotationFormProps) => { } > @@ -533,7 +543,6 @@ const RotationForm = observer((props: RotationFormProps) => { @@ -617,7 +626,6 @@ const RotationForm = observer((props: RotationFormProps) => { defaultValue={shiftPeriodDefaultValue} shiftStart={shiftStart} onChange={handleActivePeriodChange} - currentTimezone={currentTimezone} disabled={disabled} errors={errors} /> @@ -660,7 +668,7 @@ const RotationForm = observer((props: RotationFormProps) => {
- Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + Current timezone: {store.timezoneStore.selectedTimezoneLabel} {shiftId !== 'new' && ( @@ -712,7 +720,6 @@ interface ShiftPeriodProps { defaultValue: number; shiftStart: dayjs.Dayjs; onChange: (value: number) => void; - currentTimezone: Timezone; disabled: boolean; errors: any; } diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index bb3bd47c..302cb5ad 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -12,8 +12,6 @@ import UserGroups from 'components/UserGroups/UserGroups'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { getShiftName } from 'models/schedule/schedule.helpers'; import { 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'; @@ -29,8 +27,6 @@ import styles from './RotationForm.module.css'; interface RotationFormProps { onHide: () => void; shiftId: Shift['id'] | 'new'; - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; scheduleId: Schedule['id']; shiftStart?: dayjs.Dayjs; shiftEnd?: dayjs.Dayjs; @@ -46,12 +42,10 @@ const ScheduleOverrideForm: FC = (props) => { const { onHide, onCreate, - currentTimezone, scheduleId, onUpdate, onDelete, shiftId, - startMoment, shiftStart: propsShiftStart = dayjs().startOf('day').add(1, 'day'), shiftEnd: propsShiftEnd, shiftColor = getVar('--tag-warning'), @@ -116,7 +110,7 @@ const ScheduleOverrideForm: FC = (props) => { frequency: null, name: rotationName, }), - [currentTimezone, shiftStart, shiftEnd, userGroups, rotationName] + [shiftStart, shiftEnd, userGroups, rotationName, store.timezoneStore.selectedTimezoneOffset] ); useEffect(() => { @@ -172,7 +166,7 @@ const ScheduleOverrideForm: FC = (props) => { setErrors({}); store.scheduleStore - .updateRotationPreview(scheduleId, shiftId, startMoment, true, params) + .updateRotationPreview(scheduleId, shiftId, store.timezoneStore.calendarStartDate, true, params) .catch(onError) .finally(() => { setIsOpen(true); @@ -185,7 +179,7 @@ const ScheduleOverrideForm: FC = (props) => { const handleChange = useDebouncedCallback(updatePreview, 200); - useEffect(handleChange, [params, startMoment]); + useEffect(handleChange, [params, store.timezoneStore.calendarStartDate]); const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]); @@ -242,7 +236,6 @@ const ScheduleOverrideForm: FC = (props) => { disabled={disabled} value={shiftStart} onChange={updateShiftStart} - timezone={currentTimezone} error={errors.shift_start} /> @@ -254,13 +247,7 @@ const ScheduleOverrideForm: FC = (props) => { } > - + = (props) => {
- Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + Current timezone: {store.timezoneStore.selectedTimezoneLabel}
- Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + Current timezone: {store.timezoneStore.selectedTimezoneLabel} {isNew ? ( diff --git a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx index 40fb415d..1e26aaf2 100644 --- a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx +++ b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx @@ -1,13 +1,13 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { DateTime, dateTime } from '@grafana/data'; import { DatePickerWithInput, TimeOfDayPicker, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; import Text from 'components/Text/Text'; -import { toDate } from 'containers/RotationForm/RotationForm.helpers'; -import { Timezone } from 'models/timezone/timezone.types'; +import { useStore } from 'state/useStore'; import styles from 'containers/RotationForm/RotationForm.module.css'; @@ -15,72 +15,85 @@ const cx = cn.bind(styles); interface DateTimePickerProps { value: dayjs.Dayjs; - timezone: Timezone; onChange: (value: dayjs.Dayjs) => void; disabled?: boolean; - minMoment?: dayjs.Dayjs; onFocus?: () => void; onBlur?: () => void; error?: string[]; } -const DateTimePicker = (props: DateTimePickerProps) => { - const { value: propValue, minMoment, timezone, onChange, disabled, onFocus, onBlur, error } = props; +const DateTimePicker = observer( + ({ value: propValue, onChange, disabled, onFocus, onBlur, error }: DateTimePickerProps) => { + const { + timezoneStore: { getDateInSelectedTimezone }, + } = useStore(); + const valueInSelectedTimezone = getDateInSelectedTimezone(propValue); + const valueAsDate = valueInSelectedTimezone.toDate(); - const value = useMemo(() => toDate(propValue, timezone), [propValue, timezone]); + const handleDateChange = (newDate: Date) => { + const localMoment = getDateInSelectedTimezone(dayjs(newDate)); + const newValue = localMoment + .set('year', newDate.getFullYear()) + .set('month', newDate.getMonth()) + .set('date', newDate.getDate()) + .set('hour', valueAsDate.getHours()) + .set('minute', valueAsDate.getMinutes()) + .set('second', valueAsDate.getSeconds()); - const minDate = useMemo(() => (minMoment ? toDate(minMoment, timezone) : undefined), [minMoment, timezone]); + onChange(newValue); + }; + const handleTimeChange = (newMoment: DateTime) => { + const selectedHour = newMoment.hour(); + const selectedMinute = newMoment.minute(); + const newValue = valueInSelectedTimezone.set('hour', selectedHour).set('minute', selectedMinute); - const handleDateChange = (newDate: Date) => { - const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); + onChange(newValue); + }; - const newValue = localMoment - .set('year', newDate.getFullYear()) - .set('month', newDate.getMonth()) - .set('date', newDate.getDate()) - .set('hour', value.getHours()) - .set('minute', value.getMinutes()) - .set('second', value.getSeconds()); + const getTimeValueInSelectedTimezone = () => { + const time = dateTime(valueInSelectedTimezone.format()); + time.set('hour', valueInSelectedTimezone.hour()); + time.set('minute', valueInSelectedTimezone.minute()); + time.set('second', valueInSelectedTimezone.second()); + return time; + }; - onChange(newValue); - }; - const handleTimeChange = (newMoment: DateTime) => { - const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); - const newDate = newMoment.toDate(); - const newValue = localMoment - .set('year', value.getFullYear()) - .set('month', value.getMonth()) - .set('date', value.getDate()) - .set('hour', newDate.getHours()) - .set('minute', newDate.getMinutes()) - .set('second', newDate.getSeconds()); + const getDateForDatePicker = () => { + const date = new Date(); + date.setFullYear(valueInSelectedTimezone.year()); + date.setMonth(valueInSelectedTimezone.month()); + date.setDate(valueInSelectedTimezone.date()); + date.setHours(valueInSelectedTimezone.hour()); + date.setMinutes(valueInSelectedTimezone.minute()); + date.setSeconds(valueInSelectedTimezone.second()); + return date; + }; - onChange(newValue); - }; - - return ( - -
-
- + return ( + +
+
+ +
+
+ +
-
- -
-
- {error && {error}} - - ); -}; + {error && {error}} + + ); + } +); export default DateTimePicker; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index b39fd4c2..0a972402 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -9,13 +9,12 @@ import { CSSTransition, TransitionGroup } from 'react-transition-group'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import Text from 'components/Text/Text'; -import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; import RotationForm from 'containers/RotationForm/RotationForm'; +import TimelineMarks from 'containers/TimelineMarks/TimelineMarks'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { getColor, getLayersFromStore } from 'models/schedule/schedule.helpers'; import { Layer, Schedule, ScheduleType, Shift, ShiftSwap, Event } from 'models/schedule/schedule.types'; -import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; @@ -29,8 +28,6 @@ import styles from './Rotations.module.css'; const cx = cn.bind(styles); interface RotationsProps extends WithStoreProps { - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; shiftIdToShowRotationForm?: Shift['id'] | 'new'; scheduleId: Schedule['id']; onShowRotationForm: (shiftId: Shift['id'] | 'new') => void; @@ -63,8 +60,6 @@ class Rotations extends Component { render() { const { scheduleId, - startMoment, - currentTimezone, onCreate, onUpdate, onDelete, @@ -78,13 +73,16 @@ class Rotations extends Component { const { layerPriority, shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state; const base = 7 * 24 * 60; // in minutes - const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); + const diff = store.timezoneStore.currentDateInSelectedTimezone.diff( + store.timezoneStore.calendarStartDate, + 'minutes' + ); const currentTimeX = diff / base; const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; - const layers = getLayersFromStore(store, scheduleId, startMoment); + const layers = getLayersFromStore(store, scheduleId, store.timezoneStore.calendarStartDate); const options = layers ? layers.map((layer) => ({ @@ -136,7 +134,11 @@ class Rotations extends Component { size="md" /> ) : ( - )} @@ -155,7 +157,7 @@ class Rotations extends Component {
- + {!currentTimeHidden && (
)} @@ -177,8 +179,6 @@ class Rotations extends Component { events={events} layerIndex={layerIndex} rotationIndex={rotationIndex} - startMoment={startMoment} - currentTimezone={currentTimezone} transparent={isPreview} tutorialParams={isPreview && store.scheduleStore.rotationFormLiveParams} filters={filters} @@ -202,7 +202,7 @@ class Rotations extends Component {
- +
{ @@ -211,8 +211,6 @@ class Rotations extends Component { events={[]} layerIndex={0} rotationIndex={0} - startMoment={startMoment} - currentTimezone={currentTimezone} />
@@ -226,7 +224,7 @@ class Rotations extends Component { if (disabled) { return; } - this.handleAddLayer(nextPriority, startMoment); + this.handleAddLayer(nextPriority, store.timezoneStore.calendarStartDate); }} > + Add new layer with rotation @@ -240,8 +238,6 @@ class Rotations extends Component { shiftColor={findColor(shiftIdToShowRotationForm, layers)} scheduleId={scheduleId} layerPriority={layerPriority} - startMoment={startMoment} - currentTimezone={currentTimezone} shiftStart={shiftStartToShowRotationForm} shiftEnd={shiftEndToShowRotationForm} onHide={() => { @@ -299,7 +295,7 @@ class Rotations extends Component { }; handleAddRotation = (option: SelectableValue) => { - const { startMoment, disabled } = this.props; + const { disabled, store } = this.props; if (disabled) { return; @@ -308,7 +304,7 @@ class Rotations extends Component { this.setState( { layerPriority: option.value, - shiftStartToShowRotationForm: startMoment, + shiftStartToShowRotationForm: store.timezoneStore.calendarStartDate, }, () => { this.onShowRotationForm('new'); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 89166869..528232d7 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { FC, useEffect } from 'react'; import { HorizontalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -8,8 +8,8 @@ import { CSSTransition, TransitionGroup } from 'react-transition-group'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import Text from 'components/Text/Text'; -import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; +import TimelineMarks from 'containers/TimelineMarks/TimelineMarks'; import { flattenShiftEvents, getLayersFromStore, @@ -17,7 +17,6 @@ import { getShiftsFromStore, } from 'models/schedule/schedule.helpers'; import { Schedule, ShiftSwap, Event } from 'models/schedule/schedule.types'; -import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; @@ -29,8 +28,6 @@ import styles from './Rotations.module.css'; const cx = cn.bind(styles); interface ScheduleFinalProps extends WithStoreProps { - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; scheduleId: Schedule['id']; simplified?: boolean; onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void; @@ -40,82 +37,80 @@ interface ScheduleFinalProps extends WithStoreProps { onSlotClick?: (event: Event) => void; } -@observer -class ScheduleFinal extends Component { - render() { - const { startMoment, currentTimezone, store, simplified, scheduleId, filters, onShowShiftSwapForm, onSlotClick } = - this.props; - +const ScheduleFinal: FC = observer( + ({ store, simplified, scheduleId, filters, onShowShiftSwapForm, onShowOverrideForm, onSlotClick }) => { + const { + timezoneStore: { currentDateInSelectedTimezone, calendarStartDate, selectedTimezoneOffset }, + scheduleStore: { refreshEvents }, + } = store; const base = 7 * 24 * 60; // in minutes - const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); + const diff = currentDateInSelectedTimezone.diff(calendarStartDate, 'minutes'); const currentTimeX = diff / base; - const shifts = flattenShiftEvents(getShiftsFromStore(store, scheduleId, startMoment)); + const shifts = flattenShiftEvents(getShiftsFromStore(store, scheduleId, calendarStartDate)); - const layers = getLayersFromStore(store, scheduleId, startMoment); + const layers = getLayersFromStore(store, scheduleId, calendarStartDate); - const overrides = getOverridesFromStore(store, scheduleId, startMoment); + const overrides = getOverridesFromStore(store, scheduleId, calendarStartDate); const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; const getColor = (event: Event) => findColor(event.shift?.pk, layers, overrides); + const handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { + onShowOverrideForm('new', shiftStart, shiftEnd); + }; + + useEffect(() => { + refreshEvents(scheduleId); + }, [selectedTimezoneOffset]); + return ( - <> -
- {!simplified && ( -
- -
- - Final schedule - -
-
-
- )} -
- {!currentTimeHidden &&
} - - - {shifts && shifts.length ? ( - shifts.map(({ events }, index) => { - return ( - - - - ); - }) - ) : ( - - - - )} - +
+ {!simplified && ( +
+ +
+ + Final schedule + +
+
+ )} +
+ {!currentTimeHidden &&
} + + + {shifts && shifts.length ? ( + shifts.map(({ events }, index) => { + return ( + + + + ); + }) + ) : ( + + + + )} +
- +
); } - - handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { - const { onShowOverrideForm } = this.props; - - onShowOverrideForm('new', shiftStart, shiftEnd); - }; -} +); export default withMobXProviderContext(ScheduleFinal); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index accbdebe..76c3921e 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -8,9 +8,9 @@ import { CSSTransition, TransitionGroup } from 'react-transition-group'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import Text from 'components/Text/Text'; -import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm'; +import TimelineMarks from 'containers/TimelineMarks/TimelineMarks'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { getLayersFromStore, @@ -20,8 +20,7 @@ import { SHIFT_SWAP_COLOR, } from 'models/schedule/schedule.helpers'; import { Schedule, Shift, ShiftEvents, ShiftSwap } from 'models/schedule/schedule.types'; -import { Timezone } from 'models/timezone/timezone.types'; -import { getStartOfDay, getUTCString } from 'pages/schedule/Schedule.helpers'; +import { getUTCString } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { UserActions } from 'utils/authorization'; @@ -34,10 +33,8 @@ import styles from './Rotations.module.css'; const cx = cn.bind(styles); interface ScheduleOverridesProps extends WithStoreProps { - startMoment: dayjs.Dayjs; shiftStartToShowOverrideForm: dayjs.Dayjs; shiftEndToShowOverrideForm: dayjs.Dayjs; - currentTimezone: Timezone; scheduleId: Schedule['id']; shiftIdToShowRotationForm?: Shift['id'] | 'new'; onShowRotationForm: (shiftId: Shift['id'] | 'new') => void; @@ -65,8 +62,6 @@ class ScheduleOverrides extends Component
{!currentTimeHidden &&
} - + {shiftSwaps && shiftSwaps.length ? shiftSwaps.map(({ isPreview, events }, index) => ( @@ -159,8 +157,6 @@ class ScheduleOverrides extends Component { if (event.is_gap) { return; @@ -181,8 +177,6 @@ class ScheduleOverrides extends Component { this.onRotationClick(shiftId, shiftStart, shiftEnd); }} @@ -196,8 +190,6 @@ class ScheduleOverrides extends Component { this.onRotationClick('new', shiftStart, shiftEnd); }} @@ -212,8 +204,6 @@ class ScheduleOverrides extends Component { @@ -262,11 +252,12 @@ class ScheduleOverrides extends Component { - this.onShowRotationForm('new'); - }); + this.setState( + { shiftStartToShowOverrideForm: store.timezoneStore.currentDateInSelectedTimezone.startOf('day') }, + () => { + this.onShowRotationForm('new'); + } + ); }; handleHide = () => { diff --git a/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx b/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx index 55d8c659..e62e9b86 100644 --- a/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx +++ b/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx @@ -1,23 +1,21 @@ -import React, { Component } from 'react'; +import React, { FC, useEffect } from 'react'; import { Badge, Button, HorizontalGroup, Icon } from '@grafana/ui'; import cn from 'classnames/bind'; -import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; import Avatar from 'components/Avatar/Avatar'; import Text from 'components/Text/Text'; -import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import Rotation from 'containers/Rotation/Rotation'; +import TimelineMarks from 'containers/TimelineMarks/TimelineMarks'; +import { ActionKey } from 'models/loader/action-keys'; import { getColorForSchedule, getPersonalShiftsFromStore } from 'models/schedule/schedule.helpers'; import { Event } from 'models/schedule/schedule.types'; -import { Timezone } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; -import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; -import { WithStoreProps } from 'state/types'; -import { withMobXProviderContext } from 'state/withStore'; +import { getStartOfWeekBasedOnCurrentDate } from 'pages/schedule/Schedule.helpers'; +import { useStore } from 'state/useStore'; import { PLUGIN_ROOT } from 'utils/consts'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; @@ -26,175 +24,127 @@ import styles from './Rotations.module.css'; const cx = cn.bind(styles); -interface SchedulePersonalProps extends WithStoreProps, RouteComponentProps { - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; +interface SchedulePersonalProps extends RouteComponentProps { userPk: User['pk']; onSlotClick?: (event: Event) => void; } -interface SchedulePersonalState { - startMoment?: dayjs.Dayjs; -} +const SchedulePersonal: FC = observer(({ userPk, onSlotClick, history }) => { + const store = useStore(); + const { timezoneStore, scheduleStore, userStore, loaderStore } = store; -@observer -class SchedulePersonal extends Component { - state: SchedulePersonalState = {}; + useEffect(() => { + updatePersonalEvents(); + }, [timezoneStore.selectedTimezoneOffset]); - constructor(props) { - super(props); - - this.state = { - startMoment: props.startMoment, - }; - } - - componentDidMount() { - const { store } = this.props; - const { startMoment } = this.state; - - store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment, 9, true); - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { - const { store } = this.props; - const { startMoment } = this.state; - - if (prevProps.currentTimezone !== this.props.currentTimezone) { - const oldTimezone = prevProps.currentTimezone; - - this.setState((oldState) => { - const wDiff = oldState.startMoment.diff(getStartOfWeek(oldTimezone), 'weeks'); - - return { ...oldState, startMoment: getStartOfWeek(this.props.currentTimezone).add(wDiff, 'weeks') }; - }); - } - - if (prevState.startMoment !== startMoment) { - store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment); - } - } - - handleTodayClick = () => { - const { store } = this.props; - - this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }); + const updatePersonalEvents = () => { + scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate, 9, true); }; - handleLeftClick = () => { - const { startMoment } = this.state; - - this.setState({ startMoment: startMoment.add(-7, 'day') }); + const handleTodayClick = () => { + timezoneStore.setCalendarStartDate(getStartOfWeekBasedOnCurrentDate(timezoneStore.currentDateInSelectedTimezone)); }; - handleRightClick = () => { - const { startMoment } = this.state; - - this.setState({ startMoment: startMoment.add(7, 'day') }); + const handleLeftClick = () => { + timezoneStore.setCalendarStartDate(timezoneStore.calendarStartDate.subtract(7, 'day')); + scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate); }; - render() { - const { userPk, currentTimezone, store, onSlotClick } = this.props; - const { startMoment } = this.state; - - const base = 7 * 24 * 60; // in minutes - const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); - - const currentTimeX = diff / base; - - const shifts = getPersonalShiftsFromStore(store, userPk, startMoment); - - const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; - - const getColor = (event: Event) => getColorForSchedule(event.schedule?.id); - - const isOncall = store.scheduleStore.onCallNow[userPk]; - - const storeUser = store.userStore.items[userPk]; - - return ( - <> -
-
-
- - - - On-call schedule {storeUser.username} - - - {isOncall ? ( - - ) : ( - /* @ts-ignore */ - - )} - - - - - {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} - - - - - - - - - -
-
-
- {!currentTimeHidden &&
} - - - {shifts && shifts.length ? ( - shifts.map(({ events }, index) => { - return ( - - - - ); - }) - ) : ( - - - - )} - -
-
- - ); - } - - openSchedule = (event: Event) => { - const { history } = this.props; + const handleRightClick = () => { + timezoneStore.setCalendarStartDate(timezoneStore.calendarStartDate.add(7, 'day')); + scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate); + }; + const openSchedule = (event: Event) => { history.push(`${PLUGIN_ROOT}/schedules/${event.schedule?.id}`); }; -} -export default withRouter(withMobXProviderContext(SchedulePersonal)); + const base = 7 * 24 * 60; // in minutes + const diff = timezoneStore.currentDateInSelectedTimezone.diff(timezoneStore.calendarStartDate, 'minutes'); + + const currentTimeX = diff / base; + + const shifts = getPersonalShiftsFromStore(store, userPk, timezoneStore.calendarStartDate); + + const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; + + const getColor = (event: Event) => getColorForSchedule(event.schedule?.id); + + const isOncall = scheduleStore.onCallNow[userPk]; + + const storeUser = userStore.items[userPk]; + + const emptyRotationsText = loaderStore.isLoading(ActionKey.UPDATE_PERSONAL_EVENTS) + ? 'Loading ...' + : 'There are no schedules relevant to user'; + + return ( +
+
+
+ + + + On-call schedule {storeUser.username} + + {isOncall ? ( + + ) : ( + /* @ts-ignore */ + + )} + + + + + {timezoneStore.calendarStartDate.format('DD MMM')} -{' '} + {timezoneStore.calendarStartDate.add(6, 'day').format('DD MMM')} + + + + + + + + + +
+
+
+ {!currentTimeHidden &&
} + + + {shifts?.length ? ( + shifts.map(({ events }, index) => { + return ( + + + + ); + }) + ) : ( + + + + )} + +
+
+ ); +}); + +export default withRouter(SchedulePersonal); diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 32ad21b0..67d9547a 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -11,8 +11,7 @@ import Text from 'components/Text/Text'; import WorkingHours from 'components/WorkingHours/WorkingHours'; import { getShiftName, SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers'; import { Event, ShiftSwap } from 'models/schedule/schedule.types'; -import { getTzOffsetString } from 'models/timezone/timezone.helpers'; -import { Timezone } from 'models/timezone/timezone.types'; +import { getOffsetOfCurrentUser, getTzOffsetString } from 'models/timezone/timezone.helpers'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; @@ -22,8 +21,6 @@ import styles from './ScheduleSlot.module.css'; interface ScheduleSlotProps { event: Event; - startMoment: dayjs.Dayjs; - currentTimezone: Timezone; handleAddOverride: (event: React.MouseEvent) => void; handleAddShiftSwap: (event: React.MouseEvent) => void; handleOpenSchedule: (event: React.MouseEvent) => void; @@ -39,7 +36,6 @@ const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { const { event, - currentTimezone, color, handleAddOverride, handleAddShiftSwap, @@ -63,12 +59,12 @@ const ScheduleSlot: FC = observer((props) => { const renderEvent = (event): React.ReactElement | React.ReactElement[] => { if (event.shiftSwapId) { - return ; + return ; } if (event.is_gap) { return ( - }> + }>
); @@ -95,7 +91,6 @@ const ScheduleSlot: FC = observer((props) => { filters={filters} start={start} duration={duration} - currentTimezone={currentTimezone} color={color} currentMoment={currentMoment} showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle} @@ -114,12 +109,11 @@ export default ScheduleSlot; interface ShiftSwapEventProps { event: Event; - currentTimezone: Timezone; currentMoment: dayjs.Dayjs; } const ShiftSwapEvent = (props: ShiftSwapEventProps) => { - const { event, currentTimezone, currentMoment } = props; + const { event, currentMoment } = props; const store = useStore(); @@ -144,7 +138,7 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => { const benefactorStoreUser = store.userStore.items[shiftSwap?.benefactor?.pk]; const scheduleSlotContent = ( -
+
{shiftSwap && ( {beneficiary && } @@ -176,7 +170,6 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => { beneficiaryName={beneficiary?.display_name} user={benefactorStoreUser || beneficiaryStoreUser} benefactorName={benefactor?.display_name} - currentTimezone={currentTimezone} event={event} color={SHIFT_SWAP_COLOR} currentMoment={currentMoment} @@ -190,7 +183,6 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => { interface RegularEventProps { event: Event; - currentTimezone: Timezone; handleAddOverride: (event: React.MouseEvent) => void; handleAddShiftSwap: (event: React.MouseEvent) => void; handleOpenSchedule: (event: React.MouseEvent) => void; @@ -209,7 +201,6 @@ const RegularEvent = (props: RegularEventProps) => { onShiftSwapClick, filters, color, - currentTimezone, start, duration, handleAddOverride, @@ -261,6 +252,7 @@ const RegularEvent = (props: RegularEventProps) => { backgroundColor, }} onClick={swap_request ? getShiftSwapClickHandler(swap_request.pk) : undefined} + data-testid="schedule-slot" > {storeUser && (!swap_request || swap_request.user) && ( { } benefactorName={isShiftSwap ? (swap_request.user ? display_name : undefined) : undefined} user={storeUser} - currentTimezone={currentTimezone} event={event} handleAddOverride={ !handleAddOverride || event.is_override || isShiftSwap || currentMoment.isAfter(dayjs(event.end)) @@ -323,7 +314,6 @@ const RegularEvent = (props: RegularEventProps) => { interface ScheduleSlotDetailsProps { user: User; isOncall?: boolean; - currentTimezone: Timezone; event: Event; handleAddOverride?: (event: React.SyntheticEvent) => void; handleAddShiftSwap?: (event: React.SyntheticEvent) => void; @@ -336,10 +326,9 @@ interface ScheduleSlotDetailsProps { title: string; } -const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { +const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => { const { user, - currentTimezone, event, handleAddOverride, handleAddShiftSwap, @@ -352,7 +341,10 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { title, } = props; - const { scheduleStore } = useStore(); + const { + scheduleStore, + timezoneStore: { currentDateInSelectedTimezone, getDateInSelectedTimezone }, + } = useStore(); const shiftId = event.shift?.pk; const shift = scheduleStore.shifts[shiftId]; @@ -418,7 +410,7 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
- User local time + User's local time
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')}
({getTzOffsetString(currentMoment.tz(user?.timezone))}) @@ -426,8 +418,8 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { Current timezone
- {currentMoment.tz(currentTimezone).format('DD MMM, HH:mm')} -
({getTzOffsetString(currentMoment.tz(currentTimezone))}) + {currentDateInSelectedTimezone.format('DD MMM, HH:mm')} +
({getTzOffsetString(currentDateInSelectedTimezone)})
@@ -437,15 +429,15 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { This shift
- {dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')} + {dayjs(event.start).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')}
- {dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')} + {dayjs(event.end).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')}
 
- {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} + {getDateInSelectedTimezone(dayjs(event.start)).format('DD MMM, HH:mm')}
- {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} + {getDateInSelectedTimezone(dayjs(event.end)).format('DD MMM, HH:mm')}
@@ -468,27 +460,29 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
); -}; +}); interface ScheduleGapDetailsProps { - currentTimezone: Timezone; event: Event; } -const ScheduleGapDetails = (props: ScheduleGapDetailsProps) => { - const { currentTimezone, event } = props; +const ScheduleGapDetails = observer((props: ScheduleGapDetailsProps) => { + const { + timezoneStore: { selectedTimezoneLabel, getDateInSelectedTimezone }, + } = useStore(); + const { event } = props; return (
- {currentTimezone} - {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} - {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} + {selectedTimezoneLabel} + {getDateInSelectedTimezone(dayjs(event.start)).format('DD MMM, HH:mm')} + {getDateInSelectedTimezone(dayjs(event.end)).format('DD MMM, HH:mm')}
); -}; +}); diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.scss b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.module.scss similarity index 100% rename from grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.scss rename to grafana-plugin/src/containers/TimelineMarks/TimelineMarks.module.scss diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx similarity index 83% rename from grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx rename to grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx index bd84c5fb..44596257 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx @@ -2,25 +2,24 @@ import React, { FC, useMemo } from 'react'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; import Text from 'components/Text/Text'; -import { Timezone } from 'models/timezone/timezone.types'; -import { getNow } from 'pages/schedule/Schedule.helpers'; +import { useStore } from 'state/useStore'; import styles from './TimelineMarks.module.scss'; interface TimelineMarksProps { - startMoment: dayjs.Dayjs; - timezone: Timezone; debug?: boolean; } const cx = cn.bind(styles); -const TimelineMarks: FC = (props) => { - const { startMoment, timezone, debug } = props; - - const currentMoment = useMemo(() => getNow(timezone), []); +const TimelineMarks: FC = observer((props) => { + const { + timezoneStore: { currentDateInSelectedTimezone, calendarStartDate }, + } = useStore(); + const { debug } = props; const momentsToRender = useMemo(() => { const hoursToSplit = 12; @@ -29,7 +28,7 @@ const TimelineMarks: FC = (props) => { const jLimit = 24 / hoursToSplit; for (let i = 0; i < 7; i++) { - const d = dayjs(startMoment).add(i, 'days'); + const d = dayjs(calendarStartDate).add(i, 'days'); const obj = { moment: d, moments: [] }; for (let j = 0; j < jLimit; j++) { const m = dayjs(d).add(j * hoursToSplit, 'hour'); @@ -38,7 +37,7 @@ const TimelineMarks: FC = (props) => { momentsToRender.push(obj); } return momentsToRender; - }, [startMoment]); + }, [calendarStartDate]); const cuts = useMemo(() => { const cuts = []; @@ -67,7 +66,7 @@ const TimelineMarks: FC = (props) => { )} {momentsToRender.map((m, i) => { - const isCurrentDay = currentMoment.isSame(m.moment, 'day'); + const isCurrentDay = currentDateInSelectedTimezone.isSame(m.moment, 'day'); // const isWeekend = m.moment.day() == 0 || m.moment.day() === 6; @@ -96,6 +95,6 @@ const TimelineMarks: FC = (props) => { })}
); -}; +}); export default TimelineMarks; diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.module.css similarity index 100% rename from grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.module.css rename to grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.module.css diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx similarity index 58% rename from grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx rename to grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx index 448d20b4..f8d518c8 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -4,100 +4,67 @@ import { SelectableValue } from '@grafana/data'; import { Select } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; +import { sortBy } from 'lodash-es'; +import { observer } from 'mobx-react'; -import { getTzOffsetString, allTimezones } from 'models/timezone/timezone.helpers'; -import { Timezone } from 'models/timezone/timezone.types'; -import { User } from 'models/user/user.types'; +import { allTimezones, getGMTTimezoneLabelBasedOnOffset, getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { useStore } from 'state/useStore'; import styles from './UserTimezoneSelect.module.css'; -interface UserTimezoneSelectProps { - users: User[]; - value: Timezone; - onChange: (value: Timezone) => void; -} - const cx = cn.bind(styles); interface TimezoneOption { - value: number; - utcOffset: number; - timezone: Timezone; + value: number; // utcOffset label: string; description: string; } -const UserTimezoneSelect: FC = (props) => { - const { users, value: propValue, onChange } = props; +interface UserTimezoneSelectProps { + scheduleId?: string; +} + +const UserTimezoneSelect: FC = observer(({ scheduleId }) => { + const store = useStore(); + const users = store.userStore.getSearchResult().results || []; const [extraOptions, setExtraOptions] = useState([ { value: 0, - utcOffset: 0, - timezone: 'UTC' as Timezone, label: 'GMT', description: '', }, ]); const options = useMemo(() => { - return users - .reduce( + return sortBy( + users.reduce( (memo, user) => { const moment = dayjs().tz(user.timezone); const utcOffset = moment.utcOffset(); - let item = memo.find((item) => item.utcOffset === utcOffset); + let item = memo.find((item) => item.value === utcOffset); if (!item) { item = { value: utcOffset, - utcOffset, - timezone: user.timezone, - label: getTzOffsetString(moment), + label: getGMTTimezoneLabelBasedOnOffset(utcOffset), description: user.username, }; + memo.push(item); } else { item.description += item.description ? ', ' + user.username : user.username; - // item.imgUrl = undefined; } return memo; }, [...extraOptions.map((option) => ({ ...option }))] - ) - .sort((a, b) => { - if (b.utcOffset === 0) { - return 1; - } - - if (a.utcOffset > b.utcOffset) { - return 1; - } - if (a.utcOffset < b.utcOffset) { - return -1; - } - - return 0; - }); + ), + ({ value }) => value + ); }, [users, extraOptions]); - const value = useMemo(() => { - const utcOffset = dayjs().tz(propValue).utcOffset(); - const option = options.find((option) => option.utcOffset === utcOffset); - - return option?.value; - }, [propValue, options]); - - const handleChange = useCallback( - ({ value }) => { - const option = options.find((option) => option.utcOffset === value); - onChange(option?.timezone); - }, - [options] - ); - const filterOption = useCallback((item: SelectableValue, searchQuery: string) => { const { data } = item; @@ -105,7 +72,7 @@ const UserTimezoneSelect: FC = (props) => { if (data.__isNew_) { return true; } - return data[key] && data[key].toLowerCase().includes(searchQuery.toLowerCase()); + return data[key]?.toLowerCase().includes(searchQuery.toLowerCase()); }); }, []); @@ -115,9 +82,10 @@ const UserTimezoneSelect: FC = (props) => { if (matched) { const now = dayjs().tz(matched); const utcOffset = now.utcOffset(); - onChange(matched); + store.timezoneStore.setSelectedTimezoneOffset(utcOffset); + store.scheduleStore.refreshEvents(scheduleId); - if (options.some((option) => option.utcOffset === utcOffset)) { + if (options.some((option) => option.value === utcOffset)) { return; } @@ -125,26 +93,25 @@ const UserTimezoneSelect: FC = (props) => { ...extraOptions, { value: utcOffset, - utcOffset, timezone: matched, label: getTzOffsetString(now), description: '', }, ]); - onChange(matched); + store.timezoneStore.setSelectedTimezoneOffset(utcOffset); + store.scheduleStore.refreshEvents(scheduleId); } }, [options] ); return ( -
+