add schedules filtering, add checing web schedules feature flag

This commit is contained in:
Maxim 2022-09-08 12:42:16 +03:00
parent 46ba3b3658
commit 23d706cc7f
19 changed files with 166 additions and 61 deletions

View file

@ -139,9 +139,10 @@ export const Root = observer((props: AppRootProps) => {
grafanaUser: window.grafanaBootData.user,
enableLiveSettings: store.hasFeature(AppFeature.LiveSettings),
enableCloudPage: store.hasFeature(AppFeature.CloudConnection),
enableNewSchedulesPage: store.hasFeature(AppFeature.WebSchedules),
backendLicense,
}),
[meta, pathWithoutLeadingSlash, page, store.features]
[meta, pathWithoutLeadingSlash, page, store.features, backendLicense]
)
);
useEffect(() => {

View file

@ -12,6 +12,7 @@ interface ScheduleCounterProps {
count: number;
tooltipTitle: string;
tooltipContent: React.ReactNode;
onHover: () => void;
}
const typeToIcon = {
@ -37,7 +38,7 @@ const typeToBackgroundColor = {
const cx = cn.bind(styles);
const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
const { type, count, tooltipTitle, tooltipContent } = props;
const { type, count, tooltipTitle, tooltipContent, onHover } = props;
return (
<Tooltip
@ -52,7 +53,7 @@ const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
</div>
}
>
<div className={cx('root', { [`root__type_${type}`]: true })}>
<div className={cx('root', { [`root__type_${type}`]: true })} onMouseEnter={onHover}>
<HorizontalGroup spacing="xs">
<Icon className={cx('icon', { [`icon__type_${type}`]: true })} name={typeToIcon[type]} />
<Text type={typeToColor[type]}>{count}</Text>

View file

@ -69,11 +69,12 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => {
</Field>
<Field label="Type">
<RadioButtonGroup
disabled
options={[
{ label: 'All', value: 'all' },
{
label: 'Web',
value: ScheduleType.Calendar,
value: ScheduleType.API,
},
{
label: 'ICal',
@ -81,7 +82,7 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => {
},
{
label: 'API',
value: ScheduleType.API,
value: ScheduleType.Calendar,
},
]}
value={value.type}

View file

@ -17,6 +17,10 @@
background: rgba(63, 62, 62, 0.45);
}
.root th:first-child {
padding-left: 20px;
}
.root td {
min-height: 60px;
padding: 10px 0;

View file

@ -64,7 +64,11 @@ const UserGroups = (props: UserGroupsProps) => {
}
const newGroups = [...value];
const lastGroup = newGroups[newGroups.length - 1];
let lastGroup = newGroups[newGroups.length - 1];
if (!lastGroup) {
lastGroup = [];
newGroups.push(lastGroup);
}
lastGroup.push(pk);
@ -73,11 +77,6 @@ const UserGroups = (props: UserGroupsProps) => {
[value]
);
const filterUsers = useCallback(
({ value: itemValue }) => !value.some((group: Array<User['pk']>) => group.some((pk) => pk === itemValue)),
[value]
);
const items = useMemo(() => toPlainArray(value, getItemData), [value]);
const onSortEnd = useCallback(
@ -134,7 +133,6 @@ const UserGroups = (props: UserGroupsProps) => {
value={null}
onChange={handleUserAdd}
getOptionLabel={({ label, value }: SelectableValue) => <UserTooltip id={value} />}
filterOptions={filterUsers}
showError={showError}
/>
</VerticalGroup>

View file

@ -256,7 +256,7 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
<Text size="medium">
<HorizontalGroup spacing="sm">
<span>[L{shiftId === 'new' ? layerPriority : shift?.priority_level}]</span>
{shiftId === 'new' ? 'New Rotation' : shift?.id}
{shiftId === 'new' ? 'New Rotation' : 'Update Rotation'}
</HorizontalGroup>
</Text>
<HorizontalGroup>

View file

@ -198,7 +198,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<Text size="medium">{shiftId === 'new' ? 'New Override' : shift?.id}</Text>
<Text size="medium">{shiftId === 'new' ? 'New Override' : 'Update Override'}</Text>
<HorizontalGroup>
<IconButton disabled variant="secondary" tooltip="Copy" name="copy" />
<IconButton disabled variant="secondary" tooltip="Code" name="brackets-curly" />

View file

@ -1,7 +1,7 @@
.root {
border: var(--border-medium);
border: var(--rotations-border);
border-radius: 2px;
background: var(--primary-background);
background: var(--rotations-background);
}
.current-time {

View file

@ -56,7 +56,14 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
return (
<div className={cx('stack')} style={{ width: `${width * 100}%` /*left: `${x * 100}%`*/ }}>
{event.is_empty ? (
{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>
</Tooltip>
) : event.is_empty ? (
<div
className={cx('root')}
style={{
@ -69,13 +76,6 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
</div>
)}
</div>
) : 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>
</Tooltip>
) : (
users.map(({ pk: userPk }, userIndex) => {
const storeUser = store.userStore.items[userPk];

View file

@ -1,5 +1,6 @@
export interface EscalationChain {
id: string;
pk: string; //? because GET related_escalation_chains returns {name,pk}[]
is_default: boolean;
name: string;
number_of_integrations: number;

View file

@ -5,6 +5,7 @@ import { action, observable, toJS } from 'mobx';
import ReactCSSTransitionGroup from 'react-transition-group'; // ES6
import BaseStore from 'models/base_store';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
import { Timezone } from 'models/timezone/timezone.types';
import { makeRequest } from 'network';
@ -63,6 +64,9 @@ export class ScheduleStore extends BaseStore {
@observable.shallow
shifts: { [id: string]: Shift } = {};
@observable.shallow
relatedEscalationChains: { [id: string]: EscalationChain[] } = {};
@observable.shallow
rotations: {
[id: string]: {
@ -122,7 +126,7 @@ export class ScheduleStore extends BaseStore {
@action
async updateItems(query = '') {
const result = await this.getAll();
const result = await makeRequest(this.path, { method: 'GET', params: { search: query } });
this.items = {
...this.items,
@ -259,6 +263,19 @@ export class ScheduleStore extends BaseStore {
return response;
}
updateRelatedEscalationChains = async (id: Schedule['id']) => {
const response = await makeRequest(`/schedules/${id}/related_escalation_chains`, {
method: 'GET',
});
this.relatedEscalationChains = {
...this.relatedEscalationChains,
[id]: response,
};
return response;
};
async updateRotationMock(rotationId: Rotation['id'], fromString: string, currentTimezone: Timezone) {
if (this.rotations[rotationId]?.[fromString]) {
return;
@ -342,7 +359,7 @@ export class ScheduleStore extends BaseStore {
async deleteOncallShift(shiftId: Shift['id']) {
return await makeRequest(`/oncall_shifts/${shiftId}`, {
method: 'DELETE',
});
}).catch(this.onApiError);
}
async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days = 9) {

View file

@ -25,6 +25,7 @@ export interface Schedule {
mention_oncall_next: boolean;
mention_oncall_start: boolean;
notify_empty_oncall: number;
number_of_escalation_chains: number;
}
export interface ScheduleEvent {

View file

@ -64,14 +64,14 @@ export const pages: PageDefinition[] = [
{
component: SchedulesPage2,
icon: 'calendar-alt',
id: 'schedules-old',
text: 'Schedules OLD',
id: 'schedules',
text: 'Schedules',
},
{
component: SchedulesPage,
icon: 'calendar-alt',
id: 'schedules',
text: 'Schedules',
id: 'schedules-new',
text: 'Schedules α',
},
{
component: SchedulePage,

View file

@ -1,7 +1,11 @@
.root {
max-width: 1600px;
margin: 0 auto;
margin-top: 24px;
--rotations-border: var(--border-medium);
--rotations-background: var(--primary-background);
}
.header {

View file

@ -22,6 +22,7 @@ import ScheduleFinal from 'containers/Rotations/ScheduleFinal';
import ScheduleOverrides from 'containers/Rotations/ScheduleOverrides';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
@ -58,6 +59,10 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
const { store } = this.props;
const { startMoment } = this.state;
if (!store.hasFeature(AppFeature.WebSchedules)) {
getLocationSrv().update({ query: { page: 'schedules' } });
}
store.userStore.updateItems();
const {
@ -89,7 +94,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<PluginLink query={{ page: 'schedules' }}>
<PluginLink query={{ page: 'schedules-new' }}>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xxl" />
</PluginLink>
<Text.Title editable editModalTitle="Schedule name" level={3} onTextChange={this.handleNameChange}>
@ -334,7 +339,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
} = this.props;
store.scheduleStore.delete(scheduleId).then(() => {
getLocationSrv().update({ query: { page: 'schedules' } });
getLocationSrv().update({ query: { page: 'schedules-new' } });
});
};

View file

@ -11,6 +11,10 @@
margin: 20px 0;
}
.loader {
padding-left: 20px;
}
/*
.root .expanded-row {
background: var(--secondary-background);

View file

@ -1,9 +1,10 @@
import React from 'react';
import { getLocationSrv } from '@grafana/runtime';
import { Button, HorizontalGroup, IconButton, VerticalGroup } from '@grafana/ui';
import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
@ -17,13 +18,12 @@ import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import Rotation from 'containers/Rotation/Rotation';
import ScheduleFinal from 'containers/Rotations/ScheduleFinal';
import { getFromString } from 'models/schedule/schedule.helpers';
import { Schedule, ScheduleType } from 'models/schedule/schedule.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { getStartOfWeek } from 'pages/schedule/Schedule.helpers';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
@ -48,7 +48,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { store } = this.props;
this.state = {
startMoment: getStartOfWeek(store.currentTimezone),
filters: { searchTerm: '', status: 'all', type: 'all' },
filters: { searchTerm: '', status: 'all', type: ScheduleType.API },
showNewScheduleSelector: false,
expandedRowKeys: [],
};
@ -57,6 +57,10 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
async componentDidMount() {
const { store } = this.props;
if (!store.hasFeature(AppFeature.WebSchedules)) {
getLocationSrv().update({ query: { page: 'schedules' } });
}
store.userStore.updateItems();
store.scheduleStore.updateItems();
}
@ -67,8 +71,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { scheduleStore } = store;
const schedules = scheduleStore.getSearchResult();
const schedules = scheduleStore.getSearchResult(/*filters.searchTerm*/);
const columns = [
{
width: '10%',
@ -84,7 +87,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
},
{
width: '45%',
title: 'OnCall',
title: 'Oncall',
key: 'users',
render: this.renderOncallNow,
},
@ -107,10 +110,20 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
},
];
const moment = dayjs().tz(store.currentTimezone);
const users = store.userStore.getSearchResult().results;
const data = schedules
? schedules
.filter((schedule) => schedule.type === ScheduleType.API)
.filter(
(schedule) =>
filters.status === 'all' ||
(filters.status === 'used' && schedule.number_of_escalation_chains) ||
(filters.status === 'unused' && !schedule.number_of_escalation_chains)
)
.filter((schedule) => !filters.searchTerm || schedule.name.includes(filters.searchTerm))
: undefined;
return (
<>
<div className={cx('root')}>
@ -132,7 +145,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
</HorizontalGroup>
<Table
columns={columns}
data={schedules}
data={data}
pagination={{ page: 1, total: 1, onChange: this.handlePageChange }}
rowKey="id"
expandable={{
@ -142,6 +155,11 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
expandRowByClick: true,
expandedRowClassName: () => cx('expanded-row'),
}}
emptyText={
<div className={cx('loader')}>
{data ? <Text type="secondary">Not found</Text> : <Text type="secondary">Loading schedules...</Text>}
</div>
}
/>
</VerticalGroup>
</div>
@ -186,7 +204,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
this.setState({ expandedRowKeys: [...this.state.expandedRowKeys, data.id] }, this.updateEvents);
} else if (!expanded && expandedRowKeys.includes(data.id)) {
const index = expandedRowKeys.indexOf(data.id);
this.setState({ expandedRowKeys: [...expandedRowKeys.splice(index, 1)] }, this.updateEvents);
const newExpandedRowKeys = [...expandedRowKeys];
newExpandedRowKeys.splice(index, 1);
this.setState({ expandedRowKeys: newExpandedRowKeys }, this.updateEvents);
}
};
@ -195,7 +215,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
const { expandedRowKeys, startMoment } = this.state;
expandedRowKeys.forEach((scheduleId) => {
store.scheduleStore.updateEvents(scheduleId, getFromString(startMoment), 'final');
store.scheduleStore.updateEvents(scheduleId, startMoment, 'rotation');
store.scheduleStore.updateEvents(scheduleId, startMoment, 'override');
store.scheduleStore.updateEvents(scheduleId, startMoment, 'final');
});
};
@ -218,25 +240,37 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
);
};
renderStatus = () => {
const escalationsCount = Math.floor(Math.random() * 10) + 1;
const warningsCount = Math.floor(Math.random() * 10) + 1;
renderStatus = (item: Schedule) => {
const {
store: { scheduleStore },
} = this.props;
const relatedEscalationChains = scheduleStore.relatedEscalationChains[item.id];
return (
<HorizontalGroup>
<ScheduleCounter
type="link"
count={escalationsCount}
count={item.number_of_escalation_chains}
tooltipTitle="Used in escalations"
tooltipContent={
<>
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 1</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 2</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 3</PluginLink>
</>
<VerticalGroup spacing="sm">
{relatedEscalationChains ? (
relatedEscalationChains.length ? (
relatedEscalationChains.map((escalationChain) => (
<PluginLink query={{ page: 'escalations', id: escalationChain.pk }}>
{escalationChain.name}
</PluginLink>
))
) : (
'Not used yet'
)
) : (
<LoadingPlaceholder>Loading related escalation chains....</LoadingPlaceholder>
)}
</VerticalGroup>
}
onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)}
/>
{/* <ScheduleCounter
type="warning"
@ -305,9 +339,19 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
};
handleSchedulesFiltersChange = (filters: SchedulesFiltersType) => {
this.setState({ filters });
this.setState({ filters }, this.debouncedUpdateSchedules);
};
applyFilters = () => {
const { filters } = this.state;
const { store } = this.props;
const { scheduleStore } = store;
// scheduleStore.updateItems(filters.searchTerm);
};
debouncedUpdateSchedules = debounce(this.applyFilters, 1000);
handlePageChange = (page: number) => {};
update = () => {
@ -316,6 +360,17 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
return scheduleStore.updateItems();
};
getUpdateRelatedEscalationChainsHandler = (scheduleId: Schedule['id']) => {
const { store } = this.props;
const { scheduleStore } = store;
return () => {
scheduleStore.updateRelatedEscalationChains(scheduleId).then(() => {
this.forceUpdate();
});
};
};
}
export default withMobXProviderContext(SchedulesPage);

View file

@ -5,4 +5,5 @@ export enum AppFeature {
MobileApp = 'mobile_app',
CloudNotifications = 'grafana_cloud_notifications',
CloudConnection = 'grafana_cloud_connection',
WebSchedules = 'web_schedules',
}

View file

@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { useMemo } from 'react';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { AppRootProps, NavModelItem } from '@grafana/data';
@ -18,11 +17,12 @@ type Args = {
};
enableLiveSettings: boolean;
enableCloudPage: boolean;
enableNewSchedulesPage: boolean;
backendLicense: string;
};
export function useForceUpdate() {
const [value, setValue] = useState(0);
const [, setValue] = useState(0);
return () => setValue((value) => value + 1);
}
@ -34,6 +34,7 @@ export function useNavModel({
grafanaUser,
enableLiveSettings,
enableCloudPage,
enableNewSchedulesPage,
backendLicense,
}: Args) {
return useMemo(() => {
@ -49,7 +50,8 @@ export function useNavModel({
hideFromTabs ||
(role === 'Admin' && grafanaUser.orgRole !== role) ||
(id === 'live-settings' && !enableLiveSettings) ||
(id === 'cloud' && !enableCloudPage),
(id === 'cloud' && !enableCloudPage) ||
(id === 'schedules-new' && !enableNewSchedulesPage),
});
if (page === id) {
@ -74,7 +76,17 @@ export function useNavModel({
node,
main: node,
};
}, [meta.info.logos.large, pages, path, page, enableLiveSettings, enableCloudPage]);
}, [
meta.info.logos.large,
pages,
path,
page,
enableLiveSettings,
enableCloudPage,
backendLicense,
enableNewSchedulesPage,
grafanaUser.orgRole,
]);
}
export function usePrevious(value: any) {