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
This commit is contained in:
Rares Mardare 2024-06-21 17:07:46 +03:00 committed by GitHub
parent 615081a112
commit 16e98da64a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 326 additions and 163 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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();
}
});

View file

@ -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();
}
});

View file

@ -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' });

View file

@ -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);

View file

@ -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);

View file

@ -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');

View file

@ -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');

View file

@ -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';

View file

@ -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
};

View file

@ -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<void> => {
// 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) => {

View file

@ -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<HTMLElement>('.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,
};
}

View file

@ -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<boolean>(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
</Text>
}
data-testid="rotation-start"
>
<DateTimePicker
value={rotationStart}
@ -636,6 +627,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
/>
</HorizontalGroup>
}
data-testid="rotation-end"
>
{endLess ? (
<div style={{ lineHeight: '32px' }}>
@ -771,7 +763,9 @@ export const RotationForm = observer((props: RotationFormProps) => {
</div>
<div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
<Text type="secondary">
Current timezone: <Text type="primary">{store.timezoneStore.selectedTimezoneLabel}</Text>
</Text>
<HorizontalGroup>
{shiftId !== 'new' && (
<Tooltip content="Stop the current rotation and start a new one">
@ -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<number> {
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<HTMLElement>('.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));
}
});

View file

@ -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<RotationFormProps> = (props) => {
const store = useStore();
const theme = useTheme2();
const {
onHide,
onCreate,
@ -46,16 +49,18 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (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<string>(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<dayjs.Dayjs>(propsShiftStart);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(propsShiftEnd || propsShiftStart.add(24, 'hours'));
@ -66,6 +71,10 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (props) => {
width="430px"
onDismiss={onHide}
contentElement={(props, children) => (
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }}>
<Draggable
handle=".drag-handler"
defaultClassName={cx('draggable')}
positionOffset={{ x: 0, y: offsetTop }}
position={draggablePosition}
bounds={{ ...bounds } || 'body'}
onStart={onDraggableInit}
onStop={(_e, data) => setDraggablePosition({ x: data.x, y: data.y })}
>
<div {...props}>{children}</div>
</Draggable>
)}
@ -215,7 +229,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
</HorizontalGroup>
<HorizontalGroup>
{shiftId !== 'new' && (
<WithConfirm>
<WithConfirm title="Are you sure you want to delete override?">
<IconButton variant="secondary" tooltip="Delete" name="trash-alt" onClick={handleDeleteClick} />
</WithConfirm>
)}
@ -228,49 +242,70 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
/>
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('override-form-content')} data-testid="override-inputs">
<VerticalGroup>
<HorizontalGroup align="flex-start">
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override period start
</Text>
}
>
<DateTimePicker
disabled={disabled}
value={shiftStart}
onChange={updateShiftStart}
error={errors.shift_start}
/>
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override period end
</Text>
}
>
<DateTimePicker disabled={disabled} value={shiftEnd} onChange={setShiftEnd} error={errors.shift_end} />
</Field>
</HorizontalGroup>
<UserGroups
disabled={disabled}
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={false}
renderUser={(pk: ApiSchemas['User']['pk']) => (
<UserItem pk={pk} shiftColor={shiftColor} shiftStart={params.shift_start} shiftEnd={params.shift_end} />
)}
showError={Boolean(errors.rolling_users)}
/>
</VerticalGroup>
<div className={cx('container')}>
<div className={cx('override-form-content')} data-testid="override-inputs">
<VerticalGroup>
<HorizontalGroup align="flex-start">
<Field
className={cx('date-time-picker')}
data-testid="override-start"
label={
<Text type="primary" size="small">
Override period start
</Text>
}
>
<DateTimePicker
disabled={disabled}
value={shiftStart}
utcOffset={store.timezoneStore.selectedTimezoneOffset}
onChange={updateShiftStart}
error={errors.shift_start}
/>
</Field>
<Field
className={cx('date-time-picker')}
data-testid="override-end"
label={
<Text type="primary" size="small">
Override period end
</Text>
}
>
<DateTimePicker
disabled={disabled}
value={shiftEnd}
utcOffset={store.timezoneStore.selectedTimezoneOffset}
onChange={setShiftEnd}
error={errors.shift_end}
/>
</Field>
</HorizontalGroup>
<UserGroups
disabled={disabled}
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={false}
renderUser={(pk: ApiSchemas['User']['pk']) => (
<UserItem
pk={pk}
shiftColor={shiftColor}
shiftStart={params.shift_start}
shiftEnd={params.shift_end}
/>
)}
showError={Boolean(errors.rolling_users)}
/>
</VerticalGroup>
</div>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
<Text type="secondary">
Current timezone: <Text type="primary">{store.timezoneStore.selectedTimezoneLabel}</Text>
</Text>
<HorizontalGroup>
<Button variant="primary" onClick={handleCreate} disabled={disabled || !isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}
@ -280,4 +315,18 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
</VerticalGroup>
</Modal>
);
async function onResize() {
setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`));
setDraggablePosition({ x: 0, y: 0 });
}
function onDraggableInit(_e: DraggableEvent, data: DraggableData) {
if (!data) {
return;
}
setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop));
}
};

View file

@ -4,6 +4,14 @@ import { getColor, getOverrideColor } from 'models/schedule/schedule.helpers';
import { Layer, Shift } from 'models/schedule/schedule.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
import { waitForElement } from 'utils/DOM';
export const calculateScheduleFormOffset = async (queryClassName: string) => {
const modal = await waitForElement(queryClassName);
const modalHeight = modal.clientHeight;
return document.documentElement.scrollHeight / 2 - modalHeight / 2;
};
// DatePickers will convert the date passed to local timezone, instead we want to use the date in the given timezone
export const toDatePickerDate = (value: dayjs.Dayjs, timezoneOffset: number) => {

View file

@ -16,7 +16,6 @@ import { TimelineMarks } from 'containers/TimelineMarks/TimelineMarks';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { getColor, getLayersFromStore, scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers';
import { Schedule, ScheduleType, Shift, ShiftSwap, Event, Layer } from 'models/schedule/schedule.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { getCurrentTimeX, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
@ -34,13 +33,11 @@ interface RotationsProps extends WithStoreProps {
layerPriorityToShowRotationForm?: Layer['priority'];
scheduleId: Schedule['id'];
onShowRotationForm: (shiftId: Shift['id'] | 'new', layerPriority?: Layer['priority']) => void;
onClick: (id: Shift['id'] | 'new') => void;
onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void;
onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new', params?: Partial<ShiftSwap>) => void;
onCreate: () => void;
onUpdate: () => void;
onDelete: () => void;
onShiftSwapRequest: (beneficiary: ApiSchemas['User']['pk'], swap_start: string, swap_end: string) => void;
disabled: boolean;
filters: ScheduleFiltersType;
onSlotClick?: (event: Event) => void;
@ -362,4 +359,6 @@ class _Rotations extends Component<RotationsProps, RotationsState> {
};
}
export const Rotations = withMobXProviderContext(withTheme2(_Rotations));
export const Rotations = withMobXProviderContext(withTheme2(_Rotations)) as unknown as React.ComponentClass<
Omit<RotationsProps, 'store' | 'theme'>
>;

View file

@ -22,7 +22,7 @@ import {
SHIFT_SWAP_COLOR,
} from 'models/schedule/schedule.helpers';
import { Schedule, Shift, ShiftEvents, ShiftSwap } from 'models/schedule/schedule.types';
import { getCurrentTimeX } from 'pages/schedule/Schedule.helpers';
import { getCurrentTimeX, toDateWithTimezoneOffset } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { HTML_ID } from 'utils/DOM';
@ -39,7 +39,7 @@ interface ScheduleOverridesProps extends WithStoreProps {
shiftEndToShowOverrideForm: dayjs.Dayjs;
scheduleId: Schedule['id'];
shiftIdToShowRotationForm?: Shift['id'] | 'new';
onShowRotationForm: (shiftId: Shift['id'] | 'new') => void;
onShowOverridesForm: (shiftId: Shift['id'] | 'new') => void;
onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new') => void;
onCreate: () => void;
onUpdate: () => void;
@ -205,8 +205,14 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
shiftId={shiftIdToShowRotationForm}
shiftColor={findColor(shiftIdToShowRotationForm, undefined, shifts)}
scheduleId={scheduleId}
shiftStart={propsShiftStartToShowOverrideForm || shiftStartToShowOverrideForm}
shiftEnd={propsShiftEndToShowOverrideForm || shiftEndToShowOverrideForm}
shiftStart={toDateWithTimezoneOffset(
propsShiftStartToShowOverrideForm || shiftStartToShowOverrideForm,
store.timezoneStore.selectedTimezoneOffset
)}
shiftEnd={toDateWithTimezoneOffset(
propsShiftEndToShowOverrideForm || shiftEndToShowOverrideForm,
store.timezoneStore.selectedTimezoneOffset
)}
onHide={() => {
this.handleHide();
@ -241,7 +247,7 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
}
this.setState({ shiftStartToShowOverrideForm: shiftStart, shiftEndToShowOverrideForm: shiftEnd }, () => {
this.onShowRotationForm(shiftId);
this.onShowOverridesForm(shiftId);
});
};
@ -256,22 +262,24 @@ class _ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverr
this.setState(
{ shiftStartToShowOverrideForm: store.timezoneStore.currentDateInSelectedTimezone.startOf('day') },
() => {
this.onShowRotationForm('new');
this.onShowOverridesForm('new');
}
);
};
handleHide = () => {
this.setState({ shiftStartToShowOverrideForm: undefined, shiftEndToShowOverrideForm: undefined }, () => {
this.onShowRotationForm(undefined);
this.onShowOverridesForm(undefined);
});
};
onShowRotationForm = (shiftId: Shift['id']) => {
const { onShowRotationForm } = this.props;
onShowOverridesForm = (shiftId: Shift['id']) => {
const { onShowOverridesForm } = this.props;
onShowRotationForm(shiftId);
onShowOverridesForm(shiftId);
};
}
export const ScheduleOverrides = withMobXProviderContext(withTheme2(_ScheduleOverrides));
export const ScheduleOverrides = withMobXProviderContext(
withTheme2(_ScheduleOverrides)
) as unknown as React.ComponentClass<Omit<ScheduleOverridesProps, 'store' | 'theme'>>;

View file

@ -417,7 +417,7 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
layerPriorityToShowRotationForm={layerPriorityToShowRotationForm}
onShowRotationForm={this.handleShowRotationForm}
onShowOverrideForm={this.handleShowOverridesForm}
disabled={disabledRotationForm}
disabled={Boolean(disabledRotationForm)}
filters={filters}
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}
onSlotClick={shiftSwapIdToShowForm ? this.adjustShiftSwapForm : undefined}
@ -431,9 +431,9 @@ class _SchedulePage extends React.Component<SchedulePageProps, SchedulePageState
onUpdate={this.refreshEventsAndClearPreview}
onDelete={this.refreshEventsAndClearPreview}
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
onShowRotationForm={this.handleShowOverridesForm}
disabled={disabledOverrideForm}
disableShiftSwaps={disabledShiftSwaps}
onShowOverridesForm={this.handleShowOverridesForm}
disabled={Boolean(disabledOverrideForm)}
disableShiftSwaps={Boolean(disabledShiftSwaps)}
shiftStartToShowOverrideForm={shiftStartToShowOverrideForm}
shiftEndToShowOverrideForm={shiftEndToShowOverrideForm}
onShowShiftSwapForm={!shiftSwapIdToShowForm ? this.handleShowShiftSwapForm : undefined}

View file

@ -1,4 +1,4 @@
export const waitForElement = (selector: string) => {
export const waitForElement = (selector: string): Promise<Element> => {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));

View file

@ -47,6 +47,16 @@ export function useQueryParams(): URLSearchParams {
return React.useMemo(() => new URLSearchParams(search), [search]);
}
export function useResize(onResizeHandler: () => void) {
useEffect(() => {
window.addEventListener('resize', onResizeHandler);
return () => {
window.removeEventListener('resize', onResizeHandler);
};
}, []);
}
export function useDebouncedCallback<A extends any[]>(callback: (...args: A) => void, wait: number) {
// track args & timeout handle between calls
const argsRef = useRef<A>();