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 <innokenty.konstantinov@grafana.com>
Co-authored-by: Matias Bordese <mbordese@gmail.com>
This commit is contained in:
Maxim Mordasov 2023-06-20 16:18:56 +03:00 committed by GitHub
parent 9e15fe6875
commit 9f0064f21b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 2389 additions and 980 deletions

View file

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

View file

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

View file

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

View file

@ -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<CollapseProps> = (props) => {
onClick={onHeaderClickCallback}
data-testid="test__toggle"
>
<Icon name={isOpen ? 'angle-down' : 'angle-right'} size="xl" className={cx('icon')} />
<Icon name={'angle-right'} size="xl" className={cx('icon', { 'icon--rotated': isOpen })} />
<div className={cx('label')}> {label}</div>
</div>
{isOpen && (

View file

@ -0,0 +1,3 @@
.collapse {
margin-bottom: 16px;
}

View file

@ -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<GFormProps, {}> {
render() {
const { form, data } = this.props;
const openFields = form.fields.filter((field) => !field.collapsed);
const collapsedfields = form.fields.filter((field) => field.collapsed);
return (
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={this.handleSubmit}>
{({ 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<GFormProps, {}> {
})}
</Field>
);
});
};
return (
<>
{openFields.map(renderField)}
<Collapse isOpen={false} label="Notification settings" className={cx('collapse')}>
{collapsedfields.map(renderField)}
</Collapse>
</>
);
}}
</Form>
);

View file

@ -22,4 +22,5 @@ export interface FormItem {
validation?: (v: any) => boolean;
};
extra?: any;
collapsed?: boolean;
}

View file

@ -18,7 +18,8 @@
box-shadow: var(--shadows-z3);
border-radius: 2px;
z-index: 10;
overflow: scroll;
/* overflow: scroll; */
}
/*

View file

@ -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<PropsWithChildren<ModalProps>> = (props) => {
const { title, children, onDismiss, width = '600px', contentElement, isOpen = true } = props;
const { title, children, onDismiss, width = '600px', contentElement, isOpen = true, top, className } = props;
return (
<ReactModal
@ -31,13 +32,14 @@ const Modal: FC<PropsWithChildren<ModalProps>> = (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}

View file

@ -0,0 +1,3 @@
.root {
display: block;
}

View file

@ -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<HTMLInputElement>) => {
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 (
<div className={cx('root')}>
<InlineSwitch
showLabel
label="Highlight my shifts"
value={value.users.includes(currentUserPk)}
onChange={handleShowMyShiftsOnlyClick}
/>
</div>
);
};
export default SchedulesFilters;

View file

@ -0,0 +1,5 @@
import { User } from 'models/user/user.types';
export interface ScheduleFiltersType {
users: Array<User['pk']>;
}

View file

@ -41,7 +41,8 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
<div className={cx('root')} data-testid="schedule-quality">
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
<TooltipBadge
borderType="link"
borderType="success"
icon="link"
addPadding
text={schedule.number_of_escalation_chains}
tooltipTitle="Used in escalations"
@ -62,6 +63,7 @@ const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) =>
{schedule.warnings?.length > 0 && (
<TooltipBadge
borderType="warning"
icon="exclamation-triangle"
addPadding
text={schedule.warnings.length}
tooltipTitle="Warnings"

View file

@ -1,25 +0,0 @@
import moment from 'moment-timezone';
export function optionToDateString(option: string) {
switch (option) {
case 'today':
return moment().startOf('day').format('YYYY-MM-DD');
case 'tomorrow':
return moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
default:
return moment().add(2, 'day').startOf('day').format('YYYY-MM-DD');
}
}
export function dateStringToOption(dateString: string) {
const today = moment().startOf('day').format('YYYY-MM-DD');
if (dateString === today) {
return 'today';
}
const tomorrow = moment().add(1, 'day').startOf('day').format('YYYY-MM-DD');
if (dateString === tomorrow) {
return 'tomorrow';
}
return 'custom';
}

View file

@ -1,13 +0,0 @@
.right {
display: flex;
flex-wrap: wrap;
row-gap: 4px;
column-gap: 8px;
}
@media screen and (max-width: 1600px) {
.right {
order: 3;
width: 100%;
}
}

View file

@ -46,6 +46,10 @@
&--large {
font-size: 20px;
}
&--clickable {
cursor: pointer;
}
}
.no-wrap {
@ -67,10 +71,17 @@
}
.icon-button {
margin-left: 4px;
margin-left: 8px;
display: none;
}
.root:hover .icon-button {
display: inline-block;
}
.with-maxWidth {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
}

View file

@ -24,6 +24,8 @@ interface TextProps extends HTMLAttributes<HTMLElement> {
clearBeforeEdit?: boolean;
hidden?: boolean;
editModalTitle?: string;
maxWidth?: string;
clickable?: boolean;
}
interface TextInterface extends React.FC<TextProps> {
@ -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 && (
<IconButton
onClick={handleEditClick}
variant="primary"
className={cx('icon-button')}
tooltip="Edit"
tooltipPlacement="top"
name="edit"
name="pen"
/>
)}
{copyable && (

View file

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

View file

@ -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<TimelineMarksProps> = (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<TimelineMarksProps> = (props) => {
))}
</svg>
)}
{momentsToRender.map((m, i) => {
const isCurrentDay = currentMoment.isSame(m.moment, 'day');
// const isWeekend = m.moment.day() == 0 || m.moment.day() === 6;
return (
<div key={i} className={cx('weekday')}>
<div key={i} className={cx('weekday' /* , { 'weekday--weekend': isWeekend } */)}>
<div className={cx('weekday-title')}>
<Text type="secondary" strong={isCurrentDay}>
{m.moment.format('ddd D MMM')}

View file

@ -21,6 +21,11 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = `
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
403
</span>
@ -34,6 +39,11 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = `
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
You do not have access to view this page.
@ -69,6 +79,11 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = `
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
403
</span>
@ -82,6 +97,11 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = `
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
You do not have access to view this page.
@ -117,6 +137,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
403
</span>
@ -130,6 +155,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
You do not have access to view this page.
@ -165,6 +195,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
403
</span>
@ -178,6 +213,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
You do not have access to view this page.
@ -213,6 +253,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
403
</span>
@ -226,6 +271,11 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer
>
<span
className="root text text--undefined text--medium"
style={
Object {
"maxWidth": undefined,
}
}
>
You do not have access to view this page.

View file

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

View file

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

View file

@ -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 = () => <IconButton className={cx('icon')} name="draggabledots"
const SortableHandleHoc = SortableHandle(DragHandle);
const UserGroups = (props: UserGroupsProps) => {
const { value, onChange, isMultipleGroups, renderUser, showError } = props;
const { value, onChange, isMultipleGroups, renderUser, showError, disabled } = props;
const rootRef = useRef<HTMLDivElement>();
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) => (
<li className={cx('user')}>
{renderUser(item.data)}
<div className={cx('user-buttons')}>
<HorizontalGroup>
<IconButton className={cx('icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
<SortableHandleHoc />
</HorizontalGroup>
</div>
{!disabled && (
<div className={cx('user-buttons')}>
<HorizontalGroup>
<IconButton className={cx('icon')} name="trash-alt" onClick={getDeleteItemHandler(index)} />
<SortableHandleHoc />
</HorizontalGroup>
</div>
)}
</li>
);
return (
<div className={cx('root')}>
<div className={cx('root')} ref={rootRef}>
<VerticalGroup>
<RemoteSelect
key={items.length}
showSearch
placeholder="Add user"
href={`/users/?permission=${UserActions.NotificationsRead.permission}&filters=true`}
value={null}
onChange={handleUserAdd}
showError={showError}
maxMenuHeight={150}
requiredUserAction={UserActions.UserSettingsWrite}
/>
{!disabled && (
<RemoteSelect
key={items.length}
showSearch
placeholder="Add user"
href={`/users/?permission=${UserActions.NotificationsRead.permission}&filters=true`}
value={null}
onChange={handleUserAdd}
showError={showError}
maxMenuHeight={150}
requiredUserAction={UserActions.UserSettingsWrite}
/>
)}
<SortableList
renderItem={renderItem}
axis="y"
@ -128,6 +149,7 @@ const UserGroups = (props: UserGroupsProps) => {
handleDeleteItem={handleDeleteUser}
isMultipleGroups={isMultipleGroups}
useDragHandle
allowCreate={!disabled}
/>
</VerticalGroup>
</div>
@ -146,33 +168,36 @@ interface SortableListProps {
handleDeleteItem: (index: number) => void;
isMultipleGroups: boolean;
renderItem: (item: Item, index: number) => React.ReactElement;
allowCreate?: boolean;
}
const SortableList = SortableContainer<SortableListProps>(({ items, handleAddGroup, isMultipleGroups, renderItem }) => {
return (
<ul className={cx('groups')}>
{items.map((item, index) =>
item.type === 'item' ? (
<SortableItem key={item.key} index={index}>
{renderItem(item, index)}
</SortableItem>
) : isMultipleGroups ? (
<SortableItem key={item.key} index={index}>
<li className={cx('separator')}>
<Text type="secondary">{item.data.name}</Text>
const SortableList = SortableContainer<SortableListProps>(
({ items, handleAddGroup, isMultipleGroups, renderItem, allowCreate }) => {
return (
<ul className={cx('groups')}>
{items.map((item, index) =>
item.type === 'item' ? (
<SortableItem key={item.key} index={index}>
{renderItem(item, index)}
</SortableItem>
) : isMultipleGroups ? (
<SortableItem key={item.key} index={index}>
<li className={cx('separator')}>
<Text type="secondary">{item.data.name}</Text>
</li>
</SortableItem>
) : null
)}
{allowCreate && isMultipleGroups && items[items.length - 1]?.type === 'item' && (
<SortableItem disabled key="New Group" index={items.length + 1}>
<li onClick={handleAddGroup} className={cx('separator', { separator__clickable: true })}>
<Text type="primary">+ Add user group</Text>
</li>
</SortableItem>
) : null
)}
{isMultipleGroups && items[items.length - 1]?.type === 'item' && (
<SortableItem disabled key="New Group" index={items.length + 1}>
<li onClick={handleAddGroup} className={cx('separator', { separator__clickable: true })}>
<Text type="secondary">Add user group +</Text>
</li>
</SortableItem>
)}
</ul>
);
});
)}
</ul>
);
}
);
export default UserGroups;

View file

@ -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<UserTimezoneSelectProps> = (props) => {
const { users, value: propValue, onChange } = props;
const [extraOptions, setExtraOptions] = useState<TimezoneOption[]>([
{
value: 0,
utcOffset: 0,
timezone: 'UTC' as Timezone,
label: 'GMT',
description: '',
},
]);
const options = useMemo(() => {
return users
.reduce(
@ -46,15 +65,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (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<UserTimezoneSelectProps> = (props) => {
return 0;
});
}, [users]);
}, [users, extraOptions]);
const value = useMemo(() => {
const utcOffset = dayjs().tz(propValue).utcOffset();
@ -87,9 +98,70 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
[options]
);
const filterOption = useCallback((item: SelectableValue<number>, 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 (
<div className={cx('root')}>
<Select value={value} onChange={handleChange} width={30} placeholder={propValue} options={options} />
<Select
value={value}
onChange={handleChange}
width={30}
placeholder={propValue}
options={options}
filterOption={filterOption}
allowCustomValue
onCreateOption={handleCreateOption}
formatCreateLabel={(input: string) => {
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`;
}
}}
/>
</div>
);
};

View file

@ -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<WorkingHoursProps> = (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 (
<svg
version="1.1"
width="100%"
height="28px"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<svg version="1.1" width="100%" height="28px" xmlns="http://www.w3.org/2000/svg" className={className}>
<defs>
<pattern id="stripes" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.15)" strokeWidth="10" />
</pattern>
<pattern id="stripes_strong" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.2)" strokeWidth="10" />
</pattern>
</defs>
{nonWorkingMoments.map((moment, index) => {
const start = moment.start.diff(startMoment, 'seconds');
const diff = moment.end.diff(moment.start, 'seconds');
return (
<rect
className={cx('stripes')}
key={index}
x={`${(start * 100) / duration}%`}
y={0}
width={`${(diff * 100) / duration}%`}
height="100%"
fill="url(#stripes)"
/>
);
})}
{nonWorkingMoments &&
nonWorkingMoments.map((moment, index) => {
const start = moment.start.diff(startMoment, 'seconds');
const diff = moment.end.diff(moment.start, 'seconds');
return (
<rect
className={cx('stripes')}
key={index}
x={`${(start * 100) / duration}%`}
y={0}
width={`${(diff * 100) / duration}%`}
height="100%"
fill={`${strong ? 'url(#stripes_strong)' : 'url(#stripes)'}`}
/>
);
})}
</svg>
);
};

View file

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

View file

@ -1,3 +0,0 @@
export const getLabel = (layerIndex: number, rotationIndex) => {
return `L ${layerIndex + 1}-${rotationIndex + 1}`;
};

View file

@ -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<RotationProps> = (props) => {
const {
events,
scheduleId,
layerIndex,
rotationIndex,
startMoment,
currentTimezone,
color,
onClick,
days = 7,
transparent = false,
tutorialParams,
onClick,
handleAddOverride,
simplified,
filters,
} = props;
const [animate, _setAnimate] = useState<boolean>(true);
const handleClick = (event) => {
const handleRotationClick = (event: React.MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
event.stopPropagation();
handleAddOverride(dayjs(scheduleEvent.start), dayjs(scheduleEvent.end));
};
};
const x = useMemo(() => {
@ -68,13 +84,8 @@ const Rotation: FC<RotationProps> = (props) => {
return firstShiftOffset / base;
}, [events]);
let eventIndexToShowLabel = -1;
if (!isNaN(layerIndex) && !isNaN(rotationIndex)) {
eventIndexToShowLabel = events.findIndex((event) => dayjs(event.start).isSameOrAfter(startMoment));
}
return (
<div className={cx('root')} onClick={handleClick}>
<div className={cx('root')} onClick={handleRotationClick}>
<div className={cx('timeline')}>
{tutorialParams && <RotationTutorial startMoment={startMoment} {...tutorialParams} />}
{events ? (
@ -83,16 +94,18 @@ const Rotation: FC<RotationProps> = (props) => {
className={cx('slots', { slots__animate: animate, slots__transparent: transparent })}
style={{ transform: `translate(${x * 100}%, 0)` }}
>
{events.map((event, index) => {
{events.map((event) => {
return (
<ScheduleSlot
scheduleId={scheduleId}
key={event.start}
key={hash(event)}
event={event}
startMoment={startMoment}
currentTimezone={currentTimezone}
color={color}
label={index === eventIndexToShowLabel && getLabel(layerIndex, rotationIndex)}
handleAddOverride={getAddOverrideClickHandler(event)}
simplified={simplified}
filters={filters}
/>
);
})}

View file

@ -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 (
<HorizontalGroup spacing="sm">
<div onFocus={onFocus} onBlur={onBlur}>
<DatePickerWithInput minDate={minDate} disabled={disabled} value={value} onChange={handleDateChange} />
</div>
<div onFocus={onFocus} onBlur={onBlur}>
<TimeOfDayPicker disabled={disabled} value={dateTime(value)} onChange={handleTimeChange} />
</div>
</HorizontalGroup>
);
};
export default DateTimePicker;

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,11 @@
export interface RotationCreateData {}
export interface RotationData {}
export enum RepeatEveryPeriod {
'DAYS' = 0,
'WEEKS' = 1,
'MONTHS' = 2,
'HOURS' = 3,
'MINUTES' = 4,
}

View file

@ -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<RotationFormProps> = (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<dayjs.Dayjs>(shiftMoment);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(shiftMoment.add(24, 'hours'));
const [rotationName, setRotationName] = useState<string>(shiftId === 'new' ? 'Override' : 'Update override');
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(propsShiftStart);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(propsShiftEnd || propsShiftStart.add(24, 'hours'));
const [offsetTop, setOffsetTop] = useState<number>(0);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
const updateShiftStart = useCallback(
(value) => {
const diff = shiftEnd.diff(shiftStart);
@ -80,7 +88,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (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<RotationFormProps> = (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 (
<>
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
</div>
<WorkingHours
timezone={timezone}
workingHours={workingHours}
startMoment={dayjs(params.shift_start)}
duration={dayjs(params.shift_end).diff(dayjs(params.shift_start), 'seconds')}
className={cx('working-hours')}
style={{ backgroundColor: shiftColor }}
/>
</>
);
};
const shift = store.scheduleStore.shifts[shiftId];
useEffect(() => {
@ -129,12 +114,14 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (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<RotationFormProps> = (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 (
<Modal
top="0"
isOpen={isOpen}
width="430px"
onDismiss={onHide}
@ -196,7 +206,12 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text size="medium">{shiftId === 'new' ? 'New Override' : 'Update Override'}</Text>
<HorizontalGroup spacing="sm">
{shiftId === 'new' && <Tag color={shiftColor}>New</Tag>}
<Text.Title onTextChange={handleRotationNameChange} level={5} editable>
{rotationName}
</Text.Title>
</HorizontalGroup>
<HorizontalGroup>
{shiftId !== 'new' && (
<WithConfirm>
@ -204,48 +219,66 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
</WithConfirm>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
<IconButton
name="times"
variant="secondary"
tooltip={shiftId === 'new' ? 'Cancel' : 'Close'}
onClick={onHide}
/>
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('content')} data-testid="override-inputs">
<div className={cx('override-form-content')} data-testid="override-inputs">
<VerticalGroup>
<HorizontalGroup>
<HorizontalGroup align="flex-start">
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override start
Override period start
</Text>
}
>
<DateTimePicker value={shiftStart} onChange={updateShiftStart} timezone={currentTimezone} />
<DateTimePicker
disabled={disabled}
value={shiftStart}
onChange={updateShiftStart}
timezone={currentTimezone}
error={errors.shift_start}
/>
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Override end
Override period end
</Text>
}
>
<DateTimePicker value={shiftEnd} onChange={setShiftEnd} timezone={currentTimezone} />
<DateTimePicker
disabled={disabled}
value={shiftEnd}
onChange={setShiftEnd}
timezone={currentTimezone}
error={errors.shift_end}
/>
</Field>
</HorizontalGroup>
<UserGroups
disabled={disabled}
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={false}
renderUser={renderUser}
showError={!isFormValid}
renderUser={(pk: User['pk']) => (
<UserItem pk={pk} shiftColor={shiftColor} shiftStart={params.shift_start} shiftEnd={params.shift_end} />
)}
showError={Boolean(errors.rolling_users)}
/>
</VerticalGroup>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<Text type="secondary">Current timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="secondary" onClick={onHide}>
{shiftId === 'new' ? 'Cancel' : 'Close'}
</Button>
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid || disableAction}>
<Button variant="primary" onClick={handleCreate} disabled={disabled || !isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup>

View file

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

View file

@ -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 (
<div className={cx('days', { days_disabled: disabled })}>
{options.map(({ display_name, value: itemValue }) => (
<div
key={display_name}
onClick={getDayClickHandler(itemValue as string)}
className={cx('day', { day__selected: value.includes(itemValue as string) })}
>
{display_name.substring(0, 2)}
</div>
))}
</div>
);
};
export default DaysSelector;

View file

@ -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<boolean>(false);
const handleIsForceDeleteChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setIsForceDelete(event.target.checked);
}, []);
const handleConfirmClick = useCallback(() => {
onConfirm(isForceDelete);
}, [isForceDelete]);
return (
<GrafanaModal isOpen onDismiss={onHide} title="Delete rotation" className={cx('confirmation-modal')}>
<VerticalGroup spacing="lg">
<VerticalGroup>
<Text type="secondary">
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.
</Text>
</VerticalGroup>
<InlineSwitch
transparent
showLabel
label="Delete past shifts"
value={isForceDelete}
onChange={handleIsForceDeleteChange}
/>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmClick}>
Delete
</Button>
</HorizontalGroup>
</VerticalGroup>
</GrafanaModal>
);
};
export default DeletionModal;

View file

@ -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 (
<div className={cx('root', className)}>
<Select disabled={disabled} value={value} options={options} onChange={handleChange} />
</div>
);
};
export default TimeUnitSelector;

View file

@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import Text from 'components/Text/Text';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from 'containers/RotationForm/RotationForm.module.css';
const cx = cn.bind(styles);
interface UserItemProps {
pk: User['pk'];
shiftColor: string;
shiftStart: string;
shiftEnd: string;
}
const WEEK_IN_SECONDS = 60 * 60 * 24 * 7;
const UserItem = ({ pk, shiftColor, shiftStart, shiftEnd }: UserItemProps) => {
const { userStore } = useStore();
useEffect(() => {
if (!userStore.items[pk]) {
userStore.updateItem(pk);
}
}, []);
const name = userStore.items[pk]?.username;
const desc = userStore.items[pk]?.timezone;
const workingHours = userStore.items[pk]?.working_hours;
const timezone = userStore.items[pk]?.timezone;
const duration = dayjs(shiftEnd).diff(dayjs(shiftStart), 'seconds');
return (
<div style={{ backgroundColor: shiftColor, width: '100%' }}>
{duration <= WEEK_IN_SECONDS && (
<WorkingHours
timezone={timezone}
workingHours={workingHours}
startMoment={dayjs(shiftStart)}
duration={duration}
className={cx('working-hours')}
/>
)}
<div className={cx('user-title')}>
<Text strong>{name}</Text> <Text style={{ color: 'var(--always-gray)' }}>({desc})</Text>
</div>
</div>
);
};
export default UserItem;

View file

@ -0,0 +1,84 @@
import React, { useCallback, useMemo } from 'react';
import { DateTime, dateTime, SelectableValue } from '@grafana/data';
import { Select, 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 { useStore } from 'state/useStore';
import styles from 'containers/RotationForm/RotationForm.module.css';
const cx = cn.bind(styles);
interface WeekdayTimePickerProps {
value: dayjs.Dayjs;
timezone: Timezone;
onWeekDayChange: (value: number) => void;
onTimeChange: (hh: number, mm: number, ss: number) => void;
disabled?: boolean;
hideWeekday?: boolean;
weekStart: string;
error?: string[];
}
const weekdays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
const WeekdayTimePicker = (props: WeekdayTimePickerProps) => {
const { value: propValue, timezone, hideWeekday, disabled, weekStart, onWeekDayChange, onTimeChange, error } = props;
const { scheduleStore } = useStore();
const value = useMemo(() => toDate(propValue, timezone), [propValue, timezone]);
const options = useMemo(() => {
const index = scheduleStore.byDayOptions.findIndex(
({ display_name }) => display_name.toLowerCase() === weekStart.toLowerCase()
);
return [...scheduleStore.byDayOptions.slice(index), ...scheduleStore.byDayOptions.slice(0, index)].map(
({ display_name, value }) => ({
label: display_name.substring(0, 3),
value: weekdays.findIndex((val) => val === value),
})
);
}, [weekStart]);
const handleWeekDayChange = useCallback(
({ value: newValue }: SelectableValue) => {
const oldIndex = options.findIndex(({ value: optionValue }) => optionValue === value.getDay());
const newIndex = options.findIndex(({ value: optionValue }) => optionValue === newValue);
onWeekDayChange(newIndex - oldIndex);
},
[options, value]
);
const handleTimeChange = useCallback(
(newMoment: DateTime) => {
// @ts-ignore actually new newMoment has second method
onTimeChange(newMoment.hour(), newMoment.minute(), newMoment.second());
},
[value]
);
return (
<VerticalGroup>
<div style={{ display: 'flex', flexWrap: 'nowrap', gap: '8px' }}>
{!hideWeekday && (
<div style={{ width: '58%' }} className={cx({ 'control--error': Boolean(error) })}>
<Select options={options} onChange={handleWeekDayChange} value={value.getDay()} />
</div>
)}
<div style={{ width: hideWeekday ? '100%' : '42%' }} className={cx({ 'control--error': Boolean(error) })}>
<TimeOfDayPicker disabled={disabled} value={dateTime(value)} onChange={handleTimeChange} />
</div>
</div>
{error && <Text type="danger">{error}</Text>}
</VerticalGroup>
);
};
export default WeekdayTimePicker;

View file

@ -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<RotationsProps, RotationsState> {
state: RotationsState = {
layerPriority: undefined,
shiftMomentToShowRotationForm: undefined,
shiftStartToShowRotationForm: undefined,
shiftEndToShowRotationForm: undefined,
};
render() {
@ -62,8 +67,9 @@ class Rotations extends Component<RotationsProps, RotationsState> {
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<RotationsProps, RotationsState> {
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<RotationsProps, RotationsState> {
</Button>
</WithPermissionControlTooltip>
)
) : (
) : options.length > 0 ? (
<ValuePicker
label="Add rotation"
options={options}
@ -126,6 +128,10 @@ class Rotations extends Component<RotationsProps, RotationsState> {
variant="primary"
size="md"
/>
) : (
<Button variant="primary" icon="plus" onClick={() => this.handleAddLayer(nextPriority, startMoment)}>
Add rotation
</Button>
)}
</HorizontalGroup>
</div>
@ -141,7 +147,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
</HorizontalGroup>
</div>
<div className={cx('rotations')}>
<TimelineMarks startMoment={startMoment} />
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
{!currentTimeHidden && (
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
)}
@ -154,9 +160,10 @@ class Rotations extends Component<RotationsProps, RotationsState> {
>
<Rotation
scheduleId={scheduleId}
onClick={(moment) => {
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<RotationsProps, RotationsState> {
currentTimezone={currentTimezone}
transparent={isPreview}
tutorialParams={isPreview && store.scheduleStore.rotationFormLiveParams}
filters={filters}
/>
</CSSTransition>
))}
@ -184,12 +192,12 @@ class Rotations extends Component<RotationsProps, RotationsState> {
</div>
<div className={cx('header-plus-content')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />
<TimelineMarks startMoment={startMoment} />
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<div className={cx('rotations')}>
<Rotation
scheduleId={scheduleId}
onClick={(moment) => {
this.handleAddLayer(nextPriority, moment);
onClick={(shiftStart, shiftEnd) => {
this.handleAddLayer(nextPriority, shiftStart, shiftEnd);
}}
events={[]}
layerIndex={0}
@ -212,7 +220,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
this.handleAddLayer(nextPriority, startMoment);
}}
>
<Text type={disabled ? 'disabled' : 'primary'}>+ Add rotations layer</Text>
<Text type={disabled ? 'disabled' : 'primary'}>+ Add new layer with rotation</Text>
</div>
)}
</div>
@ -225,7 +233,8 @@ class Rotations extends Component<RotationsProps, RotationsState> {
layerPriority={layerPriority}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftMoment={shiftMomentToShowRotationForm}
shiftStart={shiftStartToShowRotationForm}
shiftEnd={shiftEndToShowRotationForm}
onHide={() => {
this.hideRotationForm();
@ -246,34 +255,38 @@ class Rotations extends Component<RotationsProps, RotationsState> {
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<RotationsProps, RotationsState> {
this.setState(
{
layerPriority: option.value,
shiftMomentToShowRotationForm: startMoment,
shiftStartToShowRotationForm: startMoment,
},
() => {
this.onShowRotationForm('new');
@ -298,7 +311,8 @@ class Rotations extends Component<RotationsProps, RotationsState> {
this.setState(
{
layerPriority: undefined,
shiftMomentToShowRotationForm: undefined,
shiftStartToShowRotationForm: undefined,
shiftEndToShowRotationForm: undefined,
},
() => {
this.onShowRotationForm(undefined);
@ -311,6 +325,12 @@ class Rotations extends Component<RotationsProps, RotationsState> {
onShowRotationForm(shiftId);
};
handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => {
const { onShowOverrideForm } = this.props;
onShowOverrideForm('new', shiftStart, shiftEnd);
};
}
export default withMobXProviderContext(Rotations);

View file

@ -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<ScheduleFinalProps, ScheduleOverridesState
};
render() {
const { startMoment, currentTimezone, store, hideHeader, scheduleId } = this.props;
const { startMoment, currentTimezone, store, simplified, scheduleId, filters } = this.props;
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
@ -60,7 +63,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
return (
<>
<div className={cx('root')}>
{!hideHeader && (
{!simplified && (
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>
@ -73,7 +76,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
)}
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} />
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, events }, index) => {
@ -87,6 +90,9 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
currentTimezone={currentTimezone}
color={findColor(shiftId, layers, overrides)}
onClick={this.getRotationClickHandler(shiftId)}
handleAddOverride={this.handleShowOverrideForm}
simplified={simplified}
filters={filters}
/>
</CSSTransition>
);
@ -121,6 +127,12 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
};
onSearchTermChangeCallback = () => {};
handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => {
const { onShowOverrideForm } = this.props;
onShowOverrideForm('new', shiftStart, shiftEnd);
};
}
export default withMobXProviderContext(ScheduleFinal);

View file

@ -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<ScheduleOverridesProps, ScheduleOverridesState> {
state: ScheduleOverridesState = {
shiftMomentToShowOverrideForm: undefined,
shiftStartToShowOverrideForm: undefined,
shiftEndToShowOverrideForm: undefined,
};
render() {
@ -59,8 +65,11 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
store,
shiftIdToShowRotationForm,
disabled,
shiftStartToShowOverrideForm: propsShiftStartToShowOverrideForm,
shiftEndToShowOverrideForm: propsShiftEndToShowOverrideForm,
filters,
} = this.props;
const { shiftMomentToShowOverrideForm } = this.state;
const { shiftStartToShowOverrideForm, shiftEndToShowOverrideForm } = this.state;
const shifts = getOverridesFromStore(store, scheduleId, startMoment) as ShiftEvents[];
@ -104,7 +113,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} />
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ shiftId, isPreview, events }, rotationIndex) => (
@ -116,10 +125,11 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
color={getOverrideColor(rotationIndex)}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(moment) => {
this.onRotationClick(shiftId, moment);
onClick={(shiftStart, shiftEnd) => {
this.onRotationClick(shiftId, shiftStart, shiftEnd);
}}
transparent={isPreview}
filters={filters}
/>
</CSSTransition>
))
@ -130,8 +140,8 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(moment) => {
this.onRotationClick('new', moment);
onClick={(shiftStart, shiftEnd) => {
this.onRotationClick('new', shiftStart, shiftEnd);
}}
/>
</CSSTransition>
@ -146,7 +156,8 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
shiftMoment={shiftMomentToShowOverrideForm}
shiftStart={propsShiftStartToShowOverrideForm || shiftStartToShowOverrideForm}
shiftEnd={propsShiftEndToShowOverrideForm || shiftEndToShowOverrideForm}
onHide={() => {
this.handleHide();
@ -173,14 +184,14 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
);
}
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({ shiftMomentToShowOverrideForm: moment }, () => {
this.setState({ shiftStartToShowOverrideForm: shiftStart, shiftEndToShowOverrideForm: shiftEnd }, () => {
this.onShowRotationForm(shiftId);
});
};
@ -195,13 +206,13 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
// use start of current day as default start time for override
const startMoment = getStartOfDay(store.currentTimezone);
this.setState({ shiftMomentToShowOverrideForm: startMoment }, () => {
this.setState({ shiftStartToShowOverrideForm: startMoment }, () => {
this.onShowRotationForm('new');
});
};
handleHide = () => {
this.setState({ shiftMomentToShowOverrideForm: undefined }, () => {
this.setState({ shiftStartToShowOverrideForm: undefined, shiftEndToShowOverrideForm: undefined }, () => {
this.onShowRotationForm(undefined);
});
};

View file

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

View file

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

View file

@ -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<HTMLDivElement>) => void;
color?: string;
label?: string;
simplified?: boolean;
filters?: ScheduleFiltersType;
}
const cx = cn.bind(styles);
const ScheduleSlot: FC<ScheduleSlotProps> = 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<number>(0);
const start = dayjs(event.start);
const end = dayjs(event.end);
@ -48,20 +47,13 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
const width = duration / base;
const handleMouseMove = useCallback((event) => {
setMouseX(event.nativeEvent.offsetX);
}, []);
const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now;
return (
<div className={cx('stack')} style={{ width: `${width * 100}%` }}>
{event.is_gap ? (
<Tooltip content={<ScheduleGapDetails event={event} currentTimezone={currentTimezone} />}>
<div className={cx('root', 'root__type_gap')} style={{}}>
{trackMouse && mouseX > 0 && <div style={{ left: `${mouseX}px` }} className={cx('time')} />}
{label && <div className={cx('label')}>{label}</div>}
</div>
<div className={cx('root', 'root__type_gap')} />
</Tooltip>
) : event.is_empty ? (
<div
@ -69,18 +61,12 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
style={{
backgroundColor: color,
}}
>
{label && (
<div className={cx('label')} style={{ color }}>
{label}
</div>
)}
</div>
/>
) : (
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<ScheduleSlotProps> = observer((props) => {
style={{
backgroundColor: color,
}}
onMouseMove={trackMouse ? handleMouseMove : undefined}
onMouseLeave={trackMouse ? () => setMouseX(0) : undefined}
>
{trackMouse && mouseX > 0 && <div style={{ left: `${mouseX}px` }} className={cx('time')} />}
{storeUser && (
<WorkingHours
className={cx('working-hours')}
@ -107,14 +90,7 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
duration={duration}
/>
)}
<div className={cx('title')}>
{userIndex === 0 && label && (
<div className={cx('label')} style={{ color }}>
{label}
</div>
)}
{title}
</div>
<div className={cx('title')}>{title}</div>
</div>
);
@ -124,6 +100,7 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
return (
<Tooltip
interactive
key={userPk}
content={
<ScheduleSlotDetails
@ -131,6 +108,9 @@ const ScheduleSlot: FC<ScheduleSlotProps> = 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 (
<div className={cx('details')}>
<HorizontalGroup>
<VerticalGroup spacing="sm">
<HorizontalGroup spacing="sm">
{isOncall && <IsOncallIcon className={cx('is-oncall-icon')} />}
<Text type="secondary">{user?.username}</Text>
<VerticalGroup>
<HorizontalGroup>
<div className={cx('details-icon')}>
<div className={cx('badge')} style={{ backgroundColor: color }} />
</div>
<Text type="primary" maxWidth="222px">
{getShiftName(shift)}
</Text>
</HorizontalGroup>
<HorizontalGroup align="flex-start">
<div className={cx('details-icon')}>
<Icon className={cx('icon')} name="user" />
</div>
<Text type="primary" className={cx('username')}>
{user?.username}
</Text>
</HorizontalGroup>
<HorizontalGroup align="flex-start">
<div className={cx('details-icon')}>
<Icon className={cx('icon')} name="clock-nine" />
</div>
<Text type="primary" className={cx('second-column')}>
User local time
<br />
{currentMoment.tz(user.timezone).format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentMoment.tz(user.timezone))})
</Text>
<Text type="secondary">
Current timezone
<br />
{currentMoment.tz(currentTimezone).format('DD MMM, HH:mm')}
<br />({getTzOffsetString(currentMoment.tz(currentTimezone))})
</Text>
</HorizontalGroup>
<HorizontalGroup align="flex-start">
<div className={cx('details-icon')}>
<Icon className={cx('icon')} name="arrows-h" />
</div>
<Text type="primary" className={cx('second-column')}>
This shift
<br />
{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}
<br />
{dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}
</Text>
<Text type="secondary">
&nbsp; <br />
{dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')}
<br />
{dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')}
</Text>
</HorizontalGroup>
{!simplified && !event.is_override && (
<HorizontalGroup justify="flex-end">
<Button size="sm" variant="secondary" onClick={handleAddOverride}>
+ Override
</Button>
</HorizontalGroup>
<HorizontalGroup>
<VerticalGroup spacing="none">
<HorizontalGroup spacing="sm">
<img src={Line} />
<VerticalGroup spacing="none">
<Text type="secondary">{dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}</Text>
<Text type="secondary">{dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
</HorizontalGroup>
</VerticalGroup>
<VerticalGroup spacing="sm">
<Text type="primary">{currentTimezone}</Text>
<VerticalGroup spacing="none">
<Text type="primary">{dayjs(event.start).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
<Text type="primary">{dayjs(event.end).tz(currentTimezone).format('DD MMM, HH:mm')}</Text>
</VerticalGroup>
</VerticalGroup>
</HorizontalGroup>
)}
</VerticalGroup>
</div>
);
};

View file

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

View file

@ -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<UsersTimezonesProps> = (props) => {
const store = useStore();
const { userStore } = store;
const { userIds, tz, onTzChange, onCallNow, scheduleId, startMoment } = props;
@ -74,55 +76,75 @@ const UsersTimezones: FC<UsersTimezonesProps> = (props) => {
return (
<div className={cx('root')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Schedule team and timezones
</Text.Title>
<WorkingHours
strong
startMoment={currentMoment.startOf('day')}
duration={24 * 60 * 60}
timezone={userStore.currentUser.timezone}
workingHours={userStore.currentUser.working_hours}
className={cx('working-hours')}
/>
{/* <div className={cx('shades', 'shades--left')} />
<div className={cx('shades', 'shades--right')} /> */}
<div className={cx('content')}>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<div className={cx('title')}>
<Text.Title level={4} type="primary">
Schedule team and timezones
</Text.Title>
</div>
</HorizontalGroup>
<div className={cx('timezone-select')}>
<Text type="secondary">
Current timezone: {tz}, local time: {currentMoment.format('HH:mm')}
</Text>
</div>
</HorizontalGroup>
<div className={cx('timezone-select')}>
<Text type="secondary">
Current timezone: {tz}, local time: {currentMoment.format('HH:mm')}
</Text>
</div>
</HorizontalGroup>
</div>
<div className={cx('users')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX}%` }} />
<UserAvatars
users={users}
onCallNow={onCallNow}
onTzChange={onTzChange}
currentMoment={currentMoment}
startMoment={startMoment}
scheduleId={scheduleId}
/>
</div>
<div className={cx('time-stripe')}>
<div className={cx('current-user-stripe')} />
<div className={cx('time-marks')}>
{momentsToRender.map((mm, index) => (
<div key={index} className={cx('time-mark')} style={{ width: `${100 / jLimit}%` }}>
<span
className={cx('time-mark-text', {
'time-mark-text__translated': index > 0,
})}
>
</div>
<div className={cx('users')}>
<div className={cx('current-time')} style={{ left: `${currentTimeX}%` }} />
{users && users.length ? (
<UserAvatars
users={users}
onCallNow={onCallNow}
onTzChange={onTzChange}
currentMoment={currentMoment}
startMoment={startMoment}
scheduleId={scheduleId}
/>
) : (
<HorizontalGroup justify="center" align="flex-start">
<HorizontalGroup>
<Icon className={cx('icon')} name="users-alt" />
<Text type="secondary">Add rotation to see users</Text>
</HorizontalGroup>
</HorizontalGroup>
)}
</div>
<div className={cx('time-marks-wrapper')}>
<div className={cx('time-marks')}>
{momentsToRender.map((mm, index) => (
<div key={index} className={cx('time-mark')} style={{ width: `${100 / jLimit}%` }}>
<span
className={cx('time-mark-text', {
'time-mark-text__translated': index > 0,
})}
>
<Text type="secondary" size="small">
{mm.format('HH:mm')}
</Text>
</span>
</div>
))}
<div key={jLimit} className={cx('time-mark')}>
<span className={cx('time-mark-text')}>
<Text type="secondary" size="small">
{mm.format('HH:mm')}
24:00
</Text>
</span>
</div>
))}
<div key={jLimit} className={cx('time-mark')}>
<span className={cx('time-mark-text')}>
<Text type="secondary" size="small">
24:00
</Text>
</span>
</div>
</div>
</div>
@ -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<Partial<User>>;
}

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
const tzs: string[] = [
export const tzs: string[] = [
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa',

View file

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

View file

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

View file

@ -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<SchedulePageProps, SchedulePageState>
showScheduleICalSettings: false,
errorData: initErrorDataState(),
lastUpdated: 0,
filters: { users: [] },
};
}
@ -97,6 +103,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
render() {
const {
store,
query,
match: {
params: { id: scheduleId },
},
@ -110,6 +117,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
showEditForm,
showScheduleICalSettings,
errorData,
shiftStartToShowOverrideForm,
shiftEndToShowOverrideForm,
filters,
} = this.state;
const { isNotFoundError } = errorData;
@ -122,12 +132,14 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
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 (
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
@ -139,7 +151,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<VerticalGroup spacing="lg" align="center">
<Text.Title level={1}>404</Text.Title>
<Text.Title level={4}>Schedule not found</Text.Title>
<PluginLink query={{ page: 'schedules' }}>
<PluginLink query={{ page: 'schedules', ...query }}>
<Button variant="secondary" icon="arrow-left" size="md">
Go to Schedules page
</Button>
@ -151,7 +163,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<div className={cx('title')}>
<PluginLink query={{ page: 'schedules' }}>
<PluginLink query={{ page: 'schedules', ...query }}>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
</PluginLink>
<Text.Title
@ -239,6 +251,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</Text.Title>
</HorizontalGroup>
<ScheduleFilters
value={filters}
onChange={(value) => this.setState({ filters: value })}
currentUserPk={store.userStore.currentUserPk}
/>
</HorizontalGroup>
</div>
<ScheduleFinal
@ -247,6 +264,8 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
startMoment={startMoment}
onClick={this.handleShowForm}
disabled={disabledRotationForm}
onShowOverrideForm={this.handleShowOverridesForm}
filters={filters}
/>
<Rotations
scheduleId={scheduleId}
@ -257,7 +276,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
onDelete={this.handleDeleteRotation}
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
onShowRotationForm={this.handleShowRotationForm}
onShowOverrideForm={this.handleShowOverridesForm}
disabled={disabledRotationForm}
filters={filters}
/>
<ScheduleOverrides
scheduleId={scheduleId}
@ -269,6 +290,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
onShowRotationForm={this.handleShowOverridesForm}
disabled={disabledOverrideForm}
shiftStartToShowOverrideForm={shiftStartToShowOverrideForm}
shiftEndToShowOverrideForm={shiftEndToShowOverrideForm}
filters={filters}
/>
</div>
</VerticalGroup>
@ -329,8 +353,12 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
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) => {

View file

@ -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<Schedule['id']>;
scheduleIdToEdit?: Schedule['id'];
@ -63,11 +64,11 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
showNewScheduleSelector: false,
expandedRowKeys: [],
scheduleIdToEdit: undefined,
page: 1,
page: !isNaN(Number(props.query.p)) ? Number(props.query.p) : 1,
};
}
async componentDidMount() {
/* async componentDidMount() {
const {
store,
query: { p },
@ -78,17 +79,15 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
await store.scheduleStore.updateItems(filters, page, () => 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<SchedulesPageProps, SchedulesPageSta
},
{
width: '25%',
title: 'Oncall',
title: 'On-call now',
key: 'users',
render: this.renderOncallNow,
},
@ -232,10 +231,10 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
};
handleCreateSchedule = (data: Schedule) => {
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<SchedulesPageProps, SchedulesPageSta
return (
<div className={cx('schedule')}>
<TimelineMarks startMoment={startMoment} />
<TimelineMarks startMoment={startMoment} timezone={store.currentTimezone} />
<div className={cx('rotations')}>
<ScheduleFinal
hideHeader
simplified
scheduleId={data.id}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
@ -284,9 +283,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
};
getScheduleClickHandler = (scheduleId: Schedule['id']) => {
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<SchedulesPageProps, SchedulesPageSta
<HorizontalGroup>
{item.number_of_escalation_chains > 0 && (
<TooltipBadge
borderType="link"
borderType="success"
icon="link"
text={item.number_of_escalation_chains}
tooltipTitle="Used in escalations"
@ -356,7 +355,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
};
renderName = (item: Schedule) => {
return <PluginLink query={{ page: 'schedules', id: item.id }}>{item.name}</PluginLink>;
const { query } = this.props;
return <PluginLink query={{ page: 'schedules', id: item.id, ...query }}>{item.name}</PluginLink>;
};
renderOncallNow = (item: Schedule, _index: number) => {
@ -426,42 +427,39 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { scheduleStore } = store;
return () => {
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']) => {

View file

@ -166,7 +166,7 @@ export const Root = observer((props: AppRootProps) => {
<Schedules query={query} />
</Route>
<Route path={getRoutesForPage('schedule')} exact>
<Schedule />
<Schedule query={query} />
</Route>
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
<OutgoingWebhooks query={query} />

View file

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

View file

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