Improve direct user paging (#1471)

# What this PR does

Direct user paging feature improvements

## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/1358

## Checklist

- [ ] Tests updated
- [ ] Documentation added
- [ ] `CHANGELOG.md` updated
This commit is contained in:
Maxim Mordasov 2023-03-13 17:36:05 +03:00 committed by GitHub
parent 15f6898426
commit 82a9f8a5e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 278 additions and 104 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Enable web overrides for Terraform-based schedules
- Direct user paging improvements ([1358](https://github.com/grafana/oncall/issues/1358))
## v1.1.36 (2023-03-09)

View file

@ -0,0 +1,19 @@
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
export const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
name: 'Manual Alert Group',
fields: [
{
name: 'title',
type: FormItemType.Input,
label: 'Title',
validation: { required: true },
},
{
name: 'message',
type: FormItemType.TextArea,
label: 'Description',
validation: { required: true },
},
],
};

View file

@ -5,13 +5,14 @@ import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import GForm from 'components/GForm/GForm';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import Text from 'components/Text/Text';
import EscalationVariants from 'containers/EscalationVariants/EscalationVariants';
import { prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { useStore } from 'state/useStore';
import { manualAlertFormConfig } from './ManualAlertGroup.config';
import styles from './ManualAlertGroup.module.css';
interface ManualAlertGroupProps {
@ -21,23 +22,6 @@ interface ManualAlertGroupProps {
const cx = cn.bind(styles);
const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
name: 'Manual Alert Group',
fields: [
{
name: 'title',
type: FormItemType.Input,
label: 'Title',
validation: { required: true },
},
{
name: 'message',
type: FormItemType.TextArea,
label: 'Describe what is going on',
},
],
};
const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
const store = useStore();
const [userResponders, setUserResponders] = useState([]);

View file

@ -13,12 +13,13 @@ interface PluginLinkProps {
wrap?: boolean;
children: any;
query?: Record<string, any>;
target?: string;
}
const cx = cn.bind(styles);
const PluginLink: FC<PluginLinkProps> = (props) => {
const { children, query, disabled, className, wrap = true } = props;
const { children, query, disabled, className, wrap = true, target } = props;
const newPath = useMemo(() => getPathFromQueryParams(query), [query]);
@ -35,6 +36,7 @@ const PluginLink: FC<PluginLinkProps> = (props) => {
return (
<Link
target={target}
onClick={handleClick}
className={cx('root', className, { 'no-wrap': !wrap, root_disabled: disabled })}
to={newPath}

View file

@ -21,9 +21,17 @@
overflow: scroll;
}
.schedule-table {
.table {
height: 120px;
overflow: auto;
& tr:hover {
background: var(--background-secondary) !important;
}
& tbody tr:nth-child(odd) {
background: unset;
}
}
.responders-filters {
@ -32,6 +40,8 @@
.responder-item {
cursor: pointer;
width: 280px;
overflow: hidden;
}
.body {
@ -43,12 +53,12 @@
margin-bottom: 20px;
width: 100%;
& > li .trash-button {
& > li .hover-button {
display: none;
}
& > li:hover .trash-button {
display: block;
& > li:hover .hover-button {
display: inline-flex;
}
& > li {
@ -79,3 +89,17 @@
background: #299c46;
}
}
.radio-buttons {
margin: 8px;
}
.select {
width: 150px !important;
}
.responder-name {
max-width: 250px;
overflow: hidden;
white-space: nowrap;
}

View file

@ -1,16 +1,15 @@
import React, { useState, useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { ToolbarButton, ButtonGroup, HorizontalGroup, Icon, Select, IconButton, Label } from '@grafana/ui';
import { HorizontalGroup, Icon, Select, IconButton, Label, Tooltip, Button } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import UserWarning from 'containers/UserWarningModal/UserWarning';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { User } from 'models/user/user.types';
import { UserActions } from 'utils/authorization';
@ -24,7 +23,7 @@ const cx = cn.bind(styles);
export interface EscalationVariantsProps {
onUpdateEscalationVariants: (data: any) => void;
value: { scheduleResponders; userResponders };
variant?: 'default' | 'primary';
variant?: 'secondary' | 'primary';
hideSelected?: boolean;
}
@ -124,29 +123,17 @@ const EscalationVariants = observer(
</>
)}
<div className={cx('assign-responders-button')}>
<ButtonGroup>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<ToolbarButton
icon="users-alt"
variant={variant}
onClick={() => {
setShowEscalationVariants(true);
}}
>
Add responders
</ToolbarButton>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<ToolbarButton
isOpen={false}
narrow
variant={variant}
onClick={() => {
setShowEscalationVariants(true);
}}
/>
</WithPermissionControlTooltip>
</ButtonGroup>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<Button
icon="users-alt"
variant={variant}
onClick={() => {
setShowEscalationVariants(true);
}}
>
Add responders
</Button>
</WithPermissionControlTooltip>
</div>
{showEscalationVariants && (
<EscalationVariantsPopup
@ -170,7 +157,17 @@ const EscalationVariants = observer(
onUserSelect={(user: User) => {
onUpdateEscalationVariants({
...value,
userResponders: [...value.userResponders, { type: ResponderType.User, data: user, important: false }],
userResponders: [
...value.userResponders,
{
type: ResponderType.User,
data: user,
important:
user.notification_chain_verbal.important && !user.notification_chain_verbal.default
? true
: false,
},
],
});
}}
/>
@ -188,20 +185,72 @@ const UserResponder = ({ important, data, onImportantChange, handleDelete }) =>
<div className={cx('timeline-icon-background', { 'timeline-icon-background--green': true })}>
<Avatar size="big" src={data?.avatar} />
</div>
<Text>
{data?.username} ({getTzOffsetString(dayjs().tz(data?.timezone))})
</Text>
<Select
isSearchable={false}
value={Number(important)}
options={[
{ value: 1, label: 'Important' },
{ value: 0, label: 'Default' },
]}
onChange={onImportantChange}
<Text className={cx('responder-name')}>{data?.username}</Text>
{data.notification_chain_verbal.default || data.notification_chain_verbal.important ? (
<HorizontalGroup>
<Text type="secondary">by</Text>
<Select
className={cx('select')}
isSearchable={false}
value={Number(important)}
options={[
{
value: 0,
label: 'Default',
description: 'Use "Default notifications" from user\'s personal settings',
},
{
value: 1,
label: 'Important',
description: 'Use "Important notifications" from user\'s personal settings',
},
]}
// @ts-ignore
isOptionDisabled={({ value }) =>
(value === 0 && !data.notification_chain_verbal.default) ||
(value === 1 && !data.notification_chain_verbal.important)
}
getOptionLabel={({ value, label }) => {
return (
<Text
type={
(value === 0 && !data.notification_chain_verbal.default) ||
(value === 1 && !data.notification_chain_verbal.important)
? 'disabled'
: 'primary'
}
>
{label}
</Text>
);
}}
onChange={onImportantChange}
/>
<Text type="secondary">notification chain</Text>
</HorizontalGroup>
) : (
<HorizontalGroup>
<Tooltip content="User doesn't have configured notification chains">
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
</Tooltip>
</HorizontalGroup>
)}
</HorizontalGroup>
<HorizontalGroup>
<PluginLink className={cx('hover-button')} target="_blank" query={{ page: 'users', id: data.pk }}>
<IconButton
tooltip="Open user profile in new tab"
style={{ color: 'var(--always-gray)' }}
name="external-link-alt"
/>
</PluginLink>
<IconButton
tooltip="Remove responder"
className={cx('hover-button')}
name="trash-alt"
onClick={handleDelete}
/>
</HorizontalGroup>
<IconButton className={cx('trash-button')} name="trash-alt" onClick={handleDelete} />
</HorizontalGroup>
</li>
);
@ -215,18 +264,39 @@ const ScheduleResponder = ({ important, data, onImportantChange, handleDelete })
<div className={cx('timeline-icon-background')}>
<Icon size="lg" name="calendar-alt" />
</div>
<Text>{data.name}</Text>
<Text className={cx('responder-name')}>{data.name}</Text>
<Text type="secondary">by</Text>
<Select
className={cx('select')}
isSearchable={false}
value={Number(important)}
options={[
{ value: 1, label: 'Important' },
{ value: 0, label: 'Default' },
{ value: 0, label: 'Default', description: 'Use "Default notifications" from users personal settings' },
{
value: 1,
label: 'Important',
description: 'Use "Important notifications" from users personal settings',
},
]}
onChange={onImportantChange}
/>
<Text type="secondary">notification policies</Text>
</HorizontalGroup>
<HorizontalGroup>
<PluginLink className={cx('hover-button')} target="_blank" query={{ page: 'schedules', id: data.id }}>
<IconButton
tooltip="Open schedule in new tab"
style={{ color: 'var(--always-gray)' }}
name="external-link-alt"
/>
</PluginLink>
<IconButton
className={cx('hover-button')}
tooltip="Remove responder"
name="trash-alt"
onClick={handleDelete}
/>
</HorizontalGroup>
<IconButton className={cx('trash-button')} name="trash-alt" onClick={handleDelete} />
</HorizontalGroup>
</li>
);

View file

@ -1,11 +1,10 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Icon, RadioButtonGroup } from '@grafana/ui';
import { Icon, Input, RadioButtonGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import GTable from 'components/GTable/GTable';
import SearchInput from 'components/SearchInput/SearchInput';
import Text from 'components/Text/Text';
import { EscalationVariantsProps } from 'containers/EscalationVariants/EscalationVariants';
import styles from 'containers/EscalationVariants/EscalationVariants.module.scss';
@ -40,6 +39,14 @@ const EscalationVariantsPopup = observer((props: EscalationVariantsPopupProps) =
const [usersSearchTerm, setUsersSearchTerm] = useState('');
const [schedulesSearchTerm, setSchedulesSearchTerm] = useState('');
const handleSetSchedulesSearchTerm = useCallback((e) => {
setSchedulesSearchTerm(e.target.value);
}, []);
const handleSetUsersSearchTerm = useCallback((e) => {
setUsersSearchTerm(e.target.value);
}, []);
const handleOptionChange = useCallback((option: string) => {
setActiveOption(option);
}, []);
@ -142,42 +149,51 @@ const EscalationVariantsPopup = observer((props: EscalationVariantsPopupProps) =
{ value: 'schedules', label: 'Schedules' },
{ value: 'users', label: 'Users' },
]}
className={cx('radio-buttons')}
value={activeOption}
onChange={handleOptionChange}
fullWidth
/>
{activeOption === 'schedules' && (
<>
<SearchInput
<Input
prefix={<Icon name="search" />}
key="schedules search"
className={cx('responders-filters')}
value={schedulesSearchTerm}
onChange={setSchedulesSearchTerm}
placeholder="Search schedules..."
// @ts-ignore
width={'unset'}
onChange={handleSetSchedulesSearchTerm}
/>
<GTable
emptyText={store.scheduleStore.getSearchResult()?.results ? 'No schedules found' : 'Loading...'}
rowKey="id"
columns={scheduleColumns}
data={store.scheduleStore.getSearchResult()?.results}
className={cx('schedule-table')}
className={cx('table')}
showHeader={false}
/>
</>
)}
{activeOption === 'users' && (
<>
<SearchInput
<Input
prefix={<Icon name="search" />}
key="users search"
// @ts-ignore
width={'unset'}
className={cx('responders-filters')}
placeholder="Search users..."
value={usersSearchTerm}
onChange={setUsersSearchTerm}
onChange={handleSetUsersSearchTerm}
/>
<GTable
emptyText={store.userStore.getSearchResult()?.results ? 'No users found' : 'Loading...'}
rowKey="id"
columns={userColumns}
data={store.userStore.getSearchResult()?.results}
className={cx('schedule-table')}
className={cx('table')}
showHeader={false}
/>
</>

View file

@ -20,3 +20,7 @@
border-radius: 50%;
background: #6ccf8e;
}
.modal {
width: 650px;
}

View file

@ -86,11 +86,11 @@ const UserWarning: FC<UserWarningProps> = (props) => {
);
return (
<Modal isOpen title="Add responder" onDismiss={onHide}>
<Modal isOpen title="Add responder" onDismiss={onHide} className={cx('modal')}>
<VerticalGroup className={cx('user-warning')}>
{showUserHasNoNotificationPolicyWarning && (
<HorizontalGroup>
<Icon name="exclamation-triangle" style={{ color: 'orange' }} />
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
<Text>
<Text strong>{user.username}</Text> has no notification policy
</Text>
@ -129,7 +129,12 @@ const UserWarning: FC<UserWarningProps> = (props) => {
</ul>
)}
<Text>
Are you sure you want to select <Text strong>{user.username}</Text>?
<HorizontalGroup>
<Icon name="question-circle" />
<Text>
Are you sure you want to select <Text strong>{user.username}</Text>?
</Text>
</HorizontalGroup>
</Text>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>

View file

@ -181,9 +181,17 @@
& > li {
padding: 8px 12px;
width: 100%;
& .hover-button {
display: none;
}
}
& > li:hover {
background: var(--background-secondary);
& .hover-button {
display: inline-flex;
}
}
}

View file

@ -373,7 +373,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
<HorizontalGroup>
<EscalationVariants
variant="default"
variant="secondary"
hideSelected
value={prepareForEdit(incident.paged_users)}
onUpdateEscalationVariants={this.handleAddResponders}

View file

@ -1,14 +1,17 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { HorizontalGroup, IconButton } from '@grafana/ui';
import { HorizontalGroup, Icon, IconButton, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
import styles from './../Incident.module.scss';
@ -20,7 +23,7 @@ interface PagedUsersProps {
onRemove: (id: User['pk']) => void;
}
const PagedUsers = (props: PagedUsersProps) => {
const PagedUsers = observer((props: PagedUsersProps) => {
const { pagedUsers, onRemove } = props;
const getPagedUserRemoveHandler = useCallback((id: User['pk']) => {
@ -29,6 +32,17 @@ const PagedUsers = (props: PagedUsersProps) => {
};
}, []);
const { userStore } = useStore();
useEffect(() => {
pagedUsers &&
pagedUsers.forEach((user) => {
if (!userStore.items[user.pk]) {
userStore.updateItem(user.pk);
}
});
}, [pagedUsers]);
if (!pagedUsers || !pagedUsers.length) {
return null;
}
@ -39,31 +53,58 @@ const PagedUsers = (props: PagedUsersProps) => {
Current responders
</Text.Title>
<ul className={cx('paged-users-list')}>
{pagedUsers.map((pagedUser) => (
<li key={pagedUser.pk}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Avatar size="big" src={pagedUser.avatar} />
<Text strong>{pagedUser.username}</Text>
{pagedUsers.map((pagedUser) => {
const storeUser = userStore.items[pagedUser.pk];
return (
<li key={pagedUser.pk}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<Avatar size="big" src={pagedUser.avatar} />
<Text strong>{pagedUser.username}</Text>
{Boolean(
storeUser &&
!storeUser.notification_chain_verbal.default &&
!storeUser.notification_chain_verbal.important
) && (
<Tooltip content="User doesn't have configured notification chains">
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
</Tooltip>
)}
</HorizontalGroup>
<HorizontalGroup>
<PluginLink
className={cx('hover-button')}
target="_blank"
query={{ page: 'users', id: pagedUser.pk }}
>
<IconButton
tooltip="Open user profile in new tab"
style={{ color: 'var(--always-gray)' }}
name="external-link-alt"
/>
</PluginLink>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<WithConfirm
title={`Are you sure to remove "${pagedUser.username}" from responders?`}
confirmText="Remove"
>
<IconButton
className={cx('hover-button')}
onClick={getPagedUserRemoveHandler(pagedUser.pk)}
tooltip="Remove from responders"
name="trash-alt"
/>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
</HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<WithConfirm
title={`Are you sure to remove "${pagedUser.username}" from responders?`}
confirmText="Remove"
>
<IconButton
onClick={getPagedUserRemoveHandler(pagedUser.pk)}
tooltip="Remove from responders"
name="trash-alt"
/>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
</li>
))}
</li>
);
})}
</ul>
</div>
);
};
});
export default PagedUsers;