Rares/score quality (#1324)
# What this PR does #118 ## Checklist - [x] Tests updated - [ ] Documentation added - [x] `CHANGELOG.md` updated
This commit is contained in:
parent
defae434ba
commit
15f6898426
29 changed files with 1137 additions and 187 deletions
|
|
@ -140,6 +140,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
- Add direct user paging ([823](https://github.com/grafana/oncall/issues/823))
|
||||
- Add App Store link to web UI ([1328](https://github.com/grafana/oncall/pull/1328))
|
||||
- Added Schedule Score quality within the schedule view ([118](https://github.com/grafana/oncall/issues/118))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,29 @@
|
|||
.root {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.root__type_link {
|
||||
padding: 2px 4px;
|
||||
background: rgba(27, 133, 94, 0.15);
|
||||
border: 1px solid var(--success-text-color);
|
||||
border: 1px solid var(--tag-border-success);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.root__type_warning {
|
||||
padding: 2px 4px;
|
||||
background: rgba(245, 183, 61, 0.18);
|
||||
border: 1px solid var(--warning-text-color);
|
||||
border: 1px solid var(--tag-border-warning);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.text__type_link,
|
||||
.icon__type_link {
|
||||
color: var(--success-text-color);
|
||||
color: var(--tag-text-success);
|
||||
}
|
||||
|
||||
.text__type_warning,
|
||||
.icon__type_warning {
|
||||
color: var(--warning-text-color);
|
||||
color: var(--tag-text-warning);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ interface ScheduleCounterProps {
|
|||
count: number;
|
||||
tooltipTitle: string;
|
||||
tooltipContent: React.ReactNode;
|
||||
onHover: () => void;
|
||||
addPadding?: boolean;
|
||||
onHover?: () => void;
|
||||
}
|
||||
|
||||
const typeToIcon = {
|
||||
|
|
@ -20,15 +21,10 @@ const typeToIcon = {
|
|||
warning: 'exclamation-triangle',
|
||||
};
|
||||
|
||||
const typeToColor = {
|
||||
link: 'success',
|
||||
warning: 'warning',
|
||||
};
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
|
||||
const { type, count, tooltipTitle, tooltipContent, onHover } = props;
|
||||
const { type, count, tooltipTitle, tooltipContent, onHover, addPadding } = props;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
|
|
@ -37,16 +33,16 @@ const ScheduleCounter: FC<ScheduleCounterProps> = (props) => {
|
|||
content={
|
||||
<div className={cx('tooltip', { [`tooltip__type_${type}`]: true })}>
|
||||
<VerticalGroup>
|
||||
<Text type={typeToColor[type]}>{tooltipTitle}</Text>
|
||||
<Text type="secondary">{tooltipTitle}</Text>
|
||||
<Text type="secondary">{tooltipContent}</Text>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={cx('root', { [`root__type_${type}`]: true })} onMouseEnter={onHover}>
|
||||
<div className={cx('root', { [`root__type_${type}`]: true }, { padding: addPadding })} onMouseEnter={onHover}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Icon className={cx('icon', { [`icon__type_${type}`]: true })} name={typeToIcon[type] as IconName} />
|
||||
<Text type={typeToColor[type] as TextType}>{count}</Text>
|
||||
<Text className={cx('text', { [`text__type_${type}`]: true })}>{count}</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
.root {
|
||||
padding: 4px 10px;
|
||||
gap: 10px;
|
||||
background: var(--primary-background);
|
||||
border: var(--border-medium);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.details {
|
||||
width: auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
background-color: var(--secondary-background);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-filler {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.progress-filler__type_success {
|
||||
background-color: var(--success-text-color);
|
||||
}
|
||||
|
||||
.progress-filler__type_warning {
|
||||
background-color: var(--warning-text-color);
|
||||
}
|
||||
|
||||
.quality-text {
|
||||
float: right;
|
||||
line-height: 16px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.quality-text__type_success {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.quality-text__type_warning {
|
||||
color: #111217;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
$score-primary: rgba(27, 133, 94, 0.15);
|
||||
$score-warning: rgba(245, 183, 61, 0.18);
|
||||
$score-danger: rgba(209, 14, 92, 0.15);
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quality {
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px 3px 10px;
|
||||
|
||||
&--danger {
|
||||
background-color: $score-danger;
|
||||
color: var(--tag-text-danger);
|
||||
border: 1px solid var(--tag-border-danger);
|
||||
}
|
||||
&--warning {
|
||||
background-color: $score-warning;
|
||||
color: var(--tag-text-warning);
|
||||
border: 1px solid var(--tag-border-warning);
|
||||
}
|
||||
&--primary {
|
||||
background-color: $score-primary;
|
||||
color: var(--tag-text-success);
|
||||
border: 1px solid var(--tag-border-success);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +1,131 @@
|
|||
import React, { FC, useCallback, useState } from 'react';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
|
||||
import { HorizontalGroup, VerticalGroup, Icon, IconButton, Tooltip } from '@grafana/ui';
|
||||
import { Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
|
||||
import { ScheduleQualityDetails } from 'components/ScheduleQualityDetails/ScheduleQualityDetails';
|
||||
import Tag from 'components/Tag/Tag';
|
||||
import Text from 'components/Text/Text';
|
||||
import { Schedule, ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
import styles from './ScheduleQuality.module.css';
|
||||
|
||||
interface ScheduleQualityProps {
|
||||
quality: number;
|
||||
}
|
||||
import styles from './ScheduleQuality.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const ScheduleQuality: FC<ScheduleQualityProps> = (props) => {
|
||||
const { quality } = props;
|
||||
|
||||
return (
|
||||
<Tooltip placement="bottom-end" interactive content={<SheduleQualityDetails quality={quality} />}>
|
||||
<div className={cx('root')}>
|
||||
<HorizontalGroup spacing="sm">
|
||||
<Text type="secondary">Quality:</Text>
|
||||
<Text type="primary">{Math.floor(quality * 100)}%</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface ScheduleQualityDetailsProps {
|
||||
quality: number;
|
||||
interface ScheduleQualityProps {
|
||||
schedule: Schedule;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
const SheduleQualityDetails = (props: ScheduleQualityDetailsProps) => {
|
||||
const { quality } = props;
|
||||
const ScheduleQuality: FC<ScheduleQualityProps> = ({ schedule, lastUpdated }) => {
|
||||
const { scheduleStore } = useStore();
|
||||
const [qualityResponse, setQualityResponse] = useState<ScheduleScoreQualityResponse>(undefined);
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
if (schedule.id) {
|
||||
fetchScoreQuality();
|
||||
}
|
||||
}, [schedule.id, lastUpdated]);
|
||||
|
||||
const type = quality > 0.8 ? 'success' : 'warning';
|
||||
if (!qualityResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const qualityPercent = quality * 100;
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded((expanded) => !expanded);
|
||||
}, []);
|
||||
const relatedEscalationChains = scheduleStore.relatedEscalationChains[schedule.id];
|
||||
|
||||
return (
|
||||
<div className={cx('details')}>
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">Schedule quality</Text>
|
||||
<div className={cx('progress')}>
|
||||
<div
|
||||
style={{ width: `${qualityPercent}%` }}
|
||||
className={cx('progress-filler', {
|
||||
[`progress-filler__type_${type}`]: true,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cx('quality-text', {
|
||||
[`quality-text__type_${type}`]: true,
|
||||
})}
|
||||
>
|
||||
{qualityPercent}%
|
||||
</div>{' '}
|
||||
</div>
|
||||
</div>
|
||||
{type === 'success' && (
|
||||
<Text type="primary">
|
||||
You are doing a great job! <br />
|
||||
Schedule is well balanced for all members.
|
||||
</Text>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && (
|
||||
<ScheduleCounter
|
||||
type="link"
|
||||
addPadding
|
||||
count={schedule.number_of_escalation_chains}
|
||||
tooltipTitle="Used in escalations"
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{relatedEscalationChains.map((escalationChain) => (
|
||||
<div key={escalationChain.pk}>
|
||||
<PluginLink query={{ page: 'escalations', id: escalationChain.pk }} className={cx('link')}>
|
||||
<Text type="link">{escalationChain.name}</Text>
|
||||
</PluginLink>
|
||||
</div>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{type === 'warning' && <Text type="primary">Your schedule has balance problems.</Text>}
|
||||
<hr style={{ width: '100%' }} />
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="sm">
|
||||
<Icon name="info-circle" />
|
||||
<Text type="secondary">Calculation methodology</Text>
|
||||
</HorizontalGroup>
|
||||
<IconButton name="angle-down" onClick={handleExpandClick} />
|
||||
</HorizontalGroup>
|
||||
{expanded && (
|
||||
<Text type="secondary">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum purus egestas porta ultricies.
|
||||
Sed quis maximus sem. Phasellus semper pulvinar sapien ac euismod.
|
||||
</Text>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
||||
{schedule.warnings?.length > 0 && (
|
||||
<ScheduleCounter
|
||||
type="warning"
|
||||
addPadding
|
||||
count={schedule.warnings.length}
|
||||
tooltipTitle="Warnings"
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="none">
|
||||
{schedule.warnings.map((warning, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
interactive
|
||||
content={
|
||||
<ScheduleQualityDetails quality={qualityResponse} getScheduleQualityString={getScheduleQualityString} />
|
||||
}
|
||||
>
|
||||
<div className={cx('u-cursor-default')}>
|
||||
<Tag className={cx('tag', getTagClass())}>
|
||||
Quality: <strong>{getScheduleQualityString(qualityResponse.total_score)}</strong>
|
||||
</Tag>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function getScheduleQualityString(score: number): ScheduleScoreQualityResult {
|
||||
if (score < 20) {
|
||||
return ScheduleScoreQualityResult.Bad;
|
||||
}
|
||||
if (score < 40) {
|
||||
return ScheduleScoreQualityResult.Low;
|
||||
}
|
||||
if (score < 60) {
|
||||
return ScheduleScoreQualityResult.Medium;
|
||||
}
|
||||
if (score < 80) {
|
||||
return ScheduleScoreQualityResult.Good;
|
||||
}
|
||||
return ScheduleScoreQualityResult.Great;
|
||||
}
|
||||
|
||||
async function fetchScoreQuality() {
|
||||
await Promise.all([
|
||||
scheduleStore.getScoreQuality(schedule.id).then((qualityResponse) => setQualityResponse(qualityResponse)),
|
||||
scheduleStore.updateRelatedEscalationChains(schedule.id),
|
||||
]);
|
||||
}
|
||||
|
||||
function getTagClass() {
|
||||
if (qualityResponse?.total_score < 20) {
|
||||
return 'tag--danger';
|
||||
}
|
||||
if (qualityResponse?.total_score < 60) {
|
||||
return 'tag--warning';
|
||||
}
|
||||
return 'tag--primary';
|
||||
}
|
||||
};
|
||||
|
||||
export default ScheduleQuality;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
$padding: 8px;
|
||||
$width: 280px;
|
||||
|
||||
.root {
|
||||
width: $width;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
&--withTopPadding {
|
||||
padding-top: $padding;
|
||||
}
|
||||
|
||||
&--withLateralPadding {
|
||||
padding-left: $padding;
|
||||
padding-right: $padding;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-bottom: calc($padding / 2);
|
||||
}
|
||||
.header__subText {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.line-break {
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--always-gray);
|
||||
margin-top: 8px;
|
||||
opacity: 15%;
|
||||
}
|
||||
|
||||
.metholodogy {
|
||||
padding: 4px 0px;
|
||||
}
|
||||
|
||||
.text {
|
||||
word-wrap: break-word;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.email {
|
||||
max-width: calc($width - $padding);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { HorizontalGroup, Icon, IconButton } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import { ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types';
|
||||
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
|
||||
import styles from './ScheduleQualityDetails.module.scss';
|
||||
import { ScheduleQualityProgressBar } from './ScheduleQualityProgressBar';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface ScheduleQualityDetailsProps {
|
||||
quality: ScheduleScoreQualityResponse;
|
||||
getScheduleQualityString: (score: number) => ScheduleScoreQualityResult;
|
||||
}
|
||||
|
||||
export const ScheduleQualityDetails: FC<ScheduleQualityDetailsProps> = ({ quality, getScheduleQualityString }) => {
|
||||
const { userStore } = useStore();
|
||||
const { total_score: score, comments, overloaded_users } = quality;
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [overloadedUsers, setOverloadedUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded((expanded) => !expanded);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoComments = comments.filter((c) => c.type === 'info');
|
||||
const warningComments = comments.filter((c) => c.type === 'warning');
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('container', 'container--withLateralPadding')}>
|
||||
<Text type={cx('secondary', 'header')}>
|
||||
Schedule quality:{' '}
|
||||
<Text style={{ color: getScheduleQualityMatchingColor(score) }} className={cx('header__subText')}>
|
||||
{getScheduleQualityString(score)}
|
||||
</Text>
|
||||
</Text>
|
||||
<ScheduleQualityProgressBar completed={quality.total_score} numTotalSteps={5} />
|
||||
</div>
|
||||
|
||||
<div className={cx('container', 'container--withTopPadding', 'container--withLateralPadding')}>
|
||||
{comments?.length > 0 && (
|
||||
<>
|
||||
{/* Show Info comments */}
|
||||
{infoComments?.length > 0 && (
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('row')}>
|
||||
<Icon name="info-circle" />
|
||||
<div className={cx('container')}>
|
||||
{infoComments.map((comment, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
{comment.text}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Warning comments afterwards */}
|
||||
{warningComments?.length > 0 && (
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('row')}>
|
||||
<Icon name="calendar-alt" />
|
||||
<div className={cx('container')}>
|
||||
<Text type="secondary">Rotation structure issues</Text>
|
||||
{warningComments.map((comment, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
{comment.text}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{overloadedUsers?.length > 0 && (
|
||||
<div className={cx('container')}>
|
||||
<div className={cx('row')}>
|
||||
<Icon name="users-alt" />
|
||||
<div className={cx('container')}>
|
||||
<Text type="secondary">Overloaded users</Text>
|
||||
{overloadedUsers.map((overloadedUser, index) => (
|
||||
<Text type="primary" className={cx('email')} key={index}>
|
||||
{overloadedUser.email} ({getTzOffsetString(dayjs().tz(overloadedUser.timezone))})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cx('line-break')} />
|
||||
|
||||
<div className={cx('container', 'container--withTopPadding', 'container--withLateralPadding')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="sm">
|
||||
<Icon name="calculator-alt" />
|
||||
<Text type="secondary" className={cx('metholodogy')}>
|
||||
Calculation methodology
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<IconButton name={expanded ? 'arrow-down' : 'arrow-right'} onClick={handleExpandClick} />
|
||||
</HorizontalGroup>
|
||||
{expanded && (
|
||||
<Text type="primary" className={cx('text')}>
|
||||
The latest 90 days are taken into consideration when calculating the overall schedule quality.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function fetchUsers() {
|
||||
if (!overloaded_users?.length) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const allUsersList: User[] = userStore.getSearchResult().results;
|
||||
const overloadedUsers = [];
|
||||
|
||||
allUsersList.forEach((user) => {
|
||||
if (overloaded_users.indexOf(user['pk']) !== -1) {
|
||||
overloadedUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setOverloadedUsers(overloadedUsers);
|
||||
}
|
||||
|
||||
function getScheduleQualityMatchingColor(score: number): string {
|
||||
if (score < 20) {
|
||||
return getVar('--tag-text-danger');
|
||||
}
|
||||
if (score < 60) {
|
||||
return getVar('--tag-text-warning');
|
||||
}
|
||||
return getVar('--tag-text-success');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
$border-radius: 2px;
|
||||
|
||||
@mixin progressBar($color) {
|
||||
background-color: $color;
|
||||
border-radius: $border-radius;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.c-progressBar__wrapper {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.c-progressBar__bar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.c-progressBar__bar--warning {
|
||||
background-color: var(--warning-text-color);
|
||||
}
|
||||
.c-progressBar__bar--error {
|
||||
background-color: var(--error-text-color);
|
||||
}
|
||||
.c-progressBar__bar--primary {
|
||||
background-color: var(--success-text-color);
|
||||
}
|
||||
|
||||
.c-progressBar__row {
|
||||
background-color: var(--gray-8);
|
||||
}
|
||||
|
||||
.c-progressBar__row:first-child,
|
||||
.c-progressBar__row:first-child > .c-progressBar__bar {
|
||||
border-top-left-radius: $border-radius;
|
||||
border-bottom-left-radius: $border-radius;
|
||||
}
|
||||
.c-progressBar__row:last-child,
|
||||
.c-progressBar__row:last-child > .c-progressBar__bar {
|
||||
border-top-right-radius: $border-radius;
|
||||
border-bottom-right-radius: $border-radius;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { ScheduleQualityProgressBar } from './ScheduleQualityProgressBar';
|
||||
|
||||
const NUM_STEPS = 5;
|
||||
const DANGER_CLASS = 'c-progressBar__bar--danger';
|
||||
const WARNING_CLASS = 'c-progressBar__bar--warning';
|
||||
const SUCCESS_CLASS = 'c-progressBar__bar--primary';
|
||||
|
||||
describe('SourceCode', () => {
|
||||
test('It renders 0% complete', () => {
|
||||
render(<ScheduleQualityProgressBar completed={0} numTotalSteps={NUM_STEPS} />);
|
||||
|
||||
expect(screen.queryAllByTestId<HTMLElement>('progressBar__bar').length).toEqual(NUM_STEPS);
|
||||
const allBars = screen.queryAllByTestId<HTMLElement>('progressBar__bar');
|
||||
allBars.forEach((bar) => expect(bar.getAttribute('style').includes('width: 0%')));
|
||||
});
|
||||
|
||||
test('It renders 100% complete', () => {
|
||||
render(<ScheduleQualityProgressBar completed={100} numTotalSteps={NUM_STEPS} />);
|
||||
|
||||
expect(screen.queryAllByTestId<HTMLElement>('progressBar__bar').length).toEqual(NUM_STEPS);
|
||||
const allBars = screen.queryAllByTestId<HTMLElement>('progressBar__bar');
|
||||
allBars.forEach((bar) => expect(bar.getAttribute('style').includes('width: 100%')));
|
||||
});
|
||||
|
||||
test.each([0, 25, 30, 50, 65, 70, 100])('It renders at %p%', (completed) => {
|
||||
const component = render(<ScheduleQualityProgressBar completed={completed} numTotalSteps={NUM_STEPS} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each([0, 10, 19])('It renders as danger at <20% completion', (completed) => {
|
||||
render(<ScheduleQualityProgressBar completed={completed} numTotalSteps={NUM_STEPS} />);
|
||||
|
||||
screen
|
||||
.queryAllByTestId<HTMLElement>('progress__bar')
|
||||
.forEach((elem) => expect(Array.from(elem.classList).includes(DANGER_CLASS)));
|
||||
});
|
||||
|
||||
test.each([20, 31, 41, 61])('It renders as warning at <60% completion', (completed) => {
|
||||
render(<ScheduleQualityProgressBar completed={completed} numTotalSteps={NUM_STEPS} />);
|
||||
|
||||
screen
|
||||
.queryAllByTestId<HTMLElement>('progress__bar')
|
||||
.forEach((elem) => expect(Array.from(elem.classList).includes(WARNING_CLASS)));
|
||||
});
|
||||
|
||||
test.each([60, 61, 79, 99, 100])('It renders as success at >=60% completion', (completed) => {
|
||||
render(<ScheduleQualityProgressBar completed={completed} numTotalSteps={NUM_STEPS} />);
|
||||
|
||||
screen
|
||||
.queryAllByTestId<HTMLElement>('progress__bar')
|
||||
.forEach((elem) => expect(Array.from(elem.classList).includes(SUCCESS_CLASS)));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import styles from './ScheduleQualityProgressBar.module.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
completed: number;
|
||||
className?: string;
|
||||
numTotalSteps?: number;
|
||||
}
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export const ScheduleQualityProgressBar: React.FC<ProgressBarProps> = ({ className, completed, numTotalSteps }) => {
|
||||
const classList = ['c-progressBar__bar', className || ''];
|
||||
|
||||
return (
|
||||
<div className={cx('c-progressBar__wrapper')}>
|
||||
{!numTotalSteps && <div className={classList.join(' ')} style={{ width: `${completed}%` }} />}
|
||||
{renderSteps(numTotalSteps, completed)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderSteps(numTotalSteps: number, completed: number) {
|
||||
if (!numTotalSteps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxFillPerRow = 100 / numTotalSteps;
|
||||
const rowFill = calculateRowFill(numTotalSteps, completed);
|
||||
|
||||
return new Array(numTotalSteps).fill(0).map((_row, index) => {
|
||||
const percentWidth = rowFill[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cx('c-progressBar__row', 'c-progressBar__row--progress')}
|
||||
data-testid="progressBar__row"
|
||||
style={{ width: `${maxFillPerRow}%` }}
|
||||
>
|
||||
<div
|
||||
className={cx('c-progressBar__bar', getClassForCompletionLevel())}
|
||||
data-testid="progressBar__bar"
|
||||
style={{ width: `${percentWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getClassForCompletionLevel() {
|
||||
if (completed < 20) {
|
||||
return 'c-progressBar__bar--danger';
|
||||
}
|
||||
if (completed < 60) {
|
||||
return 'c-progressBar__bar--warning';
|
||||
}
|
||||
return 'c-progressBar__bar--primary';
|
||||
}
|
||||
|
||||
function calculateRowFill(numTotalSteps: number, completed: number): number[] {
|
||||
const fillPerRows = [];
|
||||
const maxFillPerRow = 100 / numTotalSteps;
|
||||
let leftToFill = completed;
|
||||
|
||||
new Array(numTotalSteps).fill(0).forEach((_value, index) => {
|
||||
let currentFill: number;
|
||||
|
||||
currentFill = leftToFill - maxFillPerRow < 0 ? leftToFill : maxFillPerRow;
|
||||
|
||||
leftToFill -= maxFillPerRow;
|
||||
|
||||
let percentWidth = Math.max(0, (currentFill * 100) / maxFillPerRow);
|
||||
const shouldSetMinValueInitially = completed > 0 && Math.floor(percentWidth) === 0 && !index;
|
||||
|
||||
if (shouldSetMinValueInitially) {
|
||||
percentWidth = 1;
|
||||
}
|
||||
|
||||
fillPerRows.push(percentWidth);
|
||||
});
|
||||
|
||||
return fillPerRows;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SourceCode It renders at 0% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--danger"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--danger"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--danger"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--danger"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--danger"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SourceCode It renders at 25% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 25%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SourceCode It renders at 30% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SourceCode It renders at 50% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--warning"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SourceCode It renders at 65% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 25%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SourceCode It renders at 70% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 0%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`SourceCode It renders at 100% 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="c-progressBar__wrapper"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c-progressBar__row c-progressBar__row--progress"
|
||||
data-testid="progressBar__row"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="c-progressBar__bar c-progressBar__bar--primary"
|
||||
data-testid="progressBar__bar"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
.root {
|
||||
border-radius: 4px;
|
||||
border-radius: 2px;
|
||||
padding: 1px 7px 4px 7px;
|
||||
color: white;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import cn from 'classnames/bind';
|
|||
import styles from 'components/Tag/Tag.module.css';
|
||||
|
||||
interface TagProps {
|
||||
color: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
children?: any;
|
||||
onClick?: (ev) => void;
|
||||
|
|
@ -16,14 +16,14 @@ const cx = cn.bind(styles);
|
|||
|
||||
const Tag: FC<TagProps> = (props) => {
|
||||
const { children, color, className, onClick } = props;
|
||||
const style: React.CSSProperties = {};
|
||||
|
||||
if (color) {
|
||||
style.backgroundColor = color;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{ backgroundColor: color }}
|
||||
className={cx('root', className)}
|
||||
onClick={onClick}
|
||||
ref={props.forwardedRef}
|
||||
>
|
||||
<span style={style} className={cx('root', className)} onClick={onClick} ref={props.forwardedRef}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
color: var(--primary-text-link);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--error-text-color);
|
||||
}
|
||||
|
||||
&--success {
|
||||
color: var(--green-5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { openNotification } from 'utils';
|
|||
|
||||
import styles from './Text.module.scss';
|
||||
|
||||
export type TextType = 'primary' | 'secondary' | 'disabled' | 'link' | 'success' | 'warning';
|
||||
export type TextType = 'primary' | 'secondary' | 'disabled' | 'link' | 'success' | 'warning' | 'danger';
|
||||
|
||||
interface TextProps extends HTMLAttributes<HTMLElement> {
|
||||
type?: TextType;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import SlackConnector from 'containers/AlertRules/parts/connectors/SlackConnecto
|
|||
import TelegramConnector from 'containers/AlertRules/parts/connectors/TelegramConnector';
|
||||
import { ChannelFilter } from 'models/channel_filter';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
|
||||
interface ChatOpsConnectorsProps {
|
||||
channelFilterId: ChannelFilter['id'];
|
||||
|
|
@ -26,7 +27,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Timeline.Item number={0} color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}>
|
||||
<Timeline.Item number={0} color={getVar('--tag-secondary')}>
|
||||
<VerticalGroup>
|
||||
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
|
||||
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/W
|
|||
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
|
||||
import { EscalationPolicyOption } from 'models/escalation_policy/escalation_policy.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
import styles from './EscalationChainSteps.module.css';
|
||||
|
|
@ -92,10 +93,7 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => {
|
|||
) : (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
)}
|
||||
<Timeline.Item
|
||||
number={(escalationPolicyIds?.length || 0) + offset + 1}
|
||||
color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}
|
||||
>
|
||||
<Timeline.Item number={(escalationPolicyIds?.length || 0) + offset + 1} color={getVar('--tag-secondary')}>
|
||||
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
|
||||
<Select
|
||||
isSearchable
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { Timezone } from 'models/timezone/timezone.types';
|
|||
import { User } from 'models/user/user.types';
|
||||
import { getDateTime, getUTCString } from 'pages/schedule/Schedule.helpers';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { getCoords, waitForElement } from 'utils/DOM';
|
||||
import { getCoords, getVar, waitForElement } from 'utils/DOM';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
|
||||
import DateTimePicker from './DateTimePicker';
|
||||
|
|
@ -50,7 +50,7 @@ const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {
|
|||
shiftId,
|
||||
startMoment,
|
||||
shiftMoment = dayjs().startOf('day').add(1, 'day'),
|
||||
shiftColor = getComputedStyle(document.documentElement).getPropertyValue('--tag-warning'),
|
||||
shiftColor = getVar('--tag-warning'),
|
||||
} = props;
|
||||
|
||||
const store = useStore();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
Layer,
|
||||
ShiftEvents,
|
||||
RotationFormLiveParams,
|
||||
ScheduleScoreQualityResponse,
|
||||
} from './schedule.types';
|
||||
|
||||
export class ScheduleStore extends BaseStore {
|
||||
|
|
@ -174,6 +175,11 @@ export class ScheduleStore extends BaseStore {
|
|||
};
|
||||
}
|
||||
|
||||
async getScoreQuality(scheduleId: Schedule['id']): Promise<ScheduleScoreQualityResponse> {
|
||||
const tomorrow = getFromString(dayjs().add(1, 'day'));
|
||||
return await makeRequest(`/schedules/${scheduleId}/quality?date=${tomorrow}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
@action
|
||||
async reloadIcal(scheduleId: Schedule['id']) {
|
||||
await makeRequest(`/schedules/${scheduleId}/reload_ical/`, {
|
||||
|
|
|
|||
|
|
@ -111,3 +111,17 @@ export interface ShiftEvents {
|
|||
events: Event[];
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleScoreQualityResponse {
|
||||
total_score: number;
|
||||
comments: Array<{ type: 'warning' | 'info'; text: string }>;
|
||||
overloaded_users: string[];
|
||||
}
|
||||
|
||||
export enum ScheduleScoreQualityResult {
|
||||
Bad = 'Bad',
|
||||
Low = 'Low',
|
||||
Medium = 'Medium',
|
||||
Good = 'Good',
|
||||
Great = 'Great',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Alert as AlertType, Alert, IncidentStatus } from 'models/alertgroup/ale
|
|||
import { User } from 'models/user/user.types';
|
||||
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
|
||||
import { move } from 'state/helpers';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
import { TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
switch (alert.status) {
|
||||
case IncidentStatus.Firing:
|
||||
return (
|
||||
<Tag color="var(--tag-danger)" className={cx('status-tag')}>
|
||||
<Tag color={getVar('--tag-danger')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Firing
|
||||
</Text>
|
||||
|
|
@ -33,7 +34,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
);
|
||||
case IncidentStatus.Acknowledged:
|
||||
return (
|
||||
<Tag color="var(--tag-warning)" className={cx('status-tag')}>
|
||||
<Tag color={getVar('--tag-warning')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Acknowledged
|
||||
</Text>
|
||||
|
|
@ -41,7 +42,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
);
|
||||
case IncidentStatus.Resolved:
|
||||
return (
|
||||
<Tag color="var(--tag-primary)" className={cx('status-tag')}>
|
||||
<Tag color={getVar('--tag-primary')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Resolved
|
||||
</Text>
|
||||
|
|
@ -49,7 +50,7 @@ export function getIncidentStatusTag(alert: Alert) {
|
|||
);
|
||||
case IncidentStatus.Silenced:
|
||||
return (
|
||||
<Tag color="var(--tag-secondary)" className={cx('status-tag')}>
|
||||
<Tag color={getVar('--tag-secondary')} className={cx('status-tag')}>
|
||||
<Text strong size="small">
|
||||
Silenced
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
|
|||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { Alert, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import styles from 'pages/incidents/parts/IncidentDropdown.module.scss';
|
||||
import { getVar } from 'utils/DOM';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
import { SilenceSelect } from './SilenceSelect';
|
||||
|
|
@ -17,15 +18,15 @@ const cx = cn.bind(styles);
|
|||
|
||||
const getIncidentTagColor = (alert: Alert) => {
|
||||
if (alert.status === IncidentStatus.Resolved) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-primary');
|
||||
return getVar('--tag-primary');
|
||||
}
|
||||
if (alert.status === IncidentStatus.Firing) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-danger');
|
||||
return getVar('--tag-danger');
|
||||
}
|
||||
if (alert.status === IncidentStatus.Acknowledged) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-warning');
|
||||
return getVar('--tag-warning');
|
||||
}
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary');
|
||||
return getVar('--tag-secondary');
|
||||
};
|
||||
|
||||
function ListMenu({ alert, openMenu }: { alert: Alert; openMenu: React.MouseEventHandler<HTMLElement> }) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@
|
|||
--rotations-background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
column-gap: 8px;
|
||||
row-gap: 8px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky; /* TODO check */
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
initErrorDataState,
|
||||
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import ScheduleQuality from 'components/ScheduleQuality/ScheduleQuality';
|
||||
import Text from 'components/Text/Text';
|
||||
import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect';
|
||||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
|
|
@ -46,6 +46,7 @@ interface SchedulePageState extends PageBaseState {
|
|||
isLoading: boolean;
|
||||
showEditForm: boolean;
|
||||
showScheduleICalSettings: boolean;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
@observer
|
||||
|
|
@ -64,6 +65,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
showEditForm: false,
|
||||
showScheduleICalSettings: false,
|
||||
errorData: initErrorDataState(),
|
||||
lastUpdated: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -147,20 +149,20 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
<VerticalGroup spacing="lg">
|
||||
<div className={cx('header')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('title')}>
|
||||
<PluginLink query={{ page: 'schedules' }}>
|
||||
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
|
||||
</PluginLink>
|
||||
<Text.Title
|
||||
editable
|
||||
editable={false}
|
||||
editModalTitle="Schedule name"
|
||||
level={2}
|
||||
onTextChange={this.handleNameChange}
|
||||
>
|
||||
{schedule?.name}
|
||||
</Text.Title>
|
||||
{schedule && <ScheduleWarning item={schedule} />}
|
||||
</HorizontalGroup>
|
||||
{schedule && <ScheduleQuality schedule={schedule} lastUpdated={this.state.lastUpdated} />}
|
||||
</div>
|
||||
<HorizontalGroup spacing="lg">
|
||||
{users && (
|
||||
<HorizontalGroup>
|
||||
|
|
@ -353,6 +355,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
|
|||
|
||||
const { startMoment } = this.state;
|
||||
|
||||
this.setState((prevState) => ({
|
||||
// this will update schedule score
|
||||
lastUpdated: prevState.lastUpdated + 1,
|
||||
}));
|
||||
|
||||
store.scheduleStore
|
||||
.updateItem(scheduleId) // to refresh current oncall users
|
||||
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaToolti
|
|||
import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter';
|
||||
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
|
||||
import SchedulesFilters from 'components/SchedulesFilters/SchedulesFilters';
|
||||
import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types';
|
||||
import Table from 'components/Table/Table';
|
||||
|
|
@ -103,7 +102,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
render: this.renderType,
|
||||
},
|
||||
{
|
||||
width: '5%',
|
||||
width: '10%',
|
||||
title: 'Status',
|
||||
key: 'name',
|
||||
render: (item: Schedule) => this.renderStatus(item),
|
||||
|
|
@ -130,11 +129,6 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
title: 'Slack user group',
|
||||
render: this.renderUserGroup,
|
||||
},
|
||||
{
|
||||
width: '5%',
|
||||
key: 'warning',
|
||||
render: this.renderWarning,
|
||||
},
|
||||
{
|
||||
width: '50px',
|
||||
key: 'buttons',
|
||||
|
|
@ -290,10 +284,6 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
return typeToVerbal[value];
|
||||
};
|
||||
|
||||
renderWarning = (item: Schedule) => {
|
||||
return <ScheduleWarning item={item} />;
|
||||
};
|
||||
|
||||
renderStatus = (item: Schedule) => {
|
||||
const {
|
||||
store: { scheduleStore },
|
||||
|
|
@ -329,6 +319,24 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
onHover={this.getUpdateRelatedEscalationChainsHandler(item.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.warnings?.length > 0 && (
|
||||
<ScheduleCounter
|
||||
type="warning"
|
||||
addPadding
|
||||
count={item.warnings.length}
|
||||
tooltipTitle="Warnings"
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="none">
|
||||
{item.warnings.map((warning, index) => (
|
||||
<Text type="primary" key={index}>
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,3 +53,7 @@
|
|||
cursor: not-allowed !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.u-cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
:root {
|
||||
--maintenance-background: repeating-linear-gradient(45deg, #f6ba52, #f6ba52 20px, #ffd180 20px, #ffd180 40px);
|
||||
--gren-5: #6ccf8e;
|
||||
--green-5: #6ccf8e;
|
||||
--green-6: #73d13d;
|
||||
--red-5: #ff4d4f;
|
||||
--orange-5: #ffa940;
|
||||
--warning-1: #f8d06b;
|
||||
--blue-2: #bae7ff;
|
||||
--gray-5: #d9d9d9;
|
||||
--gray-8: #595959;
|
||||
|
|
@ -16,12 +17,16 @@
|
|||
--always-gray: #ccccdc;
|
||||
--title-marginBottom: 16px;
|
||||
--opacity: 0.5;
|
||||
|
||||
/* These seem to slightly differ from warning/success/error colors from below */
|
||||
--tag-danger: #e02f44;
|
||||
--tag-warning: #c69b06;
|
||||
--tag-primary: #299c46;
|
||||
--tag-secondary: #464c54;
|
||||
--tag-border-danger: rgb(151, 11, 27);
|
||||
--tag-text-danger: rgb(247, 144, 156);
|
||||
--tag-border-warning: rgb(150, 75, 0);
|
||||
--tag-text-warning: rgb(255, 190, 124);
|
||||
--tag-border-success: rgb(49, 100, 43);
|
||||
--tag-text-success: rgb(165, 214, 159);
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
|
|
@ -34,9 +39,9 @@
|
|||
--primary-text-color: rgb(36, 41, 46);
|
||||
--secondary-text-color: rgba(36, 41, 46, 0.75);
|
||||
--disabled-text-color: rgba(36, 41, 46, 0.5);
|
||||
--warning-text-color: rgb(255, 120, 10);
|
||||
--success-text-color: rgb(10, 118, 78);
|
||||
--error-text-color: rgb(207, 14, 91);
|
||||
--warning-text-color: #f5b73d;
|
||||
--success-text-color: #1a7f4b;
|
||||
--error-text-color: #ff5286;
|
||||
--primary-text-link: #1f62e0;
|
||||
--timeline-icon-background: rgba(70, 76, 84, 0);
|
||||
--timeline-icon-background-resolution-note: rgba(50, 116, 217, 0);
|
||||
|
|
@ -63,9 +68,9 @@
|
|||
--primary-text-color: rgb(204, 204, 220);
|
||||
--secondary-text-color: rgba(204, 204, 220, 0.65);
|
||||
--disabled-text-color: rgba(204, 204, 220, 0.4);
|
||||
--warning-text-color: rgb(255, 120, 10);
|
||||
--success-text-color: rgb(108, 207, 142);
|
||||
--error-text-color: rgb(255, 82, 134);
|
||||
--warning-text-color: #f5b73d;
|
||||
--success-text-color: #1a7f4b;
|
||||
--error-text-color: #ff5286;
|
||||
--primary-text-link: #6e9fff;
|
||||
--timeline-icon-background: rgba(70, 76, 84, 1);
|
||||
--timeline-icon-background-resolution-note: rgba(50, 116, 217, 1);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export const waitForElement = (selector: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const getVar = (cssVar: string): string => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar);
|
||||
};
|
||||
|
||||
export const getCoords = (elem) => {
|
||||
// crossbrowser version
|
||||
const box = elem.getBoundingClientRect();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue