Rares/score quality (#1324)

# What this PR does

#118 

## Checklist

- [x] Tests updated
- [ ] Documentation added
- [x] `CHANGELOG.md` updated
This commit is contained in:
Rares Mardare 2023-03-13 15:02:29 +02:00 committed by GitHub
parent defae434ba
commit 15f6898426
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1137 additions and 187 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
.root {
border-radius: 4px;
border-radius: 2px;
padding: 1px 7px 4px 7px;
color: white;
}

View file

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

View file

@ -23,6 +23,10 @@
color: var(--primary-text-link);
}
&--danger {
color: var(--error-text-color);
}
&--success {
color: var(--green-5);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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/`, {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -53,3 +53,7 @@
cursor: not-allowed !important;
pointer-events: none;
}
.u-cursor-default {
cursor: default;
}

View file

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

View file

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