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)
This commit is contained in:
Dominik Broj 2024-01-08 14:57:01 +01:00 committed by GitHub
parent 5337baa0fc
commit 82b5a877d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 824 additions and 888 deletions

View file

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

View file

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

View file

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

View file

@ -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<ScheduleQualityProps> = ({ schedule, lastUpdated }) => {
const { scheduleStore } = useStore();
const [qualityResponse, setQualityResponse] = useState<ScheduleScoreQualityResponse>(undefined);
const ScheduleQuality: FC<ScheduleQualityProps> = 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 (
<>
<div className={cx('root')} data-testid="schedule-quality">
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
{relatedScheduleEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
<TooltipBadge
borderType="success"
icon="link"
@ -48,7 +49,7 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
tooltipTitle="Used in escalations"
tooltipContent={
<VerticalGroup spacing="sm">
{relatedEscalationChains.map((escalationChain) => (
{relatedScheduleEscalationChains.map((escalationChain) => (
<div key={escalationChain.pk}>
<PluginLink query={{ page: 'escalations', id: escalationChain.pk }} className="link">
<Text type="link">{escalationChain.name}</Text>
@ -82,13 +83,11 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
<Tooltip
placement="bottom-start"
interactive
content={
<ScheduleQualityDetails quality={qualityResponse} getScheduleQualityString={getScheduleQualityString} />
}
content={<ScheduleQualityDetails quality={quality} getScheduleQualityString={getScheduleQualityString} />}
>
<div className={cx('u-cursor-default')}>
<Tag className={cx('tag', getTagClass())}>
Quality: <strong>{getScheduleQualityString(qualityResponse.total_score)}</strong>
Quality: <strong>{getScheduleQualityString(quality.total_score)}</strong>
</Tag>
</div>
</Tooltip>
@ -112,22 +111,15 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ 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;

View file

@ -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<RotationProps> = (props) => {
const Rotation: FC<RotationProps> = observer((props) => {
const {
timezoneStore: { calendarStartDate },
} = useStore();
const {
events,
startMoment,
currentTimezone,
color: propsColor,
days = 7,
transparent = false,
@ -71,7 +71,7 @@ const Rotation: FC<RotationProps> = (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<RotationProps> = (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<RotationProps> = (props) => {
return (
<div className={cx('root')} onClick={onClick && handleRotationClick}>
<div className={cx('timeline')}>
{tutorialParams && <RotationTutorial startMoment={startMoment} {...tutorialParams} />}
{tutorialParams && <RotationTutorial {...tutorialParams} />}
{events ? (
events.length ? (
<div
@ -154,8 +154,6 @@ const Rotation: FC<RotationProps> = (props) => {
<ScheduleSlot
key={hash(event)}
event={event}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={propsColor || getColor(event)}
handleAddOverride={getAddOverrideClickHandler(event)}
handleAddShiftSwap={getAddShiftSwapClickHandler(event)}
@ -179,7 +177,7 @@ const Rotation: FC<RotationProps> = (props) => {
</div>
</div>
);
};
});
const Empty = ({ text }: { text: string }) => {
return (

View file

@ -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<RotationProps> = (props) => {
const { startMoment, days = 7, shiftStart, shiftEnd, rotationStart, focusElementName } = props;
const RotationTutorial: FC<RotationProps> = 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<RotationProps> = (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 (
<div className={cx('slots', 'slots--tutorial')} style={{ transform: `translate(${x * 100}%, 0)` }}>
@ -73,7 +77,7 @@ const RotationTutorial: FC<RotationProps> = (props) => {
})}
</div>
);
};
});
const TutorialSlot = (props: { style: React.CSSProperties; active: boolean }) => {
const { style, active } = props;

View file

@ -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) => {
</Draggable>
)}
>
<div className={cx('root')}>
<div className={cx('root')} data-testid="rotation-form">
<div>
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="sm">
@ -501,10 +513,8 @@ const RotationForm = observer((props: RotationFormProps) => {
}
>
<DateTimePicker
//minMoment={shiftStart}
value={rotationStart}
onChange={handleRotationStartChange}
timezone={currentTimezone}
error={errors.rotation_start}
disabled={disabled}
/>
@ -533,7 +543,6 @@ const RotationForm = observer((props: RotationFormProps) => {
<DateTimePicker
value={rotationEnd}
onChange={setRotationEnd}
timezone={currentTimezone}
error={errors.until}
disabled={disabled}
/>
@ -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) => {
</div>
<div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
<HorizontalGroup>
{shiftId !== 'new' && (
<Tooltip content="Stop the current rotation and start a new one">
@ -712,7 +720,6 @@ interface ShiftPeriodProps {
defaultValue: number;
shiftStart: dayjs.Dayjs;
onChange: (value: number) => void;
currentTimezone: Timezone;
disabled: boolean;
errors: any;
}

View file

@ -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<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (props) => {
disabled={disabled}
value={shiftStart}
onChange={updateShiftStart}
timezone={currentTimezone}
error={errors.shift_start}
/>
</Field>
@ -254,13 +247,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
</Text>
}
>
<DateTimePicker
disabled={disabled}
value={shiftEnd}
onChange={setShiftEnd}
timezone={currentTimezone}
error={errors.shift_end}
/>
<DateTimePicker disabled={disabled} value={shiftEnd} onChange={setShiftEnd} error={errors.shift_end} />
</Field>
</HorizontalGroup>
<UserGroups
@ -276,7 +263,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
</VerticalGroup>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
<HorizontalGroup>
<Button variant="primary" onClick={handleCreate} disabled={disabled || !isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}

View file

@ -12,8 +12,6 @@ import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers';
import { Schedule, ShiftSwap } from 'models/schedule/schedule.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { getUTCString } from 'pages/schedule/Schedule.helpers';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
@ -28,17 +26,13 @@ const cx = cn.bind(styles);
interface ShiftSwapFormProps {
id: ShiftSwap['id'] | 'new';
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
params: Partial<ShiftSwap>;
currentTimezone: Timezone;
onUpdate: () => void;
onHide: () => void;
}
const ShiftSwapForm = (props: ShiftSwapFormProps) => {
const { onUpdate, onHide, id, scheduleId, startMoment, params: defaultParams, currentTimezone } = props;
const { onUpdate, onHide, id, scheduleId, params: defaultParams } = props;
const [shiftSwap, setShiftSwap] = useState({ ...defaultParams });
@ -84,13 +78,13 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
useEffect(() => {
if (id === 'new') {
store.scheduleStore.updateShiftsSwapPreview(scheduleId, startMoment, {
store.scheduleStore.updateShiftsSwapPreview(scheduleId, store.timezoneStore.calendarStartDate, {
id: 'new',
beneficiary: { pk: currentUserPk },
...shiftSwap,
});
}
}, [shiftSwap, startMoment]);
}, [shiftSwap, store.timezoneStore.calendarStartDate]);
const handleDescriptionChange = useCallback(
(event) => {
@ -173,7 +167,6 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
<HorizontalGroup height="auto">
<Field label="Swap start">
<DateTimePicker
timezone={store.currentTimezone}
disabled={!isNew}
value={dayjs(shiftSwap.swap_start)}
onChange={handleShiftSwapStartChange}
@ -181,7 +174,6 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
</Field>
<Field label="Swap end">
<DateTimePicker
timezone={store.currentTimezone}
disabled={!isNew}
value={dayjs(shiftSwap.swap_end)}
onChange={handleShiftSwapEndChange}
@ -211,7 +203,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<Text type="secondary">Current timezone: {store.timezoneStore.selectedTimezoneLabel}</Text>
<HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
{isNew ? (

View file

@ -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 (
<VerticalGroup>
<div style={{ display: 'flex', flexWrap: 'nowrap', gap: '8px' }}>
<div
onFocus={onFocus}
onBlur={onBlur}
style={{ width: '58%' }}
className={cx({ 'control--error': Boolean(error) })}
>
<DatePickerWithInput open minDate={minDate} disabled={disabled} value={value} onChange={handleDateChange} />
return (
<VerticalGroup>
<div style={{ display: 'flex', flexWrap: 'nowrap', gap: '8px' }}>
<div
onFocus={onFocus}
onBlur={onBlur}
style={{ width: '58%' }}
className={cx({ 'control--error': Boolean(error) })}
>
<DatePickerWithInput open disabled={disabled} value={getDateForDatePicker()} onChange={handleDateChange} />
</div>
<div
onFocus={onFocus}
onBlur={onBlur}
style={{ width: '42%' }}
className={cx({ 'control--error': Boolean(error) })}
data-testid="date-time-picker"
>
<TimeOfDayPicker disabled={disabled} value={getTimeValueInSelectedTimezone()} onChange={handleTimeChange} />
</div>
</div>
<div
onFocus={onFocus}
onBlur={onBlur}
style={{ width: '42%' }}
className={cx({ 'control--error': Boolean(error) })}
>
<TimeOfDayPicker disabled={disabled} value={dateTime(value)} onChange={handleTimeChange} />
</div>
</div>
{error && <Text type="danger">{error}</Text>}
</VerticalGroup>
);
};
{error && <Text type="danger">{error}</Text>}
</VerticalGroup>
);
}
);
export default DateTimePicker;

View file

@ -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<RotationsProps, RotationsState> {
render() {
const {
scheduleId,
startMoment,
currentTimezone,
onCreate,
onUpdate,
onDelete,
@ -78,13 +73,16 @@ class Rotations extends Component<RotationsProps, RotationsState> {
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<RotationsProps, RotationsState> {
size="md"
/>
) : (
<Button variant="primary" icon="plus" onClick={() => this.handleAddLayer(nextPriority, startMoment)}>
<Button
variant="primary"
icon="plus"
onClick={() => this.handleAddLayer(nextPriority, store.timezoneStore.calendarStartDate)}
>
Add rotation
</Button>
)}
@ -155,7 +157,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TimelineMarks />
{!currentTimeHidden && (
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
)}
@ -177,8 +179,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
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<RotationsProps, RotationsState> {
</div>
<div className={cx('header-plus-content')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TimelineMarks />
<div className={cx('rotations')}>
<Rotation
onClick={(shiftStart, shiftEnd) => {
@ -211,8 +211,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
events={[]}
layerIndex={0}
rotationIndex={0}
startMoment={startMoment}
currentTimezone={currentTimezone}
/>
</div>
</div>
@ -226,7 +224,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
if (disabled) {
return;
}
this.handleAddLayer(nextPriority, startMoment);
this.handleAddLayer(nextPriority, store.timezoneStore.calendarStartDate);
}}
>
<Text type={disabled ? 'disabled' : 'primary'}>+ Add new layer with rotation</Text>
@ -240,8 +238,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
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<RotationsProps, RotationsState> {
};
handleAddRotation = (option: SelectableValue) => {
const { startMoment, disabled } = this.props;
const { disabled, store } = this.props;
if (disabled) {
return;
@ -308,7 +304,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
this.setState(
{
layerPriority: option.value,
shiftStartToShowRotationForm: startMoment,
shiftStartToShowRotationForm: store.timezoneStore.calendarStartDate,
},
() => {
this.onShowRotationForm('new');

View file

@ -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<ScheduleFinalProps> {
render() {
const { startMoment, currentTimezone, store, simplified, scheduleId, filters, onShowShiftSwapForm, onSlotClick } =
this.props;
const ScheduleFinal: FC<ScheduleFinalProps> = 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 (
<>
<div className={cx('root')}>
{!simplified && (
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Final schedule
</Text.Title>
</div>
</HorizontalGroup>
</div>
)}
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={index}
events={events}
startMoment={startMoment}
currentTimezone={currentTimezone}
handleAddOverride={this.handleShowOverrideForm}
handleAddShiftSwap={onShowShiftSwapForm}
onShiftSwapClick={onShowShiftSwapForm}
simplified={simplified}
filters={filters}
getColor={getColor}
onSlotClick={onSlotClick}
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation events={[]} startMoment={startMoment} currentTimezone={currentTimezone} />
</CSSTransition>
)}
</TransitionGroup>
<div className={cx('root')}>
{!simplified && (
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Final schedule
</Text.Title>
</div>
</HorizontalGroup>
</div>
)}
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={index}
events={events}
handleAddOverride={handleShowOverrideForm}
handleAddShiftSwap={onShowShiftSwapForm}
onShiftSwapClick={onShowShiftSwapForm}
simplified={simplified}
filters={filters}
getColor={getColor}
onSlotClick={onSlotClick}
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation events={[]} />
</CSSTransition>
)}
</TransitionGroup>
</div>
</>
</div>
);
}
handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => {
const { onShowOverrideForm } = this.props;
onShowOverrideForm('new', shiftStart, shiftEnd);
};
}
);
export default withMobXProviderContext(ScheduleFinal);

View file

@ -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<ScheduleOverridesProps, ScheduleOverri
render() {
const {
scheduleId,
startMoment,
currentTimezone,
onCreate,
onUpdate,
onDelete,
@ -84,14 +79,17 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
} = this.props;
const { shiftStartToShowOverrideForm, shiftEndToShowOverrideForm } = this.state;
const shifts = getOverridesFromStore(store, scheduleId, startMoment) as ShiftEvents[];
const shifts = getOverridesFromStore(store, scheduleId, store.timezoneStore.calendarStartDate) as ShiftEvents[];
const layers = getLayersFromStore(store, scheduleId, startMoment);
const layers = getLayersFromStore(store, scheduleId, store.timezoneStore.calendarStartDate);
const shiftSwaps = getShiftSwapsFromStore(store, scheduleId, startMoment);
const shiftSwaps = getShiftSwapsFromStore(store, scheduleId, store.timezoneStore.calendarStartDate);
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;
@ -119,7 +117,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
const closestEvent = findClosestUserEvent(dayjs(), currentUserPk, layers);
const swapStart = closestEvent
? dayjs(closestEvent.start)
: dayjs().tz(currentTimezone).startOf('day').add(1, 'day');
: store.timezoneStore.currentDateInSelectedTimezone.startOf('day').add(1, 'day');
const swapEnd = closestEvent ? dayjs(closestEvent.end) : swapStart.add(1, 'day');
@ -151,7 +149,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TimelineMarks />
<TransitionGroup className={cx('rotations')}>
{shiftSwaps && shiftSwaps.length
? shiftSwaps.map(({ isPreview, events }, index) => (
@ -159,8 +157,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<Rotation
events={events}
color={SHIFT_SWAP_COLOR}
startMoment={startMoment}
currentTimezone={currentTimezone}
onSlotClick={(event) => {
if (event.is_gap) {
return;
@ -181,8 +177,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<Rotation
events={events}
color={getOverrideColor(index)}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(shiftStart, shiftEnd) => {
this.onRotationClick(shiftId, shiftStart, shiftEnd);
}}
@ -196,8 +190,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<Rotation
key={0}
events={[]}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(shiftStart, shiftEnd) => {
this.onRotationClick('new', shiftStart, shiftEnd);
}}
@ -212,8 +204,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
shiftId={shiftIdToShowRotationForm}
shiftColor={findColor(shiftIdToShowRotationForm, undefined, shifts)}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftStart={propsShiftStartToShowOverrideForm || shiftStartToShowOverrideForm}
shiftEnd={propsShiftEndToShowOverrideForm || shiftEndToShowOverrideForm}
onHide={() => {
@ -262,11 +252,12 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
}
// use start of current day as default start time for override
const startMoment = getStartOfDay(store.currentTimezone);
this.setState({ shiftStartToShowOverrideForm: startMoment }, () => {
this.onShowRotationForm('new');
});
this.setState(
{ shiftStartToShowOverrideForm: store.timezoneStore.currentDateInSelectedTimezone.startOf('day') },
() => {
this.onShowRotationForm('new');
}
);
};
handleHide = () => {

View file

@ -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<SchedulePersonalProps> = observer(({ userPk, onSlotClick, history }) => {
const store = useStore();
const { timezoneStore, scheduleStore, userStore, loaderStore } = store;
@observer
class SchedulePersonal extends Component<SchedulePersonalProps, SchedulePersonalState> {
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<SchedulePersonalProps>, prevState: Readonly<SchedulePersonalState>): 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 (
<>
<div className={cx('root')}>
<div className={cx('header')}>
<div className={cx('title')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Text type="secondary">
On-call schedule <Avatar src={storeUser.avatar} size="small" /> {storeUser.username}
</Text>
{isOncall ? (
<Badge text="On-call now" color="green" />
) : (
/* @ts-ignore */
<Badge text="Not on-call now" color="gray" />
)}
</HorizontalGroup>
<HorizontalGroup>
<HorizontalGroup>
<Text type="secondary">
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</Text>
<Button variant="secondary" size="sm" onClick={this.handleTodayClick}>
Today
</Button>
<HorizontalGroup spacing="xs">
<Button variant="secondary" size="sm" onClick={this.handleLeftClick}>
<Icon name="angle-left" />
</Button>
<Button variant="secondary" size="sm" onClick={this.handleRightClick}>
<Icon name="angle-right" />
</Button>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</div>
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
simplified
key={index}
events={events}
startMoment={startMoment}
currentTimezone={currentTimezone}
getColor={getColor}
onSlotClick={onSlotClick}
handleOpenSchedule={this.openSchedule}
showScheduleNameAsSlotTitle
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
events={[]}
startMoment={startMoment}
currentTimezone={currentTimezone}
emptyText="There are no schedules relevant to user"
/>
</CSSTransition>
)}
</TransitionGroup>
</div>
</div>
</>
);
}
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 (
<div className={cx('root')}>
<div className={cx('header')}>
<div className={cx('title')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Text type="secondary">
On-call schedule <Avatar src={storeUser.avatar} size="small" /> {storeUser.username}
</Text>
{isOncall ? (
<Badge text="On-call now" color="green" />
) : (
/* @ts-ignore */
<Badge text="Not on-call now" color="gray" />
)}
</HorizontalGroup>
<HorizontalGroup>
<HorizontalGroup>
<Text type="secondary">
{timezoneStore.calendarStartDate.format('DD MMM')} -{' '}
{timezoneStore.calendarStartDate.add(6, 'day').format('DD MMM')}
</Text>
<Button variant="secondary" size="sm" onClick={handleTodayClick}>
Today
</Button>
<HorizontalGroup spacing="xs">
<Button variant="secondary" size="sm" onClick={handleLeftClick}>
<Icon name="angle-left" />
</Button>
<Button variant="secondary" size="sm" onClick={handleRightClick}>
<Icon name="angle-right" />
</Button>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</div>
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks />
<TransitionGroup className={cx('rotations')}>
{shifts?.length ? (
shifts.map(({ events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
simplified
key={index}
events={events}
getColor={getColor}
onSlotClick={onSlotClick}
handleOpenSchedule={openSchedule}
showScheduleNameAsSlotTitle
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation events={[]} emptyText={emptyRotationsText} />
</CSSTransition>
)}
</TransitionGroup>
</div>
</div>
);
});
export default withRouter(SchedulePersonal);

View file

@ -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<HTMLDivElement>) => void;
handleAddShiftSwap: (event: React.MouseEvent<HTMLDivElement>) => void;
handleOpenSchedule: (event: React.MouseEvent<HTMLDivElement>) => void;
@ -39,7 +36,6 @@ const cx = cn.bind(styles);
const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
const {
event,
currentTimezone,
color,
handleAddOverride,
handleAddShiftSwap,
@ -63,12 +59,12 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
const renderEvent = (event): React.ReactElement | React.ReactElement[] => {
if (event.shiftSwapId) {
return <ShiftSwapEvent currentMoment={currentMoment} event={event} currentTimezone={currentTimezone} />;
return <ShiftSwapEvent currentMoment={currentMoment} event={event} />;
}
if (event.is_gap) {
return (
<Tooltip content={<ScheduleGapDetails event={event} currentTimezone={currentTimezone} />}>
<Tooltip content={<ScheduleGapDetails event={event} />}>
<div className={cx('root', 'root__type_gap')} />
</Tooltip>
);
@ -95,7 +91,6 @@ const ScheduleSlot: FC<ScheduleSlotProps> = 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 = (
<div className={cx('root', { 'root__type_shift-swap': true })}>
<div className={cx('root', { 'root__type_shift-swap': true })} data-testid="schedule-slot">
{shiftSwap && (
<HorizontalGroup spacing="xs">
{beneficiary && <Avatar size="xs" src={beneficiary.avatar_full} />}
@ -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<HTMLDivElement>) => void;
handleAddShiftSwap: (event: React.MouseEvent<HTMLDivElement>) => void;
handleOpenSchedule: (event: React.MouseEvent<HTMLDivElement>) => 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) && (
<WorkingHours
@ -294,7 +286,6 @@ const RegularEvent = (props: RegularEventProps) => {
}
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) => {
<Icon className={cx('icon')} name="clock-nine" />
</div>
<Text type="primary" className={cx('second-column')}>
User local time
User's local time
<br />
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentMoment.tz(user?.timezone))})
@ -426,8 +418,8 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
<Text type="secondary">
Current timezone
<br />
{currentMoment.tz(currentTimezone).format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentMoment.tz(currentTimezone))})
{currentDateInSelectedTimezone.format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentDateInSelectedTimezone)})
</Text>
</HorizontalGroup>
<HorizontalGroup align="flex-start">
@ -437,15 +429,15 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
<Text type="primary" className={cx('second-column')}>
This shift
<br />
{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}
{dayjs(event.start).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')}
<br />
{dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}
{dayjs(event.end).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')}
</Text>
<Text type="secondary">
&nbsp; <br />
{dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')}
{getDateInSelectedTimezone(dayjs(event.start)).format('DD MMM, HH:mm')}
<br />
{dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')}
{getDateInSelectedTimezone(dayjs(event.end)).format('DD MMM, HH:mm')}
</Text>
</HorizontalGroup>
<HorizontalGroup justify="flex-end">
@ -468,27 +460,29 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
</VerticalGroup>
</div>
);
};
});
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 (
<div className={cx('details')}>
<VerticalGroup>
<HorizontalGroup spacing="sm">
<VerticalGroup spacing="none">
<Text type="primary">{currentTimezone}</Text>
<Text type="primary">{dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
<Text type="primary">{dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
<Text type="primary">{selectedTimezoneLabel}</Text>
<Text type="primary">{getDateInSelectedTimezone(dayjs(event.start)).format('DD MMM, HH:mm')}</Text>
<Text type="primary">{getDateInSelectedTimezone(dayjs(event.end)).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
</div>
);
};
});

View file

@ -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<TimelineMarksProps> = (props) => {
const { startMoment, timezone, debug } = props;
const currentMoment = useMemo(() => getNow(timezone), []);
const TimelineMarks: FC<TimelineMarksProps> = observer((props) => {
const {
timezoneStore: { currentDateInSelectedTimezone, calendarStartDate },
} = useStore();
const { debug } = props;
const momentsToRender = useMemo(() => {
const hoursToSplit = 12;
@ -29,7 +28,7 @@ const TimelineMarks: FC<TimelineMarksProps> = (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<TimelineMarksProps> = (props) => {
momentsToRender.push(obj);
}
return momentsToRender;
}, [startMoment]);
}, [calendarStartDate]);
const cuts = useMemo(() => {
const cuts = [];
@ -67,7 +66,7 @@ const TimelineMarks: FC<TimelineMarksProps> = (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<TimelineMarksProps> = (props) => {
})}
</div>
);
};
});
export default TimelineMarks;

View file

@ -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<UserTimezoneSelectProps> = (props) => {
const { users, value: propValue, onChange } = props;
interface UserTimezoneSelectProps {
scheduleId?: string;
}
const UserTimezoneSelect: FC<UserTimezoneSelectProps> = observer(({ scheduleId }) => {
const store = useStore();
const users = store.userStore.getSearchResult().results || [];
const [extraOptions, setExtraOptions] = useState<TimezoneOption[]>([
{
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<number>, searchQuery: string) => {
const { data } = item;
@ -105,7 +72,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (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<UserTimezoneSelectProps> = (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<UserTimezoneSelectProps> = (props) => {
...extraOptions,
{
value: utcOffset,
utcOffset,
timezone: matched,
label: getTzOffsetString(now),
description: '',
},
]);
onChange(matched);
store.timezoneStore.setSelectedTimezoneOffset(utcOffset);
store.scheduleStore.refreshEvents(scheduleId);
}
},
[options]
);
return (
<div className={cx('root')}>
<div className={cx('root')} data-testid="timezone-select">
<Select
value={value}
onChange={handleChange}
value={options.find(({ value }) => value === store.timezoneStore.selectedTimezoneOffset)}
onChange={(option) => store.timezoneStore.setSelectedTimezoneOffset(option.value)}
width={30}
placeholder={propValue}
options={options}
filterOption={filterOption}
allowCustomValue
@ -161,6 +128,6 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
/>
</div>
);
};
});
export default UserTimezoneSelect;

View file

@ -3,12 +3,17 @@ import React, { FC } from 'react';
import { HorizontalGroup, VerticalGroup, Icon, Badge } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import ScheduleBorderedAvatar from 'components/ScheduleBorderedAvatar/ScheduleBorderedAvatar';
import Text from 'components/Text/Text';
import { isInWorkingHours } from 'components/WorkingHours/WorkingHours.helpers';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import {
getCurrentDateInTimezone,
getCurrentlyLoggedInUserDate,
getTzOffsetString,
} from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { getColorSchemeMappingForUsers } from 'pages/schedule/Schedule.helpers';
import { useStore } from 'state/useStore';
@ -21,19 +26,21 @@ interface ScheduleUserDetailsProps {
user: User;
isOncall: boolean;
scheduleId: string;
startMoment: dayjs.Dayjs;
}
const cx = cn.bind(styles);
const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
const { user, currentMoment, isOncall, scheduleId, startMoment } = props;
const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = observer((props) => {
const {
timezoneStore: { calendarStartDate },
} = useStore();
const { user, currentMoment, isOncall, scheduleId } = props;
const userMoment = currentMoment.tz(user.timezone);
const userOffsetHoursStr = getTzOffsetString(userMoment);
const isInWH = isInWorkingHours(currentMoment, user.working_hours, user.timezone);
const store = useStore();
const colorSchemeMapping = getColorSchemeMappingForUsers(store, scheduleId, startMoment);
const colorSchemeMapping = getColorSchemeMappingForUsers(store, scheduleId, calendarStartDate);
const colorSchemeList = Array.from(colorSchemeMapping[user.pk] || []);
const { organizationStore } = store;
@ -49,7 +56,7 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
height={35}
renderAvatar={() => <Avatar src={user.avatar} size="large" />}
renderIcon={() => null}
></ScheduleBorderedAvatar>
/>
<VerticalGroup spacing="xs" width="100%">
<div className={cx('username')}>
@ -70,18 +77,18 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
</Text>
</div>
<div className={cx('timezone-wrapper')}>
<div className={cx('timezone-info')}>
<div className={cx('timezone-info')} data-testid="schedule-user-details_your-current-time">
<VerticalGroup spacing="none">
<Text type="secondary">Local time</Text>
<Text type="secondary">{currentMoment.tz().format('DD MMM, HH:mm')}</Text>
<Text type="secondary">({getTzOffsetString(currentMoment)})</Text>
<Text type="secondary">Your current time</Text>
<Text type="secondary">{getCurrentlyLoggedInUserDate().format('DD MMM, HH:mm')}</Text>
<Text type="secondary">({getTzOffsetString(getCurrentlyLoggedInUserDate())})</Text>
</VerticalGroup>
</div>
<div className={cx('timezone-info')}>
<div className={cx('timezone-info')} data-testid="schedule-user-details_user-local-time">
<VerticalGroup className={cx('timezone-info')} spacing="none">
<Text>User's time</Text>
<Text>{`${userMoment.tz(user.timezone).format('DD MMM, HH:mm')}`}</Text>
<Text>User's local time</Text>
<Text>{`${getCurrentDateInTimezone(user.timezone).format('DD MMM, HH:mm')}`}</Text>
<Text>({userOffsetHoursStr})</Text>
</VerticalGroup>
</div>
@ -145,6 +152,6 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
</VerticalGroup>
</div>
);
};
});
export default ScheduleUserDetails;

View file

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,6 @@
import { Dayjs } from 'dayjs';
export const calculateTimePassedInDayPercentage = (date: Dayjs) => {
const midnight = date.startOf('day');
return (date.diff(midnight, 'minutes') / 1_440) * 100;
};

View file

@ -1,31 +1,31 @@
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { HorizontalGroup, Icon, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { sortBy } from 'lodash-es';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import ScheduleBorderedAvatar from 'components/ScheduleBorderedAvatar/ScheduleBorderedAvatar';
import ScheduleUserDetails from 'components/ScheduleUserDetails/ScheduleUserDetails';
import Text from 'components/Text/Text';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import { IsOncallIcon } from 'icons';
import { Schedule } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { getCurrentDateInTimezone } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { getColorSchemeMappingForUsers } from 'pages/schedule/Schedule.helpers';
import { useStore } from 'state/useStore';
import ScheduleUserDetails from './ScheduleUserDetails/ScheduleUserDetails';
import { calculateTimePassedInDayPercentage } from './UsersTimezones.helpers';
import styles from './UsersTimezones.module.css';
interface UsersTimezonesProps {
userIds: Array<User['pk']>;
tz: Timezone;
onCallNow: Array<Partial<User>>;
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
onTzChange: (tz: Timezone) => void;
}
const cx = cn.bind(styles);
@ -34,11 +34,14 @@ const hoursToSplit = 3;
const jLimit = 24 / hoursToSplit;
const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
const UsersTimezones: FC<UsersTimezonesProps> = observer((props) => {
const store = useStore();
const { userStore } = store;
const {
userStore,
timezoneStore: { selectedTimezoneLabel, currentDateInSelectedTimezone },
} = store;
const { userIds, tz, onTzChange, onCallNow, scheduleId, startMoment } = props;
const { userIds, onCallNow, scheduleId } = props;
useEffect(() => {
userIds.forEach((userId) => {
@ -53,15 +56,6 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
[userIds, store.userStore.items]
);
const currentMoment = useMemo(() => dayjs().tz(tz), [tz]);
const currentTimeX = useMemo(() => {
const midnight = dayjs().tz(tz).startOf('day');
const diff = currentMoment.diff(midnight, 'minutes');
return (diff / 1440) * 100;
}, [currentMoment, tz]);
const momentsToRender = useMemo(() => {
const momentsToRender = [];
@ -78,14 +72,12 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
<div className={cx('root')}>
<WorkingHours
light
startMoment={currentMoment.startOf('day')}
startMoment={currentDateInSelectedTimezone.startOf('day')}
duration={24 * 60 * 60}
timezone={userStore.currentUser?.timezone}
workingHours={userStore.currentUser?.working_hours}
className={cx('working-hours')}
/>
{/* <div className={cx('shades', 'shades--left')} />
<div className={cx('shades', 'shades--right')} /> */}
<div className={cx('content')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
@ -98,20 +90,18 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
</HorizontalGroup>
<div className={cx('timezone-select')}>
<Text type="secondary">
Current timezone: {tz}, local time: {currentMoment.format('HH:mm')}
Current timezone: {selectedTimezoneLabel}, local time: {currentDateInSelectedTimezone.format('HH:mm')}
</Text>
</div>
</HorizontalGroup>
</div>
<div className={cx('users')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX}%` }} />
{users && users.length ? (
<CurrentTimeLineIndicator />
{users?.length ? (
<UserAvatars
users={users}
onCallNow={onCallNow}
onTzChange={onTzChange}
currentMoment={currentMoment}
startMoment={startMoment}
currentMoment={currentDateInSelectedTimezone}
scheduleId={scheduleId}
/>
) : (
@ -150,70 +140,66 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
</div>
</div>
);
};
});
const CurrentTimeLineIndicator = observer(() => {
const {
timezoneStore: { currentDateInSelectedTimezone },
} = useStore();
return (
<div
className={cx('current-time')}
style={{ left: `${calculateTimePassedInDayPercentage(currentDateInSelectedTimezone)}%` }}
/>
);
});
interface UserAvatarsProps {
users: User[];
currentMoment: dayjs.Dayjs;
startMoment: dayjs.Dayjs;
scheduleId: Schedule['id'];
onTzChange: (timezone: Timezone) => void;
onCallNow: Array<Partial<User>>;
}
const UserAvatars = (props: UserAvatarsProps) => {
const { users, currentMoment, onCallNow, scheduleId, startMoment } = props;
const userGroups = useMemo(() => {
return users
.reduce((memo, user) => {
const userUtcOffset = dayjs().tz(user.timezone).utcOffset();
let group = memo.find((group) => group.utcOffset === userUtcOffset);
if (!group) {
group = { utcOffset: userUtcOffset, users: [] };
memo.push(group);
}
group.users.push(user);
const { users, currentMoment, onCallNow, scheduleId } = props;
const userGroups = useMemo(
() =>
sortBy(
users.reduce((memo, user) => {
const userUtcOffset = dayjs().tz(user.timezone).utcOffset();
let group = memo.find((group) => group.utcOffset === userUtcOffset);
if (!group) {
group = { utcOffset: userUtcOffset, users: [] };
memo.push(group);
}
group.users.push(user);
return memo;
}, [])
.sort((a, b) => {
if (a.utcOffset > b.utcOffset) {
return 1;
}
if (a.utcOffset < b.utcOffset) {
return -1;
}
return 0;
});
}, [users]);
return memo;
}, []),
({ utcOffset }) => utcOffset
),
[users]
);
const [activeUtcOffset, setActiveUtcOffset] = useState<number | undefined>(undefined);
return (
<div className={cx('user-avatars')}>
{userGroups.map((group, idx) => {
const userCurrentMoment = dayjs(currentMoment).tz(group.users[0].timezone); // TODO try using group.utcOffset
const diff = userCurrentMoment.diff(userCurrentMoment.startOf('day'), 'minutes');
const xPos = (diff / (60 * 24)) * 100;
return (
<AvatarGroup
key={idx}
activeUtcOffset={activeUtcOffset}
utcOffset={group.utcOffset}
onSetActiveUtcOffset={setActiveUtcOffset}
// onTzChange={onTzChange}
xPos={xPos}
users={group.users}
startMoment={startMoment}
currentMoment={currentMoment}
scheduleId={scheduleId}
onCallNow={onCallNow}
/>
);
})}
{userGroups.map((group, idx) => (
<AvatarGroup
key={idx}
activeUtcOffset={activeUtcOffset}
utcOffset={group.utcOffset}
onSetActiveUtcOffset={setActiveUtcOffset}
xPos={calculateTimePassedInDayPercentage(getCurrentDateInTimezone(group.users[0].timezone))}
users={group.users}
currentMoment={currentMoment}
scheduleId={scheduleId}
onCallNow={onCallNow}
/>
))}
</div>
);
};
@ -221,13 +207,11 @@ const UserAvatars = (props: UserAvatarsProps) => {
interface AvatarGroupProps {
users: User[];
xPos: number;
startMoment: dayjs.Dayjs;
currentMoment: dayjs.Dayjs;
utcOffset: number;
scheduleId: Schedule['id'];
onSetActiveUtcOffset: (utcOffset: number | undefined) => void;
activeUtcOffset: number;
onTzChange?: (timezone: Timezone) => void;
onCallNow: Array<Partial<User>>;
}
@ -235,18 +219,16 @@ const LIMIT = 3;
const AVATAR_WIDTH = 32;
const AVATAR_GAP = 5;
const AvatarGroup = (props: AvatarGroupProps) => {
const AvatarGroup = observer((props: AvatarGroupProps) => {
const {
users: propsUsers,
currentMoment,
xPos,
onTzChange,
utcOffset,
onSetActiveUtcOffset,
activeUtcOffset,
onCallNow,
scheduleId,
startMoment,
} = props;
const store = useStore();
@ -271,16 +253,7 @@ const AvatarGroup = (props: AvatarGroupProps) => {
});
}, [propsUsers, onCallNow]);
const getAvatarClickHandler = useCallback(
(timezone: Timezone) => {
return () => {
onTzChange(timezone);
};
},
[onTzChange]
);
const colorSchemeMapping = getColorSchemeMappingForUsers(store, scheduleId, startMoment);
const colorSchemeMapping = getColorSchemeMappingForUsers(store, scheduleId, store.timezoneStore.calendarStartDate);
const width = active ? users.length * AVATAR_WIDTH + (users.length - 1) * AVATAR_GAP : AVATAR_WIDTH;
return (
@ -307,19 +280,18 @@ const AvatarGroup = (props: AvatarGroupProps) => {
user={user}
isOncall={isOncall}
scheduleId={scheduleId}
startMoment={startMoment}
/>
}
>
<div
className={cx('avatar')}
data-testid="user-avatar-in-schedule"
style={{
left: active ? `${index * (AVATAR_WIDTH + AVATAR_GAP)}px` : `${index * 10}px`,
opacity: active ? 1 : Math.max(1 - index * 0.25, 0.25),
visibility: !active && index >= LIMIT ? 'hidden' : 'visible',
zIndex: array.length - index - 1,
}}
onClick={getAvatarClickHandler(user.timezone)}
>
<ScheduleBorderedAvatar
colors={colorSchemeList}
@ -329,7 +301,7 @@ const AvatarGroup = (props: AvatarGroupProps) => {
renderIcon={() =>
isOncall ? <IsOncallIcon className={cx('is-oncall-icon')} width={14} height={13} /> : null
}
></ScheduleBorderedAvatar>
/>
</div>
</Tooltip>
);
@ -346,6 +318,6 @@ const AvatarGroup = (props: AvatarGroupProps) => {
</div>
</div>
);
};
});
export default UsersTimezones;

View file

@ -2,4 +2,5 @@ export enum ActionKey {
ADD_NEW_COLUMN_TO_ALERT_GROUP = 'ADD_NEW_COLUMN_TO_ALERT_GROUP',
REMOVE_COLUMN_FROM_ALERT_GROUP = 'REMOVE_COLUMN_FROM_ALERT_GROUP',
RESET_COLUMNS_FROM_ALERT_GROUP = 'RESET_COLUMNS_FROM_ALERT_GROUP',
UPDATE_PERSONAL_EVENTS = 'UPDATE_PERSONAL_EVENTS',
}

View file

@ -1,13 +1,17 @@
import dayjs from 'dayjs';
import { action, makeObservable, observable, runInAction } from 'mobx';
import { PageErrorData } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import { getWrongTeamResponseInfo } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
import BaseStore from 'models/base_store';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { ActionKey } from 'models/loader/action-keys';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { SelectOption } from 'state/types';
import { AutoLoadingState } from 'utils/decorators';
import {
createShiftSwapEventFromShiftSwap,
@ -42,6 +46,9 @@ export class ScheduleStore extends BaseStore {
@observable.shallow
items: { [id: string]: Schedule } = {};
@observable
quality: ScheduleScoreQualityResponse;
@observable.shallow
shifts: { [id: string]: Shift } = {};
@ -113,7 +120,10 @@ export class ScheduleStore extends BaseStore {
byDayOptions: SelectOption[] = [];
@observable
scheduleId: Schedule['id'];
refreshEventsError?: Partial<PageErrorData> = {
isWrongTeamError: false,
wrongTeamNoPermissions: false,
};
constructor(rootStore: RootStore) {
super(rootStore);
@ -209,8 +219,15 @@ export class ScheduleStore extends BaseStore {
};
}
async getScoreQuality(scheduleId: Schedule['id']): Promise<ScheduleScoreQualityResponse> {
return await makeRequest(`/schedules/${scheduleId}/quality`, { method: 'GET' });
@action.bound
async getScoreQuality(scheduleId: Schedule['id']) {
const [quality] = await Promise.all([
makeRequest(`/schedules/${scheduleId}/quality`, { method: 'GET' }),
this.updateRelatedEscalationChains(scheduleId),
]);
runInAction(() => {
this.quality = quality;
});
}
async reloadIcal(scheduleId: Schedule['id']) {
@ -247,6 +264,8 @@ export class ScheduleStore extends BaseStore {
data: { type, schedule: scheduleId, ...params },
method: 'POST',
});
await this.rootStore.scheduleStore.refreshEvents(scheduleId);
await this.getScoreQuality(scheduleId);
runInAction(() => {
this.shifts = {
@ -506,6 +525,29 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
async refreshEvents(scheduleId: string) {
this.refreshEventsError = {};
const startMoment = this.rootStore.timezoneStore.calendarStartDate;
try {
const schedule = await this.loadItem(scheduleId);
this.rootStore.setPageTitle(schedule?.name);
} catch (error) {
runInAction(() => {
this.refreshEventsError = getWrongTeamResponseInfo(error);
});
}
this.updateRelatedUsers(scheduleId); // to refresh related users
await Promise.all([
this.updateEvents(scheduleId, startMoment, 'rotation'),
this.updateEvents(scheduleId, startMoment, 'override'),
this.updateEvents(scheduleId, startMoment, 'final'),
this.updateShiftSwaps(scheduleId, startMoment),
]);
}
async updateFrequencyOptions() {
return await makeRequest(`/oncall_shifts/frequency_options/`, {
method: 'GET',
@ -587,6 +629,7 @@ export class ScheduleStore extends BaseStore {
});
}
@AutoLoadingState(ActionKey.UPDATE_PERSONAL_EVENTS)
@action
async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9, isUpdateOnCallNow = false) {
const fromString = getFromString(startMoment);

View file

@ -595,11 +595,26 @@ export const allTimezones = [
'Zulu',
];
export const getTzOffsetString = (moment: dayjs.Dayjs) => {
const userOffset = moment.utcOffset();
const userOffsetHours = userOffset / 60;
const userOffsetHoursStr =
userOffsetHours > 0 ? `+${userOffsetHours} GMT` : userOffset < 0 ? `${userOffsetHours} GMT` : `GMT`;
// TODO: move it to utils
return userOffsetHoursStr;
export const getTzOffsetString = (date: dayjs.Dayjs) => {
const userOffset = date.utcOffset();
const userOffsetHours = userOffset / 60;
if (userOffsetHours === 0) {
return 'GMT';
}
return userOffsetHours > 0 ? `GMT+${userOffsetHours}` : `GMT${userOffsetHours}`;
};
export const getGMTTimezoneLabelBasedOnOffset = (offset: number) => {
const userOffsetHours = offset / 60;
if (userOffsetHours === 0) {
return 'GMT';
}
return `GMT${userOffsetHours >= 0 ? '+' : ''}${userOffsetHours}`;
};
export const getCurrentlyLoggedInUserDate = () => dayjs();
export const getOffsetOfCurrentUser = () => dayjs().utcOffset();
export const getCurrentDateInTimezone = (timezone: string) => dayjs().tz(timezone);

View file

@ -0,0 +1,58 @@
import dayjs, { Dayjs } from 'dayjs';
import { observable, action, computed, makeObservable } from 'mobx';
// TODO: move utils from Schedule.helpers to common place
import { getStartOfWeekBasedOnCurrentDate } from 'pages/schedule/Schedule.helpers';
import { RootStore } from 'state';
import { getOffsetOfCurrentUser, getGMTTimezoneLabelBasedOnOffset } from './timezone.helpers';
export class TimezoneStore {
constructor(rootStore: RootStore) {
makeObservable(this);
this.rootStore = rootStore;
}
rootStore: RootStore;
@observable
selectedTimezoneOffset = getOffsetOfCurrentUser();
@observable
calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone);
@action.bound
async setSelectedTimezoneOffset(offset: number) {
this.selectedTimezoneOffset = offset;
this.calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone);
}
@action.bound
setCalendarStartDate(date: Dayjs) {
this.calendarStartDate = date;
}
@action.bound
setSelectedTimezoneOffsetBasedOnTz(timezone: string) {
this.selectedTimezoneOffset = dayjs().tz(timezone).utcOffset();
}
@action.bound
getDateInSelectedTimezone(date: Dayjs | string) {
if (typeof date === 'string') {
date = dayjs(date);
}
return dayjs(date.format()).utcOffset(this.selectedTimezoneOffset);
}
@computed
get selectedTimezoneLabel() {
return getGMTTimezoneLabelBasedOnOffset(this.selectedTimezoneOffset);
}
@computed
get currentDateInSelectedTimezone() {
return dayjs().utcOffset(this.selectedTimezoneOffset);
}
}

View file

@ -82,7 +82,7 @@ export class UserStore extends BaseStore {
this.update(id, { timezone });
}
this.rootStore.currentTimezone = timezone;
this.rootStore.timezoneStore.setSelectedTimezoneOffsetBasedOnTz(timezone);
return timezone;
}

View file

@ -70,10 +70,7 @@ import styles from './Incident.module.scss';
const cx = cn.bind(styles);
const INTEGRATION_NAME_LENGTH_LIMIT = 30;
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {
pageTitle: string;
setPageTitle: (value: string) => void;
}
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
interface IncidentPageState extends PageBaseState {
showIntegrationSettings?: boolean;
@ -99,9 +96,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
}
componentWillUnmount(): void {
const { setPageTitle } = this.props;
setPageTitle(undefined);
this.props.store.setPageTitle('');
}
componentDidUpdate(prevProps: IncidentPageProps) {
@ -118,13 +113,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
match: {
params: { id },
},
setPageTitle,
} = this.props;
store.alertGroupStore
.getAlert(id)
.then((alertGroup) => {
setPageTitle(`#${alertGroup.inside_organization_number} ${alertGroup.render_for_web.title}`);
store.setPageTitle(`#${alertGroup.inside_organization_number} ${alertGroup.render_for_web.title}`);
})
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
};
@ -262,7 +256,6 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
match: {
params: { id },
},
pageTitle,
} = this.props;
const { alerts } = store.alertGroupStore;
@ -288,7 +281,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
{/* @ts-ignore*/}
<HorizontalGroup align="baseline">
<Text.Title level={3} data-testid="incident-title">
{pageTitle}
{store.pageTitle}
</Text.Title>
{incident.root_alert_group && (
<Text type="secondary">

View file

@ -16,7 +16,7 @@ const mondayDayOffset = {
};
export const getWeekStartString = () => {
const weekStart = (config.bootData.user.weekStart || '').toLowerCase();
const weekStart = (config?.bootData?.user?.weekStart || '').toLowerCase();
if (!weekStart || weekStart === 'browser') {
return 'monday';
@ -40,6 +40,12 @@ export const getStartOfWeek = (tz: Timezone) => {
.add(mondayDayOffset[getWeekStartString()], 'day');
};
export const getStartOfWeekBasedOnCurrentDate = (date: dayjs.Dayjs) => {
return date
.startOf('isoWeek') // it's Monday always
.add(mondayDayOffset[getWeekStartString()], 'day');
};
export const getUTCString = (moment: dayjs.Dayjs) => {
return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z');
};

View file

@ -6,17 +6,12 @@ import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import PluginLink from 'components/PluginLink/PluginLink';
import ScheduleFilters from 'components/ScheduleFilters/ScheduleFilters';
import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types';
import ScheduleQuality from 'components/ScheduleQuality/ScheduleQuality';
import Text from 'components/Text/Text';
import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import ShiftSwapForm from 'containers/RotationForm/ShiftSwapForm';
import Rotations from 'containers/Rotations/Rotations';
@ -24,28 +19,24 @@ import ScheduleFinal from 'containers/Rotations/ScheduleFinal';
import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides';
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
import ScheduleICalSettings from 'containers/ScheduleIcalLink/ScheduleIcalLink';
import UserTimezoneSelect from 'containers/UserTimezoneSelect/UserTimezoneSelect';
import UsersTimezones from 'containers/UsersTimezones/UsersTimezones';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { Event, Schedule, ScheduleType, Shift, ShiftSwap } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { getStartOfWeek } from './Schedule.helpers';
import { getStartOfWeekBasedOnCurrentDate } from './Schedule.helpers';
import styles from './Schedule.module.css';
const cx = cn.bind(styles);
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {
pageTitle: string;
setPageTitle: (value: string) => void;
}
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {}
interface SchedulePageState extends PageBaseState {
startMoment: dayjs.Dayjs;
interface SchedulePageState {
schedulePeriodType: string;
renderType: string;
shiftIdToShowRotationForm?: Shift['id'];
@ -64,13 +55,12 @@ interface SchedulePageState extends PageBaseState {
@observer
class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState> {
highlightMyShiftsWasToggled = false;
scheduleId = this.props.match.params.id;
constructor(props: SchedulePageProps) {
super(props);
const { store } = this.props;
this.state = {
startMoment: getStartOfWeek(store.currentTimezone),
schedulePeriodType: 'week',
renderType: 'timeline',
shiftIdToShowRotationForm: undefined,
@ -78,56 +68,44 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
isLoading: true,
showEditForm: false,
showScheduleICalSettings: false,
errorData: initErrorDataState(),
lastUpdated: 0,
filters: { users: [] },
};
}
async componentDidMount() {
const {
store,
match: {
params: { id },
},
} = this.props;
const { store } = this.props;
store.userStore.updateItems();
store.scheduleStore.updateFrequencyOptions();
store.scheduleStore.updateDaysOptions();
await store.scheduleStore.updateOncallShifts(id); // TODO we should know shifts to render Rotations
await this.updateEvents();
await store.scheduleStore.updateOncallShifts(this.scheduleId); // TODO we should know shifts to render Rotations
await store.scheduleStore.refreshEvents(this.scheduleId);
this.setState({ isLoading: false });
}
componentWillUnmount() {
const { store, setPageTitle } = this.props;
const { store } = this.props;
store.scheduleStore.clearPreview();
setPageTitle(undefined);
store.setPageTitle(undefined);
}
render() {
const {
store,
query,
pageTitle,
match: {
params: { id: scheduleId },
},
} = this.props;
const {
startMoment,
shiftIdToShowRotationForm,
shiftIdToShowOverridesForm,
showEditForm,
showScheduleICalSettings,
errorData,
shiftStartToShowOverrideForm,
shiftEndToShowOverrideForm,
filters,
@ -135,9 +113,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftSwapParamsToShowForm,
} = this.state;
const { isNotFoundError } = errorData;
const { isNotFoundError } = store.scheduleStore.refreshEventsError;
const { scheduleStore, currentTimezone } = store;
const { scheduleStore } = store;
const users = store.userStore.getSearchResult().results;
const schedule = scheduleStore.items[scheduleId];
@ -163,7 +141,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftSwapIdToShowForm;
return (
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
<PageErrorHandlingWrapper
errorData={store.scheduleStore.refreshEventsError}
objectName="schedule"
pageName="schedules"
>
{() => (
<>
<div className={cx('root')}>
@ -193,19 +175,15 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
level={2}
onTextChange={this.handleNameChange}
>
{pageTitle}
{store.pageTitle}
</Text.Title>
{schedule && <ScheduleQuality schedule={schedule} lastUpdated={this.state.lastUpdated} />}
{schedule && <ScheduleQuality schedule={schedule} />}
</div>
<HorizontalGroup spacing="lg">
{users && (
<HorizontalGroup>
<Text type="secondary">Current timezone:</Text>
<UserTimezoneSelect
value={currentTimezone}
users={users}
onChange={this.handleTimezoneChange}
/>
<UserTimezoneSelect scheduleId={scheduleId} />
</HorizontalGroup>
)}
<HorizontalGroup>
@ -241,15 +219,12 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<div className={cx('users-timezones')}>
<UsersTimezones
scheduleId={scheduleId}
startMoment={startMoment}
onCallNow={schedule?.on_call_now || []}
userIds={
scheduleStore.relatedUsers[scheduleId]
? Object.keys(scheduleStore.relatedUsers[scheduleId])
: []
}
tz={currentTimezone}
onTzChange={this.handleTimezoneChange}
/>
</div>
@ -269,7 +244,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
</Button>
</HorizontalGroup>
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
{store.timezoneStore.calendarStartDate.format('DD MMM')} -{' '}
{store.timezoneStore.calendarStartDate.add(6, 'day').format('DD MMM')}
</Text.Title>
</HorizontalGroup>
<ScheduleFilters
@ -281,8 +257,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
</div>
<ScheduleFinal
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
disabled={disabledRotationForm}
onShowOverrideForm={this.handleShowOverridesForm}
filters={filters}
@ -303,8 +277,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
/>
<Rotations
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onCreate={this.handleCreateRotation}
onUpdate={this.handleUpdateRotation}
onDelete={this.handleDeleteRotation}
@ -318,8 +290,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
/>
<ScheduleOverrides
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onCreate={this.handleCreateOverride}
onUpdate={this.handleUpdateOverride}
onDelete={this.handleDeleteOverride}
@ -359,8 +329,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<ShiftSwapForm
id={shiftSwapIdToShowForm}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
params={shiftSwapParamsToShowForm}
onHide={this.handleHideShiftSwapForm}
onUpdate={this.handleUpdateShiftSwaps}
@ -378,13 +346,12 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
match: {
params: { id: scheduleId },
},
setPageTitle,
} = this.props;
const { scheduleStore } = store;
return scheduleStore.loadItem(scheduleId).then((schedule) => {
setPageTitle(schedule?.name);
store.setPageTitle(schedule?.name);
});
};
@ -406,7 +373,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
match: {
params: { id: scheduleId },
},
setPageTitle,
} = this.props;
const schedule = store.scheduleStore.items[scheduleId];
@ -415,46 +381,14 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
.update(scheduleId, { type: schedule.type, name: value })
.then(() => store.scheduleStore.loadItem(scheduleId))
.then((schedule) => {
setPageTitle(schedule?.name);
store.setPageTitle(schedule?.name);
});
};
updateEvents = () => {
const {
store,
match: {
params: { id: scheduleId },
},
setPageTitle,
} = this.props;
const { startMoment } = this.state;
this.setState((prevState) => ({
// this will update schedule score
lastUpdated: prevState.lastUpdated + 1,
}));
store.scheduleStore
.loadItem(scheduleId) // to refresh current oncall users
.then((schedule) => {
setPageTitle(schedule?.name);
})
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
store.scheduleStore.updateRelatedUsers(scheduleId); // to refresh related users
return Promise.all([
store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation'),
store.scheduleStore.updateEvents(scheduleId, startMoment, 'override'),
store.scheduleStore.updateEvents(scheduleId, startMoment, 'final'),
store.scheduleStore.updateShiftSwaps(scheduleId, startMoment),
]);
};
handleCreateRotation = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
@ -462,7 +396,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
handleCreateOverride = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
@ -470,7 +404,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
handleUpdateRotation = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
@ -478,7 +412,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
handleUpdateShiftSwaps = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
@ -486,7 +420,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
handleDeleteRotation = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
@ -494,7 +428,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
handleDeleteOverride = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
@ -502,25 +436,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
handleUpdateOverride = () => {
const { store } = this.props;
this.updateEvents().then(() => {
store.scheduleStore.refreshEvents(this.scheduleId).then(() => {
store.scheduleStore.clearPreview();
});
};
handleTimezoneChange = (value: Timezone) => {
const { store } = this.props;
const oldTimezone = store.currentTimezone;
this.setState((oldState) => {
const wDiff = oldState.startMoment.diff(getStartOfWeek(oldTimezone), 'weeks');
return { ...oldState, startMoment: getStartOfWeek(value).add(wDiff, 'weeks') };
}, this.updateEvents);
store.currentTimezone = value;
};
handleShedulePeriodTypeChange = (value: string) => {
this.setState({ schedulePeriodType: value });
};
@ -530,23 +450,28 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
};
handleDateRangeUpdate = async () => {
await this.updateEvents();
await this.props.store.scheduleStore.refreshEvents(this.scheduleId);
this.forceUpdate();
};
handleTodayClick = () => {
const { store } = this.props;
this.setState({ startMoment: getStartOfWeek(store.currentTimezone) }, this.handleDateRangeUpdate);
store.timezoneStore.setCalendarStartDate(
getStartOfWeekBasedOnCurrentDate(store.timezoneStore.currentDateInSelectedTimezone)
);
this.handleDateRangeUpdate();
};
handleLeftClick = () => {
const { startMoment } = this.state;
this.setState({ startMoment: startMoment.add(-7, 'day') }, this.handleDateRangeUpdate);
const { store } = this.props;
store.timezoneStore.setCalendarStartDate(store.timezoneStore.calendarStartDate.subtract(7, 'day'));
this.handleDateRangeUpdate();
};
handleRightClick = () => {
const { startMoment } = this.state;
this.setState({ startMoment: startMoment.add(7, 'day') }, this.handleDateRangeUpdate);
const { store } = this.props;
store.timezoneStore.setCalendarStartDate(store.timezoneStore.calendarStartDate.add(7, 'day'));
this.handleDateRangeUpdate();
};
handleExportClick = () => {
@ -564,7 +489,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
await scheduleStore.reloadIcal(scheduleId);
store.scheduleStore.updateOncallShifts(scheduleId);
this.updateEvents();
store.scheduleStore.refreshEvents(scheduleId);
};
};

View file

@ -2,7 +2,6 @@ import React, { SyntheticEvent } from 'react';
import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import qs from 'query-string';
import { RouteComponentProps, withRouter } from 'react-router-dom';
@ -13,9 +12,7 @@ import PluginLink from 'components/PluginLink/PluginLink';
import Table from 'components/Table/Table';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
@ -23,11 +20,11 @@ import ScheduleFinal from 'containers/Rotations/ScheduleFinal';
import SchedulePersonal from 'containers/Rotations/SchedulePersonal';
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
import TeamName from 'containers/TeamName/TeamName';
import TimelineMarks from 'containers/TimelineMarks/TimelineMarks';
import UserTimezoneSelect from 'containers/UserTimezoneSelect/UserTimezoneSelect';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { Schedule } from 'models/schedule/schedule.types';
import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps, PageProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
@ -41,7 +38,6 @@ const cx = cn.bind(styles);
interface SchedulesPageProps extends WithStoreProps, RouteComponentProps, PageProps {}
interface SchedulesPageState {
startMoment: dayjs.Dayjs;
filters: RemoteFiltersType;
showNewScheduleSelector: boolean;
expandedRowKeys: Array<Schedule['id']>;
@ -53,10 +49,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
constructor(props: SchedulesPageProps) {
super(props);
const { store } = this.props;
this.state = {
startMoment: getStartOfWeek(store.currentTimezone),
filters: { searchTerm: '', type: undefined, used: undefined, mine: undefined },
showNewScheduleSelector: false,
expandedRowKeys: [],
@ -74,14 +67,12 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
render() {
const { store, query } = this.props;
const { showNewScheduleSelector, expandedRowKeys, scheduleIdToEdit, startMoment } = this.state;
const { showNewScheduleSelector, expandedRowKeys, scheduleIdToEdit } = this.state;
const { results, count, page_size } = store.scheduleStore.getSearchResult();
const page = store.filtersStore.currentTablePageNum[PAGE.Schedules];
const users = store.userStore.getSearchResult().results;
return (
<>
<div className={cx('root')}>
@ -89,13 +80,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
<HorizontalGroup justify="space-between">
<Text.Title level={3}>Schedules</Text.Title>
<div className={cx('schedules__actions')}>
{users && (
<UserTimezoneSelect
value={store.currentTimezone}
users={users}
onChange={this.handleTimezoneChange}
/>
)}
<UserTimezoneSelect />
<WithPermissionControlTooltip userAction={UserActions.SchedulesWrite}>
<Button variant="primary" onClick={this.handleCreateScheduleClick}>
+ New schedule
@ -105,11 +90,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
</HorizontalGroup>
</div>
<div className={cx('schedule', 'schedule-personal')}>
<SchedulePersonal
userPk={store.userStore.currentUserPk}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
/>
<SchedulePersonal userPk={store.userStore.currentUserPk} />
</div>
<div className={cx('schedules__filters-container')}>
<RemoteFilters
@ -169,14 +150,6 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
);
}
handleTimezoneChange = (value: Timezone) => {
const { store } = this.props;
store.currentTimezone = value;
this.setState({ startMoment: getStartOfWeek(value) }, this.updateEvents);
};
handleCreateScheduleClick = () => {
this.setState({ showNewScheduleSelector: true });
};
@ -191,45 +164,27 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { expandedRowKeys } = this.state;
if (expanded && !expandedRowKeys.includes(data.id)) {
this.setState({ expandedRowKeys: [...this.state.expandedRowKeys, data.id] }, this.updateEvents);
this.setState({ expandedRowKeys: [...this.state.expandedRowKeys, data.id] }, () => {
this.props.store.scheduleStore.refreshEvents(data.id);
});
} else if (!expanded && expandedRowKeys.includes(data.id)) {
const index = expandedRowKeys.indexOf(data.id);
const newExpandedRowKeys = [...expandedRowKeys];
newExpandedRowKeys.splice(index, 1);
this.setState({ expandedRowKeys: newExpandedRowKeys }, this.updateEvents);
this.setState({ expandedRowKeys: newExpandedRowKeys }, () => {
this.props.store.scheduleStore.refreshEvents(data.id);
});
}
};
updateEvents = () => {
const { store } = this.props;
const { expandedRowKeys, startMoment } = this.state;
expandedRowKeys.forEach((scheduleId) => {
store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation');
store.scheduleStore.updateEvents(scheduleId, startMoment, 'override');
store.scheduleStore.updateEvents(scheduleId, startMoment, 'final');
});
};
renderSchedule = (data: Schedule) => {
const { startMoment } = this.state;
const { store } = this.props;
return (
<div className={cx('schedule')}>
<TimelineMarks startMoment={startMoment} timezone={store.currentTimezone} />
<div className={cx('rotations')}>
<ScheduleFinal
simplified
scheduleId={data.id}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
onSlotClick={this.getScheduleClickHandler(data.id)}
/>
</div>
renderSchedule = (data: Schedule) => (
<div className={cx('schedule')}>
<TimelineMarks />
<div className={cx('rotations')}>
<ScheduleFinal simplified scheduleId={data.id} onSlotClick={this.getScheduleClickHandler(data.id)} />
</div>
);
};
</div>
);
getScheduleClickHandler = (scheduleId: Schedule['id']) => {
const { history, query } = this.props;
@ -404,10 +359,14 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
update = () => {
const { store } = this.props;
const { startMoment } = this.state;
const page = store.filtersStore.currentTablePageNum[PAGE.Schedules];
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment, 9, true);
store.scheduleStore.updatePersonalEvents(
store.userStore.currentUserPk,
store.timezoneStore.calendarStartDate,
9,
true
);
// For removal we need to check if count is 1, which means we should change the page to the previous one
const { results } = store.scheduleStore.getSearchResult();

View file

@ -1,17 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import './dayjs';
import { LoadingPlaceholder } from '@grafana/ui';
import classnames from 'classnames';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isoWeek from 'dayjs/plugin/isoWeek';
import localeData from 'dayjs/plugin/localeData';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import weekday from 'dayjs/plugin/weekday';
import { observer, Provider } from 'mobx-react';
import Header from 'navbar/Header/Header';
import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar';
@ -42,16 +34,6 @@ import { useStore } from 'state/useStore';
import { isUserActionAllowed } from 'utils/authorization';
import { DEFAULT_PAGE } from 'utils/consts';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
dayjs.extend(isoWeek);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
import 'assets/style/vars.css';
import 'assets/style/global.css';
import 'assets/style/utils.css';
@ -70,9 +52,7 @@ export const GrafanaPluginRootPage = (props: AppRootProps) => {
};
export const Root = observer((props: AppRootProps) => {
const { isBasicDataLoaded, loadBasicData, loadMasterData } = useStore();
const [pageTitle, setPageTitle] = useState('');
const { isBasicDataLoaded, loadBasicData, loadMasterData, pageTitle } = useStore();
const location = useLocation();
@ -147,7 +127,7 @@ export const Root = observer((props: AppRootProps) => {
<Incidents query={query} />
</Route>
<Route path={getRoutesForPage('alert-group')} exact>
<Incident query={query} pageTitle={pageTitle} setPageTitle={setPageTitle} />
<Incident query={query} />
</Route>
<Route path={getRoutesForPage('users')} exact>
<Users query={query} />
@ -165,7 +145,7 @@ export const Root = observer((props: AppRootProps) => {
<Schedules query={query} />
</Route>
<Route path={getRoutesForPage('schedule')} exact>
<Schedule query={query} pageTitle={pageTitle} setPageTitle={setPageTitle} />
<Schedule query={query} />
</Route>
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
<OutgoingWebhooks query={query} />

View file

@ -0,0 +1,20 @@
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isoWeek from 'dayjs/plugin/isoWeek';
import localeData from 'dayjs/plugin/localeData';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import weekday from 'dayjs/plugin/weekday';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
dayjs.extend(isoWeek);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);

View file

@ -1,7 +1,6 @@
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'grafana/app/core/core';
import { action, computed, makeObservable, observable, runInAction } from 'mobx';
import moment from 'moment-timezone';
import qs from 'query-string';
import { OnCallAppPluginMeta } from 'types';
@ -28,7 +27,7 @@ import { ScheduleStore } from 'models/schedule/schedule';
import { SlackStore } from 'models/slack/slack';
import { SlackChannelStore } from 'models/slack_channel/slack_channel';
import { TelegramChannelStore } from 'models/telegram_channel/telegram_channel';
import { Timezone } from 'models/timezone/timezone.types';
import { TimezoneStore } from 'models/timezone/timezone';
import { UserStore } from 'models/user/user';
import { UserGroupStore } from 'models/user_group/user_group';
import { makeRequest } from 'network';
@ -51,9 +50,6 @@ export class RootBaseStore {
@observable
isBasicDataLoaded = false;
@observable
currentTimezone: Timezone = moment.tz.guess() as Timezone;
@observable
backendVersion = '';
@ -83,6 +79,9 @@ export class RootBaseStore {
@observable
incidentFilters: any;
@observable
pageTitle = '';
@observable
incidentsPage: any = this.initialQuery.p ? Number(this.initialQuery.p) : 1;
@ -115,6 +114,7 @@ export class RootBaseStore {
globalSettingStore = new GlobalSettingStore(this);
filtersStore = new FiltersStore(this);
labelsStore = new LabelStore(this);
timezoneStore = new TimezoneStore(this);
msteamsChannelStore: MSTeamsChannelStore = new MSTeamsChannelStore(this);
loaderStore = LoaderStore;
@ -322,6 +322,11 @@ export class RootBaseStore {
});
}
@action.bound
setPageTitle(title: string) {
this.pageTitle = title;
}
@action
async removeSlackIntegration() {
await this.slackStore.removeSlackIntegration();

View file

@ -6,8 +6,8 @@
"rootDirs": ["src"],
"baseUrl": "src",
"typeRoots": ["./node_modules/@types"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"strict": false,
"resolveJsonModule": true,
"noImplicitAny": false,