diff --git a/engine/apps/alerts/tasks/check_escalation_finished.py b/engine/apps/alerts/tasks/check_escalation_finished.py index fa3a699afd..aa8fb78b71 100644 --- a/engine/apps/alerts/tasks/check_escalation_finished.py +++ b/engine/apps/alerts/tasks/check_escalation_finished.py @@ -229,6 +229,8 @@ def check_escalation_finished_task() -> None: if total_alert_groups_count == 0 else (total_alert_groups_count - failed_alert_groups_count) / total_alert_groups_count * 100 ) + task_logger.info(f"Alert groups that failed escalation: {failed_alert_groups_count}") + task_logger.info(f"Alert groups total: {total_alert_groups_count}") task_logger.info(f"Alert group notifications success ratio: {success_ratio:.2f}") if alert_group_ids_that_failed_audit: diff --git a/engine/apps/slack/models/slack_usergroup.py b/engine/apps/slack/models/slack_usergroup.py index 244d4f22b3..acb172642e 100644 --- a/engine/apps/slack/models/slack_usergroup.py +++ b/engine/apps/slack/models/slack_usergroup.py @@ -143,7 +143,12 @@ def update_or_create_slack_usergroup_from_slack(cls, slack_id: str, slack_team_i sc = SlackClient(slack_team_identity) usergroups = sc.usergroups_list()["usergroups"] - usergroup = [ug for ug in usergroups if ug["id"] == slack_id][0] + try: + usergroup = [ug for ug in usergroups if ug["id"] == slack_id][0] + except IndexError: + # user group not found + return + try: members = sc.usergroups_users_list(usergroup=usergroup["id"])["users"] except SlackAPIError: diff --git a/engine/apps/slack/tests/test_user_group.py b/engine/apps/slack/tests/test_user_group.py index c1f9c09c18..ea06bd49dd 100644 --- a/engine/apps/slack/tests/test_user_group.py +++ b/engine/apps/slack/tests/test_user_group.py @@ -168,6 +168,27 @@ def test_update_or_create_slack_usergroup_from_slack( assert usergroup.is_active +@patch.object( + SlackClient, + "usergroups_list", + return_value=build_slack_response( + { + "ok": True, + "usergroups": [{"id": "test_slack_id", "name": "test_name", "handle": "test_handle", "date_delete": 0}], + } + ), +) +@pytest.mark.django_db +def test_update_or_create_slack_usergroup_from_slack_group_not_found( + mock_usergroups_list, make_organization_with_slack_team_identity +): + organization, slack_team_identity = make_organization_with_slack_team_identity() + SlackUserGroup.update_or_create_slack_usergroup_from_slack("other_id", slack_team_identity) + + # no group is created, no error is raised + assert SlackUserGroup.objects.count() == 0 + + @patch.object( SlackClient, "usergroups_users_list", diff --git a/grafana-plugin/.config/webpack/webpack.config.ts b/grafana-plugin/.config/webpack/webpack.config.ts index 9bd75a0fb2..93f20a24ee 100644 --- a/grafana-plugin/.config/webpack/webpack.config.ts +++ b/grafana-plugin/.config/webpack/webpack.config.ts @@ -95,14 +95,6 @@ const config = async (env): Promise => { }, }, }, - { - test: /\.css$/, - use: ['style-loader', 'css-loader'], - }, - { - test: /\.s[ac]ss$/, - use: ['style-loader', 'css-loader', 'sass-loader'], - }, { test: /\.(png|jpe?g|gif|svg)$/, type: 'asset/resource', diff --git a/grafana-plugin/e2e-tests/schedules/scheduleView.test.ts b/grafana-plugin/e2e-tests/schedules/scheduleView.test.ts new file mode 100644 index 0000000000..1def16456e --- /dev/null +++ b/grafana-plugin/e2e-tests/schedules/scheduleView.test.ts @@ -0,0 +1,31 @@ +import { scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers'; +import { ScheduleView } from 'models/schedule/schedule.types'; +import { HTML_ID } from 'utils/DOM'; + +import { expect, test } from '../fixtures'; +import { generateRandomValue } from '../utils/forms'; +import { createOnCallScheduleWithRotation } from '../utils/schedule'; + +test.skip('schedule view (week/2 weeks/month) toggler works', async ({ adminRolePage }) => { + const { page, userName } = adminRolePage; + + const onCallScheduleName = generateRandomValue(); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); + + // ScheduleView.OneWeek is selected by default + expect(await page.getByLabel(ScheduleView.OneWeek, { exact: true }).isChecked()).toBe(true); + + expect(await page.locator(`#${HTML_ID.SCHEDULE_FINAL} .TEST_weekday`).count()).toStrictEqual( + scheduleViewToDaysInOneRow[ScheduleView.OneWeek] + ); + + await page.getByLabel(ScheduleView.TwoWeeks, { exact: true }).click(); + expect(await page.getByLabel(ScheduleView.TwoWeeks, { exact: true }).isChecked()).toBe(true); + expect(await page.locator(`#${HTML_ID.SCHEDULE_FINAL} .TEST_weekday`).count()).toStrictEqual( + scheduleViewToDaysInOneRow[ScheduleView.TwoWeeks] + ); + + await page.getByLabel(ScheduleView.OneMonth, { exact: true }).click(); + expect(await page.getByLabel(ScheduleView.OneMonth, { exact: true }).isChecked()).toBe(true); + expect(await page.locator(`#${HTML_ID.SCHEDULE_FINAL} .TEST_weekday`).count()).toBeGreaterThanOrEqual(28); +}); diff --git a/grafana-plugin/src/assets/style/vars.css b/grafana-plugin/src/assets/style/vars.css index 199730edf6..e9d6e9a7e6 100644 --- a/grafana-plugin/src/assets/style/vars.css +++ b/grafana-plugin/src/assets/style/vars.css @@ -27,6 +27,7 @@ --hover-selected: #f4f5f5; --background-canvas: #f4f5f5; --background-secondary: #f4f5f5; + --background-disabled: rgba(204, 204, 220, 0.11); --border-medium-color: rgba(36, 41, 46, 0.3); --border-medium: 1px solid rgba(36, 41, 46, 0.3); --border-strong: 1px solid rgba(36, 41, 46, 0.4); @@ -61,6 +62,7 @@ --oncall-icon-stroke-color: #181b1f; --background-canvas: #111217; --background-secondary: #22252b; + --background-disabled: rgba(204, 204, 220, 0.04); --border-medium-color: rgba(204, 204, 220, 0.15); --border-medium: 1px solid rgba(204, 204, 220, 0.15); --border-strong: 1px solid rgba(204, 204, 220, 0.25); diff --git a/grafana-plugin/src/containers/Rotation/Rotation.module.css b/grafana-plugin/src/containers/Rotation/Rotation.module.css index 87cb524c0d..33e8c51069 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.module.css +++ b/grafana-plugin/src/containers/Rotation/Rotation.module.css @@ -24,10 +24,6 @@ position: relative; } -.root:last-child .timeline { - padding-bottom: 0; -} - .slots { width: 100%; display: flex; @@ -39,10 +35,6 @@ opacity: 0.5; } -.slots__animate { - transition: transform 500ms ease; -} - .current-time { position: absolute; left: 450px; @@ -64,11 +56,6 @@ margin: 0 2px; } -.slots--tutorial { - position: absolute; - background: rgba(61, 113, 217, 0.15); -} - .pointer { position: absolute; top: -9px; @@ -82,15 +69,3 @@ transform: scale(1); opacity: 1; } - -.tutorial-slot { - width: 175px; - height: 28px; - border-radius: 2px; - margin: 0 1px; - padding: 4px; -} - -.tutorial-slot--active { - box-shadow: var(--shadows-z3); -} diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index f75e738a06..6f56d6a02c 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -1,4 +1,4 @@ -import React, { FC, useMemo, useState } from 'react'; +import React, { FC, useMemo } from 'react'; import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -9,11 +9,10 @@ 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 { scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers'; +import { Event, ScheduleView, ShiftSwap } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; -import { RotationTutorial } from './RotationTutorial'; - import styles from './Rotation.module.css'; const cx = cn.bind(styles); @@ -28,27 +27,26 @@ interface RotationProps { handleAddShiftSwap?: (id: 'new', params: Partial) => void; handleOpenSchedule?: (event: Event) => void; onShiftSwapClick?: (swapId: ShiftSwap['id']) => void; - days?: number; transparent?: boolean; - tutorialParams?: RotationFormLiveParams; simplified?: boolean; filters?: ScheduleFiltersType; getColor?: (event: Event) => string; onSlotClick?: (event: Event) => void; emptyText?: string; showScheduleNameAsSlotTitle?: boolean; + startDate?: dayjs.Dayjs; + scheduleView?: ScheduleView; } export const Rotation: FC = observer((props) => { const { timezoneStore: { calendarStartDate, getDateInSelectedTimezone }, + scheduleStore: { scheduleView: storeScheduleView }, } = useStore(); const { events, color: propsColor, - days = 7, transparent = false, - tutorialParams, onClick, handleAddOverride, handleAddShiftSwap, @@ -60,18 +58,24 @@ export const Rotation: FC = observer((props) => { onSlotClick, emptyText, showScheduleNameAsSlotTitle, + startDate: propsStartDate, + scheduleView: propsScheduleView, } = props; - const [animate, _setAnimate] = useState(true); + const scheduleView = propsScheduleView || storeScheduleView; + + const startDate = propsStartDate || calendarStartDate; + + const days = scheduleViewToDaysInOneRow[scheduleView]; const handleRotationClick = (event: React.MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - rect.left; //x position within the element. const width = event.currentTarget.offsetWidth; - const dayOffset = Math.floor((x / width) * 7); + const dayOffset = Math.floor((x / width) * scheduleViewToDaysInOneRow[scheduleView]); - const shiftStart = calendarStartDate.add(dayOffset, 'day'); + const shiftStart = startDate.add(dayOffset, 'day'); const shiftEnd = shiftStart.add(1, 'day'); onClick(shiftStart, shiftEnd); @@ -134,27 +138,27 @@ export const Rotation: FC = observer((props) => { const firstShift = events[0]; const firstShiftOffset = getDateInSelectedTimezone(firstShift.start).diff( - getDateInSelectedTimezone(calendarStartDate), + getDateInSelectedTimezone(startDate), 'seconds' ); const base = 60 * 60 * 24 * days; return firstShiftOffset / base; - }, [events, calendarStartDate]); + }, [events, startDate]); return (
- {tutorialParams && } {events ? ( events.length ? (
{events.map((event) => { return ( = observer((props) => { - const { - timezoneStore: { calendarStartDate }, - } = useStore(); - const { days = 7, shiftStart, shiftEnd, rotationStart, focusElementName } = props; - - const duration = shiftEnd.diff(shiftStart, 'seconds'); - - const events = useMemo(() => { - return [ - { - start: dayjs(shiftStart), - end: dayjs(shiftStart).add(duration, 'seconds'), - }, - ]; - }, [shiftStart, duration]); - - const base = 60 * 60 * 24 * days; - - const pointerX = useMemo(() => { - if (focusElementName === undefined) { - return undefined; - } - - const moment = props[focusElementName]; - const firstEvent = events[0]; - const diff = dayjs(moment).diff(firstEvent.start, 'seconds'); - - return diff / base; - }, [focusElementName, events, rotationStart]); - - const x = useMemo(() => { - if (!events || !events.length) { - return 0; - } - - const firstEvent = events[0]; - const firstShiftOffset = dayjs(firstEvent.start).diff(calendarStartDate, 'seconds'); - const base = 60 * 60 * 24 * days; - - return firstShiftOffset / base; - }, [events, calendarStartDate]); - - return ( -
- - {events.map((event, index) => { - const duration = event.end.diff(event.start, 'seconds'); - const width = duration / base; - return ( - - ); - })} -
- ); -}); - -const TutorialSlot = (props: { style: React.CSSProperties; active: boolean }) => { - const { style, active } = props; - - return
; -}; - -const Pointer = (props: { className: string; style: React.CSSProperties }) => { - const { className, style } = props; - - return ( - - - - - - - - - - ); -}; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 23799c0520..c9c6376add 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -108,7 +108,7 @@ export const RotationForm = observer((props: RotationFormProps) => { const [rotationName, setRotationName] = useState(`[L${layerPriority}] Rotation`); const [isOpen, setIsOpen] = useState(false); - const [offsetTop, setOffsetTop] = useState(0); + const [offsetTop, setOffsetTop] = useState(GRAFANA_HEADER_HEIGHT + 10); const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined); const [shiftStart, setShiftStart] = useState(propsShiftStart); @@ -198,7 +198,10 @@ export const RotationForm = observer((props: RotationFormProps) => { } catch (err) { onError(err); } finally { - setIsOpen(true); + // wait until a scroll to the "Rotations" happened + setTimeout(() => { + setIsOpen(true); + }, 100); } }; diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index cf79d6006a..3a10da6827 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -15,7 +15,7 @@ import { Schedule, Shift } from 'models/schedule/schedule.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; -import { getCoords, waitForElement } from 'utils/DOM'; +import { HTML_ID, getCoords, waitForElement } from 'utils/DOM'; import { GRAFANA_HEADER_HEIGHT } from 'utils/consts'; import { useDebouncedCallback } from 'utils/hooks'; @@ -79,7 +79,7 @@ export const ScheduleOverrideForm: FC = (props) => { useEffect(() => { (async () => { if (isOpen) { - const elm = await waitForElement('#overrides-list'); + const elm = await waitForElement(`#${HTML_ID.SCHEDULE_OVERRIDES_AND_SWAPS}`); const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; const coords = getCoords(elm); const offsetTop = Math.min( @@ -171,7 +171,10 @@ export const ScheduleOverrideForm: FC = (props) => { } catch (err) { onError(err); } finally { - setIsOpen(true); + // wait until a scroll to the "Overrides and swaps" happened + setTimeout(() => { + setIsOpen(true); + }, 100); } }; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.styles.ts b/grafana-plugin/src/containers/Rotations/Rotations.styles.ts index 1654e45cb9..c0f9104679 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.styles.ts +++ b/grafana-plugin/src/containers/Rotations/Rotations.styles.ts @@ -4,49 +4,43 @@ import { GrafanaTheme2 } from '@grafana/data'; export const getRotationsStyles = (theme: GrafanaTheme2) => { return { root: css` - background: 1px solid ${theme.colors.background.secondary}; - border: ${theme.colors.border.weak}; + background: ${theme.colors.background.secondary}; + border: 1px solid ${theme.colors.border.weak}; display: flex; flex-direction: column; border-radius: ${theme.shape.radius.default}; + + &--withNoBackgroundAndBorder { + background: none; + border: none; + } `, currentTime: css` position: absolute; width: 1px; - background: ${theme.colors.gradients.brandVertical} + background: ${theme.colors.gradients.brandVertical}; top: 0; bottom: 0; z-index: 1; - transition: left 500ms ease; `, header: css` - padding: 0 10px; - `, - - title: css` - margin: 16px 0; + padding: 12px; `, layer: css` display: block; `, - rotations: css` - position: relative; + layerFirst: css` + padding-top: 32px; `, layerTitle: css` - text-align: center; - font-weight: 500; - line-height: 16px; - padding: 8px; - background: ${theme.colors.background.secondary}; - - &:hover { - background: rgba(204, 204, 220, 0.12); - } + margin: 8px; + border: ${theme.colors.border.weak}; + background: ${theme.colors.background.canvas}; `, rotationsPlusTitle: css` @@ -54,25 +48,6 @@ export const getRotationsStyles = (theme: GrafanaTheme2) => { flex-direction: column; `, - headerPlusContent: css` - position: relative; - padding-top: 26px; - padding-bottom: 26px; - `, - - layerHeader: css` - padding: 12px; - display: flex; - justify-content: space-between; - `, - - layerHeaderTitle: css` - font-weight: 400; - font-size: 14px; - line-height: 20px; - color: rgba(204, 204, 220, 0.65); - `, - layerContent: css` position: relative; `, @@ -81,7 +56,7 @@ export const getRotationsStyles = (theme: GrafanaTheme2) => { font-weight: 400; font-size: 12px; line-height: 16px; - text-align: center; + text-align: left; padding: 12px; cursor: pointer; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 58f5d88fac..5ba47b9243 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; +import { cx } from '@emotion/css'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { ValuePicker, HorizontalGroup, Button, Tooltip, withTheme2 } from '@grafana/ui'; import dayjs from 'dayjs'; @@ -7,16 +8,19 @@ import { observer } from 'mobx-react'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; +import { Tag } from 'components/Tag/Tag'; import { Text } from 'components/Text/Text'; 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 { getColor, getLayersFromStore, scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers'; +import { Schedule, ScheduleType, Shift, ShiftSwap, Event, Layer } from 'models/schedule/schedule.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; +import { getCurrentTimeX } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; +import { HTML_ID } from 'utils/DOM'; import { UserActions } from 'utils/authorization/authorization'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; @@ -27,8 +31,9 @@ import animationStyles from './Rotations.module.css'; interface RotationsProps extends WithStoreProps { shiftIdToShowRotationForm?: Shift['id'] | 'new'; + layerPriorityToShowRotationForm?: Layer['priority']; scheduleId: Schedule['id']; - onShowRotationForm: (shiftId: Shift['id'] | 'new') => void; + onShowRotationForm: (shiftId: Shift['id'] | 'new', layerPriority?: Layer['priority']) => void; onClick: (id: Shift['id'] | 'new') => void; onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void; onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new', params?: Partial) => void; @@ -43,7 +48,6 @@ interface RotationsProps extends WithStoreProps { } interface RotationsState { - layerPriority?: Layer['priority']; shiftStartToShowRotationForm?: dayjs.Dayjs; shiftEndToShowRotationForm?: dayjs.Dayjs; } @@ -51,7 +55,6 @@ interface RotationsState { @observer class _Rotations extends Component { state: RotationsState = { - layerPriority: undefined, shiftStartToShowRotationForm: undefined, shiftEndToShowRotationForm: undefined, }; @@ -68,18 +71,18 @@ class _Rotations extends Component { filters, onShowShiftSwapForm, onSlotClick, + layerPriorityToShowRotationForm, theme, } = this.props; - const { layerPriority, shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state; - const base = 7 * 24 * 60; // in minutes - const diff = store.timezoneStore.currentDateInSelectedTimezone.diff( + const { shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state; + + const currentTimeX = getCurrentTimeX( + store.timezoneStore.currentDateInSelectedTimezone, store.timezoneStore.calendarStartDate, - 'minutes' + scheduleViewToDaysInOneRow[store.scheduleStore.scheduleView] * 24 * 60 ); - const currentTimeX = diff / base; - const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; const layers = getLayersFromStore(store, scheduleId, store.timezoneStore.calendarStartDate); @@ -97,31 +100,30 @@ class _Rotations extends Component { const isTypeReadOnly = schedule && (schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar); + const styles = getRotationsStyles(theme); return ( <> -
+
-
- - Rotations - -
+ + Rotations + {disabled ? ( isTypeReadOnly ? (
-
) : ( - @@ -131,12 +133,12 @@ class _Rotations extends Component { label="Add rotation" options={options} onChange={this.handleAddRotation} - variant="primary" + variant="secondary" size="md" /> ) : (
{layers && layers.length ? ( - + + + {!currentTimeHidden && ( +
+ )} {layers.map((layer, layerIndex) => ( -
-
- - Layer {layer.priority} - -
-
- - {!currentTimeHidden && ( -
- )} - +
+ + + Layer {layer.priority} + + +
+ {layer.shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( { layerIndex={layerIndex} rotationIndex={rotationIndex} transparent={isPreview} - tutorialParams={isPreview && store.scheduleStore.rotationFormLiveParams} filters={filters} onSlotClick={onSlotClick} /> @@ -198,26 +207,24 @@ class _Rotations extends Component { ))} ) : ( -
-
-
- - Layer 1 - -
-
-
- -
- { - this.handleAddLayer(nextPriority, shiftStart, shiftEnd); - }} - events={[]} - layerIndex={0} - rotationIndex={0} - /> -
+
+ +
+
+ + + Layer 1 + + +
+ { + this.handleAddLayer(nextPriority, shiftStart, shiftEnd); + }} + events={[]} + layerIndex={0} + rotationIndex={0} + />
@@ -232,18 +239,17 @@ class _Rotations extends Component { this.handleAddLayer(nextPriority, store.timezoneStore.calendarStartDate); }} > - + Add new layer with rotation + + Add new layer with rotation
)}
- {shiftIdToShowRotationForm && ( { @@ -292,12 +298,9 @@ class _Rotations extends Component { return; } - this.setState( - { layerPriority, shiftStartToShowRotationForm: shiftStart, shiftEndToShowRotationForm: shiftEnd }, - () => { - this.onShowRotationForm('new'); - } - ); + this.setState({ shiftStartToShowRotationForm: shiftStart, shiftEndToShowRotationForm: shiftEnd }, () => { + this.onShowRotationForm('new', layerPriority); + }); }; handleAddRotation = (option: SelectableValue) => { @@ -309,11 +312,10 @@ class _Rotations extends Component { this.setState( { - layerPriority: option.value, shiftStartToShowRotationForm: store.timezoneStore.calendarStartDate, }, () => { - this.onShowRotationForm('new'); + this.onShowRotationForm('new', option.value); } ); }; @@ -321,20 +323,19 @@ class _Rotations extends Component { hideRotationForm = () => { this.setState( { - layerPriority: undefined, shiftStartToShowRotationForm: undefined, shiftEndToShowRotationForm: undefined, }, () => { - this.onShowRotationForm(undefined); + this.onShowRotationForm(undefined, undefined); } ); }; - onShowRotationForm = (shiftId: Shift['id']) => { + onShowRotationForm = (shiftId: Shift['id'], layerPriority?: Layer['priority']) => { const { onShowRotationForm } = this.props; - onShowRotationForm(shiftId); + onShowRotationForm(shiftId, layerPriority); }; handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 058540508d..723f217bac 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -1,9 +1,12 @@ -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; -import { HorizontalGroup, useStyles2 } from '@grafana/ui'; +import { cx } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { HorizontalGroup, useStyles2, withTheme2 } from '@grafana/ui'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { bem } from 'styles/utils.styles'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import { Text } from 'components/Text/Text'; @@ -14,10 +17,14 @@ import { getLayersFromStore, getOverridesFromStore, getShiftsFromStore, + getTotalDaysToDisplay, + scheduleViewToDaysInOneRow, } from 'models/schedule/schedule.helpers'; -import { Schedule, ShiftSwap, Event } from 'models/schedule/schedule.types'; +import { Event, Schedule, ScheduleView, ShiftSwap } from 'models/schedule/schedule.types'; +import { getCurrentTimeX } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; +import { HTML_ID } from 'utils/DOM'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; import { findColor } from './Rotations.helpers'; @@ -33,19 +40,29 @@ interface ScheduleFinalProps extends WithStoreProps { disabled?: boolean; filters: ScheduleFiltersType; onSlotClick?: (event: Event) => void; + scheduleView?: ScheduleView; + theme: GrafanaTheme2; } const _ScheduleFinal: FC = observer( - ({ store, simplified, scheduleId, filters, onShowShiftSwapForm, onShowOverrideForm, onSlotClick }) => { + ({ + store, + simplified, + scheduleId, + filters, + onShowShiftSwapForm, + onShowOverrideForm, + onSlotClick, + scheduleView: propsScheduleView, + }) => { const { timezoneStore: { currentDateInSelectedTimezone, calendarStartDate }, + scheduleStore: { scheduleView: storeScheduleView }, } = store; - const base = 7 * 24 * 60; // in minutes - const diff = currentDateInSelectedTimezone.diff(calendarStartDate, 'minutes'); const styles = useStyles2(getRotationsStyles); - const currentTimeX = diff / base; + const scheduleView = propsScheduleView || storeScheduleView; const shifts = flattenShiftEvents(getShiftsFromStore(store, scheduleId, calendarStartDate)); @@ -53,59 +70,88 @@ const _ScheduleFinal: FC = observer( 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); }; + const rows = useMemo(() => { + const totalDays = getTotalDaysToDisplay(scheduleView, calendarStartDate); + const rows = []; + for (let i = 0; i < totalDays / scheduleViewToDaysInOneRow[scheduleView]; i++) { + rows.push({ + startDate: calendarStartDate.add(scheduleViewToDaysInOneRow[scheduleView] * i, 'days'), + }); + } + return rows; + }, [calendarStartDate, scheduleView]); + return ( -
+
{!simplified && (
-
- - Final schedule - -
+ + Final schedule +
)} -
- {!currentTimeHidden &&
} - - - {shifts && shifts.length ? ( - shifts.map(({ events }, index) => { - return ( - - - - ); - }) - ) : ( - - - - )} - +
+ {rows.map(({ startDate }, index) => ( + + +
+ {shifts?.length ? ( + shifts.map(({ events }, index) => { + return ( + + + + ); + }) + ) : ( + + + + )} + + ))}
); } ); -export const ScheduleFinal = withMobXProviderContext(_ScheduleFinal); +export const ScheduleFinal = withMobXProviderContext(withTheme2(_ScheduleFinal)); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index f2171b6f30..261aeba731 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; +import { cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, HorizontalGroup, Tooltip, withTheme2 } from '@grafana/ui'; import dayjs from 'dayjs'; @@ -7,26 +8,28 @@ import { observer } from 'mobx-react'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; +import { Tag } from 'components/Tag/Tag'; import { Text } from 'components/Text/Text'; 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, getOverrideColor, getOverridesFromStore, getShiftSwapsFromStore, + scheduleViewToDaysInOneRow, SHIFT_SWAP_COLOR, } from 'models/schedule/schedule.helpers'; import { Schedule, Shift, ShiftEvents, ShiftSwap } from 'models/schedule/schedule.types'; -import { getUTCString } from 'pages/schedule/Schedule.helpers'; +import { getCurrentTimeX } from 'pages/schedule/Schedule.helpers'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; +import { HTML_ID } from 'utils/DOM'; import { UserActions } from 'utils/authorization/authorization'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; -import { findClosestUserEvent, findColor } from './Rotations.helpers'; +import { findColor } from './Rotations.helpers'; import { getRotationsStyles } from './Rotations.styles'; import animationStyles from './Rotations.module.css'; @@ -37,7 +40,7 @@ interface ScheduleOverridesProps extends WithStoreProps { scheduleId: Schedule['id']; shiftIdToShowRotationForm?: Shift['id'] | 'new'; onShowRotationForm: (shiftId: Shift['id'] | 'new') => void; - onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new', params?: Partial) => void; + onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new') => void; onCreate: () => void; onUpdate: () => void; onDelete: () => void; @@ -73,61 +76,42 @@ class _ScheduleOverrides extends Component 1; const schedule = store.scheduleStore.items[scheduleId]; const isTypeReadOnly = !schedule?.enable_web_overrides; + const styles = getRotationsStyles(theme); return ( <> -
+
-
- - Overrides and swaps - -
+ + Overrides and swaps +
-
+
{!currentTimeHidden &&
} - - {shiftSwaps && shiftSwaps.length - ? shiftSwaps.map(({ isPreview, events }, index) => ( - - { - if (event.is_gap) { - return; - } - onShowShiftSwapForm(event.shiftSwapId); - }} - transparent={isPreview} - filters={filters} - /> - - )) - : null} - - + {shiftSwaps && shiftSwaps.length ? ( + + + + Swaps + + + {shiftSwaps.map(({ isPreview, events }, index) => ( + + { + if (event.is_gap) { + return; + } + onShowShiftSwapForm(event.shiftSwapId); + }} + transparent={isPreview} + filters={filters} + /> + + ))} + + ) : null} + + {shifts && shifts.length ? ( + + + + Overrides + + + + ) : null} {shifts && shifts.length ? ( shifts.map(({ shiftId, isPreview, events }, index) => ( diff --git a/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx b/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx index 0f291c97ed..9adb92d7ea 100644 --- a/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx +++ b/grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx @@ -1,6 +1,8 @@ import React, { FC, useEffect } from 'react'; -import { Badge, Button, HorizontalGroup, Icon, useStyles2 } from '@grafana/ui'; +import { cx } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Badge, Button, HorizontalGroup, Icon, useStyles2, withTheme2 } from '@grafana/ui'; import { observer } from 'mobx-react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; @@ -10,10 +12,15 @@ import { Text } from 'components/Text/Text'; 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 { + getColorForSchedule, + getPersonalShiftsFromStore, + getTotalDaysToDisplay, + scheduleViewToDaysInOneRow, +} from 'models/schedule/schedule.helpers'; +import { Event, ScheduleView } from 'models/schedule/schedule.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; -import { getStartOfWeekBasedOnCurrentDate } from 'pages/schedule/Schedule.helpers'; +import { getCurrentTimeX, getStartOfWeekBasedOnCurrentDate } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; import { PLUGIN_ROOT } from 'utils/consts'; import { useIsLoading } from 'utils/hooks'; @@ -26,6 +33,7 @@ import animationStyles from './Rotations.module.css'; interface SchedulePersonalProps extends RouteComponentProps { userPk: ApiSchemas['User']['pk']; onSlotClick?: (event: Event) => void; + theme: GrafanaTheme2; } const _SchedulePersonal: FC = observer(({ userPk, onSlotClick, history }) => { @@ -38,7 +46,7 @@ const _SchedulePersonal: FC = observer(({ userPk, onSlotC }, [timezoneStore.selectedTimezoneOffset]); const updatePersonalEvents = () => { - scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate, 9, true); + scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate, true); }; const handleTodayClick = () => { @@ -46,12 +54,22 @@ const _SchedulePersonal: FC = observer(({ userPk, onSlotC }; const handleLeftClick = () => { - timezoneStore.setCalendarStartDate(timezoneStore.calendarStartDate.subtract(7, 'day')); + timezoneStore.setCalendarStartDate( + timezoneStore.calendarStartDate.subtract( + getTotalDaysToDisplay(ScheduleView.OneWeek, store.timezoneStore.calendarStartDate), + 'day' + ) + ); scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate); }; const handleRightClick = () => { - timezoneStore.setCalendarStartDate(timezoneStore.calendarStartDate.add(7, 'day')); + timezoneStore.setCalendarStartDate( + timezoneStore.calendarStartDate.add( + getTotalDaysToDisplay(ScheduleView.OneWeek, store.timezoneStore.calendarStartDate), + 'day' + ) + ); scheduleStore.updatePersonalEvents(userStore.currentUserPk, timezoneStore.calendarStartDate); }; @@ -59,10 +77,11 @@ const _SchedulePersonal: FC = observer(({ userPk, onSlotC history.push(`${PLUGIN_ROOT}/schedules/${event.schedule?.id}`); }; - const base = 7 * 24 * 60; // in minutes - const diff = timezoneStore.currentDateInSelectedTimezone.diff(timezoneStore.calendarStartDate, 'minutes'); - - const currentTimeX = diff / base; + const currentTimeX = getCurrentTimeX( + timezoneStore.currentDateInSelectedTimezone, + timezoneStore.calendarStartDate, + scheduleViewToDaysInOneRow[ScheduleView.OneWeek] * 24 * 60 + ); const shifts = getPersonalShiftsFromStore(store, userPk, timezoneStore.calendarStartDate); @@ -81,50 +100,49 @@ const _SchedulePersonal: FC = observer(({ userPk, onSlotC return (
-
- + + + + On-call schedule {storeUser.username} + + {isOncall ? ( + + ) : ( + /* @ts-ignore */ + + )} + + - On-call schedule {storeUser.username} + {timezoneStore.calendarStartDate.format('DD MMM')} -{' '} + {timezoneStore.calendarStartDate.add(6, 'day').format('DD MMM')} - {isOncall ? ( - - ) : ( - /* @ts-ignore */ - - )} - - - - - {timezoneStore.calendarStartDate.format('DD MMM')} -{' '} - {timezoneStore.calendarStartDate.add(6, 'day').format('DD MMM')} - - + + + - - - - -
+
-
+
{!currentTimeHidden &&
} - - + + {shifts?.length ? ( shifts.map(({ events }, index) => { return ( = observer(({ userPk, onSlotC ); }); -export const SchedulePersonal = withRouter(_SchedulePersonal); +export const SchedulePersonal = withRouter(withTheme2(_SchedulePersonal)); diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.helpers.ts b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.helpers.ts index ab26807eaa..b66acdeeaa 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.helpers.ts +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.helpers.ts @@ -1,5 +1,24 @@ +import { SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; export const getTitle = (user: ApiSchemas['User']) => { return user ? user.username.split(' ')[0] : null; }; + +export const getScheduleSlotStyleParams = (baseColor: string, isShiftSwap: boolean, hasBenefactor: boolean) => { + let color = baseColor; + let backgroundColor = color; + let border = undefined; + let textColor = '#fff'; + if (isShiftSwap) { + backgroundColor = SHIFT_SWAP_COLOR; + if (!hasBenefactor) { + color = SHIFT_SWAP_COLOR; + backgroundColor = 'transparent'; + border = `1px solid ${SHIFT_SWAP_COLOR}`; + textColor = SHIFT_SWAP_COLOR; + } + } + + return { color, backgroundColor, border, textColor }; +}; diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 2a4a2a1e01..81161a8372 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -7,19 +7,19 @@ import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import { COLORS, getLabelCss } from 'styles/utils.styles'; -import { Avatar } from 'components/Avatar/Avatar'; import NonExistentUserName from 'components/NonExistentUserName/NonExistentUserName'; import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; 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 { getShiftName, scheduleViewToDaysInOneRow, SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers'; +import { Event, ScheduleView, ShiftSwap } from 'models/schedule/schedule.types'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { useStore } from 'state/useStore'; +import { truncateTitle } from 'utils/string'; -import { getTitle } from './ScheduleSlot.helpers'; +import { getScheduleSlotStyleParams, getTitle } from './ScheduleSlot.helpers'; interface ScheduleSlotProps { event: Event; @@ -31,13 +31,13 @@ interface ScheduleSlotProps { filters?: ScheduleFiltersType; onClick: (event: React.MouseEvent) => void; showScheduleNameAsSlotTitle?: boolean; + scheduleView?: ScheduleView; } -const ONE_WEEK_IN_SECONDS = 7 * 24 * 60 * 60; - export const ScheduleSlot: FC = observer((props) => { const { timezoneStore: { getDateInSelectedTimezone }, + scheduleStore: { scheduleView: storeScheduleView }, } = useStore(); const styles = useStyles2(getStyles); @@ -51,14 +51,19 @@ export const ScheduleSlot: FC = observer((props) => { filters, onClick, showScheduleNameAsSlotTitle, + scheduleView: propsScheduleView, } = props; + const scheduleView = propsScheduleView || storeScheduleView; + const start = getDateInSelectedTimezone(event.start); const end = getDateInSelectedTimezone(event.end); const durationInSeconds = end.diff(start, 'seconds'); - const width = Math.max(durationInSeconds / ONE_WEEK_IN_SECONDS, 0); + const rowInSeconds = scheduleViewToDaysInOneRow[scheduleView] * 24 * 60 * 60; + + const width = Math.max(durationInSeconds / rowInSeconds, 0); const currentMoment = useMemo(() => dayjs(), []); @@ -160,21 +165,26 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => { const beneficiaryStoreUser = store.userStore.items[shiftSwap?.beneficiary?.pk]; const benefactorStoreUser = store.userStore.items[shiftSwap?.benefactor?.pk]; + const { backgroundColor, border, textColor } = getScheduleSlotStyleParams( + SHIFT_SWAP_COLOR, + true, + Boolean(shiftSwap?.benefactor) + ); + const scheduleSlotContent = ( -
+
{shiftSwap && ( - - {beneficiary && } - {benefactor ? ( - - ) : ( -
- - ? - -
- )} -
+
+ {truncateTitle(beneficiary.display_name, 9)} → {benefactor ? truncateTitle(benefactor.display_name, 9) : '?'} +
)}
); @@ -223,7 +233,7 @@ const RegularEvent = (props: RegularEventProps) => { event, onShiftSwapClick, filters, - color, + color: propsColor, start, duration, handleAddOverride, @@ -262,18 +272,25 @@ const RegularEvent = (props: RegularEventProps) => { const isShiftSwap = Boolean(swap_request); - const title = isShiftSwap ? 'Shift swap' : showScheduleNameAsSlotTitle ? schedule?.name : getShiftName(shift); + const title = isShiftSwap + ? `Shift swap to ${getShiftName(shift)}` + : showScheduleNameAsSlotTitle + ? schedule?.name + : getShiftName(shift); - let backgroundColor = color; - if (isShiftSwap) { - backgroundColor = SHIFT_SWAP_COLOR; - } + const { color, backgroundColor, border, textColor } = getScheduleSlotStyleParams( + propsColor, + Boolean(swap_request), + Boolean(swap_request?.user) + ); const scheduleSlotContent = (
{ /> )}
- {swap_request && !swap_request.user ? : userTitle} + {swap_request && !swap_request.user ? truncateTitle(userTitle, 9) + ' → ?' : userTitle}
); @@ -322,7 +339,7 @@ const RegularEvent = (props: RegularEventProps) => { : handleAddShiftSwap } handleOpenSchedule={handleOpenSchedule} - color={backgroundColor} + color={color} currentMoment={currentMoment} /> } @@ -523,7 +540,7 @@ const getStyles = (theme: GrafanaTheme2) => { position: relative; display: flex; overflow: hidden; - margin: 0 1px; + margin-right: 1px; padding: 4px; align-items: center; transition: opacity 0.2s ease; @@ -552,13 +569,6 @@ const getStyles = (theme: GrafanaTheme2) => { visibility: hidden; `, - // TODO: Same here - swap: css` - border-radius: 10px; - background: #ff99002e; - height: 20px; - `, - noUser: css` width: 12px; height: 12px; @@ -574,7 +584,6 @@ const getStyles = (theme: GrafanaTheme2) => { title: css` z-index: 1; - color: #fff; font-size: 12px; width: 100%; font-weight: 500; diff --git a/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.module.scss b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.module.scss index bd73516db3..d572d3fc14 100644 --- a/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.module.scss +++ b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.module.scss @@ -1,7 +1,6 @@ .root { position: absolute; display: flex; - z-index: 1; width: 100%; top: 0; bottom: 0; @@ -11,21 +10,22 @@ pointer-events: none; } +.root--borderBottom { + border-bottom: var(--border-medium); +} + .weekday { - width: calc(100% / 7); display: flex; flex-direction: column; justify-content: space-between; +} - &--weekend { - background: repeating-linear-gradient( - -45deg, - var(--background-canvas), - var(--background-canvas) 5px, - transparent 5px, - transparent 8px - ); - } +.weekday:not(:last-child) { + border-right: var(--border-medium); +} + +.weekday--weekEnd { + background: var(--background-disabled); } .weekday-title { @@ -35,10 +35,6 @@ flex-grow: 1; } -.weekday:not(:last-child) .weekday-title { - border-right: var(--border-medium); -} - .weekday-times { width: 100%; display: flex; diff --git a/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx index 95d23fce44..9685b66762 100644 --- a/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/containers/TimelineMarks/TimelineMarks.tsx @@ -5,12 +5,17 @@ import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import { Text } from 'components/Text/Text'; +import { scheduleViewToDaysInOneRow } from 'models/schedule/schedule.helpers'; +import { ScheduleView } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; import styles from './TimelineMarks.module.scss'; interface TimelineMarksProps { debug?: boolean; + startDate?: dayjs.Dayjs; + withBorderBottom?: boolean; + scheduleView?: ScheduleView; } const cx = cn.bind(styles); @@ -18,8 +23,14 @@ const cx = cn.bind(styles); export const TimelineMarks: FC = observer((props) => { const { timezoneStore: { currentDateInSelectedTimezone, calendarStartDate }, + scheduleStore: { scheduleView: storeScheduleView }, } = useStore(); - const { debug } = props; + const { debug, startDate: propsStartDate, withBorderBottom = false, scheduleView: propsScheduleView } = props; + + const startDate = propsStartDate || calendarStartDate; + const scheduleView = propsScheduleView || storeScheduleView; + + const days = scheduleViewToDaysInOneRow[scheduleView]; const momentsToRender = useMemo(() => { const hoursToSplit = 12; @@ -27,8 +38,8 @@ export const TimelineMarks: FC = observer((props) => { const momentsToRender = []; const jLimit = 24 / hoursToSplit; - for (let i = 0; i < 7; i++) { - const d = dayjs(calendarStartDate).add(i, 'days'); + for (let i = 0; i < days; i++) { + const d = dayjs(startDate).add(i, 'days'); const obj = { moment: d, moments: [] }; for (let j = 0; j < jLimit; j++) { const m = dayjs(d).add(j * hoursToSplit, 'hour'); @@ -37,27 +48,27 @@ export const TimelineMarks: FC = observer((props) => { momentsToRender.push(obj); } return momentsToRender; - }, [calendarStartDate]); + }, [startDate, days]); const cuts = useMemo(() => { const cuts = []; - for (let i = 0; i <= 24 * 7; i++) { + for (let i = 0; i <= 24 * days; i++) { cuts.push({}); } return cuts; - }, []); + }, [days]); return ( -
+
{debug && ( {cuts.map((_cut, index) => ( @@ -68,16 +79,20 @@ export const TimelineMarks: FC = observer((props) => { {momentsToRender.map((m, i) => { const isCurrentDay = currentDateInSelectedTimezone.isSame(m.moment, 'day'); - // const isWeekend = m.moment.day() == 0 || m.moment.day() === 6; + const isWeekend = m.moment.day() === 0 || m.moment.day() === 6; return ( -
+
- - {m.moment.format('ddd D MMM')} + + {m.moment.date() === 1 ? m.moment.format('ddd D MMM') : m.moment.format('ddd D')}
-
+ {/*
{m.moments.map((mm, j) => (
= observer((props) => {
))} -
+
*/}
); })} diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx index 7ec461ccb1..97a6ed602b 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -83,7 +83,7 @@ export const UsersTimezones: FC = observer((props) => {
- + Schedule team and timezones
diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.test.ts b/grafana-plugin/src/models/schedule/schedule.helpers.test.ts new file mode 100644 index 0000000000..e6f2b22760 --- /dev/null +++ b/grafana-plugin/src/models/schedule/schedule.helpers.test.ts @@ -0,0 +1,26 @@ +import dayjs from 'dayjs'; + +import { getTotalDaysToDisplay } from './schedule.helpers'; +import { ScheduleView } from './schedule.types'; + +describe('getTotalDaysToDisplay', () => { + const date = dayjs('2024-05-27T00:00:00Z').utc(); + + it(`should return correct total days to display in final schedule if scheduleView=Week`, () => { + const result = getTotalDaysToDisplay(ScheduleView.OneWeek, date); + + expect(result).toBe(7); + }); + + it(`should return correcnt total days to display in final schedule if scheduleView=2 weeks`, () => { + const result = getTotalDaysToDisplay(ScheduleView.TwoWeeks, date); + + expect(result).toBe(14); + }); + + it(`should return correcnt total days to display in final schedule if scheduleView=Month`, () => { + const result = getTotalDaysToDisplay(ScheduleView.OneMonth, date); + + expect(result).toBe(35); + }); +}); diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 3a1ca043a0..be78e98ccf 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -3,7 +3,7 @@ import dayjs from 'dayjs'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { RootStore } from 'state/rootStore'; -import { Event, Layer, Schedule, ScheduleType, Shift, ShiftEvents, ShiftSwap } from './schedule.types'; +import { Event, Layer, Schedule, ScheduleType, ScheduleView, Shift, ShiftEvents, ShiftSwap } from './schedule.types'; export const getFromString = (moment: dayjs.Dayjs) => { return moment.format('YYYY-MM-DD'); @@ -63,6 +63,32 @@ export const fillGaps = (events: Event[]) => { return newEvents; }; +export const scheduleViewToDaysInOneRow = { + [ScheduleView.OneWeek]: 7, + [ScheduleView.TwoWeeks]: 14, + [ScheduleView.OneMonth]: 7, +}; + +export const getTotalDaysToDisplay = (scheduleView: ScheduleView, calendarStartDate: dayjs.Dayjs) => { + switch (scheduleView) { + case ScheduleView.OneWeek: + return 7; + case ScheduleView.TwoWeeks: + return 14; + case ScheduleView.OneMonth: + const firstDayOfCurrentMonth = + calendarStartDate.date() === 1 ? calendarStartDate : calendarStartDate.add(1, 'month').startOf('month'); + + const lastDayOfCurrentMonth = firstDayOfCurrentMonth.endOf('month'); + + const lastDayOfLastWeek = lastDayOfCurrentMonth.endOf('isoWeek'); + + const totalDays = lastDayOfLastWeek.diff(calendarStartDate, 'days') + 1; + + return totalDays; + } +}; + export const splitToShifts = (events: Event[]) => { const shifts: Array<{ shiftId: Shift['id']; priority: Shift['priority_level']; events: Event[] }> = []; @@ -397,13 +423,13 @@ export const enrichOverrides = ( const L1_COLORS = ['#3D71D9', '#6D609C', '#4D3B72', '#8214A0']; -const L2_COLORS = ['#3CB979', '#188343', '#84362A', '#521913']; +const L2_COLORS = ['#299C46', '#517A00', '#84362A', '#521913']; const L3_COLORS = ['#377277', '#638282', '#364E4E', '#423220']; -const OVERRIDE_COLORS = ['#C69B06', '#C2C837']; +const OVERRIDE_COLORS = ['#EF9C48']; -export const SHIFT_SWAP_COLOR = '#C69B06'; +export const SHIFT_SWAP_COLOR = '#DC7532'; const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS]; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index cf84a325c1..081dd95cfe 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -21,6 +21,7 @@ import { fillGapsInShifts, flattenShiftEvents, getFromString, + getTotalDaysToDisplay, splitToLayers, splitToShifts, unFlattenShiftEvents, @@ -34,9 +35,9 @@ import { Event, Layer, ShiftEvents, - RotationFormLiveParams, ScheduleScoreQualityResponse, ShiftSwap, + ScheduleView, } from './schedule.types'; export class ScheduleStore extends BaseStore { @@ -108,9 +109,6 @@ export class ScheduleStore extends BaseStore { @observable overridePreview?: { [fromString: string]: ShiftEvents[] }; - @observable - rotationFormLiveParams: RotationFormLiveParams = undefined; - @observable scheduleToScheduleEvents: { [id: string]: ScheduleEvent[]; @@ -125,6 +123,9 @@ export class ScheduleStore extends BaseStore { wrongTeamNoPermissions: false, }; + @observable + scheduleView = ScheduleView.OneWeek; + constructor(rootStore: RootStore) { super(rootStore); @@ -133,6 +134,11 @@ export class ScheduleStore extends BaseStore { this.path = '/schedules/'; } + @action.bound + setScheduleView(value: ScheduleView) { + this.scheduleView = value; + } + @action.bound async loadItem(id: Schedule['id'], skipErrorHandling = false): Promise { const schedule = await this.getById(id, skipErrorHandling); @@ -277,10 +283,6 @@ export class ScheduleStore extends BaseStore { return response; } - setRotationFormLiveParams(params: RotationFormLiveParams) { - this.rotationFormLiveParams = params; - } - async updateRotationPreview( scheduleId: Schedule['id'], shiftId: Shift['id'] | 'new', @@ -290,12 +292,14 @@ export class ScheduleStore extends BaseStore { ) { const type = isOverride ? 3 : 2; + const days = getTotalDaysToDisplay(this.scheduleView, this.rootStore.timezoneStore.calendarStartDate); + const fromString = getFromString(startMoment); const dayBefore = startMoment.subtract(1, 'day'); const response = await makeRequest(`/oncall_shifts/preview/`, { - params: { date: getFromString(dayBefore), days: 8 }, + params: { date: getFromString(dayBefore), days }, data: { type, schedule: scheduleId, shift_pk: shiftId === 'new' ? undefined : shiftId, ...params }, method: 'POST', }); @@ -356,7 +360,6 @@ export class ScheduleStore extends BaseStore { this.rotationPreview = undefined; this.overridePreview = undefined; this.shiftSwapsPreview = undefined; - this.rotationFormLiveParams = undefined; } @action.bound @@ -497,7 +500,7 @@ export class ScheduleStore extends BaseStore { } @action.bound - async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days = 9) { + async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days) { const dayBefore = startMoment.subtract(1, 'day'); const response = await makeRequest(`/schedules/${scheduleId}/filter_events/`, { @@ -530,10 +533,13 @@ export class ScheduleStore extends BaseStore { } @action.bound - async refreshEvents(scheduleId: string) { + async refreshEvents(scheduleId: string, scheduleView?: ScheduleView) { this.refreshEventsError = {}; const startMoment = this.rootStore.timezoneStore.calendarStartDate; + const days = + getTotalDaysToDisplay(scheduleView || this.scheduleView, this.rootStore.timezoneStore.calendarStartDate) + 1; + try { const schedule = await this.loadItem(scheduleId); this.rootStore.setPageTitle(schedule?.name); @@ -545,9 +551,9 @@ export class ScheduleStore extends BaseStore { 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.updateEvents(scheduleId, startMoment, 'rotation', days), + this.updateEvents(scheduleId, startMoment, 'override', days), + this.updateEvents(scheduleId, startMoment, 'final', days), this.updateShiftSwaps(scheduleId, startMoment), ]); } @@ -605,9 +611,11 @@ export class ScheduleStore extends BaseStore { } @action.bound - async updateShiftSwaps(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, days = 9) { + async updateShiftSwaps(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs) { const fromString = getFromString(startMoment); + const days = getTotalDaysToDisplay(this.scheduleView, this.rootStore.timezoneStore.calendarStartDate) + 1; + const dayBefore = startMoment.subtract(1, 'day'); const result = await makeRequest(`/schedules/${scheduleId}/filter_shift_swaps/`, { @@ -647,14 +655,11 @@ export class ScheduleStore extends BaseStore { @AutoLoadingState(ActionKey.UPDATE_PERSONAL_EVENTS) @action.bound - async updatePersonalEvents( - userPk: ApiSchemas['User']['pk'], - startMoment: dayjs.Dayjs, - days = 9, - isUpdateOnCallNow = false - ) { + async updatePersonalEvents(userPk: ApiSchemas['User']['pk'], startMoment: dayjs.Dayjs, isUpdateOnCallNow = false) { const fromString = getFromString(startMoment); + const days = getTotalDaysToDisplay(ScheduleView.OneWeek, this.rootStore.timezoneStore.calendarStartDate) + 1; + const dayBefore = startMoment.subtract(1, 'day'); const { is_oncall, schedules } = await makeRequest(`/schedules/current_user_events/`, { diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 902d7c1ed7..bf77f32d71 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -1,5 +1,3 @@ -import dayjs from 'dayjs'; - import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { UserGroup } from 'models/user_group/user_group.types'; @@ -11,11 +9,10 @@ export enum ScheduleType { 'API', } -export interface RotationFormLiveParams { - rotationStart: dayjs.Dayjs; - shiftStart: dayjs.Dayjs; - shiftEnd: dayjs.Dayjs; - focusElementName: string; +export enum ScheduleView { + 'OneWeek' = 'Week', + 'TwoWeeks' = '2 weeks', + 'OneMonth' = 'Month', } export interface Schedule { diff --git a/grafana-plugin/src/models/timezone/timezone.ts b/grafana-plugin/src/models/timezone/timezone.ts index 3b9b8d9def..e8561c7955 100644 --- a/grafana-plugin/src/models/timezone/timezone.ts +++ b/grafana-plugin/src/models/timezone/timezone.ts @@ -2,7 +2,8 @@ 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 { ScheduleView } from 'models/schedule/schedule.types'; +import { getCalendarStartDate } from 'pages/schedule/Schedule.helpers'; import { RootStore } from 'state/rootStore'; import { getOffsetOfCurrentUser, getGMTTimezoneLabelBasedOnOffset } from './timezone.helpers'; @@ -19,13 +20,19 @@ export class TimezoneStore { @observable selectedTimezoneOffset = getOffsetOfCurrentUser(); + /* @observable + calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone); */ + @observable - calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone); + calendarStartDate = getCalendarStartDate(this.currentDateInSelectedTimezone, ScheduleView.OneWeek); @action.bound setSelectedTimezoneOffset(offset: number) { this.selectedTimezoneOffset = offset; - this.calendarStartDate = getStartOfWeekBasedOnCurrentDate(this.currentDateInSelectedTimezone); + this.calendarStartDate = getCalendarStartDate( + this.currentDateInSelectedTimezone, + this.rootStore.scheduleStore.scheduleView + ); } @action.bound diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.test.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.test.ts new file mode 100644 index 0000000000..0dc686fae4 --- /dev/null +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.test.ts @@ -0,0 +1,45 @@ +import dayjs from 'dayjs'; + +import { ScheduleView } from 'models/schedule/schedule.types'; + +import { getNewCalendarStartDate } from './Schedule.helpers'; + +describe('getNewCalendarStartDate', () => { + const date = dayjs('2024-05-27T00:00:00Z').utc(); + + it(`should return correct next calendar date if scheduleView=Week`, () => { + const result = getNewCalendarStartDate(date, ScheduleView.OneWeek, 'next'); + + expect(result.toString()).toBe('Mon, 03 Jun 2024 00:00:00 GMT'); + }); + + it(`should return correct previous calendar date if scheduleView=Week`, () => { + const result = getNewCalendarStartDate(date, ScheduleView.OneWeek, 'prev'); + + expect(result.toString()).toBe('Mon, 20 May 2024 00:00:00 GMT'); + }); + + it(`should return correct next calendar date if scheduleView=2 weeks`, () => { + const result = getNewCalendarStartDate(date, ScheduleView.TwoWeeks, 'next'); + + expect(result.toString()).toBe('Mon, 10 Jun 2024 00:00:00 GMT'); + }); + + it(`should return correct previous calendar date if scheduleView=2 weeks`, () => { + const result = getNewCalendarStartDate(date, ScheduleView.TwoWeeks, 'prev'); + + expect(result.toString()).toBe('Mon, 13 May 2024 00:00:00 GMT'); + }); + + it(`should return correct next calendar date if scheduleView=Month`, () => { + const result = getNewCalendarStartDate(date, ScheduleView.OneMonth, 'next'); + + expect(result.toString()).toBe('Mon, 01 Jul 2024 00:00:00 GMT'); + }); + + it(`should return correct previous calendar date if scheduleView=Month`, () => { + const result = getNewCalendarStartDate(date, ScheduleView.OneMonth, 'prev'); + + expect(result.toString()).toBe('Mon, 29 Apr 2024 00:00:00 GMT'); + }); +}); diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index cfcdb123cb..440960570e 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -2,8 +2,13 @@ import { config } from '@grafana/runtime'; import dayjs from 'dayjs'; import { findColor } from 'containers/Rotations/Rotations.helpers'; -import { getLayersFromStore, getOverridesFromStore, getShiftsFromStore } from 'models/schedule/schedule.helpers'; -import { Event, Layer } from 'models/schedule/schedule.types'; +import { + getLayersFromStore, + getOverridesFromStore, + getShiftsFromStore, + getTotalDaysToDisplay, +} from 'models/schedule/schedule.helpers'; +import { Event, Layer, ScheduleView } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { RootStore } from 'state/rootStore'; import { SelectOption } from 'state/types'; @@ -35,6 +40,35 @@ export const getStartOfWeekBasedOnCurrentDate = (date: dayjs.Dayjs) => { return date.startOf('isoWeek'); // it's Monday always }; +export const getCalendarStartDate = (date: dayjs.Dayjs, scheduleView: ScheduleView) => { + switch (scheduleView) { + case ScheduleView.OneMonth: + const startOfMonth = date.startOf('month'); + return startOfMonth.startOf('isoWeek'); + default: + return date.startOf('isoWeek'); + } +}; + +export const getNewCalendarStartDate = (date: dayjs.Dayjs, scheduleView: ScheduleView, direction: 'prev' | 'next') => { + switch (scheduleView) { + case ScheduleView.OneMonth: + return direction === 'prev' + ? date.subtract(1, 'day').startOf('month').startOf('isoWeek') + : date.add(10, 'days').endOf('month').add(1, 'day').startOf('month').startOf('isoWeek'); + default: + return direction === 'prev' + ? date.subtract(getTotalDaysToDisplay(scheduleView, date), 'days') + : date.add(getTotalDaysToDisplay(scheduleView, date), 'days'); + } +}; + +export const getCurrentTimeX = (currentDate: dayjs.Dayjs, startDate: dayjs.Dayjs, baseInMinutes: number) => { + const diff = currentDate.diff(startDate, 'minutes'); + + return diff / baseInMinutes; +}; + export const getUTCString = (moment: dayjs.Dayjs) => { return moment.utc().format('YYYY-MM-DDTHH:mm:ss.000Z'); }; diff --git a/grafana-plugin/src/pages/schedule/Schedule.styles.ts b/grafana-plugin/src/pages/schedule/Schedule.styles.ts index 8c4bc0bd61..077d1ac22b 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.styles.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.styles.ts @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; -export const getScheduleStyles = () => { +export const getScheduleStyles = (theme: GrafanaTheme2) => { return { title: css` display: flex; @@ -27,6 +28,11 @@ export const getScheduleStyles = () => { controls: css` width: 100%; + position: sticky; + top: 0; + z-index: 2; + padding: 16px 0; + background-color: ${theme.colors.background.primary}; `, rotations: css` diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 3a8713dfce..ff8625d6a9 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -10,6 +10,11 @@ import { Icon, Modal, withTheme2, + Dropdown, + Menu, + ButtonGroup, + RadioButtonGroup, + DatePicker, } from '@grafana/ui'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; @@ -24,6 +29,7 @@ import { Text } from 'components/Text/Text'; import { WithConfirm } from 'components/WithConfirm/WithConfirm'; import { ShiftSwapForm } from 'containers/RotationForm/ShiftSwapForm'; import { Rotations } from 'containers/Rotations/Rotations'; +import { findClosestUserEvent } from 'containers/Rotations/Rotations.helpers'; import { ScheduleFinal } from 'containers/Rotations/ScheduleFinal'; import { ScheduleOverrides } from 'containers/Rotations/ScheduleOverrides'; import { ScheduleForm } from 'containers/ScheduleForm/ScheduleForm'; @@ -31,14 +37,16 @@ import { ScheduleICalSettings } from 'containers/ScheduleIcalLink/ScheduleIcalLi 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 { getLayersFromStore, getTotalDaysToDisplay } from 'models/schedule/schedule.helpers'; +import { Event, Layer, Schedule, ScheduleType, ScheduleView, Shift, ShiftSwap } from 'models/schedule/schedule.types'; import { UserHelper } from 'models/user/user.helpers'; import { PageProps, WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; +import { HTML_ID, scrollToElement } from 'utils/DOM'; import { isUserActionAllowed, UserActions } from 'utils/authorization/authorization'; import { PLUGIN_ROOT } from 'utils/consts'; -import { getStartOfWeekBasedOnCurrentDate } from './Schedule.helpers'; +import { getCalendarStartDate, getNewCalendarStartDate, getUTCString } from './Schedule.helpers'; import { getScheduleStyles } from './Schedule.styles'; interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> { @@ -49,6 +57,7 @@ interface SchedulePageState { schedulePeriodType: string; renderType: string; shiftIdToShowRotationForm?: Shift['id']; + layerPriorityToShowRotationForm: Layer['priority']; shiftIdToShowOverridesForm?: Shift['id']; shiftStartToShowOverrideForm?: dayjs.Dayjs; shiftEndToShowOverrideForm?: dayjs.Dayjs; @@ -59,6 +68,7 @@ interface SchedulePageState { filters: ScheduleFiltersType; shiftSwapIdToShowForm?: ShiftSwap['id'] | 'new'; shiftSwapParamsToShowForm?: Partial; + calendarStartDatePickerIsOpen: boolean; } @observer @@ -73,12 +83,14 @@ class _SchedulePage extends React.Component + {() => ( <>
@@ -193,7 +210,7 @@ class _SchedulePage extends React.Component {users && ( - Current timezone: + View in timezone: )} @@ -212,6 +229,52 @@ class _SchedulePage extends React.Component )} + + {layers?.map((layer, index) => ( + { + scrollToElement(document.getElementById(HTML_ID.SCHEDULE_ROTATIONS)); + + this.handleShowRotationForm('new', layer.priority); + }} + /> + ))} + { + scrollToElement(document.getElementById(HTML_ID.SCHEDULE_ROTATIONS)); + + this.handleShowRotationForm('new', nextPriority); + }} + /> + { + scrollToElement(document.getElementById(HTML_ID.SCHEDULE_OVERRIDES_AND_SWAPS)); + + this.handleShowShiftSwapForm('new'); + }} + /> + { + scrollToElement(document.getElementById(HTML_ID.SCHEDULE_OVERRIDES_AND_SWAPS)); + + this.handleShowOverridesForm('new'); + }} + /> + + } + > + + + - + {store.timezoneStore.calendarStartDate.format('DD MMM')} -{' '} - {store.timezoneStore.calendarStartDate.add(6, 'day').format('DD MMM')} + {store.timezoneStore.calendarStartDate + .add( + getTotalDaysToDisplay(scheduleStore.scheduleView, timezoneStore.calendarStartDate) - 1, + 'day' + ) + .format('DD MMM')} + { + this.setState({ calendarStartDatePickerIsOpen: !calendarStartDatePickerIsOpen }); + }} + /> + { + store.timezoneStore.setCalendarStartDate( + getCalendarStartDate(dayjs(newDate), scheduleView) + ); + this.handleDateRangeUpdate(); + this.setState({ calendarStartDatePickerIsOpen: false }); + }} + onClose={() => this.setState({ calendarStartDatePickerIsOpen: false })} + /> + + + { + scheduleStore.setScheduleView(value); + if (value === ScheduleView.OneMonth) { + timezoneStore.setCalendarStartDate( + getCalendarStartDate( + timezoneStore.calendarStartDate.endOf('isoWeek').startOf('month'), + value + ) + ); + } + + scheduleStore.refreshEvents(scheduleId); + }} + /> + this.setState({ filters: value })} + currentUserPk={store.userStore.currentUserPk} + /> - this.setState({ filters: value })} - currentUserPk={store.userStore.currentUserPk} - />
- - + {/* we need to render to allow Rotations show rotaion modal form */} +
+ +
+ {/* we need to render to allow ScheduleOverrides show overrides modal form */} +
+ +
)} @@ -365,8 +482,8 @@ class _SchedulePage extends React.Component { - this.setState({ shiftIdToShowRotationForm: shiftId }); + handleShowRotationForm = (shiftId: Shift['id'] | 'new', layerPriority?: Layer['priority']) => { + this.setState({ shiftIdToShowRotationForm: shiftId, layerPriorityToShowRotationForm: layerPriority }); }; handleShowOverridesForm = (shiftId: Shift['id'] | 'new', shiftStart?: dayjs.Dayjs, shiftEnd?: dayjs.Dayjs) => { @@ -415,20 +532,35 @@ class _SchedulePage extends React.Component { const { store } = this.props; store.timezoneStore.setCalendarStartDate( - getStartOfWeekBasedOnCurrentDate(store.timezoneStore.currentDateInSelectedTimezone) + getCalendarStartDate(store.timezoneStore.currentDateInSelectedTimezone, store.scheduleStore.scheduleView) ); this.handleDateRangeUpdate(); }; handleLeftClick = () => { const { store } = this.props; - store.timezoneStore.setCalendarStartDate(store.timezoneStore.calendarStartDate.subtract(7, 'day')); + const { scheduleStore, timezoneStore } = store; + + const newCalendarStartDate = getNewCalendarStartDate( + timezoneStore.calendarStartDate, + scheduleStore.scheduleView, + 'prev' + ); + + store.timezoneStore.setCalendarStartDate(newCalendarStartDate); this.handleDateRangeUpdate(); }; handleRightClick = () => { const { store } = this.props; - store.timezoneStore.setCalendarStartDate(store.timezoneStore.calendarStartDate.add(7, 'day')); + const { scheduleStore, timezoneStore } = store; + const newCalendarStartDate = getNewCalendarStartDate( + timezoneStore.calendarStartDate, + scheduleStore.scheduleView, + 'next' + ); + + store.timezoneStore.setCalendarStartDate(newCalendarStartDate); this.handleDateRangeUpdate(); }; @@ -464,15 +596,37 @@ class _SchedulePage extends React.Component) => { - const { filters } = this.state; - + handleShowShiftSwapForm = (id: ShiftSwap['id'] | 'new') => { const { - store: { userStore }, + store, + match: { + params: { id: scheduleId }, + }, } = this.props; - if (!filters.users.includes(userStore.currentUserPk)) { - this.setState({ filters: { ...filters, users: [...this.state.filters.users, userStore.currentUserPk] } }); + const { + userStore: { currentUserPk }, + timezoneStore: { currentDateInSelectedTimezone }, + } = store; + + const layers = getLayersFromStore(store, scheduleId, store.timezoneStore.calendarStartDate); + + const { filters } = this.state; + + const closestEvent = findClosestUserEvent(dayjs(), currentUserPk, layers); + const swapStart = closestEvent + ? dayjs(closestEvent.start) + : currentDateInSelectedTimezone.startOf('day').add(1, 'day'); + + const swapEnd = closestEvent ? dayjs(closestEvent.end) : swapStart.add(1, 'day'); + + const params = { + swap_start: getUTCString(swapStart), + swap_end: getUTCString(swapEnd), + }; + + if (!filters.users.includes(currentUserPk)) { + this.setState({ filters: { ...filters, users: [...this.state.filters.users, currentUserPk] } }); this.highlightMyShiftsWasToggled = true; } diff --git a/grafana-plugin/src/pages/schedules/Schedules.styles.ts b/grafana-plugin/src/pages/schedules/Schedules.styles.ts index 7bbc2947f6..b2c9390650 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.styles.ts +++ b/grafana-plugin/src/pages/schedules/Schedules.styles.ts @@ -4,7 +4,13 @@ export const getSchedulesStyles = () => { return { schedule: css` position: relative; - margin: 20px 0; + `, + + table: css` + td { + padding-top: 5px; + padding-bottom: 5px; + } `, schedulePersonal: css` diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 40c47d01e9..7a16317d09 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -22,10 +22,9 @@ 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 { Schedule, ScheduleView } from 'models/schedule/schedule.types'; import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers'; import { WithStoreProps, PageProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; @@ -83,7 +82,10 @@ class _SchedulesPage extends React.Component Schedules
- + + View in timezone: + +