From 919ba866148365019787a24b605c9c17c8f5a727 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Fri, 21 Jun 2024 17:07:46 +0300 Subject: [PATCH] Tweaks to overrides (added draggable bounds, take timezone into consideration) (#4553) # What this PR does Part of https://github.com/grafana/oncall/issues/4428 ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall/issues/4547 --- e2e-tests/alerts/onCallSchedule.test.ts | 4 +- e2e-tests/insights/insights.test.ts | 4 +- e2e-tests/schedules/addOverride.test.ts | 46 ++++- e2e-tests/schedules/addRotation.test.ts | 43 +++++ e2e-tests/schedules/quality.test.ts | 4 +- e2e-tests/schedules/scheduleDetails.test.ts | 4 +- e2e-tests/schedules/scheduleView.test.ts | 4 +- e2e-tests/schedules/schedulesList.test.ts | 4 +- e2e-tests/schedules/timezones.test.ts | 7 +- e2e-tests/utils/constants.ts | 2 + e2e-tests/utils/grafanaProfile.ts | 5 +- e2e-tests/utils/schedule.ts | 9 +- .../RotationForm/RotationForm.helpers.ts | 41 +++++ src/containers/RotationForm/RotationForm.tsx | 76 ++------ .../RotationForm/ScheduleOverrideForm.tsx | 171 +++++++++++------- src/containers/Rotations/Rotations.helpers.ts | 8 + src/containers/Rotations/Rotations.tsx | 7 +- .../Rotations/ScheduleOverrides.tsx | 30 +-- src/pages/schedule/Schedule.tsx | 8 +- src/utils/DOM.ts | 2 +- src/utils/hooks.tsx | 10 + 21 files changed, 326 insertions(+), 163 deletions(-) create mode 100644 e2e-tests/schedules/addRotation.test.ts diff --git a/e2e-tests/alerts/onCallSchedule.test.ts b/e2e-tests/alerts/onCallSchedule.test.ts index f2e8d0f124..6859ec0378 100644 --- a/e2e-tests/alerts/onCallSchedule.test.ts +++ b/e2e-tests/alerts/onCallSchedule.test.ts @@ -3,7 +3,7 @@ import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup'; import { createEscalationChain, EscalationStep } from '../utils/escalationChain'; import { generateRandomValue } from '../utils/forms'; import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; -import { createOnCallScheduleWithRotation } from '../utils/schedule'; +import { createOnCallSchedule } from '../utils/schedule'; test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; @@ -11,7 +11,7 @@ test('we can create an oncall schedule + receive an alert', async ({ adminRolePa const integrationName = generateRandomValue(); const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createOnCallSchedule(page, onCallScheduleName, userName); await createEscalationChain( page, escalationChainName, diff --git a/e2e-tests/insights/insights.test.ts b/e2e-tests/insights/insights.test.ts index 14d2feeca7..411a8cd4f4 100644 --- a/e2e-tests/insights/insights.test.ts +++ b/e2e-tests/insights/insights.test.ts @@ -6,7 +6,7 @@ import { createEscalationChain, EscalationStep } from '../utils/escalationChain' import { clickButton, generateRandomValue } from '../utils/forms'; import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; -import { createOnCallScheduleWithRotation } from '../utils/schedule'; +import { createOnCallSchedule } from '../utils/schedule'; /** * Insights is dependent on Scenes which were only added in Grafana 10.0.0 @@ -66,7 +66,7 @@ test.describe.skip('Insights', () => { const escalationChainName = generateRandomValue(); const integrationName = generateRandomValue(); const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createOnCallSchedule(page, onCallScheduleName, userName); await createEscalationChain( page, escalationChainName, diff --git a/e2e-tests/schedules/addOverride.test.ts b/e2e-tests/schedules/addOverride.test.ts index 709c85511f..a741c9338a 100644 --- a/e2e-tests/schedules/addOverride.test.ts +++ b/e2e-tests/schedules/addOverride.test.ts @@ -1,14 +1,16 @@ import dayjs from 'dayjs'; -import { test, expect } from '../fixtures'; +import { test, expect, Locator } from '../fixtures'; +import { MOSCOW_TIMEZONE } from '../utils/constants'; import { clickButton, generateRandomValue } from '../utils/forms'; -import { createOnCallScheduleWithRotation, getOverrideFormDateInputs } from '../utils/schedule'; +import { setTimezoneInProfile } from '../utils/grafanaProfile'; +import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule'; -test('default dates in override creation modal are correct', async ({ adminRolePage }) => { +test('Default dates in override creation modal are set to today', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createOnCallSchedule(page, onCallScheduleName, userName); await clickButton({ page, buttonText: 'Add override' }); @@ -20,3 +22,39 @@ test('default dates in override creation modal are correct', async ({ adminRoleP expect(overrideFormDateInputs.start.isSame(expectedStart)).toBe(true); expect(overrideFormDateInputs.end.isSame(expectedEnd)).toBe(true); }); + +test('Fills in override time and reacts to timezone change', async ({ adminRolePage }) => { + const { page, userName } = adminRolePage; + + await setTimezoneInProfile(page, MOSCOW_TIMEZONE); // UTC+3 + + const onCallScheduleName = generateRandomValue(); + await createOnCallSchedule(page, onCallScheduleName, userName, false); + + await clickButton({ page, buttonText: 'Add override' }); + + const overrideStartEl = page.getByTestId('override-start'); + await changeDatePickerTime(overrideStartEl, '02'); + await expect(overrideStartEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('02:00'); + + const overrideEndEl = page.getByTestId('override-end'); + await changeDatePickerTime(overrideEndEl, '12'); + await expect(overrideEndEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('12:00'); + + await page.getByRole('dialog').click(); // clear focus + + await page.getByTestId('timezone-select').locator('svg').click(); + await page.getByText('GMT', { exact: true }).click(); + + // expect times to go back by -3 + await expect(overrideStartEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('23:00'); + await expect(overrideEndEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('09:00'); + + async function changeDatePickerTime(element: Locator, value: string) { + await element.getByRole('img').click(); + // set minutes to {value} + await page.locator('.rc-time-picker-panel').getByRole('button', { name: value }).first().click(); + // set seconds to 00 + await page.getByRole('button', { name: '00' }).nth(1).click(); + } +}); diff --git a/e2e-tests/schedules/addRotation.test.ts b/e2e-tests/schedules/addRotation.test.ts new file mode 100644 index 0000000000..478e69033e --- /dev/null +++ b/e2e-tests/schedules/addRotation.test.ts @@ -0,0 +1,43 @@ +import { test, expect, Locator } from '../fixtures'; +import { MOSCOW_TIMEZONE } from '../utils/constants'; +import { clickButton, generateRandomValue } from '../utils/forms'; +import { setTimezoneInProfile } from '../utils/grafanaProfile'; +import { createOnCallSchedule } from '../utils/schedule'; + +test('Fills in Rotation time and reacts to timezone change', async ({ adminRolePage }) => { + const { page, userName } = adminRolePage; + + await setTimezoneInProfile(page, MOSCOW_TIMEZONE); // UTC+3 + + const onCallScheduleName = generateRandomValue(); + await createOnCallSchedule(page, onCallScheduleName, userName, false); + + await clickButton({ page, buttonText: 'Add rotation' }); + // enable Rotation End + await page.getByTestId('rotation-end').getByLabel('Toggle switch').click(); + + const startEl = page.getByTestId('rotation-start'); + await changeDatePickerTime(startEl, '02'); + await expect(startEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('02:00'); + + const endEl = page.getByTestId('rotation-end'); + await changeDatePickerTime(endEl, '12'); + await expect(endEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('12:00'); + + await page.getByRole('dialog').click(); // clear focus + + await page.getByTestId('timezone-select').locator('svg').click(); + await page.getByText('GMT', { exact: true }).click(); + + // expect times to go back by -3 + await expect(startEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('23:00'); + await expect(endEl.getByTestId('date-time-picker').getByRole('textbox')).toHaveValue('09:00'); + + async function changeDatePickerTime(element: Locator, value: string) { + await element.getByRole('img').click(); + // set minutes to {value} + await page.locator('.rc-time-picker-panel').getByRole('button', { name: value }).first().click(); + // set seconds to 00 + await page.getByRole('button', { name: '00' }).nth(1).click(); + } +}); diff --git a/e2e-tests/schedules/quality.test.ts b/e2e-tests/schedules/quality.test.ts index abd26d9c3c..82d84212c1 100644 --- a/e2e-tests/schedules/quality.test.ts +++ b/e2e-tests/schedules/quality.test.ts @@ -1,12 +1,12 @@ import { test, expect } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; -import { createOnCallScheduleWithRotation } from '../utils/schedule'; +import { createOnCallSchedule } from '../utils/schedule'; test('check schedule quality for simple 1-user schedule', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createOnCallSchedule(page, onCallScheduleName, userName); const scheduleQualityElement = page.getByTestId('schedule-quality'); await scheduleQualityElement.waitFor({ state: 'visible' }); diff --git a/e2e-tests/schedules/scheduleDetails.test.ts b/e2e-tests/schedules/scheduleDetails.test.ts index e1bd7f3295..e092bb610a 100644 --- a/e2e-tests/schedules/scheduleDetails.test.ts +++ b/e2e-tests/schedules/scheduleDetails.test.ts @@ -1,13 +1,13 @@ import { test, expect } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; -import { createOnCallScheduleWithRotation, createRotation } from '../utils/schedule'; +import { createOnCallSchedule, createRotation } from '../utils/schedule'; test(`user can see the other user's details`, async ({ adminRolePage, editorRolePage }) => { const { page, userName: adminUserName } = adminRolePage; const editorUserName = editorRolePage.userName; const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, adminUserName); + await createOnCallSchedule(page, onCallScheduleName, adminUserName); await createRotation(page, editorUserName, false); await page.waitForTimeout(1_000); diff --git a/e2e-tests/schedules/scheduleView.test.ts b/e2e-tests/schedules/scheduleView.test.ts index 1def16456e..0f27f47ac1 100644 --- a/e2e-tests/schedules/scheduleView.test.ts +++ b/e2e-tests/schedules/scheduleView.test.ts @@ -4,13 +4,13 @@ import { HTML_ID } from 'utils/DOM'; import { expect, test } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; -import { createOnCallScheduleWithRotation } from '../utils/schedule'; +import { createOnCallSchedule } from '../utils/schedule'; test.skip('schedule view (week/2 weeks/month) toggler works', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createOnCallSchedule(page, onCallScheduleName, userName); // ScheduleView.OneWeek is selected by default expect(await page.getByLabel(ScheduleView.OneWeek, { exact: true }).isChecked()).toBe(true); diff --git a/e2e-tests/schedules/schedulesList.test.ts b/e2e-tests/schedules/schedulesList.test.ts index d83b570ebd..31c50c1fcf 100644 --- a/e2e-tests/schedules/schedulesList.test.ts +++ b/e2e-tests/schedules/schedulesList.test.ts @@ -1,13 +1,13 @@ import { expect, test } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; import { goToOnCallPage } from '../utils/navigation'; -import { createOnCallScheduleWithRotation } from '../utils/schedule'; +import { createOnCallSchedule } from '../utils/schedule'; test('schedule calendar and list of schedules is correctly displayed', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + await createOnCallSchedule(page, onCallScheduleName, userName); await goToOnCallPage(page, 'schedules'); diff --git a/e2e-tests/schedules/timezones.test.ts b/e2e-tests/schedules/timezones.test.ts index 32f5689d6d..71df96f620 100644 --- a/e2e-tests/schedules/timezones.test.ts +++ b/e2e-tests/schedules/timezones.test.ts @@ -4,15 +4,14 @@ import isoWeek from 'dayjs/plugin/isoWeek'; import utc from 'dayjs/plugin/utc'; import { test } from '../fixtures'; +import { MOSCOW_TIMEZONE } from '../utils/constants'; import { clickButton, generateRandomValue } from '../utils/forms'; import { setTimezoneInProfile } from '../utils/grafanaProfile'; -import { createOnCallScheduleWithRotation } from '../utils/schedule'; +import { createOnCallSchedule } from '../utils/schedule'; dayjs.extend(utc); dayjs.extend(isoWeek); -const MOSCOW_TIMEZONE = 'Europe/Moscow'; - test.use({ timezoneId: MOSCOW_TIMEZONE }); // GMT+3 the whole year const currentUtcTimeHour = dayjs().utc().format('HH'); const currentUtcDate = dayjs().utc().format('DD MMM'); @@ -25,7 +24,7 @@ test('dates in schedule are correct according to selected current timezone', asy await setTimezoneInProfile(page, MOSCOW_TIMEZONE); const onCallScheduleName = generateRandomValue(); - await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + 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'); diff --git a/e2e-tests/utils/constants.ts b/e2e-tests/utils/constants.ts index f6969efdf7..5345d1c4d7 100644 --- a/e2e-tests/utils/constants.ts +++ b/e2e-tests/utils/constants.ts @@ -10,3 +10,5 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true'; export const IS_CLOUD = !IS_OPEN_SOURCE; + +export const MOSCOW_TIMEZONE = 'Europe/Moscow'; diff --git a/e2e-tests/utils/grafanaProfile.ts b/e2e-tests/utils/grafanaProfile.ts index 672de8cdb7..add2e32f32 100644 --- a/e2e-tests/utils/grafanaProfile.ts +++ b/e2e-tests/utils/grafanaProfile.ts @@ -1,11 +1,14 @@ -import { Page } from '@playwright/test'; +import { Page, expect } from '@playwright/test'; import { goToGrafanaPage } from './navigation'; export const setTimezoneInProfile = async (page: Page, timezone: string) => { await goToGrafanaPage(page, '/profile'); + await expect(page.getByLabel('Time zone picker')).toBeVisible(); + await page.getByLabel('Time zone picker').click(); await page.getByLabel('Select options menu').getByText(timezone).click(); await page.getByTestId('data-testid-shared-prefs-save').click(); await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); // wait for reload }; diff --git a/e2e-tests/utils/schedule.ts b/e2e-tests/utils/schedule.ts index e0e64f4424..c08172f199 100644 --- a/e2e-tests/utils/schedule.ts +++ b/e2e-tests/utils/schedule.ts @@ -4,10 +4,11 @@ import dayjs from 'dayjs'; import { clickButton, selectDropdownValue } from './forms'; import { goToOnCallPage } from './navigation'; -export const createOnCallScheduleWithRotation = async ( +export const createOnCallSchedule = async ( page: Page, scheduleName: string, - userName: string + userName: string, + withRotation = true ): Promise => { // go to the schedules page await goToOnCallPage(page, 'schedules'); @@ -22,7 +23,9 @@ export const createOnCallScheduleWithRotation = async ( // Add a new layer w/ the current user to it await clickButton({ page, buttonText: 'Create Schedule' }); - await createRotation(page, userName); + if (withRotation) { + await createRotation(page, userName); + } }; export const createRotation = async (page: Page, userName: string, isFirstScheduleRotation = true) => { diff --git a/src/containers/RotationForm/RotationForm.helpers.ts b/src/containers/RotationForm/RotationForm.helpers.ts index da86c3b551..98597fcb79 100644 --- a/src/containers/RotationForm/RotationForm.helpers.ts +++ b/src/containers/RotationForm/RotationForm.helpers.ts @@ -1,4 +1,8 @@ import { Dayjs, ManipulateType } from 'dayjs'; +import { DraggableData } from 'react-draggable'; + +import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; +import { GRAFANA_HEADER_HEIGHT, GRAFANA_LEGACY_SIDEBAR_WIDTH } from 'utils/consts'; import { RepeatEveryPeriod } from './RotationForm.types'; @@ -173,3 +177,40 @@ export const dayJSAddWithDSTFixed = ({ return newDateCandidate.add(diff, 'minutes'); }; + +export function getDraggableModalCoordinatesOnInit( + data: DraggableData, + offsetTop: number +): { + left: number; + right: number; + top: number; + bottom: number; +} { + if (!data) { + return undefined; + } + + const scrollBarReferenceElements = document.querySelectorAll('.scrollbar-view'); + // top navbar display has 2 scrollbar-view elements (navbar & content) + const baseReferenceElRect = ( + scrollBarReferenceElements.length === 1 ? scrollBarReferenceElements[0] : scrollBarReferenceElements[1] + ).getBoundingClientRect(); + + const { right, bottom } = baseReferenceElRect; + + return isTopNavbar() + ? { + // values are adjusted by any padding/margin differences + left: -data.node.offsetLeft + 4, + right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12, + top: -offsetTop + GRAFANA_HEADER_HEIGHT + 4, + bottom: bottom - data.node.offsetHeight - offsetTop - 12, + } + : { + left: -data.node.offsetLeft + 4 + GRAFANA_LEGACY_SIDEBAR_WIDTH, + right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12, + top: -offsetTop + 4, + bottom: bottom - data.node.offsetHeight - offsetTop - 12, + }; +} diff --git a/src/containers/RotationForm/RotationForm.tsx b/src/containers/RotationForm/RotationForm.tsx index b068f68e94..cc609ac4a8 100644 --- a/src/containers/RotationForm/RotationForm.tsx +++ b/src/containers/RotationForm/RotationForm.tsx @@ -40,6 +40,7 @@ import { TimeUnit, timeUnitsToSeconds, TIME_UNITS_ORDER, + getDraggableModalCoordinatesOnInit, } from 'containers/RotationForm/RotationForm.helpers'; import { RepeatEveryPeriod } from 'containers/RotationForm/RotationForm.types'; import { DateTimePicker } from 'containers/RotationForm/parts/DateTimePicker'; @@ -47,6 +48,7 @@ import { DaysSelector } from 'containers/RotationForm/parts/DaysSelector'; import { DeletionModal } from 'containers/RotationForm/parts/DeletionModal'; import { TimeUnitSelector } from 'containers/RotationForm/parts/TimeUnitSelector'; import { UserItem } from 'containers/RotationForm/parts/UserItem'; +import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers'; import { getShiftName } from 'models/schedule/schedule.helpers'; import { Schedule, Shift } from 'models/schedule/schedule.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; @@ -58,12 +60,11 @@ import { getUTCWeekStart, getWeekStartString, toDateWithTimezoneOffset, + toDateWithTimezoneOffsetAtMidnight, } from 'pages/schedule/Schedule.helpers'; -import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { useStore } from 'state/useStore'; -import { getCoords, waitForElement } from 'utils/DOM'; -import { GRAFANA_HEADER_HEIGHT, GRAFANA_LEGACY_SIDEBAR_WIDTH } from 'utils/consts'; -import { useDebouncedCallback } from 'utils/hooks'; +import { GRAFANA_HEADER_HEIGHT } from 'utils/consts'; +import { useDebouncedCallback, useResize } from 'utils/hooks'; import styles from './RotationForm.module.css'; @@ -85,17 +86,11 @@ interface RotationFormProps { const getStartShift = (start: dayjs.Dayjs, timezoneOffset: number, isNewRotation = false) => { if (isNewRotation) { - // all new rotations default to midnight in selected timezone offset - return toDateWithTimezoneOffset(start, timezoneOffset) - .set('date', 1) - .set('year', start.year()) - .set('month', start.month()) - .set('date', start.date()) - .set('hour', 0) - .set('minute', 0) - .set('second', 0); + // default to midnight for new rotations + return toDateWithTimezoneOffsetAtMidnight(start, timezoneOffset); } + // not always midnight return toDateWithTimezoneOffset(start, timezoneOffset); }; @@ -156,12 +151,7 @@ export const RotationForm = observer((props: RotationFormProps) => { const [showDeleteRotationConfirmation, setShowDeleteRotationConfirmation] = useState(false); const debouncedOnResize = useDebouncedCallback(onResize, 250); - useEffect(() => { - window.addEventListener('resize', debouncedOnResize); - return () => { - window.removeEventListener('resize', debouncedOnResize); - }; - }, []); + useResize(debouncedOnResize); useEffect(() => { if (rotationStart.isBefore(shiftStart)) { @@ -178,7 +168,7 @@ export const RotationForm = observer((props: RotationFormProps) => { useEffect(() => { (async () => { if (isOpen) { - setOffsetTop(await calculateOffsetTop()); + setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`)); } })(); }, [isOpen]); @@ -612,6 +602,7 @@ export const RotationForm = observer((props: RotationFormProps) => { Starts } + data-testid="rotation-start" > { /> } + data-testid="rotation-end" > {endLess ? (
@@ -771,7 +763,9 @@ export const RotationForm = observer((props: RotationFormProps) => {
- Current timezone: {store.timezoneStore.selectedTimezoneLabel} + + Current timezone: {store.timezoneStore.selectedTimezoneLabel} + {shiftId !== 'new' && ( @@ -803,20 +797,9 @@ export const RotationForm = observer((props: RotationFormProps) => { ); async function onResize() { - setDraggablePosition({ x: 0, y: await calculateOffsetTop() }); - } + setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`)); - async function calculateOffsetTop(): Promise { - const elm = await waitForElement(`#layer${shiftId === 'new' ? layerPriority : shift?.priority_level}`); - const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; - const coords = getCoords(elm); - - const offsetTop = Math.max( - Math.min(coords.top - modal?.offsetHeight - 10, document.body.offsetHeight - modal?.offsetHeight - 10), - GRAFANA_HEADER_HEIGHT + 10 - ); - - return offsetTop; + setDraggablePosition({ x: 0, y: 0 }); } function onDraggableInit(_e: DraggableEvent, data: DraggableData) { @@ -824,30 +807,7 @@ export const RotationForm = observer((props: RotationFormProps) => { return; } - const scrollBarReferenceElements = document.querySelectorAll('.scrollbar-view'); - // top navbar display has 2 scrollbar-view elements (navbar & content) - const baseReferenceElRect = ( - scrollBarReferenceElements.length === 1 ? scrollBarReferenceElements[0] : scrollBarReferenceElements[1] - ).getBoundingClientRect(); - - const { right, bottom } = baseReferenceElRect; - - setDraggableBounds( - isTopNavbar() - ? { - // values are adjusted by any padding/margin differences - left: -data.node.offsetLeft + 4, - right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12, - top: -offsetTop + GRAFANA_HEADER_HEIGHT + 4, - bottom: bottom - data.node.offsetHeight - offsetTop - 12, - } - : { - left: -data.node.offsetLeft + 4 + GRAFANA_LEGACY_SIDEBAR_WIDTH, - right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12, - top: -offsetTop + 4, - bottom: bottom - data.node.offsetHeight - offsetTop - 12, - } - ); + setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop)); } }); diff --git a/src/containers/RotationForm/ScheduleOverrideForm.tsx b/src/containers/RotationForm/ScheduleOverrideForm.tsx index fcd6ce74f8..beda68590e 100644 --- a/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -3,22 +3,22 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { IconButton, VerticalGroup, HorizontalGroup, Field, Button, useTheme2 } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; -import Draggable from 'react-draggable'; +import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; import { Modal } from 'components/Modal/Modal'; import { Tag } from 'components/Tag/Tag'; import { Text } from 'components/Text/Text'; import { UserGroups } from 'components/UserGroups/UserGroups'; import { WithConfirm } from 'components/WithConfirm/WithConfirm'; +import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers'; import { getShiftName } from 'models/schedule/schedule.helpers'; import { Schedule, Shift } from 'models/schedule/schedule.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; -import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers'; +import { getDateTime, getUTCString, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; -import { HTML_ID, getCoords, waitForElement } from 'utils/DOM'; -import { GRAFANA_HEADER_HEIGHT } from 'utils/consts'; -import { useDebouncedCallback } from 'utils/hooks'; +import { useDebouncedCallback, useResize } from 'utils/hooks'; +import { getDraggableModalCoordinatesOnInit } from './RotationForm.helpers'; import { DateTimePicker } from './parts/DateTimePicker'; import { UserItem } from './parts/UserItem'; @@ -39,6 +39,9 @@ interface RotationFormProps { const cx = cn.bind(styles); export const ScheduleOverrideForm: FC = (props) => { + const store = useStore(); + const theme = useTheme2(); + const { onHide, onCreate, @@ -46,16 +49,18 @@ export const ScheduleOverrideForm: FC = (props) => { onUpdate, onDelete, shiftId, - shiftStart: propsShiftStart = dayjs().startOf('day').add(1, 'day'), + shiftStart: propsShiftStart = store.timezoneStore.calendarStartDate, shiftEnd: propsShiftEnd, shiftColor: shiftColorProp, } = props; - const store = useStore(); - const theme = useTheme2(); - const [rotationName, setRotationName] = useState(shiftId === 'new' ? 'Override' : 'Update override'); + const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined); + const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>( + undefined + ); + const [shiftStart, setShiftStart] = useState(propsShiftStart); const [shiftEnd, setShiftEnd] = useState(propsShiftEnd || propsShiftStart.add(24, 'hours')); @@ -66,6 +71,10 @@ export const ScheduleOverrideForm: FC = (props) => { const [errors, setErrors] = useState<{ [key: string]: string[] }>({}); const shiftColor = shiftColorProp || theme.colors.warning.main; + const debouncedOnResize = useDebouncedCallback(onResize, 250); + + useResize(debouncedOnResize); + const updateShiftStart = useCallback( (value) => { const diff = shiftEnd.diff(shiftStart); @@ -79,15 +88,7 @@ export const ScheduleOverrideForm: FC = (props) => { useEffect(() => { (async () => { if (isOpen) { - const elm = await waitForElement(`#${HTML_ID.SCHEDULE_OVERRIDES_AND_SWAPS}`); - const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; - const coords = getCoords(elm); - const offsetTop = Math.min( - Math.max(coords.top - modal?.offsetHeight - 10, GRAFANA_HEADER_HEIGHT + 10), - document.body.offsetHeight - modal?.offsetHeight - 10 - ); - - setOffsetTop(offsetTop); + setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`)); } })(); }, [isOpen]); @@ -102,6 +103,11 @@ export const ScheduleOverrideForm: FC = (props) => { } }, [shiftId]); + useEffect(() => { + setShiftStart(toDateWithTimezoneOffset(shiftStart, store.timezoneStore.selectedTimezoneOffset)); + setShiftEnd(toDateWithTimezoneOffset(shiftEnd, store.timezoneStore.selectedTimezoneOffset)); + }, [store.timezoneStore.selectedTimezoneOffset]); + const params = useMemo( () => ({ rotation_start: getUTCString(shiftStart), @@ -200,7 +206,15 @@ export const ScheduleOverrideForm: FC = (props) => { width="430px" onDismiss={onHide} contentElement={(props, children) => ( - + setDraggablePosition({ x: data.x, y: data.y })} + >
{children}
)} @@ -215,7 +229,7 @@ export const ScheduleOverrideForm: FC = (props) => {
{shiftId !== 'new' && ( - + )} @@ -228,49 +242,70 @@ export const ScheduleOverrideForm: FC = (props) => { />
-
- - - - Override period start - - } - > - - - - Override period end - - } - > - - - - ( - - )} - showError={Boolean(errors.rolling_users)} - /> - + +
+
+ + + + Override period start + + } + > + + + + + Override period end + + } + > + + + + + ( + + )} + showError={Boolean(errors.rolling_users)} + /> + +
- Current timezone: {store.timezoneStore.selectedTimezoneLabel} + + Current timezone: {store.timezoneStore.selectedTimezoneLabel} +