From 9f0064f21b00b3baadc9d0e15e0525977f7c234f Mon Sep 17 00:00:00 2001 From: Maxim Mordasov Date: Tue, 20 Jun 2023 16:18:56 +0300 Subject: [PATCH] Schedules 2nd iteration updates (#1720) # What this PR does Features and bugs related to [[Q1 2023] Iteration with Schedules](https://github.com/grafana/oncall-private/issues/1660) milestone ## Which issue(s) this PR fixes ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [ ] 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) --------- Co-authored-by: Innokentii Konstantinov Co-authored-by: Matias Bordese --- CHANGELOG.md | 9 + engine/apps/api/serializers/on_call_shifts.py | 11 +- ...llapse.module.css => Collapse.module.scss} | 7 + .../src/components/Collapse/Collapse.tsx | 4 +- .../src/components/GForm/GForm.module.scss | 3 + grafana-plugin/src/components/GForm/GForm.tsx | 22 +- .../src/components/GForm/GForm.types.ts | 1 + .../src/components/Modal/Modal.module.css | 3 +- grafana-plugin/src/components/Modal/Modal.tsx | 6 +- .../ScheduleFilters.module.scss | 3 + .../ScheduleFilters/ScheduleFilters.tsx | 50 + .../ScheduleFilters/ScheduleFilters.types.ts | 5 + .../ScheduleQuality/ScheduleQuality.tsx | 4 +- .../SchedulesFilters.helpers.ts | 25 - .../SchedulesFilters.module.scss | 13 - .../src/components/Text/Text.module.scss | 13 +- grafana-plugin/src/components/Text/Text.tsx | 11 +- ...s.module.css => TimelineMarks.module.scss} | 10 + .../TimelineMarks/TimelineMarks.tsx | 14 +- .../__snapshots__/Unauthorized.test.tsx.snap | 50 + .../UserGroups/UserGroups.helpers.ts | 2 +- .../UserGroups/UserGroups.module.css | 7 +- .../src/components/UserGroups/UserGroups.tsx | 123 +- .../UserTimezoneSelect/UserTimezoneSelect.tsx | 98 +- .../components/WorkingHours/WorkingHours.tsx | 61 +- .../RemoteFilters/RemoteFilters.helpers.ts | 2 +- .../containers/Rotation/Rotation.helpers.ts | 3 - .../src/containers/Rotation/Rotation.tsx | 45 +- .../RotationForm/DateTimePicker.tsx | 81 -- .../RotationForm/RotationForm.helpers.ts | 172 +++ .../RotationForm/RotationForm.module.css | 85 +- .../containers/RotationForm/RotationForm.tsx | 1005 +++++++++++------ .../RotationForm/RotationForm.types.ts | 8 + .../RotationForm/ScheduleOverrideForm.tsx | 145 ++- .../RotationForm/parts/DateTimePicker.tsx | 86 ++ .../RotationForm/parts/DaysSelector.tsx | 53 + .../RotationForm/parts/DeletionModal.tsx | 59 + .../RotationForm/parts/TimeUnitSelector.tsx | 45 + .../RotationForm/parts/UserItem.tsx | 57 + .../RotationForm/parts/WeekdayTimePicker.tsx | 84 ++ .../src/containers/Rotations/Rotations.tsx | 74 +- .../containers/Rotations/ScheduleFinal.tsx | 20 +- .../Rotations/ScheduleOverrides.tsx | 37 +- .../ScheduleForm/ScheduleForm.config.ts | 78 +- .../ScheduleSlot/ScheduleSlot.module.css | 33 +- .../containers/ScheduleSlot/ScheduleSlot.tsx | 152 +-- .../UsersTimezones/UsersTimezones.module.css | 51 +- .../UsersTimezones/UsersTimezones.tsx | 116 +- .../src/models/schedule/schedule.helpers.ts | 22 +- .../src/models/schedule/schedule.ts | 56 +- .../src/models/schedule/schedule.types.ts | 3 +- .../src/models/timezone/timezone.types.ts | 2 +- grafana-plugin/src/models/user/user.ts | 22 +- .../src/pages/schedule/Schedule.helpers.ts | 89 +- .../src/pages/schedule/Schedule.tsx | 40 +- .../src/pages/schedules/Schedules.tsx | 66 +- .../src/plugin/GrafanaPluginRootPage.tsx | 2 +- grafana-plugin/src/utils/consts.ts | 3 + grafana-plugin/src/utils/datetime.ts | 18 + 59 files changed, 2389 insertions(+), 980 deletions(-) rename grafana-plugin/src/components/Collapse/{Collapse.module.css => Collapse.module.scss} (73%) create mode 100644 grafana-plugin/src/components/GForm/GForm.module.scss create mode 100644 grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.module.scss create mode 100644 grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx create mode 100644 grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.types.ts delete mode 100644 grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.helpers.ts delete mode 100644 grafana-plugin/src/components/SchedulesFilters/SchedulesFilters.module.scss rename grafana-plugin/src/components/TimelineMarks/{TimelineMarks.module.css => TimelineMarks.module.scss} (81%) delete mode 100644 grafana-plugin/src/containers/Rotation/Rotation.helpers.ts delete mode 100644 grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx create mode 100644 grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts create mode 100644 grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx create mode 100644 grafana-plugin/src/containers/RotationForm/parts/DaysSelector.tsx create mode 100644 grafana-plugin/src/containers/RotationForm/parts/DeletionModal.tsx create mode 100644 grafana-plugin/src/containers/RotationForm/parts/TimeUnitSelector.tsx create mode 100644 grafana-plugin/src/containers/RotationForm/parts/UserItem.tsx create mode 100644 grafana-plugin/src/containers/RotationForm/parts/WeekdayTimePicker.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fb41dc..6f147340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Make it possible to completely delete a rotation oncall [1505](https://github.com/grafana/oncall/issues/1505) +- Polish rotation modal form oncall [1506](https://github.com/grafana/oncall/issues/1506) +- Quick actions when editing a schedule oncall [1507](https://github.com/grafana/oncall/issues/1507) +- Enable schedule related profile settings oncall [1508](https://github.com/grafana/oncall/issues/1508) +- Highlight user shifts oncall [1509](https://github.com/grafana/oncall/issues/1509) +- Rename or Description for Schedules Rotations [1460](https://github.com/grafana/oncall/issues/1406) + ## Changed - Change mobile shift notifications title and subtitle by @imtoori ([#2288](https://github.com/grafana/oncall/pull/2288)) diff --git a/engine/apps/api/serializers/on_call_shifts.py b/engine/apps/api/serializers/on_call_shifts.py index 98494d35..ddc4e0c9 100644 --- a/engine/apps/api/serializers/on_call_shifts.py +++ b/engine/apps/api/serializers/on_call_shifts.py @@ -67,6 +67,11 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer): def get_shift_end(self, obj): return obj.start + obj.duration + def to_representation(self, instance): + ret = super().to_representation(instance) + ret["week_start"] = CustomOnCallShift.ICAL_WEEKDAY_MAP[instance.week_start] + return ret + def to_internal_value(self, data): data["source"] = CustomOnCallShift.SOURCE_WEB if not data.get("shift_end"): @@ -75,11 +80,6 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer): result = super().to_internal_value(data) return result - def to_representation(self, instance): - ret = super().to_representation(instance) - ret["week_start"] = CustomOnCallShift.ICAL_WEEKDAY_MAP[instance.week_start] - return ret - def validate_by_day(self, by_day): if by_day: for day in by_day: @@ -198,7 +198,6 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer): def create(self, validated_data): validated_data = self._correct_validated_data(validated_data["type"], validated_data) - # before creation, require users set self._require_users(validated_data) instance = super().create(validated_data) diff --git a/grafana-plugin/src/components/Collapse/Collapse.module.css b/grafana-plugin/src/components/Collapse/Collapse.module.scss similarity index 73% rename from grafana-plugin/src/components/Collapse/Collapse.module.css rename to grafana-plugin/src/components/Collapse/Collapse.module.scss index 8e6eaa09..949524e2 100644 --- a/grafana-plugin/src/components/Collapse/Collapse.module.css +++ b/grafana-plugin/src/components/Collapse/Collapse.module.scss @@ -1,5 +1,6 @@ .root { border: var(--border); + width: 100%; } .header { @@ -25,4 +26,10 @@ .icon { color: var(--secondary-text-color); + transform-origin: center; + transition: transform 0.2s; + + &--rotated { + transform: rotate(90deg); + } } diff --git a/grafana-plugin/src/components/Collapse/Collapse.tsx b/grafana-plugin/src/components/Collapse/Collapse.tsx index 27644e0d..a86d5513 100644 --- a/grafana-plugin/src/components/Collapse/Collapse.tsx +++ b/grafana-plugin/src/components/Collapse/Collapse.tsx @@ -3,7 +3,7 @@ import React, { FC, useCallback, useState } from 'react'; import { Icon } from '@grafana/ui'; import cn from 'classnames/bind'; -import styles from 'components/Collapse/Collapse.module.css'; +import styles from 'components/Collapse/Collapse.module.scss'; export interface CollapseProps { label: React.ReactNode; @@ -48,7 +48,7 @@ const Collapse: FC = (props) => { onClick={onHeaderClickCallback} data-testid="test__toggle" > - +
{label}
{isOpen && ( diff --git a/grafana-plugin/src/components/GForm/GForm.module.scss b/grafana-plugin/src/components/GForm/GForm.module.scss new file mode 100644 index 00000000..3477cdb3 --- /dev/null +++ b/grafana-plugin/src/components/GForm/GForm.module.scss @@ -0,0 +1,3 @@ +.collapse { + margin-bottom: 16px; +} diff --git a/grafana-plugin/src/components/GForm/GForm.tsx b/grafana-plugin/src/components/GForm/GForm.tsx index 9ca6af54..4990c282 100644 --- a/grafana-plugin/src/components/GForm/GForm.tsx +++ b/grafana-plugin/src/components/GForm/GForm.tsx @@ -2,11 +2,17 @@ import React from 'react'; import { Field, Form, Input, InputControl, Select, Switch, TextArea } from '@grafana/ui'; import { capitalCase } from 'change-case'; +import cn from 'classnames/bind'; +import Collapse from 'components/Collapse/Collapse'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; import GSelect from 'containers/GSelect/GSelect'; import RemoteSelect from 'containers/RemoteSelect/RemoteSelect'; +import styles from './GForm.module.scss'; + +const cx = cn.bind(styles); + interface GFormProps { form: { name: string; fields: FormItem[] }; data: any; @@ -113,10 +119,13 @@ class GForm extends React.Component { render() { const { form, data } = this.props; + const openFields = form.fields.filter((field) => !field.collapsed); + const collapsedfields = form.fields.filter((field) => field.collapsed); + return (
{({ register, errors, control, getValues, setValue }) => { - return form.fields.map((formItem: FormItem, formIndex: number) => { + const renderField = (formItem: FormItem, formIndex: number) => { if (formItem.isVisible && !formItem.isVisible(getValues())) { setValue(formItem.name, undefined); // clear input value on hide return null; @@ -137,7 +146,16 @@ class GForm extends React.Component { })} ); - }); + }; + + return ( + <> + {openFields.map(renderField)} + + {collapsedfields.map(renderField)} + + + ); }} ); diff --git a/grafana-plugin/src/components/GForm/GForm.types.ts b/grafana-plugin/src/components/GForm/GForm.types.ts index 9f7dbf2f..a5adbc4f 100644 --- a/grafana-plugin/src/components/GForm/GForm.types.ts +++ b/grafana-plugin/src/components/GForm/GForm.types.ts @@ -22,4 +22,5 @@ export interface FormItem { validation?: (v: any) => boolean; }; extra?: any; + collapsed?: boolean; } diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css index 49fedd9a..81c41d19 100644 --- a/grafana-plugin/src/components/Modal/Modal.module.css +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -18,7 +18,8 @@ box-shadow: var(--shadows-z3); border-radius: 2px; z-index: 10; - overflow: scroll; + + /* overflow: scroll; */ } /* diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx index d5cf1e55..6fd0abf4 100644 --- a/grafana-plugin/src/components/Modal/Modal.tsx +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -17,12 +17,13 @@ export interface ModalProps { width: string; contentElement?: (props, children: React.ReactNode) => React.ReactNode; isOpen: boolean; + top?: string; } const cx = cn.bind(styles); const Modal: FC> = (props) => { - const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props; + const { title, children, onDismiss, width = '600px', contentElement, isOpen = true, top, className } = props; return ( > = (props) => { overlay: {}, content: { width, + top, }, }} isOpen={isOpen} onAfterOpen={() => {}} onRequestClose={onDismiss} contentLabel={title} - className={cx('root')} + className={cx('root', className)} overlayClassName={cx('overlay')} overlayElement={(_props, contentElement) => contentElement} // render without overlay to allow body scroll contentElement={contentElement} diff --git a/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.module.scss b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.module.scss new file mode 100644 index 00000000..63d08ecc --- /dev/null +++ b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.module.scss @@ -0,0 +1,3 @@ +.root { + display: block; +} diff --git a/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx new file mode 100644 index 00000000..f1be7bf3 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from 'react'; + +import { InlineSwitch } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import { User } from 'models/user/user.types'; + +import styles from './ScheduleFilters.module.scss'; +import { ScheduleFiltersType } from './ScheduleFilters.types'; + +const cx = cn.bind(styles); + +interface SchedulesFiltersProps { + value: ScheduleFiltersType; + currentUserPk: User['pk']; + onChange: (filters: ScheduleFiltersType) => void; +} + +const SchedulesFilters = (props: SchedulesFiltersProps) => { + const { value, currentUserPk, onChange } = props; + + const handleShowMyShiftsOnlyClick = useCallback( + (event: React.ChangeEvent) => { + const newUsers = [...value.users]; + + if (event.target.checked && !value.users.includes(currentUserPk)) { + newUsers.push(currentUserPk); + } else { + const index = value.users.findIndex((pk) => pk === currentUserPk); + newUsers.splice(index, 1); + } + + onChange({ ...value, users: newUsers }); + }, + [value] + ); + + return ( +
+ +
+ ); +}; + +export default SchedulesFilters; diff --git a/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.types.ts b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.types.ts new file mode 100644 index 00000000..160f8a20 --- /dev/null +++ b/grafana-plugin/src/components/ScheduleFilters/ScheduleFilters.types.ts @@ -0,0 +1,5 @@ +import { User } from 'models/user/user.types'; + +export interface ScheduleFiltersType { + users: Array; +} diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx index afbb3444..2c0c1040 100644 --- a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -41,7 +41,8 @@ const ScheduleQuality: FC = ({ schedule, lastUpdated }) =>
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && ( = ({ schedule, lastUpdated }) => {schedule.warnings?.length > 0 && ( { clearBeforeEdit?: boolean; hidden?: boolean; editModalTitle?: string; + maxWidth?: string; + clickable?: boolean; } interface TextInterface extends React.FC { @@ -52,6 +54,8 @@ const Text: TextInterface = (props) => { hidden = false, editModalTitle = 'New value', style, + maxWidth, + clickable, ...rest } = props; @@ -84,27 +88,28 @@ const Text: TextInterface = (props) => { 'root', 'text', { + 'with-maxWidth': Boolean(maxWidth), [`text--${type}`]: true, [`text--${size}`]: true, 'text--strong': strong, 'text--underline': underline, + 'text--clickable': clickable, 'no-wrap': !wrap, keyboard, }, className )} - style={style} + style={{ ...style, maxWidth }} {...rest} > {hidden ? PLACEHOLDER : children} {editable && ( )} {copyable && ( diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.scss similarity index 81% rename from grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css rename to grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.scss index ccae5a02..bd73516d 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.css +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.module.scss @@ -16,6 +16,16 @@ 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-title { diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index 63022c80..bd84c5fb 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -4,20 +4,23 @@ import cn from 'classnames/bind'; import dayjs from 'dayjs'; import Text from 'components/Text/Text'; +import { Timezone } from 'models/timezone/timezone.types'; +import { getNow } from 'pages/schedule/Schedule.helpers'; -import styles from './TimelineMarks.module.css'; +import styles from './TimelineMarks.module.scss'; interface TimelineMarksProps { startMoment: dayjs.Dayjs; + timezone: Timezone; debug?: boolean; } const cx = cn.bind(styles); const TimelineMarks: FC = (props) => { - const { startMoment, debug } = props; + const { startMoment, timezone, debug } = props; - const currentMoment = useMemo(() => dayjs(), []); + const currentMoment = useMemo(() => getNow(timezone), []); const momentsToRender = useMemo(() => { const hoursToSplit = 12; @@ -62,11 +65,14 @@ const TimelineMarks: FC = (props) => { ))} )} + {momentsToRender.map((m, i) => { const isCurrentDay = currentMoment.isSame(m.moment, 'day'); + // const isWeekend = m.moment.day() == 0 || m.moment.day() === 6; + return ( -
+
{m.moment.format('ddd D MMM')} diff --git a/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap b/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap index 30fa907f..cc8e393c 100644 --- a/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap +++ b/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap @@ -21,6 +21,11 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = ` > 403 @@ -34,6 +39,11 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = ` > You do not have access to view this page. @@ -69,6 +79,11 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = ` > 403 @@ -82,6 +97,11 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = ` > You do not have access to view this page. @@ -117,6 +137,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1 > 403 @@ -130,6 +155,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1 > You do not have access to view this page. @@ -165,6 +195,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor > 403 @@ -178,6 +213,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor > You do not have access to view this page. @@ -213,6 +253,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer > 403 @@ -226,6 +271,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer > You do not have access to view this page. diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts index 059b3c62..f17d937a 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts +++ b/grafana-plugin/src/components/UserGroups/UserGroups.helpers.ts @@ -6,7 +6,7 @@ export const toPlainArray = (groups: string[][]) => { items.push({ key: `group-${groupIndex}`, type: 'group', - data: { name: `Group ${groupIndex + 1}` }, + data: { name: `Recurrence group ${groupIndex + 1}` }, }); groups[groupIndex].forEach((item: string, itemIndex: number) => { diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index 1b49fb92..f8a4cf39 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -16,12 +16,15 @@ margin: 4px 0; display: flex; align-items: center; + justify-content: center; } .separator__clickable { cursor: pointer; + margin-top: 12px; } +/* .separator::before { display: block; content: ''; @@ -38,7 +41,7 @@ border-bottom: var(--border-medium); height: 0; margin-left: 5px; -} +} */ .groups { width: 100%; @@ -48,8 +51,6 @@ display: flex; flex-direction: column; gap: 1px; - max-height: 300px; - overflow: scroll; } .user { diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.tsx b/grafana-plugin/src/components/UserGroups/UserGroups.tsx index 70c46aa3..b0d4c6bb 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.tsx +++ b/grafana-plugin/src/components/UserGroups/UserGroups.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { VerticalGroup, HorizontalGroup, IconButton } from '@grafana/ui'; import { arrayMoveImmutable } from 'array-move'; @@ -21,6 +21,7 @@ interface UserGroupsProps { isMultipleGroups: boolean; renderUser: (id: string) => React.ReactElement; showError?: boolean; + disabled?: boolean; } const cx = cn.bind(styles); @@ -30,7 +31,9 @@ const DragHandle = () => { - const { value, onChange, isMultipleGroups, renderUser, showError } = props; + const { value, onChange, isMultipleGroups, renderUser, showError, disabled } = props; + + const rootRef = useRef(); const handleAddUserGroup = useCallback(() => { onChange([...value, []]); @@ -62,13 +65,16 @@ const UserGroups = (props: UserGroupsProps) => { const newGroups = [...value]; let lastGroup = newGroups[newGroups.length - 1]; - if (!lastGroup) { - lastGroup = []; - newGroups.push(lastGroup); + if (!isMultipleGroups || (lastGroup && !lastGroup.length)) { + if (!lastGroup) { + lastGroup = []; + newGroups.push(lastGroup); + } + lastGroup.push(pk); + } else { + newGroups.push([pk]); } - lastGroup.push(pk); - onChange(newGroups); }, [value] @@ -91,32 +97,47 @@ const UserGroups = (props: UserGroupsProps) => { }; }; + useEffect(() => { + const container = rootRef.current.parentElement.parentElement.parentElement; + const containerParent = container.parentElement; + + containerParent.scroll({ + left: 0, + top: container.scrollHeight, + behavior: 'smooth', + }); + }, [value]); + const renderItem = (item: Item, index: number) => (
  • {renderUser(item.data)} -
    - - - - -
    + {!disabled && ( +
    + + + + +
    + )}
  • ); return ( -
    +
    - + {!disabled && ( + + )} { handleDeleteItem={handleDeleteUser} isMultipleGroups={isMultipleGroups} useDragHandle + allowCreate={!disabled} />
    @@ -146,33 +168,36 @@ interface SortableListProps { handleDeleteItem: (index: number) => void; isMultipleGroups: boolean; renderItem: (item: Item, index: number) => React.ReactElement; + allowCreate?: boolean; } -const SortableList = SortableContainer(({ items, handleAddGroup, isMultipleGroups, renderItem }) => { - return ( -
      - {items.map((item, index) => - item.type === 'item' ? ( - - {renderItem(item, index)} - - ) : isMultipleGroups ? ( - -
    • - {item.data.name} +const SortableList = SortableContainer( + ({ items, handleAddGroup, isMultipleGroups, renderItem, allowCreate }) => { + return ( +
        + {items.map((item, index) => + item.type === 'item' ? ( + + {renderItem(item, index)} + + ) : isMultipleGroups ? ( + +
      • + {item.data.name} +
      • +
        + ) : null + )} + {allowCreate && isMultipleGroups && items[items.length - 1]?.type === 'item' && ( + +
      • + + Add user group
      • - ) : null - )} - {isMultipleGroups && items[items.length - 1]?.type === 'item' && ( - -
      • - Add user group + -
      • -
        - )} -
      - ); -}); + )} +
    + ); + } +); export default UserGroups; diff --git a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx index cfb69e06..3f4e1d60 100644 --- a/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/components/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -1,11 +1,12 @@ -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC, useCallback, useMemo, useState } from 'react'; +import { SelectableValue } from '@grafana/data'; import { Select } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { getTzOffsetString } from 'models/timezone/timezone.helpers'; -import { Timezone } from 'models/timezone/timezone.types'; +import { Timezone, tzs } from 'models/timezone/timezone.types'; import { User } from 'models/user/user.types'; import styles from './UserTimezoneSelect.module.css'; @@ -18,9 +19,27 @@ interface UserTimezoneSelectProps { const cx = cn.bind(styles); +interface TimezoneOption { + value: number; + utcOffset: number; + timezone: Timezone; + label: string; + description: string; +} + const UserTimezoneSelect: FC = (props) => { const { users, value: propValue, onChange } = props; + const [extraOptions, setExtraOptions] = useState([ + { + value: 0, + utcOffset: 0, + timezone: 'UTC' as Timezone, + label: 'GMT', + description: '', + }, + ]); + const options = useMemo(() => { return users .reduce( @@ -46,15 +65,7 @@ const UserTimezoneSelect: FC = (props) => { return memo; }, - [ - { - value: 0, - utcOffset: 0, - timezone: 'UTC' as Timezone, - label: 'GMT', - description: '', - }, - ] + [...extraOptions.map((option) => ({ ...option }))] ) .sort((a, b) => { if (b.utcOffset === 0) { @@ -70,7 +81,7 @@ const UserTimezoneSelect: FC = (props) => { return 0; }); - }, [users]); + }, [users, extraOptions]); const value = useMemo(() => { const utcOffset = dayjs().tz(propValue).utcOffset(); @@ -87,9 +98,70 @@ const UserTimezoneSelect: FC = (props) => { [options] ); + const filterOption = useCallback((item: SelectableValue, searchQuery: string) => { + const { data } = item; + + return ['label', 'description', 'timezone'].some((key: string) => { + if (data.__isNew_) { + return true; + } + if (!data[key]) { + console.log(item); + } + return data[key] && data[key].toLowerCase().includes(searchQuery.toLowerCase()); + }); + }, []); + + const handleCreateOption = useCallback( + (value: string) => { + const matched = tzs.find((tz) => tz.toLowerCase().includes(value.toLowerCase())); + if (matched) { + const now = dayjs().tz(matched); + const utcOffset = now.utcOffset(); + onChange(matched); + + if (options.some((option) => option.utcOffset === utcOffset)) { + return; + } + + setExtraOptions((extraOptions) => [ + ...extraOptions, + { + value: utcOffset, + utcOffset, + timezone: matched, + label: getTzOffsetString(now), + description: '', + }, + ]); + + onChange(matched); + } + }, + [options] + ); + return (
    - { + const matched = tzs.find((tz) => tz.toLowerCase().includes(input.toLowerCase())); + const now = dayjs().tz(matched); + if (matched) { + return `Select ${getTzOffsetString(now)} (${matched})`; + } else { + return `Not found`; + } + }} + />
    ); }; diff --git a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx index 13ef9bc3..effc7952 100644 --- a/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx +++ b/grafana-plugin/src/components/WorkingHours/WorkingHours.tsx @@ -16,55 +16,50 @@ interface WorkingHoursProps { startMoment: dayjs.Dayjs; duration: number; // in seconds className: string; - style?: React.CSSProperties; + strong?: boolean; } const cx = cn.bind(styles); const WorkingHours: FC = (props) => { - const { timezone, workingHours = default_working_hours, startMoment, duration, className, style } = props; + const { timezone, workingHours = default_working_hours, startMoment, duration, className, strong = false } = props; const endMoment = startMoment.add(duration, 'seconds'); - const workingMoments = useMemo( - () => getWorkingMoments(startMoment, endMoment, workingHours, timezone), - [startMoment, endMoment, workingHours, timezone] - ); + const workingMoments = useMemo(() => { + return getWorkingMoments(startMoment, endMoment, workingHours, timezone); + }, [startMoment, endMoment, workingHours, timezone]); - const nonWorkingMoments = useMemo( - () => getNonWorkingMoments(startMoment, endMoment, workingMoments), - [startMoment, endMoment, workingMoments] - ); + const nonWorkingMoments = useMemo(() => { + return getNonWorkingMoments(startMoment, endMoment, workingMoments); + }, [startMoment, endMoment, workingMoments]); return ( - + + + + - {nonWorkingMoments.map((moment, index) => { - const start = moment.start.diff(startMoment, 'seconds'); - const diff = moment.end.diff(moment.start, 'seconds'); - return ( - - ); - })} + {nonWorkingMoments && + nonWorkingMoments.map((moment, index) => { + const start = moment.start.diff(startMoment, 'seconds'); + const diff = moment.end.diff(moment.start, 'seconds'); + return ( + + ); + })} ); }; diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts index 9cc38b07..c66be54c 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts @@ -29,7 +29,7 @@ export function parseFilters( value = value.map(normalize); } else if (filterOption.type === 'daterange') { value = convertRelativeToAbsoluteDate(value); - } else if (rawValue === 'true') { + } else if ((filterOption.type === 'boolean' && rawValue === '') || rawValue === 'true') { value = true; } else if (rawValue === 'false') { value = false; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts b/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts deleted file mode 100644 index 04369117..00000000 --- a/grafana-plugin/src/containers/Rotation/Rotation.helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const getLabel = (layerIndex: number, rotationIndex) => { - return `L ${layerIndex + 1}-${rotationIndex + 1}`; -}; diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 66641862..75c8663b 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -3,12 +3,13 @@ import React, { FC, useMemo, useState } from 'react'; import { HorizontalGroup, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; +import hash from 'object-hash'; +import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot'; import { Schedule, Event, RotationFormLiveParams } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; -import { getLabel } from './Rotation.helpers'; import RotationTutorial from './RotationTutorial'; import styles from './Rotation.module.css'; @@ -23,37 +24,52 @@ interface RotationProps { rotationIndex?: number; color?: string; events: Event[]; - onClick?: (moment: dayjs.Dayjs) => void; + onClick?: (start: dayjs.Dayjs, end: dayjs.Dayjs) => void; + handleAddOverride?: (start: dayjs.Dayjs, end: dayjs.Dayjs) => void; days?: number; transparent?: boolean; tutorialParams?: RotationFormLiveParams; + simplified?: boolean; + filters?: ScheduleFiltersType; } const Rotation: FC = (props) => { const { events, scheduleId, - layerIndex, - rotationIndex, startMoment, currentTimezone, color, - onClick, days = 7, transparent = false, tutorialParams, + onClick, + handleAddOverride, + simplified, + filters, } = props; const [animate, _setAnimate] = useState(true); - const handleClick = (event) => { + 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); - onClick(startMoment.add(dayOffset, 'day')); + const shiftStart = startMoment.add(dayOffset, 'day'); + const shiftEnd = shiftStart.add(1, 'day'); + + onClick(shiftStart, shiftEnd); + }; + + const getAddOverrideClickHandler = (scheduleEvent: Event) => { + return (event: React.MouseEvent) => { + event.stopPropagation(); + + handleAddOverride(dayjs(scheduleEvent.start), dayjs(scheduleEvent.end)); + }; }; const x = useMemo(() => { @@ -68,13 +84,8 @@ const Rotation: FC = (props) => { return firstShiftOffset / base; }, [events]); - let eventIndexToShowLabel = -1; - if (!isNaN(layerIndex) && !isNaN(rotationIndex)) { - eventIndexToShowLabel = events.findIndex((event) => dayjs(event.start).isSameOrAfter(startMoment)); - } - return ( -
    +
    {tutorialParams && } {events ? ( @@ -83,16 +94,18 @@ const Rotation: FC = (props) => { className={cx('slots', { slots__animate: animate, slots__transparent: transparent })} style={{ transform: `translate(${x * 100}%, 0)` }} > - {events.map((event, index) => { + {events.map((event) => { return ( ); })} diff --git a/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx deleted file mode 100644 index a2d6d7ee..00000000 --- a/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; - -import { DateTime, dateTime } from '@grafana/data'; -import { DatePickerWithInput, HorizontalGroup, TimeOfDayPicker } from '@grafana/ui'; -import dayjs from 'dayjs'; - -import { Timezone } from 'models/timezone/timezone.types'; - -interface UserTooltipProps { - value: dayjs.Dayjs; - timezone: Timezone; - onChange: (value: dayjs.Dayjs) => void; - disabled?: boolean; - minMoment?: dayjs.Dayjs; - onFocus?: () => void; - onBlur?: () => void; -} - -const toDate = (moment: dayjs.Dayjs, timezone: Timezone) => { - const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? moment : moment.tz(timezone); - - return new Date( - localMoment.get('year'), - localMoment.get('month'), - localMoment.get('date'), - localMoment.get('hour'), - localMoment.get('minute'), - localMoment.get('second') - ); -}; - -const DateTimePicker = (props: UserTooltipProps) => { - const { value: propValue, minMoment, timezone, onChange, disabled, onFocus, onBlur } = props; - - const value = useMemo(() => toDate(propValue, timezone), [propValue, timezone]); - - const minDate = useMemo(() => (minMoment ? toDate(minMoment, timezone) : undefined), [minMoment, timezone]); - - const handleDateChange = (newDate: Date) => { - const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); - - 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()); - - onChange(newValue); - }; - const handleTimeChange = useCallback( - (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()); - - onChange(newValue); - }, - [value] - ); - - return ( - -
    - -
    -
    - -
    -
    - ); -}; - -export default DateTimePicker; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts new file mode 100644 index 00000000..d775b94b --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts @@ -0,0 +1,172 @@ +import dayjs, { ManipulateType } from 'dayjs'; + +import { Timezone } from 'models/timezone/timezone.types'; + +import { RepeatEveryPeriod } from './RotationForm.types'; + +export const getRepeatShiftsEveryOptions = (repeatEveryPeriod: number) => { + const count = repeatEveryPeriod === RepeatEveryPeriod.HOURS ? 24 : 30; + return Array.from(Array(count + 1).keys()) + .slice(1) + .map((i) => ({ label: String(i), value: i })); +}; + +export const toDate = (moment: dayjs.Dayjs, timezone: Timezone) => { + const localMoment = moment.tz(timezone); + + return new Date( + localMoment.get('year'), + localMoment.get('month'), + localMoment.get('date'), + localMoment.get('hour'), + localMoment.get('minute'), + localMoment.get('second') + ); +}; + +export interface TimeUnit { + unit: RepeatEveryPeriod; + value: number; + maxValue: number; +} + +export const repeatEveryPeriodMultiplier = { + [RepeatEveryPeriod.MONTHS]: 60 * 60 * 24 * 30, + [RepeatEveryPeriod.WEEKS]: 60 * 60 * 24 * 7, + [RepeatEveryPeriod.DAYS]: 60 * 60 * 24, + [RepeatEveryPeriod.HOURS]: 60 * 60, + [RepeatEveryPeriod.MINUTES]: 60, +}; + +export const repeatEveryPeriodToNextPeriodCount = { + [RepeatEveryPeriod.WEEKS]: Number.MAX_SAFE_INTEGER, + [RepeatEveryPeriod.DAYS]: 7, + [RepeatEveryPeriod.HOURS]: 24, + [RepeatEveryPeriod.MINUTES]: 60, +}; + +export const TIME_UNITS_ORDER = [ + RepeatEveryPeriod.WEEKS, + RepeatEveryPeriod.DAYS, + RepeatEveryPeriod.HOURS, + RepeatEveryPeriod.MINUTES, +]; + +export const repeatEveryPeriodToUnitName: { [key: number]: ManipulateType } = { + [RepeatEveryPeriod.DAYS]: 'days', + [RepeatEveryPeriod.HOURS]: 'hours', + [RepeatEveryPeriod.WEEKS]: 'weeks', + [RepeatEveryPeriod.MONTHS]: 'months', + [RepeatEveryPeriod.MINUTES]: 'minutes', +}; + +export const repeatEveryPeriodToUnitNameShortened = { + [RepeatEveryPeriod.DAYS]: 'd', + [RepeatEveryPeriod.HOURS]: 'h', + [RepeatEveryPeriod.WEEKS]: 'w', + [RepeatEveryPeriod.MONTHS]: 'mo', + [RepeatEveryPeriod.MINUTES]: 'm', +}; + +export const repeatEveryToTimeUnits = (repeatEveryPeriod: RepeatEveryPeriod, repetEveryValue: number) => { + const seconds = repeatEveryInSeconds(repeatEveryPeriod, repetEveryValue); + + return secondsToTimeUnits(seconds, repeatEveryPeriod); +}; + +export const secondsToTimeUnits = (seconds: number, repeatEveryPeriod: RepeatEveryPeriod) => { + const currentIndex = TIME_UNITS_ORDER.indexOf(repeatEveryPeriod); + + const timeUnits = []; + for (let i = currentIndex; i < TIME_UNITS_ORDER.length; i++) { + const unit = TIME_UNITS_ORDER[i]; + const value = Math.floor(seconds / repeatEveryPeriodMultiplier[unit]); + timeUnits.push({ unit, value, maxValue: value }); + seconds -= value * repeatEveryPeriodMultiplier[unit]; + } + + function cropStart(timeUnits: TimeUnit[]) { + const newTimeUnits = []; + + let fillStarted = false; + for (let i = 0; i < timeUnits.length; i++) { + const timeUnit = timeUnits[i]; + if (timeUnit.value === 0 && !fillStarted) { + continue; + } + fillStarted = true; + newTimeUnits.push(timeUnit); + } + + return newTimeUnits; + } + + function cropEnd(timeUnits: TimeUnit[]) { + const newTimeUnits = []; + + let fillStarted = false; + for (let i = timeUnits.length - 1; i >= 0; i--) { + const timeUnit = timeUnits[i]; + if (timeUnit.value === 0 && !fillStarted) { + continue; + } + fillStarted = true; + newTimeUnits.unshift(timeUnit); + } + + return newTimeUnits; + } + + return cropEnd(cropStart(timeUnits)); +}; + +export const putDownMaxValues = ( + timeUnits: TimeUnit[], + repeatEveryPeriod: RepeatEveryPeriod, + repeatEveryValue: number +) => { + for (let i = 0; i < timeUnits.length; i++) { + const timeUnit = timeUnits[i]; + if (repeatEveryPeriod === timeUnit.unit) { + timeUnit.maxValue = repeatEveryValue - 1; + } else { + timeUnit.maxValue = repeatEveryPeriodToNextPeriodCount[timeUnit.unit] - 1; + } + } + return timeUnits; +}; + +export const shiftToLower = (timeUnits: TimeUnit[]) => { + if (timeUnits.length === 1 && timeUnits[0].value === 1) { + const timeUnit = timeUnits[0]; + const currentIndex = TIME_UNITS_ORDER.indexOf(timeUnit.unit); + const nextIndex = currentIndex + 1; + + if (TIME_UNITS_ORDER[nextIndex] !== undefined) { + timeUnit.unit = TIME_UNITS_ORDER[nextIndex]; + timeUnit.value = repeatEveryPeriodToNextPeriodCount[timeUnit.unit]; + timeUnit.maxValue = timeUnit.value; + } + } + return timeUnits; +}; + +export const reduceTheLastUnitValue = (timeUnits: TimeUnit[]) => { + if (timeUnits.length) { + timeUnits[timeUnits.length - 1].value = Math.floor(timeUnits[timeUnits.length - 1].maxValue / 2); + timeUnits[timeUnits.length - 1].maxValue--; + } + + return timeUnits; +}; + +export const timeUnitsToSeconds = (units: TimeUnit[]) => + units.reduce((memo, unit) => { + memo += repeatEveryPeriodMultiplier[unit.unit] * unit.value; + + return memo; + }, 0); + +export const repeatEveryInSeconds = (repeatEveryPeriod: RepeatEveryPeriod, repeatEveryValue: number) => { + return repeatEveryPeriodMultiplier[repeatEveryPeriod] * repeatEveryValue; +}; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css index f0386707..803aad27 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css @@ -1,40 +1,54 @@ -.root { - display: block; +.body { + max-height: calc(100vh - 300px); + overflow: scroll; + margin: 15px -15px; + padding: 15px 0; + border-top: var(--border-medium); + border-bottom: var(--border-medium); } -.title { - background: var(--background-primary); - top: -15px; - position: sticky; - margin: -15px -15px 0 -15px; - padding: 15px; - z-index: 10; +.content { + padding: 0 15px; } -.draggable { - top: 10%; - position: absolute; - - /* transition: transform 300ms ease; */ +.override-form-content { + padding: 15px 0; } -.header { - width: 100%; +.two-fields { display: flex; - justify-content: space-between; + gap: 8px; + align-items: flex-start; + width: 100%; } -.control { - width: 195px; +.two-fields > div { + width: 50%; +} + +.inline-switch { + height: 18px; +} + +.active-periods { + width: 100%; +} + +.active-periods-content { + padding-top: 8px; +} + +.time-unit { + width: 200px; } .user-title { padding: 6px 10px; - z-index: 1; color: #fff; width: 330px; overflow: hidden; white-space: nowrap; + position: relative; } .working-hours { @@ -45,16 +59,16 @@ pointer-events: none; } -.inline-switch { - height: 18px; -} - .days { display: flex; gap: 14px; width: 100%; } +.days_disabled { + pointer-events: none; +} + .day { width: 28px; height: 28px; @@ -69,20 +83,15 @@ background: #3d71d9; } -.two-fields { - display: flex; - gap: 8px; - align-items: flex-start; -} - -.two-fields > div { - width: 50%; -} - -.content { - margin: 8px 0 16px 0; -} - .confirmation-modal { width: 500px; } + +.control--error { + border: 1px solid var(--error-text-color); +} + +.updated-shift-info { + margin-bottom: 10px; + width: 100%; +} diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index d4f671a0..df9ac68b 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -1,46 +1,73 @@ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - IconButton, - VerticalGroup, - HorizontalGroup, - Field, Button, - Select, + Field, + HorizontalGroup, + Icon, + IconButton, InlineSwitch, - Modal as GrafanaModal, + Select, + Switch, + Tooltip, + VerticalGroup, } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; import Draggable from 'react-draggable'; +import Block from 'components/GBlock/Block'; import Modal from 'components/Modal/Modal'; +import Tag from 'components/Tag/Tag'; import Text from 'components/Text/Text'; import UserGroups from 'components/UserGroups/UserGroups'; -import WorkingHours from 'components/WorkingHours/WorkingHours'; import RemoteSelect from 'containers/RemoteSelect/RemoteSelect'; -import { getFromString } from 'models/schedule/schedule.helpers'; +import { + getRepeatShiftsEveryOptions, + putDownMaxValues, + reduceTheLastUnitValue, + repeatEveryInSeconds, + repeatEveryPeriodMultiplier, + repeatEveryPeriodToNextPeriodCount, + repeatEveryPeriodToUnitName, + repeatEveryPeriodToUnitNameShortened, + repeatEveryToTimeUnits, + secondsToTimeUnits, + shiftToLower, + TimeUnit, + timeUnitsToSeconds, + TIME_UNITS_ORDER, +} from 'containers/RotationForm/RotationForm.helpers'; +import { RepeatEveryPeriod } from 'containers/RotationForm/RotationForm.types'; +import DateTimePicker from 'containers/RotationForm/parts/DateTimePicker'; +import DaysSelector from 'containers/RotationForm/parts/DaysSelector'; +import DeletionModal from 'containers/RotationForm/parts/DeletionModal'; +import TimeUnitSelector from 'containers/RotationForm/parts/TimeUnitSelector'; +import UserItem from 'containers/RotationForm/parts/UserItem'; +import { 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, getUTCByDay, getUTCString, getUTCWeekStart, + getWeekStartString, } from 'pages/schedule/Schedule.helpers'; -import { SelectOption } from 'state/types'; import { useStore } from 'state/useStore'; import { getCoords, waitForElement } from 'utils/DOM'; +import { GRAFANA_HEADER_HEIGTH } from 'utils/consts'; import { useDebouncedCallback } from 'utils/hooks'; -import DateTimePicker from './DateTimePicker'; - import styles from './RotationForm.module.css'; +const cx = cn.bind(styles); + interface RotationFormProps { layerPriority: number; onHide: () => void; @@ -48,20 +75,16 @@ interface RotationFormProps { currentTimezone: Timezone; scheduleId: Schedule['id']; shiftId: Shift['id'] | 'new'; - shiftMoment?: dayjs.Dayjs; + shiftStart?: dayjs.Dayjs; + shiftEnd?: dayjs.Dayjs; onCreate: () => void; onUpdate: () => void; onDelete: () => void; shiftColor?: string; + onShowRotationForm: (shiftId: Shift['id']) => void; } -const cx = cn.bind(styles); - -const repeatShiftsEveryOptions = Array.from(Array(31).keys()) - .slice(1) - .map((i) => ({ label: String(i), value: i })); - -const RotationForm: FC = observer((props) => { +const RotationForm = observer((props: RotationFormProps) => { const { onHide, onCreate, @@ -72,40 +95,53 @@ const RotationForm: FC = observer((props) => { onDelete, layerPriority, shiftId, - shiftMoment = getStartOfWeek(currentTimezone), + shiftStart: propsShiftStart = getStartOfWeek(currentTimezone), + shiftEnd: propsShiftEnd, shiftColor = '#3D71D9', + onShowRotationForm, } = props; - const [isOpen, setIsOpen] = useState(false); - const [offsetTop, setOffsetTop] = useState(0); - const [repeatEveryValue, setRepeatEveryValue] = useState(1); - const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(0); - const [selectedDays, setSelectedDays] = useState([]); - const [shiftStart, setShiftStart] = useState(shiftMoment); - const [shiftEnd, setShiftEnd] = useState(shiftMoment.add(1, 'day')); - const [rotationStart, setRotationStart] = useState(shiftMoment); - const [endLess, setEndless] = useState(true); - const [rotationEnd, setRotationEnd] = useState(shiftMoment.add(1, 'month')); - const [showDeleteRotationConfirmation, setShowDeleteRotationConfirmation] = useState(false); - const store = useStore(); const shift = store.scheduleStore.shifts[shiftId]; + const [errors, setErrors] = useState<{ [key: string]: string[] }>({}); + + const [rotationName, setRotationName] = useState(`[L${layerPriority}] Rotation`); + const [isOpen, setIsOpen] = useState(false); + const [offsetTop, setOffsetTop] = useState(0); + + const [shiftStart, setShiftStart] = useState(propsShiftStart); + const [shiftEnd, setShiftEnd] = useState(propsShiftEnd || shiftStart.add(1, 'day')); + const [activePeriod, setActivePeriod] = useState(undefined); + const [shiftPeriodDefaultValue, setShiftPeriodDefaultValue] = useState(undefined); + + const [rotationStart, setRotationStart] = useState(shiftStart); + const [endLess, setEndless] = useState(true); + const [rotationEnd, setRotationEnd] = useState(shiftStart.add(1, 'month')); + + const [repeatEveryValue, setRepeatEveryValue] = useState(1); + const [repeatEveryPeriod, setRepeatEveryPeriod] = useState(RepeatEveryPeriod.DAYS); + + const [showActiveOnSelectedDays, setShowActiveOnSelectedDays] = useState(false); + const [showActiveOnSelectedPartOfDay, setShowActiveOnSelectedPartOfDay] = useState(false); + + const [selectedDays, setSelectedDays] = useState([]); + + const [userGroups, setUserGroups] = useState([]); + + const [showDeleteRotationConfirmation, setShowDeleteRotationConfirmation] = useState(false); + useEffect(() => { if (rotationStart.isBefore(shiftStart)) { setRotationStart(shiftStart); } }, [rotationStart, shiftStart]); - const updateShiftStart = useCallback( - (value) => { - const diff = shiftEnd.diff(shiftStart); - - setShiftStart(value); - setShiftEnd(value.add(diff)); - }, - [shiftStart, shiftEnd] - ); + useEffect(() => { + if (!showActiveOnSelectedDays) { + setSelectedDays([]); + } + }, [showActiveOnSelectedDays]); useEffect(() => { if (isOpen) { @@ -113,9 +149,9 @@ const RotationForm: FC = observer((props) => { const modal = document.querySelector(`.${cx('draggable')}`) as HTMLDivElement; const coords = getCoords(elm); - const offsetTop = Math.min( - Math.max(coords.top - modal?.offsetHeight - 10, 10), - document.body.offsetHeight - modal?.offsetHeight - 10 + const offsetTop = Math.max( + Math.min(coords.top - modal?.offsetHeight - 10, document.body.offsetHeight - modal?.offsetHeight - 10), + GRAFANA_HEADER_HEIGTH + 10 ); setOffsetTop(offsetTop); @@ -123,33 +159,15 @@ const RotationForm: FC = observer((props) => { } }, [isOpen]); - const [userGroups, setUserGroups] = useState([[]]); + const handleChangeEndless = useCallback( + (event: React.ChangeEvent) => { + setEndless(!event.currentTarget.checked); + }, + [endLess] + ); - const renderUser = (userPk: User['pk']) => { - const name = store.userStore.items[userPk]?.username; - const desc = store.userStore.items[userPk]?.timezone; - const workingHours = store.userStore.items[userPk]?.working_hours; - const timezone = store.userStore.items[userPk]?.timezone; - - return ( - <> -
    - {name} ({desc}) -
    - - - ); - }; - - const handleDeleteClick = useCallback(() => { - store.scheduleStore.deleteOncallShift(shiftId).then(() => { + const handleDeleteClick = useCallback((force: boolean) => { + store.scheduleStore.deleteOncallShift(shiftId, force).then(() => { onDelete(); }); }, []); @@ -160,6 +178,29 @@ const RotationForm: FC = observer((props) => { } }, [shiftId]); + useEffect(() => { + if (shiftId === 'new') { + updatePreview(); + } + }, []); + + const updatePreview = () => { + setErrors({}); + + store.scheduleStore + .updateRotationPreview(scheduleId, shiftId, startMoment, false, params) + .catch(onError) + .finally(() => { + setIsOpen(true); + }); + }; + + const onError = useCallback((error) => { + setErrors(error.response.data); + }, []); + + const handleChange = useDebouncedCallback(updatePreview, 200); + const params = useMemo( () => ({ rotation_start: getUTCString(rotationStart), @@ -169,9 +210,10 @@ const RotationForm: FC = observer((props) => { rolling_users: userGroups, interval: repeatEveryValue, frequency: repeatEveryPeriod, - by_day: getUTCByDay(store.scheduleStore.byDayOptions, selectedDays, shiftStart), - week_start: getUTCWeekStart(store.scheduleStore.byDayOptions, shiftStart), + by_day: getUTCByDay(store.scheduleStore.byDayOptions, selectedDays, shiftStart.tz(currentTimezone)), + week_start: getUTCWeekStart(store.scheduleStore.byDayOptions, shiftStart.tz(currentTimezone)), priority_level: shiftId === 'new' ? layerPriority : shift?.priority_level, + name: rotationName, }), [ rotationStart, @@ -187,311 +229,606 @@ const RotationForm: FC = observer((props) => { layerPriority, shift, endLess, + rotationName, ] ); - const handleCreate = useCallback(() => { - if (shiftId === 'new') { - store.scheduleStore.createRotation(scheduleId, false, params).then(() => { + useEffect(handleChange, [params, startMoment]); + + const create = useCallback(() => { + store.scheduleStore + .createRotation(scheduleId, false, { ...params, name: rotationName }) + .then(() => { onCreate(); - }); - } else { - store.scheduleStore.updateRotation(shiftId, params).then(() => { - onUpdate(); - }); - } + }) + .catch(onError); }, [scheduleId, shiftId, params]); - useEffect(() => { - if (shiftId === 'new') { - updatePreview(); - } - }, []); - - const updatePreview = () => { + const update = useCallback(() => { store.scheduleStore - .updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), false, params) - .finally(() => { - setIsOpen(true); - }); - }; + .updateRotation(shiftId, params) + .then(() => { + onUpdate(); + }) + .catch(onError); + }, [shiftId, params]); - const handleChange = useDebouncedCallback(updatePreview, 200); + const updateAsNew = useCallback(() => { + store.scheduleStore + .updateRotationAsNew(shiftId, params) + .then(() => { + onUpdate(); + }) + .catch(onError); + }, [shiftId, params]); - useEffect(handleChange, [params]); + const handleEditNewerRotationClick = useCallback(() => { + onShowRotationForm(shift.updated_shift); + }, [shift?.updated_shift]); + + const handleRepeatEveryPeriodChange = useCallback( + (value) => { + setShiftPeriodDefaultValue(undefined); + + setRepeatEveryPeriod(value); + + if (!showActiveOnSelectedPartOfDay) { + if (showActiveOnSelectedDays) { + setShiftEnd(shiftStart.add(24, 'hours')); + } else { + setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[value])); + } + } + }, + [showActiveOnSelectedPartOfDay, showActiveOnSelectedDays, repeatEveryValue] + ); + + const handleRepeatEveryValueChange = useCallback( + (option) => { + const value = Math.floor(Number(option.value)); + if (isNaN(value) || value < 1) { + return; + } + + setShiftPeriodDefaultValue(undefined); + setRepeatEveryValue(value); + + if (!showActiveOnSelectedPartOfDay) { + setShiftEnd(shiftStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + } + }, + [showActiveOnSelectedPartOfDay, repeatEveryPeriod] + ); + + const handleRotationStartChange = useCallback( + (value) => { + setRotationStart(value); + setShiftStart(value); + if (showActiveOnSelectedPartOfDay) { + setShiftEnd(value.add(activePeriod, 'seconds')); + } else { + setShiftEnd(value.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + } + }, + [showActiveOnSelectedPartOfDay, activePeriod, repeatEveryPeriod, repeatEveryValue] + ); + + const handleActivePeriodChange = useCallback( + (value) => { + setActivePeriod(value); + setShiftEnd(shiftStart.add(value, 'seconds')); + }, + [shiftStart] + ); + + const handleRotationNameChange = useCallback( + (name: string) => { + setRotationName(name); + }, + [shiftId, params, shift] + ); + + const handleShowActiveOnSelectedDaysToggle = useCallback( + (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + + setShowActiveOnSelectedDays(value); + + if (value) { + setShiftEnd(shiftStart.add(24, 'hours')); + } else { + if (!showActiveOnSelectedPartOfDay) { + setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + } + } + }, + [showActiveOnSelectedPartOfDay, shiftStart, repeatEveryValue, repeatEveryPeriod] + ); + + const handleShowActiveOnSelectedPartOfDayToggle = useCallback( + (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + setShowActiveOnSelectedPartOfDay(value); + + if (!value) { + if (showActiveOnSelectedPartOfDay) { + setShiftEnd(shiftStart.add(24, 'hours')); + } else { + setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + } + } + }, + [shiftStart, repeatEveryPeriod, repeatEveryValue, showActiveOnSelectedPartOfDay] + ); + + useEffect(() => { + if (repeatEveryPeriod === RepeatEveryPeriod.MONTHS) { + setShowActiveOnSelectedPartOfDay(false); + } + }, [repeatEveryPeriod]); useEffect(() => { if (shift) { - setRotationStart(getDateTime(shift.rotation_start)); + setRotationName(getShiftName(shift)); + const rotationStart = getDateTime(shift.rotation_start); + setRotationStart(rotationStart); setRotationEnd(shift.until ? getDateTime(shift.until) : getDateTime(shift.shift_start).add(1, 'month')); - setShiftStart(getDateTime(shift.shift_start)); - setShiftEnd(getDateTime(shift.shift_end)); + const shiftStart = getDateTime(shift.shift_start); + setShiftStart(shiftStart); + const shiftEnd = getDateTime(shift.shift_end); + setShiftEnd(shiftEnd); setEndless(!shift.until); setRepeatEveryValue(shift.interval); setRepeatEveryPeriod(shift.frequency); - setSelectedDays(shift.by_day || []); + setSelectedDays(getSelectedDays(store.scheduleStore.byDayOptions, shift.by_day, shiftStart.tz(currentTimezone))); + + setShowActiveOnSelectedDays(Boolean(shift.by_day?.length)); + + const activeOnSelectedPartOfDay = + repeatEveryInSeconds(shift.frequency, shift.interval) !== shiftEnd.diff(shiftStart, 'seconds'); + + setShowActiveOnSelectedPartOfDay(activeOnSelectedPartOfDay); + if (activeOnSelectedPartOfDay) { + const activePeriod = shiftEnd.diff(shiftStart, 'seconds'); + + setActivePeriod(activePeriod); + setShiftPeriodDefaultValue(activePeriod); + } setUserGroups(shift.rolling_users); } }, [shift]); - const handleChangeEndless = useCallback( - (event: React.ChangeEvent) => { - setEndless(!event.currentTarget.checked); - }, - [endLess] - ); - - const handleRepeatEveryValueChange = useCallback((option) => { - setRepeatEveryValue(option.value); - }, []); - - const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]); - const disableAction = !endLess && rotationEnd.isBefore(dayjs().tz(currentTimezone)); - - const [focusElementName, setFocusElementName] = useState(undefined); - - const getFocusHandler = (elementName: string) => { - return () => { - setFocusElementName(elementName); - }; - }; - - const handleBlur = useCallback(() => { - setFocusElementName(undefined); - }, []); - useEffect(() => { - store.scheduleStore.setRotationFormLiveParams({ - rotationStart, - shiftStart, - shiftEnd, - focusElementName, - }); - }, [params, focusElementName]); + if (shift) { + setSelectedDays(getSelectedDays(store.scheduleStore.byDayOptions, shift.by_day, shiftStart.tz(currentTimezone))); + } + }, [currentTimezone]); + + const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]); + + const hasUpdatedShift = shift && shift.updated_shift; + const ended = shift && shift.until && getDateTime(shift.until).isBefore(dayjs()); + + const disabled = hasUpdatedShift || ended; return ( - ( - -
    {children}
    -
    - )} - > - <> -
    - - + <> + ( + +
    {children}
    +
    + )} + > +
    +
    + - [L{shiftId === 'new' ? layerPriority : shift?.priority_level}] - {shiftId === 'new' ? 'New Rotation' : 'Update Rotation'} + {shiftId === 'new' && New} + + {rotationName} + - - - {shiftId !== 'new' && ( - setShowDeleteRotationConfirmation(true)} - /> - )} - - - -
    - -
    - -
    - - Rotation start - - } - > - - - - - Rotation end - - - - } - > - {endLess ? ( -
    - Endless -
    - ) : ( - - )} -
    -
    - - + + + + +
    + + + + + + Mask by weekdays + {showActiveOnSelectedDays && ( + + )} + + - - - + + + + Limit each shift length + {showActiveOnSelectedPartOfDay && ( + + )} + {showActiveOnSelectedDays && ( + + Since masking by weekdays is enabled shift length is limited to 24h and shift will repeat + every day + + )} + + + + +
    + + Users + + + + +
    + ( + + )} + showError={Boolean(errors.rolling_users)} + /> +
    +
    +
    +
    + + Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + + {shiftId !== 'new' && ( + + + + )} + {shiftId === 'new' ? ( + + ) : ( + + + + )} + - - +
    +
    + + {showDeleteRotationConfirmation && ( + setShowDeleteRotationConfirmation(false)} onConfirm={handleDeleteClick} /> )} - + ); }); -interface DaysSelectorProps { - value: string[]; - onChange: (value: string[]) => void; - options: SelectOption[]; +interface ShiftPeriodProps { + repeatEveryPeriod: number; + repeatEveryValue: number; + defaultValue: number; + shiftStart: dayjs.Dayjs; + onChange: (value: number) => void; + currentTimezone: Timezone; + disabled: boolean; + errors: any; } -const DaysSelector = ({ value, onChange, options }: DaysSelectorProps) => { - const getDayClickHandler = (day: string) => { - return () => { - const newValue = [...value]; - if (newValue.includes(day)) { - const index = newValue.indexOf(day); - newValue.splice(index, 1); - } else { - newValue.push(day); - } - onChange(newValue); +const ShiftPeriod = ({ + repeatEveryPeriod, + repeatEveryValue, + defaultValue, + onChange, + errors, + disabled, +}: ShiftPeriodProps) => { + const [timeUnits, setTimeUnits] = useState([]); + + useEffect(() => { + if (defaultValue === undefined) { + setTimeUnits(reduceTheLastUnitValue(shiftToLower(repeatEveryToTimeUnits(repeatEveryPeriod, repeatEveryValue)))); + } else { + setTimeUnits( + putDownMaxValues(secondsToTimeUnits(defaultValue, repeatEveryPeriod), repeatEveryPeriod, repeatEveryValue) + ); + } + }, [repeatEveryPeriod, repeatEveryValue]); + + useEffect(() => { + onChange(timeUnitsToSeconds(timeUnits)); + }, [timeUnits]); + + const getTimeUnitChangeHandler = (unit: RepeatEveryPeriod) => { + return (value) => { + const newTimeUnits = [...timeUnits]; + + const timeUnit = newTimeUnits.find((timeUnit) => timeUnit.unit === unit); + timeUnit.value = value; + + setTimeUnits(newTimeUnits); }; }; + const duration = useMemo( + () => + timeUnits + .map((timeUnit) => { + return timeUnit.value + repeatEveryPeriodToUnitNameShortened[timeUnit.unit]; + }) + .join(''), + [timeUnits] + ); + + const getTimeUnitDeleteHandler = (unit: RepeatEveryPeriod) => { + return () => { + const newTimeUnits = [...timeUnits]; + + const timeUnitIndex = newTimeUnits.findIndex((timeUnit) => timeUnit.unit === unit); + newTimeUnits.splice(timeUnitIndex, 1); + + setTimeUnits(newTimeUnits); + }; + }; + + const unitToCreate = useMemo(() => { + if (!timeUnits.length) { + return reduceTheLastUnitValue(shiftToLower(repeatEveryToTimeUnits(repeatEveryPeriod, repeatEveryValue)))[0]; + } + + const minIndex = TIME_UNITS_ORDER.findIndex((tu) => tu === repeatEveryPeriod); + + const lastTimeUnit = timeUnits[timeUnits.length - 1]; + const currentIndex = lastTimeUnit ? TIME_UNITS_ORDER.findIndex((tu) => tu === lastTimeUnit.unit) : -1; + + const unit = TIME_UNITS_ORDER[Math.max(minIndex, currentIndex + 1)]; + + if (unit === undefined) { + return undefined; + } + + const maxValue = Math.min( + Math.floor( + (repeatEveryInSeconds(repeatEveryPeriod, repeatEveryValue) - timeUnitsToSeconds(timeUnits)) / + repeatEveryPeriodMultiplier[unit] + ), + repeatEveryPeriodToNextPeriodCount[unit] + ); + + if (maxValue === 0) { + return undefined; + } + + return { unit, value: 1, maxValue: maxValue - 1 }; + }, [timeUnits]); + + const handleTimeUnitAdd = useCallback(() => { + const newTimeUnits = [...timeUnits, unitToCreate]; + + setTimeUnits(newTimeUnits); + }, [unitToCreate]); + return ( -
    - {options.map(({ display_name, value: itemValue }) => ( -
    - {display_name.charAt(0)} -
    + + {timeUnits.map((unit, index: number, arr) => ( + + + {index === arr.length - 1 && ( +
    + {timeUnits.length === 0 && unitToCreate !== undefined && ( + + )} + ({duration || '0m'}) + {errors.shift_end && Shift length must be greater than zero} + ); }; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts index 91c76468..5a1ec1f1 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.types.ts @@ -1,3 +1,11 @@ export interface RotationCreateData {} export interface RotationData {} + +export enum RepeatEveryPeriod { + 'DAYS' = 0, + 'WEEKS' = 1, + 'MONTHS' = 2, + 'HOURS' = 3, + 'MINUTES' = 4, +} diff --git a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx index 4e5328bd..a555aa77 100644 --- a/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ScheduleOverrideForm.tsx @@ -6,11 +6,11 @@ import dayjs from 'dayjs'; import Draggable from 'react-draggable'; import Modal from 'components/Modal/Modal'; +import Tag from 'components/Tag/Tag'; import Text from 'components/Text/Text'; import UserGroups from 'components/UserGroups/UserGroups'; import WithConfirm from 'components/WithConfirm/WithConfirm'; -import WorkingHours from 'components/WorkingHours/WorkingHours'; -import { getFromString } from 'models/schedule/schedule.helpers'; +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'; @@ -18,9 +18,11 @@ import { User } from 'models/user/user.types'; import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; import { getCoords, getVar, waitForElement } from 'utils/DOM'; +import { GRAFANA_HEADER_HEIGTH } from 'utils/consts'; import { useDebouncedCallback } from 'utils/hooks'; -import DateTimePicker from './DateTimePicker'; +import DateTimePicker from './parts/DateTimePicker'; +import UserItem from './parts/UserItem'; import styles from './RotationForm.module.css'; @@ -30,7 +32,8 @@ interface RotationFormProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; scheduleId: Schedule['id']; - shiftMoment: dayjs.Dayjs; + shiftStart?: dayjs.Dayjs; + shiftEnd?: dayjs.Dayjs; shiftColor?: string; onCreate: () => void; onUpdate: () => void; @@ -49,19 +52,24 @@ const ScheduleOverrideForm: FC = (props) => { onDelete, shiftId, startMoment, - shiftMoment = dayjs().startOf('day').add(1, 'day'), + shiftStart: propsShiftStart = dayjs().startOf('day').add(1, 'day'), + shiftEnd: propsShiftEnd, shiftColor = getVar('--tag-warning'), } = props; const store = useStore(); - const [shiftStart, setShiftStart] = useState(shiftMoment); - const [shiftEnd, setShiftEnd] = useState(shiftMoment.add(24, 'hours')); + const [rotationName, setRotationName] = useState(shiftId === 'new' ? 'Override' : 'Update override'); + + const [shiftStart, setShiftStart] = useState(propsShiftStart); + const [shiftEnd, setShiftEnd] = useState(propsShiftEnd || propsShiftStart.add(24, 'hours')); const [offsetTop, setOffsetTop] = useState(0); const [isOpen, setIsOpen] = useState(false); + const [errors, setErrors] = useState<{ [key: string]: string[] }>({}); + const updateShiftStart = useCallback( (value) => { const diff = shiftEnd.diff(shiftStart); @@ -80,7 +88,7 @@ const ScheduleOverrideForm: FC = (props) => { const coords = getCoords(elm); const offsetTop = Math.min( - Math.max(coords.top - modal?.offsetHeight - 10, 10), + Math.max(coords.top - modal?.offsetHeight - 10, GRAFANA_HEADER_HEIGTH + 10), document.body.offsetHeight - modal?.offsetHeight - 10 ); @@ -91,29 +99,6 @@ const ScheduleOverrideForm: FC = (props) => { const [userGroups, setUserGroups] = useState([[]]); - const renderUser = (userPk: User['pk']) => { - const name = store.userStore.items[userPk]?.username; - const desc = store.userStore.items[userPk]?.timezone; - const workingHours = store.userStore.items[userPk]?.working_hours; - const timezone = store.userStore.items[userPk]?.timezone; - - return ( - <> -
    - {name} ({desc}) -
    - - - ); - }; - const shift = store.scheduleStore.shifts[shiftId]; useEffect(() => { @@ -129,12 +114,14 @@ const ScheduleOverrideForm: FC = (props) => { shift_end: getUTCString(shiftEnd), rolling_users: userGroups, frequency: null, + name: rotationName, }), - [currentTimezone, shiftStart, shiftEnd, userGroups] + [currentTimezone, shiftStart, shiftEnd, userGroups, rotationName] ); useEffect(() => { if (shift) { + setRotationName(getShiftName(shift)); setShiftStart(getDateTime(shift.shift_start)); setShiftEnd(getDateTime(shift.shift_end)); @@ -142,6 +129,13 @@ const ScheduleOverrideForm: FC = (props) => { } }, [shift]); + const handleRotationNameChange = useCallback( + (name: string) => { + setRotationName(name); + }, + [shiftId, params, shift] + ); + const handleDeleteClick = useCallback(() => { store.scheduleStore.deleteOncallShift(shiftId).then(() => { onHide(); @@ -152,13 +146,19 @@ const ScheduleOverrideForm: FC = (props) => { const handleCreate = useCallback(() => { if (shiftId === 'new') { - store.scheduleStore.createRotation(scheduleId, true, params).then(() => { - onCreate(); - }); + store.scheduleStore + .createRotation(scheduleId, true, params) + .then(() => { + onCreate(); + }) + .catch(onError); } else { - store.scheduleStore.updateRotation(shiftId, params).then(() => { - onUpdate(); - }); + store.scheduleStore + .updateRotation(shiftId, params) + .then(() => { + onUpdate(); + }) + .catch(onError); } }, [scheduleId, shiftId, params]); @@ -169,22 +169,32 @@ const ScheduleOverrideForm: FC = (props) => { }, []); const updatePreview = () => { + setErrors({}); + store.scheduleStore - .updateRotationPreview(scheduleId, shiftId, getFromString(startMoment), true, params) + .updateRotationPreview(scheduleId, shiftId, startMoment, true, params) + .catch(onError) .finally(() => { setIsOpen(true); }); }; + const onError = useCallback((error) => { + setErrors(error.response.data); + }, []); + const handleChange = useDebouncedCallback(updatePreview, 200); - const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]); - const disableAction = shiftEnd.isBefore(dayjs().tz(currentTimezone)); + useEffect(handleChange, [params, startMoment]); - useEffect(handleChange, [params]); + const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]); + + const ended = shift && shift.until && getDateTime(shift.until).isBefore(dayjs()); + const disabled = ended; return ( = (props) => { > - {shiftId === 'new' ? 'New Override' : 'Update Override'} + + {shiftId === 'new' && New} + + {rotationName} + + {shiftId !== 'new' && ( @@ -204,48 +219,66 @@ const ScheduleOverrideForm: FC = (props) => { )} + -
    +
    - + - Override start + Override period start } > - + - Override end + Override period end } > - + ( + + )} + showError={Boolean(errors.rolling_users)} />
    - Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} + Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))} - - diff --git a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx new file mode 100644 index 00000000..40fb415d --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } 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 Text from 'components/Text/Text'; +import { toDate } from 'containers/RotationForm/RotationForm.helpers'; +import { Timezone } from 'models/timezone/timezone.types'; + +import styles from 'containers/RotationForm/RotationForm.module.css'; + +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 value = useMemo(() => toDate(propValue, timezone), [propValue, timezone]); + + const minDate = useMemo(() => (minMoment ? toDate(minMoment, timezone) : undefined), [minMoment, timezone]); + + const handleDateChange = (newDate: Date) => { + const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); + + 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()); + + 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()); + + onChange(newValue); + }; + + return ( + +
    +
    + +
    +
    + +
    +
    + {error && {error}} +
    + ); +}; + +export default DateTimePicker; diff --git a/grafana-plugin/src/containers/RotationForm/parts/DaysSelector.tsx b/grafana-plugin/src/containers/RotationForm/parts/DaysSelector.tsx new file mode 100644 index 00000000..75af4a8e --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/parts/DaysSelector.tsx @@ -0,0 +1,53 @@ +import React, { useMemo } from 'react'; + +import cn from 'classnames/bind'; + +import { SelectOption } from 'state/types'; + +import styles from 'containers/RotationForm/RotationForm.module.css'; + +const cx = cn.bind(styles); + +interface DaysSelectorProps { + value: string[]; + onChange: (value: string[]) => void; + options: SelectOption[]; + weekStart: string; + disabled?: boolean; +} + +const DaysSelector = ({ value, onChange, options: optionsProp, weekStart, disabled }: DaysSelectorProps) => { + const getDayClickHandler = (day: string) => { + return () => { + const newValue = [...value]; + if (newValue.includes(day)) { + const index = newValue.indexOf(day); + newValue.splice(index, 1); + } else { + newValue.push(day); + } + onChange(newValue); + }; + }; + + const options = useMemo(() => { + const index = optionsProp.findIndex(({ display_name }) => display_name.toLowerCase() === weekStart.toLowerCase()); + return [...optionsProp.slice(index), ...optionsProp.slice(0, index)]; + }, [optionsProp, weekStart]); + + return ( +
    + {options.map(({ display_name, value: itemValue }) => ( +
    + {display_name.substring(0, 2)} +
    + ))} +
    + ); +}; + +export default DaysSelector; diff --git a/grafana-plugin/src/containers/RotationForm/parts/DeletionModal.tsx b/grafana-plugin/src/containers/RotationForm/parts/DeletionModal.tsx new file mode 100644 index 00000000..8a35f488 --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/parts/DeletionModal.tsx @@ -0,0 +1,59 @@ +import React, { ChangeEvent, useCallback, useState } from 'react'; + +import { VerticalGroup, Modal as GrafanaModal, HorizontalGroup, Button, InlineSwitch } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Text from 'components/Text/Text'; + +import styles from 'containers/RotationForm/RotationForm.module.css'; + +const cx = cn.bind(styles); + +interface DeletionModalProps { + onHide: () => void; + onConfirm: (force: boolean) => void; +} + +const DeletionModal = ({ onHide, onConfirm }: DeletionModalProps) => { + const [isForceDelete, setIsForceDelete] = useState(false); + + const handleIsForceDeleteChange = useCallback((event: ChangeEvent) => { + setIsForceDelete(event.target.checked); + }, []); + + const handleConfirmClick = useCallback(() => { + onConfirm(isForceDelete); + }, [isForceDelete]); + + return ( + + + + + This schedule is in use. As result the action will delete all shifts in the rotation which are greater than + current timestamp. All past shifts will remain in the schedule. + + + + + + + + + + + + ); +}; + +export default DeletionModal; diff --git a/grafana-plugin/src/containers/RotationForm/parts/TimeUnitSelector.tsx b/grafana-plugin/src/containers/RotationForm/parts/TimeUnitSelector.tsx new file mode 100644 index 00000000..697583ac --- /dev/null +++ b/grafana-plugin/src/containers/RotationForm/parts/TimeUnitSelector.tsx @@ -0,0 +1,45 @@ +import React, { useMemo } from 'react'; + +import { Select } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import { repeatEveryPeriodToUnitName } from 'containers/RotationForm/RotationForm.helpers'; +import { RepeatEveryPeriod } from 'containers/RotationForm/RotationForm.types'; + +import styles from 'containers/RotationForm/RotationForm.module.css'; + +const cx = cn.bind(styles); + +interface TimeUnitSelectorProps { + value: number; + unit: RepeatEveryPeriod; + maxValue: number; + onChange: (value) => void; + className?: string; + disabled?: boolean; +} + +const TimeUnitSelector = ({ value, unit, onChange, maxValue, className, disabled }: TimeUnitSelectorProps) => { + const handleChange = ({ value }) => { + onChange(value); + }; + + const options = useMemo( + () => + Array.from(Array(maxValue + 1).keys()).map((i) => ({ + label: `${String(i)} ${ + i === 1 ? repeatEveryPeriodToUnitName[unit].slice(0, -1) : repeatEveryPeriodToUnitName[unit] + }`, + value: i, + })), + [maxValue] + ); + + return ( +
    + +
    + )} +
    + +
    +
    + {error && {error}} +
    + ); +}; + +export default WeekdayTimePicker; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index a70b649a..a2262ae8 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -7,12 +7,13 @@ import dayjs from 'dayjs'; import { observer } from 'mobx-react'; 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 { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { getColor, getFromString } from 'models/schedule/schedule.helpers'; +import { getColor, getLayersFromStore } from 'models/schedule/schedule.helpers'; import { Layer, Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; @@ -33,22 +34,26 @@ interface RotationsProps extends WithStoreProps { scheduleId: Schedule['id']; onShowRotationForm: (shiftId: Shift['id'] | 'new') => void; onClick: (id: Shift['id'] | 'new') => void; + onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void; onCreate: () => void; onUpdate: () => void; onDelete: () => void; disabled: boolean; + filters: ScheduleFiltersType; } interface RotationsState { layerPriority?: Layer['priority']; - shiftMomentToShowRotationForm?: dayjs.Dayjs; + shiftStartToShowRotationForm?: dayjs.Dayjs; + shiftEndToShowRotationForm?: dayjs.Dayjs; } @observer class Rotations extends Component { state: RotationsState = { layerPriority: undefined, - shiftMomentToShowRotationForm: undefined, + shiftStartToShowRotationForm: undefined, + shiftEndToShowRotationForm: undefined, }; render() { @@ -62,8 +67,9 @@ class Rotations extends Component { store, shiftIdToShowRotationForm, disabled, + filters, } = this.props; - const { layerPriority, shiftMomentToShowRotationForm } = this.state; + const { layerPriority, shiftStartToShowRotationForm, shiftEndToShowRotationForm } = this.state; const base = 7 * 24 * 60; // in minutes const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes'); @@ -72,21 +78,17 @@ class Rotations extends Component { const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1; - const layers = store.scheduleStore.rotationPreview - ? store.scheduleStore.rotationPreview - : (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]); + const layers = getLayersFromStore(store, scheduleId, startMoment); const options = layers ? layers.map((layer) => ({ - label: `Layer ${layer.priority}`, + label: `Layer ${layer.priority} rotation`, value: layer.priority, })) : []; const nextPriority = layers && layers.length ? layers[layers.length - 1].priority + 1 : 1; - options.push({ label: 'New Layer', value: nextPriority }); - const schedule = store.scheduleStore.items[scheduleId]; const isTypeReadOnly = @@ -118,7 +120,7 @@ class Rotations extends Component { ) - ) : ( + ) : options.length > 0 ? ( { variant="primary" size="md" /> + ) : ( + )}
    @@ -141,7 +147,7 @@ class Rotations extends Component {
    - + {!currentTimeHidden && (
    )} @@ -154,9 +160,10 @@ class Rotations extends Component { > { - this.onRotationClick(shiftId, moment); + onClick={(shiftStart, shiftEnd) => { + this.onRotationClick(shiftId, shiftStart, shiftEnd); }} + handleAddOverride={this.handleShowOverrideForm} color={getColor(layerIndex, rotationIndex)} events={events} layerIndex={layerIndex} @@ -165,6 +172,7 @@ class Rotations extends Component { currentTimezone={currentTimezone} transparent={isPreview} tutorialParams={isPreview && store.scheduleStore.rotationFormLiveParams} + filters={filters} /> ))} @@ -184,12 +192,12 @@ class Rotations extends Component {
    - +
    { - this.handleAddLayer(nextPriority, moment); + onClick={(shiftStart, shiftEnd) => { + this.handleAddLayer(nextPriority, shiftStart, shiftEnd); }} events={[]} layerIndex={0} @@ -212,7 +220,7 @@ class Rotations extends Component { this.handleAddLayer(nextPriority, startMoment); }} > - + Add rotations layer + + Add new layer with rotation
    )}
    @@ -225,7 +233,8 @@ class Rotations extends Component { layerPriority={layerPriority} startMoment={startMoment} currentTimezone={currentTimezone} - shiftMoment={shiftMomentToShowRotationForm} + shiftStart={shiftStartToShowRotationForm} + shiftEnd={shiftEndToShowRotationForm} onHide={() => { this.hideRotationForm(); @@ -246,34 +255,38 @@ class Rotations extends Component { onDelete(); }} + onShowRotationForm={this.onShowRotationForm} /> )} ); } - onRotationClick = (shiftId: Shift['id'], moment?: dayjs.Dayjs) => { + onRotationClick = (shiftId: Shift['id'], shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { const { disabled } = this.props; if (disabled) { return; } - this.setState({ shiftMomentToShowRotationForm: moment }, () => { + this.setState({ shiftStartToShowRotationForm: shiftStart, shiftEndToShowRotationForm: shiftEnd }, () => { this.onShowRotationForm(shiftId); }); }; - handleAddLayer = (layerPriority: number, moment?: dayjs.Dayjs) => { + handleAddLayer = (layerPriority: number, shiftStart?: dayjs.Dayjs, shiftEnd?: dayjs.Dayjs) => { const { disabled } = this.props; if (disabled) { return; } - this.setState({ layerPriority, shiftMomentToShowRotationForm: moment }, () => { - this.onShowRotationForm('new'); - }); + this.setState( + { layerPriority, shiftStartToShowRotationForm: shiftStart, shiftEndToShowRotationForm: shiftEnd }, + () => { + this.onShowRotationForm('new'); + } + ); }; handleAddRotation = (option: SelectableValue) => { @@ -286,7 +299,7 @@ class Rotations extends Component { this.setState( { layerPriority: option.value, - shiftMomentToShowRotationForm: startMoment, + shiftStartToShowRotationForm: startMoment, }, () => { this.onShowRotationForm('new'); @@ -298,7 +311,8 @@ class Rotations extends Component { this.setState( { layerPriority: undefined, - shiftMomentToShowRotationForm: undefined, + shiftStartToShowRotationForm: undefined, + shiftEndToShowRotationForm: undefined, }, () => { this.onShowRotationForm(undefined); @@ -311,6 +325,12 @@ class Rotations extends Component { onShowRotationForm(shiftId); }; + + handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { + const { onShowOverrideForm } = this.props; + + onShowOverrideForm('new', shiftStart, shiftEnd); + }; } export default withMobXProviderContext(Rotations); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx index 0a585a7c..502eb572 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleFinal.tsx @@ -6,6 +6,7 @@ import dayjs from 'dayjs'; import { observer } from 'mobx-react'; 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'; @@ -26,9 +27,11 @@ interface ScheduleFinalProps extends WithStoreProps { startMoment: dayjs.Dayjs; currentTimezone: Timezone; scheduleId: Schedule['id']; - hideHeader?: boolean; + simplified?: boolean; onClick: (shiftId: Shift['id']) => void; + onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void; disabled?: boolean; + filters: ScheduleFiltersType; } interface ScheduleOverridesState { @@ -42,7 +45,7 @@ class ScheduleFinal extends Component
    - {!hideHeader && ( + {!simplified && (
    @@ -73,7 +76,7 @@ class ScheduleFinal extends Component {!currentTimeHidden &&
    } - + {shifts && shifts.length ? ( shifts.map(({ shiftId, events }, index) => { @@ -87,6 +90,9 @@ class ScheduleFinal extends Component ); @@ -121,6 +127,12 @@ class ScheduleFinal extends Component {}; + + handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { + const { onShowOverrideForm } = this.props; + + onShowOverrideForm('new', shiftStart, shiftEnd); + }; } export default withMobXProviderContext(ScheduleFinal); diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index afbed0b0..88ba607d 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -6,6 +6,7 @@ import dayjs from 'dayjs'; import { observer } from 'mobx-react'; 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'; @@ -28,6 +29,8 @@ 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'; @@ -36,16 +39,19 @@ interface ScheduleOverridesProps extends WithStoreProps { onUpdate: () => void; onDelete: () => void; disabled: boolean; + filters: ScheduleFiltersType; } interface ScheduleOverridesState { - shiftMomentToShowOverrideForm?: dayjs.Dayjs; + shiftStartToShowOverrideForm?: dayjs.Dayjs; + shiftEndToShowOverrideForm?: dayjs.Dayjs; } @observer class ScheduleOverrides extends Component { state: ScheduleOverridesState = { - shiftMomentToShowOverrideForm: undefined, + shiftStartToShowOverrideForm: undefined, + shiftEndToShowOverrideForm: undefined, }; render() { @@ -59,8 +65,11 @@ class ScheduleOverrides extends Component
    {!currentTimeHidden &&
    } - + {shifts && shifts.length ? ( shifts.map(({ shiftId, isPreview, events }, rotationIndex) => ( @@ -116,10 +125,11 @@ class ScheduleOverrides extends Component { - this.onRotationClick(shiftId, moment); + onClick={(shiftStart, shiftEnd) => { + this.onRotationClick(shiftId, shiftStart, shiftEnd); }} transparent={isPreview} + filters={filters} /> )) @@ -130,8 +140,8 @@ class ScheduleOverrides extends Component { - this.onRotationClick('new', moment); + onClick={(shiftStart, shiftEnd) => { + this.onRotationClick('new', shiftStart, shiftEnd); }} /> @@ -146,7 +156,8 @@ class ScheduleOverrides extends Component { this.handleHide(); @@ -173,14 +184,14 @@ class ScheduleOverrides extends Component { + onRotationClick = (shiftId: Shift['id'], shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => { const { disabled } = this.props; if (disabled) { return; } - this.setState({ shiftMomentToShowOverrideForm: moment }, () => { + this.setState({ shiftStartToShowOverrideForm: shiftStart, shiftEndToShowOverrideForm: shiftEnd }, () => { this.onShowRotationForm(shiftId); }); }; @@ -195,13 +206,13 @@ class ScheduleOverrides extends Component { + this.setState({ shiftStartToShowOverrideForm: startMoment }, () => { this.onShowRotationForm('new'); }); }; handleHide = () => { - this.setState({ shiftMomentToShowOverrideForm: undefined }, () => { + this.setState({ shiftStartToShowOverrideForm: undefined, shiftEndToShowOverrideForm: undefined }, () => { this.onShowRotationForm(undefined); }); }; diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts index c179af0b..30682942 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts @@ -2,20 +2,6 @@ import { FormItem, FormItemType } from 'components/GForm/GForm.types'; import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'; const commonFields: FormItem[] = [ - { - name: 'team', - label: 'Assign to team', - description: - 'Assigning to the teams allows you to filter Schedules and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details', - type: FormItemType.GSelect, - extra: { - modelName: 'grafanaTeamStore', - displayField: 'name', - valueField: 'id', - showSearch: true, - allowClear: true, - }, - }, { name: 'slack_channel_id', label: 'Slack channel', @@ -91,7 +77,7 @@ const commonFields: FormItem[] = [ }, description: 'Specify how to notify a team member when their shift is the next one scheduled', }, -]; +].map((field) => ({ ...field, collapsed: true })); export const iCalForm: { name: string; fields: FormItem[] } = { name: 'Schedule', @@ -101,26 +87,34 @@ export const iCalForm: { name: string; fields: FormItem[] } = { type: FormItemType.Input, validation: { required: true }, }, + { + name: 'team', + label: 'Assign to team', + description: + 'Assigning to the teams allows you to filter Schedules and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details', + type: FormItemType.GSelect, + extra: { + modelName: 'grafanaTeamStore', + displayField: 'name', + valueField: 'id', + showSearch: true, + allowClear: true, + }, + }, { name: 'ical_url_primary', label: 'Primary schedule iCal URL', type: FormItemType.TextArea, validation: { required: true }, - description: - 'You can use the primary scheduling calendar as a base schedule with restricted \n' + - 'access. The iCal URL for your primary calendar can be found in the calendar \n' + - 'integration settings of your calendar service.', + extra: { rows: 2 }, }, { name: 'ical_url_overrides', label: 'Overrides schedule iCal URL ', type: FormItemType.TextArea, - description: - 'You can use an override calendar to share with your team members. Users can add \n' + - 'events to this calendar, and they will override existing events in the primary \n' + - 'calendar. The iCal URL for your override calendar can be found in the calendar \n' + - 'integration settings of your calendar service.', + extra: { rows: 2 }, }, + ...commonFields, ], }; @@ -133,6 +127,20 @@ export const calendarForm: { name: string; fields: FormItem[] } = { type: FormItemType.Input, validation: { required: true }, }, + { + name: 'team', + label: 'Assign to team', + description: + 'Assigning to the teams allows you to filter Schedules and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details', + type: FormItemType.GSelect, + extra: { + modelName: 'grafanaTeamStore', + displayField: 'name', + valueField: 'id', + showSearch: true, + allowClear: true, + }, + }, { name: 'enable_web_overrides', label: 'Enable web interface overrides ', @@ -145,13 +153,9 @@ export const calendarForm: { name: string; fields: FormItem[] } = { name: 'ical_url_overrides', label: 'Overrides schedule iCal URL ', type: FormItemType.TextArea, - description: - 'You can use an override calendar to share with your team members. Users can add \n' + - 'events to this calendar, and they will override existing events in the primary \n' + - 'calendar. The iCal URL for your override calendar can be found in the calendar \n' + - 'integration settings of your calendar service. \n' + - 'NOTE: web overrides must be disabled to use iCal based overrides', + extra: { rows: 2 }, }, + ...commonFields, ], }; @@ -164,6 +168,20 @@ export const apiForm: { name: string; fields: FormItem[] } = { type: FormItemType.Input, validation: { required: true }, }, + { + name: 'team', + label: 'Assign to team', + description: + 'Assigning to the teams allows you to filter Schedules and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details', + type: FormItemType.GSelect, + extra: { + modelName: 'grafanaTeamStore', + displayField: 'name', + valueField: 'id', + showSearch: true, + allowClear: true, + }, + }, ...commonFields, ], }; diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css index 381d1b48..7d0af23c 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css @@ -8,6 +8,8 @@ margin: 0 1px; padding: 4px; align-items: center; + transition: opacity 0.2s ease; + cursor: pointer; } .working-hours { @@ -32,7 +34,7 @@ } .root__inactive { - opacity: 0.5; + opacity: 0.3; } .title { @@ -59,7 +61,8 @@ } .details { - width: auto; + width: 250px; + padding: 5px 0; } .details-user-status { @@ -83,5 +86,29 @@ .is-oncall-icon { color: var(--oncall-icon-stroke-color); - margin-left: -2px; + vertical-align: middle; +} + +.details-icon { + width: 16px; + margin-right: 4px; +} + +.badge { + width: 8px; + height: 8px; + border-radius: 50%; + margin: 0 auto; +} + +.username { + word-break: break-word; +} + +.second-column { + width: 102px; +} + +.icon { + color: var(--secondary-text-color); } diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index ccaa0108..04c741a4 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -1,15 +1,16 @@ -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, useMemo } from 'react'; -import { HorizontalGroup, Tooltip, VerticalGroup } from '@grafana/ui'; +import { Button, HorizontalGroup, Icon, Tooltip, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import Line from 'components/ScheduleUserDetails/img/line.svg'; +import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types'; import Text from 'components/Text/Text'; import WorkingHours from 'components/WorkingHours/WorkingHours'; -import { IsOncallIcon } from 'icons'; +import { getShiftName } from 'models/schedule/schedule.helpers'; import { Event, Schedule } 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 { useStore } from 'state/useStore'; @@ -23,20 +24,18 @@ interface ScheduleSlotProps { scheduleId: Schedule['id']; startMoment: dayjs.Dayjs; currentTimezone: Timezone; + handleAddOverride: (event: React.MouseEvent) => void; color?: string; - label?: string; + simplified?: boolean; + filters?: ScheduleFiltersType; } const cx = cn.bind(styles); const ScheduleSlot: FC = observer((props) => { - const { event, scheduleId, currentTimezone, color, label } = props; + const { event, scheduleId, currentTimezone, color, handleAddOverride, simplified, filters } = props; const { users } = event; - const trackMouse = false; - - const [mouseX, setMouseX] = useState(0); - const start = dayjs(event.start); const end = dayjs(event.end); @@ -48,20 +47,13 @@ const ScheduleSlot: FC = observer((props) => { const width = duration / base; - const handleMouseMove = useCallback((event) => { - setMouseX(event.nativeEvent.offsetX); - }, []); - const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now; return (
    {event.is_gap ? ( }> -
    - {trackMouse && mouseX > 0 &&
    } - {label &&
    {label}
    } -
    +
    ) : event.is_empty ? (
    = observer((props) => { style={{ backgroundColor: color, }} - > - {label && ( -
    - {label} -
    - )} -
    + /> ) : ( - users.map(({ display_name, pk: userPk }, userIndex) => { + users.map(({ display_name, pk: userPk }) => { const storeUser = store.userStore.items[userPk]; - const inactive = false; + const inactive = filters && filters.users.length && !filters.users.includes(userPk); const title = storeUser ? getTitle(storeUser) : display_name; @@ -94,10 +80,7 @@ const ScheduleSlot: FC = observer((props) => { style={{ backgroundColor: color, }} - onMouseMove={trackMouse ? handleMouseMove : undefined} - onMouseLeave={trackMouse ? () => setMouseX(0) : undefined} > - {trackMouse && mouseX > 0 &&
    } {storeUser && ( = observer((props) => { duration={duration} /> )} -
    - {userIndex === 0 && label && ( -
    - {label} -
    - )} - {title} -
    +
    {title}
    ); @@ -124,6 +100,7 @@ const ScheduleSlot: FC = observer((props) => { return ( = observer((props) => { isOncall={isOncall} currentTimezone={currentTimezone} event={event} + handleAddOverride={handleAddOverride} + simplified={simplified} + color={color} /> } > @@ -150,39 +130,83 @@ interface ScheduleSlotDetailsProps { isOncall: boolean; currentTimezone: Timezone; event: Event; + handleAddOverride: (event: React.SyntheticEvent) => void; + simplified?: boolean; + color: string; } const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => { - const { user, currentTimezone, event, isOncall } = props; + const { user, currentTimezone, event, handleAddOverride, simplified, color } = props; + + const store = useStore(); + const { scheduleStore } = store; + + const currentMoment = useMemo(() => dayjs(), []); + + const shift = scheduleStore.shifts[event.shift?.pk]; return (
    - - - - {isOncall && } - {user?.username} + + +
    +
    +
    + + {getShiftName(shift)} + + + +
    + +
    + + {user?.username} + +
    + +
    + +
    + + User local time +
    + {currentMoment.tz(user.timezone).format('DD MMM, HH:mm')} +
    ({getTzOffsetString(currentMoment.tz(user.timezone))}) +
    + + Current timezone +
    + {currentMoment.tz(currentTimezone).format('DD MMM, HH:mm')} +
    ({getTzOffsetString(currentMoment.tz(currentTimezone))}) +
    +
    + +
    + +
    + + This shift +
    + {dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')} +
    + {dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')} +
    + +  
    + {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} +
    + {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} +
    +
    + {!simplified && !event.is_override && ( + + - - - - - - {dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')} - {dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')} - - - - - - - {currentTimezone} - - {dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')} - {dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')} - - - + )} +
    ); }; diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css index ab794721..26ef3ea5 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.module.css @@ -4,6 +4,28 @@ flex-direction: column; background: var(--background-secondary); border-radius: var(--border-radius); + position: relative; +} + +.shades { + background: repeating-linear-gradient( + -45deg, + var(--background-canvas), + var(--background-canvas) 2px, + transparent 2px, + transparent 6px + ); + height: 100%; + position: absolute; +} + +.working-hours { + position: absolute; + height: 100%; +} + +.content { + z-index: 1; } .header { @@ -44,6 +66,15 @@ overflow: hidden; } +.users-placeholder { + width: 100%; + text-align: center; +} + +.icon { + color: var(--secondary-text-color); +} + .avatar-group { position: absolute; top: 10px; @@ -84,29 +115,13 @@ transition: opacity 0.5s ease; } -.time-stripe { +.time-marks-wrapper { position: relative; - height: 4px; - - --color: rgba(61, 113, 217, 0.2); - - background: repeating-linear-gradient(-45deg, var(--color), var(--color) 4px, transparent 4px, transparent 8px); -} - -.current-user-stripe { - position: absolute; - top: 0; - bottom: 0; - height: 4px; - background: #3d71d9; - border-radius: 2px; - left: calc((9 / 24) * 100%); - right: calc((7 / 24) * 100%); } .time-marks { position: absolute; - top: -24px; + top: -20px; display: flex; font-weight: 400; line-height: 20px; diff --git a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx index 5b63bd79..0f2c2058 100644 --- a/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/UsersTimezones.tsx @@ -1,6 +1,6 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { HorizontalGroup, Tooltip } from '@grafana/ui'; +import { HorizontalGroup, Icon, Tooltip } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; @@ -8,6 +8,7 @@ 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'; @@ -35,6 +36,7 @@ const jLimit = 24 / hoursToSplit; const UsersTimezones: FC = (props) => { const store = useStore(); + const { userStore } = store; const { userIds, tz, onTzChange, onCallNow, scheduleId, startMoment } = props; @@ -74,55 +76,75 @@ const UsersTimezones: FC = (props) => { return (
    -
    - - -
    - - Schedule team and timezones - + + {/*
    +
    */} +
    +
    + + +
    + + Schedule team and timezones + +
    +
    +
    + + Current timezone: {tz}, local time: {currentMoment.format('HH:mm')} +
    -
    - - Current timezone: {tz}, local time: {currentMoment.format('HH:mm')} - -
    - -
    -
    -
    - -
    -
    -
    -
    - {momentsToRender.map((mm, index) => ( -
    - 0, - })} - > +
    +
    +
    + {users && users.length ? ( + + ) : ( + + + + Add rotation to see users + + + )} +
    +
    +
    + {momentsToRender.map((mm, index) => ( +
    + 0, + })} + > + + {mm.format('HH:mm')} + + +
    + ))} +
    + - {mm.format('HH:mm')} + 24:00
    - ))} -
    - - - 24:00 - -
    @@ -140,7 +162,7 @@ interface UserAvatarsProps { } const UserAvatars = (props: UserAvatarsProps) => { - const { users, currentMoment, onTzChange, onCallNow, scheduleId, startMoment } = props; + const { users, currentMoment, onCallNow, scheduleId, startMoment } = props; const userGroups = useMemo(() => { return users .reduce((memo, user) => { @@ -182,7 +204,7 @@ const UserAvatars = (props: UserAvatarsProps) => { activeUtcOffset={activeUtcOffset} utcOffset={group.utcOffset} onSetActiveUtcOffset={setActiveUtcOffset} - onTzChange={onTzChange} + // onTzChange={onTzChange} xPos={xPos} users={group.users} startMoment={startMoment} @@ -205,7 +227,7 @@ interface AvatarGroupProps { scheduleId: Schedule['id']; onSetActiveUtcOffset: (utcOffset: number | undefined) => void; activeUtcOffset: number; - onTzChange: (timezone: Timezone) => void; + onTzChange?: (timezone: Timezone) => void; onCallNow: Array>; } diff --git a/grafana-plugin/src/models/schedule/schedule.helpers.ts b/grafana-plugin/src/models/schedule/schedule.helpers.ts index 3da07350..91a52043 100644 --- a/grafana-plugin/src/models/schedule/schedule.helpers.ts +++ b/grafana-plugin/src/models/schedule/schedule.helpers.ts @@ -65,13 +65,13 @@ export const getShiftsFromStore = ( startMoment: dayjs.Dayjs ): ShiftEvents[] => { return store.scheduleStore.finalPreview - ? store.scheduleStore.finalPreview + ? store.scheduleStore.finalPreview[getFromString(startMoment)] : (store.scheduleStore.events[scheduleId]?.['final']?.[getFromString(startMoment)] as any); }; export const getLayersFromStore = (store: RootStore, scheduleId: Schedule['id'], startMoment: dayjs.Dayjs): Layer[] => { return store.scheduleStore.rotationPreview - ? store.scheduleStore.rotationPreview + ? store.scheduleStore.rotationPreview[getFromString(startMoment)] : (store.scheduleStore.events[scheduleId]?.['rotation']?.[getFromString(startMoment)] as Layer[]); }; @@ -81,7 +81,7 @@ export const getOverridesFromStore = ( startMoment: dayjs.Dayjs ): Layer[] | ShiftEvents[] => { return store.scheduleStore.overridePreview - ? store.scheduleStore.overridePreview + ? store.scheduleStore.overridePreview[getFromString(startMoment)] : (store.scheduleStore.events[scheduleId]?.['override']?.[getFromString(startMoment)] as Layer[]); }; @@ -217,3 +217,19 @@ export const getOverrideColor = (rotationIndex: number) => { const normalizedRotationIndex = rotationIndex % OVERRIDE_COLORS.length; return OVERRIDE_COLORS[normalizedRotationIndex]; }; + +export const getShiftName = (shift: Shift) => { + if (!shift) { + return ''; + } + + if (shift.name) { + return shift.name; + } + + if (shift.type === 3) { + return 'Override'; + } + + return `[L${shift.priority_level}] Rotation`; +}; diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index d21f7c0b..c0a9ec48 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import { action, observable } from 'mobx'; -import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types'; +import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types'; import BaseStore from 'models/base_store'; import { EscalationChain } from 'models/escalation_chain/escalation_chain.types'; import { makeRequest } from 'network'; @@ -121,7 +121,7 @@ export class ScheduleStore extends BaseStore { @action async updateItems( - f: SchedulesFiltersType | string = { searchTerm: '', type: undefined, used: undefined, mine: undefined }, + f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined }, page = 1, shouldUpdateFn: () => boolean = undefined ) { @@ -224,7 +224,7 @@ export class ScheduleStore extends BaseStore { const response = await makeRequest(`/oncall_shifts/`, { data: { type, schedule: scheduleId, ...params }, method: 'POST', - }).catch(this.onApiError); + }); this.shifts = { ...this.shifts, @@ -241,24 +241,30 @@ export class ScheduleStore extends BaseStore { async updateRotationPreview( scheduleId: Schedule['id'], shiftId: Shift['id'] | 'new', - fromString: string, + startMoment: dayjs.Dayjs, isOverride: boolean, params: Partial ) { const type = isOverride ? 3 : 2; + const fromString = getFromString(startMoment); + + const dayBefore = startMoment.subtract(1, 'day'); + const response = await makeRequest(`/oncall_shifts/preview/`, { - params: { date: fromString }, + params: { date: getFromString(dayBefore) }, data: { type, schedule: scheduleId, shift_pk: shiftId === 'new' ? undefined : shiftId, ...params }, method: 'POST', - }).catch(this.onApiError); + }); if (isOverride) { - this.overridePreview = enrichOverrides( + const overridePreview = enrichOverrides( [...(this.events[scheduleId]?.['override']?.[fromString] as Array<{ shiftId: string; events: Event[] }>)], response.rotation, shiftId ); + + this.overridePreview = { ...this.overridePreview, [fromString]: overridePreview }; } else { const layers = enrichLayers( [...(this.events[scheduleId]?.['rotation']?.[fromString] as Layer[])], @@ -267,10 +273,10 @@ export class ScheduleStore extends BaseStore { params.priority_level ); - this.rotationPreview = layers; + this.rotationPreview = { ...this.rotationPreview, [fromString]: layers }; } - this.finalPreview = splitToShiftsAndFillGaps(response.final); + this.finalPreview = { ...this.finalPreview, [fromString]: splitToShiftsAndFillGaps(response.final) }; } @action @@ -283,9 +289,24 @@ export class ScheduleStore extends BaseStore { async updateRotation(shiftId: Shift['id'], params: Partial) { const response = await makeRequest(`/oncall_shifts/${shiftId}`, { + params: { force: true }, data: { ...params }, method: 'PUT', - }).catch(this.onApiError); + }); + + this.shifts = { + ...this.shifts, + [response.id]: response, + }; + + return response; + } + + async updateRotationAsNew(shiftId: Shift['id'], params: Partial) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, { + data: { ...params }, + method: 'PUT', + }); this.shifts = { ...this.shifts, @@ -353,9 +374,22 @@ export class ScheduleStore extends BaseStore { return response; } - async deleteOncallShift(shiftId: Shift['id']) { + @action + async saveOncallShift(shiftId: Shift['id'], data: Partial) { + const response = await makeRequest(`/oncall_shifts/${shiftId}`, { method: 'PUT', data }); + + this.shifts = { + ...this.shifts, + [shiftId]: response, + }; + + return response; + } + + async deleteOncallShift(shiftId: Shift['id'], force?: boolean) { return await makeRequest(`/oncall_shifts/${shiftId}`, { method: 'DELETE', + params: { force }, }).catch(this.onApiError); } diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 311810bf..80b2d417 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -68,7 +68,7 @@ export interface Shift { schedule: Schedule['id']; shift_end: string; shift_start: string; - title: string; + name: string; type: number; // 2 - rotations, 3 - overrides until: string | null; updated_shift: null; @@ -93,6 +93,7 @@ export interface Event { source: string; start: string; users: Array<{ display_name: User['username']; pk: User['pk'] }>; + is_override: boolean; } export interface Events { diff --git a/grafana-plugin/src/models/timezone/timezone.types.ts b/grafana-plugin/src/models/timezone/timezone.types.ts index 2b62579b..8c16ffb0 100644 --- a/grafana-plugin/src/models/timezone/timezone.types.ts +++ b/grafana-plugin/src/models/timezone/timezone.types.ts @@ -1,4 +1,4 @@ -const tzs: string[] = [ +export const tzs: string[] = [ 'Africa/Abidjan', 'Africa/Accra', 'Africa/Addis_Ababa', diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 437f392b..11fbe7fb 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -1,3 +1,4 @@ +import { config } from '@grafana/runtime'; import dayjs from 'dayjs'; import { get } from 'lodash-es'; import { action, computed, observable } from 'mobx'; @@ -56,22 +57,27 @@ export class UserStore extends BaseStore { async loadCurrentUser() { const response = await makeRequest('/user/', {}); - let timezone; - if (!response.timezone && isUserActionAllowed(UserActions.UserSettingsWrite)) { - timezone = dayjs.tz.guess(); - this.update(response.pk, { timezone }); - } - - timezone = timezone || getTimezone(response); + const timezone = await this.refreshTimezone(response.pk); this.items = { ...this.items, [response.pk]: { ...response, timezone }, }; + this.currentUserPk = response.pk; + } + + @action + async refreshTimezone(id: User['pk']) { + const { timezone: grafanaPreferencesTimezone } = config.bootData.user; + const timezone = grafanaPreferencesTimezone === 'browser' ? dayjs.tz.guess() : grafanaPreferencesTimezone; + if (isUserActionAllowed(UserActions.UserSettingsWrite)) { + this.update(id, { timezone }); + } + this.rootStore.currentTimezone = timezone; - this.currentUserPk = response.pk; + return timezone; } @action diff --git a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts index 865438c9..d1139fb4 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.helpers.ts +++ b/grafana-plugin/src/pages/schedule/Schedule.helpers.ts @@ -1,3 +1,4 @@ +import { config } from '@grafana/runtime'; import dayjs from 'dayjs'; import { findColor } from 'containers/Rotations/Rotations.helpers'; @@ -7,6 +8,20 @@ import { Timezone } from 'models/timezone/timezone.types'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; +const mondayDayOffset = { + saturday: -2, + sunday: -1, + monday: 0, + browser: 0, +}; + +export const getWeekStartString = () => { + if (!config.bootData.user.weekStart || config.bootData.user.weekStart === 'browser') { + return 'monday'; + } + return config.bootData.user.weekStart; +}; + export const getNow = (tz: Timezone) => { const now = dayjs().tz(tz); return now.utcOffset() === 0 ? now.utc() : now; @@ -17,7 +32,9 @@ export const getStartOfDay = (tz: Timezone) => { }; export const getStartOfWeek = (tz: Timezone) => { - return getNow(tz).startOf('isoWeek'); + return getNow(tz) + .startOf('isoWeek') // it's Monday always + .add(mondayDayOffset[getWeekStartString()], 'day'); }; export const getUTCString = (moment: dayjs.Dayjs) => { @@ -28,28 +45,58 @@ export const getDateTime = (date: string) => { return dayjs(date); }; -export const getUTCByDay = (dayOptions: SelectOption[], by_day: string[], moment: dayjs.Dayjs) => { - if (by_day.length && moment.day() !== moment.utc().day()) { - // when converting to UTC, shift starts on a different day, - // so we need to update the by_day list - // depending on the UTC side, move one day before or after +const getUTCDayIndex = (index: number, moment: dayjs.Dayjs, reverse: boolean) => { + let utc_index = index; + if (moment.day() !== moment.utc().day()) { let offset = moment.utcOffset(); - let UTCDays = []; - let byDayOptions = []; - dayOptions.forEach(({ value }) => byDayOptions.push(value)); - by_day.forEach((element) => { - let index = byDayOptions.indexOf(element); - if (offset < 0) { - // move one day after - UTCDays.push(byDayOptions[(index + 1) % 7]); - } else { - // move one day before - UTCDays.push(byDayOptions[(((index - 1) % 7) + 7) % 7]); - } - }); - return UTCDays; + if ((offset < 0 && !reverse) || (reverse && offset > 0)) { + // move one day after + utc_index = (utc_index + 1) % 7; + } else { + // move one day before + utc_index = utc_index - 1; + } } - return by_day; + if (utc_index < 0) { + utc_index = ((utc_index % 7) + 7) % 7; + } + return utc_index; +}; + +export const getUTCByDay = (dayOptions: SelectOption[], by_day: string[], moment: dayjs.Dayjs) => { + if (moment.day() === moment.utc().day()) { + return by_day; + } + // when converting to UTC, shift starts on a different day, + // so we need to update the by_day list + // depending on the UTC side, move one day before or after + let UTCDays = []; + let byDayOptions = []; + dayOptions.forEach(({ value }) => byDayOptions.push(value)); + by_day.forEach((element) => { + let index = byDayOptions.indexOf(element); + index = getUTCDayIndex(index, moment, false); + UTCDays.push(byDayOptions[index]); + }); + + return UTCDays; +}; + +export const getSelectedDays = (dayOptions: SelectOption[], by_day: string[], moment: dayjs.Dayjs) => { + if (moment.day() === moment.utc().day()) { + return by_day; + } + + const byDayOptions = dayOptions.map(({ value }) => value); + + let selectedTimezoneDays = []; + by_day.forEach((element) => { + let index = byDayOptions.indexOf(element); + index = getUTCDayIndex(index, moment, true); + selectedTimezoneDays.push(byDayOptions[index]); + }); + + return selectedTimezoneDays; }; export const getUTCWeekStart = (dayOptions: SelectOption[], moment: dayjs.Dayjs) => { diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index aa855e47..2c991d27 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -12,6 +12,8 @@ import { initErrorDataState, } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers'; 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'; @@ -44,10 +46,13 @@ interface SchedulePageState extends PageBaseState { renderType: string; shiftIdToShowRotationForm?: Shift['id']; shiftIdToShowOverridesForm?: Shift['id']; + shiftStartToShowOverrideForm?: dayjs.Dayjs; + shiftEndToShowOverrideForm?: dayjs.Dayjs; isLoading: boolean; showEditForm: boolean; showScheduleICalSettings: boolean; lastUpdated: number; + filters: ScheduleFiltersType; } @observer @@ -67,6 +72,7 @@ class SchedulePage extends React.Component showScheduleICalSettings: false, errorData: initErrorDataState(), lastUpdated: 0, + filters: { users: [] }, }; } @@ -97,6 +103,7 @@ class SchedulePage extends React.Component render() { const { store, + query, match: { params: { id: scheduleId }, }, @@ -110,6 +117,9 @@ class SchedulePage extends React.Component showEditForm, showScheduleICalSettings, errorData, + shiftStartToShowOverrideForm, + shiftEndToShowOverrideForm, + filters, } = this.state; const { isNotFoundError } = errorData; @@ -122,12 +132,14 @@ class SchedulePage extends React.Component const disabledRotationForm = !isUserActionAllowed(UserActions.SchedulesWrite) || schedule?.type !== ScheduleType.API || - !!shiftIdToShowRotationForm; + !!shiftIdToShowRotationForm || + shiftIdToShowOverridesForm; const disabledOverrideForm = !isUserActionAllowed(UserActions.SchedulesWrite) || !schedule?.enable_web_overrides || - !!shiftIdToShowOverridesForm; + !!shiftIdToShowOverridesForm || + shiftIdToShowRotationForm; return ( @@ -139,7 +151,7 @@ class SchedulePage extends React.Component 404 Schedule not found - + @@ -151,7 +163,7 @@ class SchedulePage extends React.Component
    - + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} + this.setState({ filters: value })} + currentUserPk={store.userStore.currentUserPk} + />
    startMoment={startMoment} onClick={this.handleShowForm} disabled={disabledRotationForm} + onShowOverrideForm={this.handleShowOverridesForm} + filters={filters} /> onDelete={this.handleDeleteRotation} shiftIdToShowRotationForm={shiftIdToShowRotationForm} onShowRotationForm={this.handleShowRotationForm} + onShowOverrideForm={this.handleShowOverridesForm} disabled={disabledRotationForm} + filters={filters} /> shiftIdToShowRotationForm={shiftIdToShowOverridesForm} onShowRotationForm={this.handleShowOverridesForm} disabled={disabledOverrideForm} + shiftStartToShowOverrideForm={shiftStartToShowOverrideForm} + shiftEndToShowOverrideForm={shiftEndToShowOverrideForm} + filters={filters} />
    @@ -329,8 +353,12 @@ class SchedulePage extends React.Component this.setState({ shiftIdToShowRotationForm: shiftId }); }; - handleShowOverridesForm = (shiftId: Shift['id'] | 'new') => { - this.setState({ shiftIdToShowOverridesForm: shiftId }); + handleShowOverridesForm = (shiftId: Shift['id'] | 'new', shiftStart?: dayjs.Dayjs, shiftEnd?: dayjs.Dayjs) => { + this.setState({ + shiftIdToShowOverridesForm: shiftId, + shiftStartToShowOverrideForm: shiftStart, + shiftEndToShowOverrideForm: shiftEnd, + }); }; handleNameChange = (value: string) => { diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index bfc58420..fb710fce 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -5,13 +5,13 @@ import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; +import qs from 'query-string'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import Avatar from 'components/Avatar/Avatar'; import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip'; import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector'; import PluginLink from 'components/PluginLink/PluginLink'; -import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types'; import Table from 'components/Table/Table'; import Text from 'components/Text/Text'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; @@ -19,6 +19,7 @@ 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'; import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; import TeamName from 'containers/TeamName/TeamName'; @@ -43,7 +44,7 @@ interface SchedulesPageProps extends WithStoreProps, RouteComponentProps, PagePr interface SchedulesPageState { startMoment: dayjs.Dayjs; - filters: SchedulesFiltersType; + filters: RemoteFiltersType; showNewScheduleSelector: boolean; expandedRowKeys: Array; scheduleIdToEdit?: Schedule['id']; @@ -63,11 +64,11 @@ class SchedulesPage extends React.Component filters === this.state.filters); this.setState({ page: p ? Number(p) : 1 }, this.updateSchedules); - } + } */ - updateSchedules = async () => { + /* updateSchedules = async () => { const { store } = this.props; const { filters, page } = this.state; - LocationHelper.update({ p: page }, 'partial'); - await store.scheduleStore.updateItems(filters, page); }; - + */ render() { const { store, query } = this.props; @@ -118,7 +117,7 @@ class SchedulesPage extends React.Component { - const { history } = this.props; + const { history, query } = this.props; if (data.type === ScheduleType.API) { - history.push(`${PLUGIN_ROOT}/schedules/${data.id}`); + history.push(`${PLUGIN_ROOT}/schedules/${data.id}?${qs.stringify(query)}`); } }; @@ -269,10 +268,10 @@ class SchedulesPage extends React.Component - +
    { - const { history } = this.props; + const { history, query } = this.props; - return () => history.push(`${PLUGIN_ROOT}/schedules/${scheduleId}`); + return () => history.push(`${PLUGIN_ROOT}/schedules/${scheduleId}?${qs.stringify(query)}`); }; renderType = (value: number) => { @@ -307,7 +306,7 @@ class SchedulesPage extends React.Component {item.number_of_escalation_chains > 0 && ( { - return {item.name}; + const { query } = this.props; + + return {item.name}; }; renderOncallNow = (item: Schedule, _index: number) => { @@ -426,42 +427,39 @@ class SchedulesPage extends React.Component { - scheduleStore.delete(id).then(() => this.update(true)); + scheduleStore.delete(id).then(() => this.update()); }; }; - handleSchedulesFiltersChange = (filters: SchedulesFiltersType) => { - this.setState({ filters }, () => this.debouncedUpdateSchedules(filters)); + handleSchedulesFiltersChange = (filters: RemoteFiltersType, isOnMount) => { + this.setState({ filters, page: isOnMount ? this.state.page : 1 }, this.debouncedUpdateSchedules); }; - applyFilters = (filters: SchedulesFiltersType) => { + applyFilters = () => { const { scheduleStore } = this.props.store; - const shouldUpdateFn = () => this.state.filters === filters; - scheduleStore.updateItems(filters, 1, shouldUpdateFn).then(() => { - if (shouldUpdateFn) { - this.setState({ page: 1 }); - } - }); + const { page, filters } = this.state; + + LocationHelper.update({ p: page }, 'partial'); + + scheduleStore.updateItems(filters, page); }; debouncedUpdateSchedules = debounce(this.applyFilters, FILTERS_DEBOUNCE_MS); handlePageChange = (page: number) => { - this.setState({ page }, this.updateSchedules); - this.setState({ expandedRowKeys: [] }); + this.setState({ page, expandedRowKeys: [] }, this.applyFilters); }; - update = (isRemoval = false) => { + update = () => { const { store } = this.props; - const { filters, page } = this.state; - const { scheduleStore } = store; + const { page } = this.state; // 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(); const newPage = results.length === 1 ? Math.max(page - 1, 1) : page; - return scheduleStore.updateItems(filters, isRemoval ? newPage : page); + this.handlePageChange(newPage); }; getUpdateRelatedEscalationChainsHandler = (scheduleId: Schedule['id']) => { diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index fd6a6f99..72572c1f 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -166,7 +166,7 @@ export const Root = observer((props: AppRootProps) => { - + diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index a5951b5c..228e72ad 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -7,6 +7,9 @@ export const APP_SUBTITLE = `Developer-friendly incident response (${plugin?.ver // License export const GRAFANA_LICENSE_OSS = 'OpenSource'; +// height of new Grafana sticky header with breadcrumbs +export const GRAFANA_HEADER_HEIGTH = 80; + // Reusable breakpoint sizes export const BREAKPOINT_TABS = 1024; diff --git a/grafana-plugin/src/utils/datetime.ts b/grafana-plugin/src/utils/datetime.ts index 1677bedd..0470c4bd 100644 --- a/grafana-plugin/src/utils/datetime.ts +++ b/grafana-plugin/src/utils/datetime.ts @@ -1,5 +1,23 @@ import { TimeOption, TimeRange, TimeZone, rangeUtil } from '@grafana/data'; +export const toHHmmss = (s: number) => { + let hours = Math.floor(s / 3600); + let minutes = Math.floor((s - hours * 3600) / 60); + + let time = ''; + if (hours > 0) { + time += hours + 'h'; + } + if (minutes > 0) { + if (minutes < 10 && hours > 0) { + time += '0'; + } + time += minutes + 'm'; + } + + return time || '0m'; +}; + // Valid mapping accepted by @grafana/ui and @grafana/data packages export const quickOptions = [ { from: 'now-5m', to: 'now', display: 'Last 5 minutes' },