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:
parent
9e15fe6875
commit
9f0064f21b
59 changed files with 2389 additions and 980 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
3
grafana-plugin/src/components/GForm/GForm.module.scss
Normal file
3
grafana-plugin/src/components/GForm/GForm.module.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.collapse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,4 +22,5 @@ export interface FormItem {
|
|||
validation?: (v: any) => boolean;
|
||||
};
|
||||
extra?: any;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
box-shadow: var(--shadows-z3);
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
overflow: scroll;
|
||||
|
||||
/* overflow: scroll; */
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { User } from 'models/user/user.types';
|
||||
|
||||
export interface ScheduleFiltersType {
|
||||
users: Array<User['pk']>;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export const getLabel = (layerIndex: number, rotationIndex) => {
|
||||
return `L ${layerIndex + 1}-${rotationIndex + 1}`;
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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
|
|
@ -1,3 +1,11 @@
|
|||
export interface RotationCreateData {}
|
||||
|
||||
export interface RotationData {}
|
||||
|
||||
export enum RepeatEveryPeriod {
|
||||
'DAYS' = 0,
|
||||
'WEEKS' = 1,
|
||||
'MONTHS' = 2,
|
||||
'HOURS' = 3,
|
||||
'MINUTES' = 4,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const tzs: string[] = [
|
||||
export const tzs: string[] = [
|
||||
'Africa/Abidjan',
|
||||
'Africa/Accra',
|
||||
'Africa/Addis_Ababa',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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']) => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue