Bug 1571404 - Set assignees for alert summaries

This commit is contained in:
ionutgoldan 2019-10-02 14:50:12 +03:00 коммит произвёл GitHub
Родитель 347628d91d
Коммит 53c639c166
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 405 добавлений и 69 удалений

Просмотреть файл

@ -3,9 +3,9 @@ import {
render, render,
cleanup, cleanup,
fireEvent, fireEvent,
wait,
waitForElement, waitForElement,
waitForElementToBeRemoved, waitForElementToBeRemoved,
wait,
} from '@testing-library/react'; } from '@testing-library/react';
import AlertsViewControls from '../../../ui/perfherder/alerts/AlertsViewControls'; import AlertsViewControls from '../../../ui/perfherder/alerts/AlertsViewControls';
@ -14,9 +14,9 @@ import { summaryStatusMap } from '../../../ui/perfherder/constants';
import repos from '../mock/repositories'; import repos from '../mock/repositories';
const testUser = { const testUser = {
username: 'test user', username: 'mozilla-ldap/test_user@mozilla.com',
is_superuser: false, is_superuser: false,
is_staff: true, isStaff: true,
email: 'test_user@mozilla.com', email: 'test_user@mozilla.com',
}; };
@ -95,6 +95,8 @@ const testAlertSummaries = [
revision: '930f0f51b681aea2a5e915a2770f80a9914ed3df', revision: '930f0f51b681aea2a5e915a2770f80a9914ed3df',
push_timestamp: 1558111832, push_timestamp: 1558111832,
prev_push_revision: '76e3a842e496d78a80cd547b7bf94f041f9bc612', prev_push_revision: '76e3a842e496d78a80cd547b7bf94f041f9bc612',
assignee_username: null,
assignee_email: null,
}, },
{ {
id: 20239, id: 20239,
@ -170,6 +172,8 @@ const testAlertSummaries = [
revision: 'd4a9b4dd03ca5c3db2bd10e8097d9817435ba37d', revision: 'd4a9b4dd03ca5c3db2bd10e8097d9817435ba37d',
push_timestamp: 1558583128, push_timestamp: 1558583128,
prev_push_revision: 'c8e9b6a81194dff2d37b4f67d23a419fd4587e49', prev_push_revision: 'c8e9b6a81194dff2d37b4f67d23a419fd4587e49',
assignee_username: 'mozilla-ldap/test_user@mozilla.com',
assignee_email: 'test_user@mozilla.com',
}, },
]; ];
@ -213,6 +217,11 @@ const mockModifyAlert = {
}, },
}; };
// eslint-disable-next-line no-unused-vars
const mockUpdateAlertSummary = (alertSummaryId, params) => ({
failureStatus: null,
});
const alertsViewControls = () => const alertsViewControls = () =>
render( render(
<AlertsViewControls <AlertsViewControls
@ -230,6 +239,9 @@ const alertsViewControls = () =>
updateViewState={() => {}} updateViewState={() => {}}
user={testUser} user={testUser}
modifyAlert={(alert, params) => mockModifyAlert.update(alert, params)} modifyAlert={(alert, params) => mockModifyAlert.update(alert, params)}
updateAlertSummary={() =>
Promise.resolve({ failureStatus: false, data: 'alert summary data' })
}
projects={repos} projects={repos}
location={{ location={{
pathname: '/alerts', pathname: '/alerts',
@ -429,5 +441,107 @@ test('selecting the alert summary checkbox then deselecting one alert only updat
modifyAlertSpy.mockClear(); modifyAlertSpy.mockClear();
}); });
test("display of alert summaries's assignee badge", async () => {
const { getAllByTitle, getAllByText } = alertsViewControls();
const ownershipBadges = getAllByTitle('Click to change assignee');
const takeButtons = getAllByText('Take');
// summary with no assignee defaults to "Unassigned" badge &
// displays the 'Take' button
expect(ownershipBadges[0]).toHaveTextContent('Unassigned');
expect(takeButtons).toHaveLength(1);
// summary with assignee displays username extracted from email &
// hides the 'Take' button
expect(ownershipBadges[1]).toHaveTextContent('test_user');
});
test("'Take' button hides when clicking on 'Unassigned' badge", async () => {
const {
getByText,
queryByText,
queryByPlaceholderText,
} = alertsViewControls();
const unassignedBadge = await waitForElement(() => getByText('Unassigned'));
await fireEvent.click(unassignedBadge);
expect(queryByText('Take')).not.toBeInTheDocument();
// and the placeholder nicely shows up
expect(queryByPlaceholderText('nobody@mozilla.org')).toBeInTheDocument();
});
test('setting an assignee on unassigned alert summary updates the badge accordingly', async () => {
const { getByText, getByPlaceholderText } = alertsViewControls();
const unassignedBadge = await waitForElement(() => getByText('Unassigned'));
fireEvent.click(unassignedBadge);
const inputField = await waitForElement(() =>
getByPlaceholderText('nobody@mozilla.org'),
);
fireEvent.change(inputField, {
target: { value: 'mozilla-ldap/test_assignee@mozilla.com' },
});
// pressing 'Enter' has some issues on react-testing-library;
// found workaround on https://github.com/testing-library/react-testing-library/issues/269
fireEvent.keyPress(inputField, { key: 'Enter', keyCode: 13 });
// ensure this updated the assignee
await waitForElement(() => getByText('test_assignee'));
});
test('setting an assignee on an already assigned summary is possible', async () => {
const { getByText, getByDisplayValue } = alertsViewControls();
const unassignedBadge = await waitForElement(() => getByText('test_user'));
fireEvent.click(unassignedBadge);
const inputField = await waitForElement(() =>
getByDisplayValue('mozilla-ldap/test_user@mozilla.com'),
);
fireEvent.change(inputField, {
target: { value: 'mozilla-ldap/test_another_user@mozilla.com' },
});
// pressing 'Enter' has some issues on react-testing-library;
// found workaround on https://github.com/testing-library/react-testing-library/issues/269
fireEvent.keyPress(inputField, { key: 'Enter', keyCode: 13 });
// ensure this updated the assignee
await waitForElement(() => getByText('test_another_user'));
});
test("'Escape' from partially editted assignee does not update original assignee", async () => {
const { getByText, getByDisplayValue } = alertsViewControls();
const unassignedBadge = await waitForElement(() => getByText('test_user'));
fireEvent.click(unassignedBadge);
const inputField = await waitForElement(() =>
getByDisplayValue('mozilla-ldap/test_user@mozilla.com'),
);
fireEvent.change(inputField, {
target: { value: 'mozilla-ldap/test_another_' },
});
fireEvent.keyDown(inputField, { key: 'Escape' });
// ensure assignee wasn't updated
await waitForElement(() => getByText('test_user'));
});
test("Clicking on 'Take' prefills with logged in user", async () => {
const { getByText, getByDisplayValue } = alertsViewControls();
const takeButton = getByText('Take');
fireEvent.click(takeButton);
// ensure it preffiled input field
await waitForElement(() =>
getByDisplayValue('mozilla-ldap/test_user@mozilla.com'),
);
});
// TODO should write tests for alert summary dropdown menu actions performed in StatusDropdown // TODO should write tests for alert summary dropdown menu actions performed in StatusDropdown
// (adding notes or marking as 'fixed', etc) // (adding notes or marking as 'fixed', etc)

Просмотреть файл

@ -5,6 +5,9 @@ import {
DropdownMenu, DropdownMenu,
DropdownItem, DropdownItem,
DropdownToggle, DropdownToggle,
Container,
Row,
Col,
} from 'reactstrap'; } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
@ -13,7 +16,15 @@ import moment from 'moment';
import { getTitle } from '../helpers'; import { getTitle } from '../helpers';
import { getJobsUrl } from '../../helpers/url'; import { getJobsUrl } from '../../helpers/url';
const AlertHeader = ({ alertSummary, repoModel, issueTrackers }) => { import Assignee from './Assignee';
const AlertHeader = ({
alertSummary,
repoModel,
issueTrackers,
user,
updateAssignee,
}) => {
const getIssueTrackerUrl = () => { const getIssueTrackerUrl = () => {
const { issueTrackerUrl } = issueTrackers.find( const { issueTrackerUrl } = issueTrackers.find(
tracker => tracker.id === alertSummary.issue_tracker, tracker => tracker.id === alertSummary.issue_tracker,
@ -25,62 +36,65 @@ const AlertHeader = ({ alertSummary, repoModel, issueTrackers }) => {
: ''; : '';
return ( return (
<div className="pl-2"> <Container>
<a <Row>
className="text-dark font-weight-bold align-middle" <a
href={`#/alerts?id=${alertSummary.id}`} className="text-dark font-weight-bold align-middle"
id={`alert summary ${alertSummary.id.toString()} title`} href={`#/alerts?id=${alertSummary.id}`}
data-testid={`alert summary ${alertSummary.id.toString()} title`} id={`alert summary ${alertSummary.id.toString()} title`}
> data-testid={`alert summary ${alertSummary.id.toString()} title`}
Alert #{alertSummary.id} - {alertSummary.repository} -{' '} >
{getTitle(alertSummary)}{' '} Alert #{alertSummary.id} - {alertSummary.repository} -{' '}
<FontAwesomeIcon {getTitle(alertSummary)}{' '}
icon={faExternalLinkAlt} <FontAwesomeIcon
className="icon-superscript" icon={faExternalLinkAlt}
/> className="icon-superscript"
</a> />
<br /> </a>
<span className="font-weight-normal"> </Row>
<span className="align-middle">{`${moment( <Row className="font-weight-normal">
<Col className="p-0" xs="auto">{`${moment(
alertSummary.push_timestamp * 1000, alertSummary.push_timestamp * 1000,
).format('ddd MMM D, HH:mm:ss')} · `}</span> ).format('ddd MMM D, HH:mm:ss')} ·`}</Col>
<UncontrolledDropdown tag="span"> <Col className="p-0" xs="auto">
<DropdownToggle <UncontrolledDropdown tag="span">
className="btn-link text-info p-0" <DropdownToggle
color="transparent" className="btn-link text-info p-0"
caret color="transparent"
> caret
{alertSummary.revision.slice(0, 12)}
</DropdownToggle>
<DropdownMenu>
<a
className="text-dark"
href={getJobsUrl({
repo: alertSummary.repository,
fromchange: alertSummary.prev_push_revision,
tochange: alertSummary.revision,
})}
target="_blank"
rel="noopener noreferrer"
> >
<DropdownItem>Jobs</DropdownItem> {alertSummary.revision.slice(0, 12)}
</a> </DropdownToggle>
<a <DropdownMenu>
className="text-dark" <a
href={repoModel.getPushLogRangeHref({ className="text-dark"
fromchange: alertSummary.prev_push_revision, href={getJobsUrl({
tochange: alertSummary.revision, repo: alertSummary.repository,
})} fromchange: alertSummary.prev_push_revision,
target="_blank" tochange: alertSummary.revision,
rel="noopener noreferrer" })}
> target="_blank"
<DropdownItem>Pushlog</DropdownItem> rel="noopener noreferrer"
</a> >
</DropdownMenu> <DropdownItem>Jobs</DropdownItem>
</UncontrolledDropdown> </a>
<a
className="text-dark"
href={repoModel.getPushLogRangeHref({
fromchange: alertSummary.prev_push_revision,
tochange: alertSummary.revision,
})}
target="_blank"
rel="noopener noreferrer"
>
<DropdownItem>Pushlog</DropdownItem>
</a>
</DropdownMenu>
</UncontrolledDropdown>
<span>·</span>
</Col>
{bugNumber && ( {bugNumber && (
<span> <Col className="p-0" xs="auto">
<span className="align-middle"> · </span>
{alertSummary.issue_tracker && issueTrackers.length > 0 ? ( {alertSummary.issue_tracker && issueTrackers.length > 0 ? (
<a <a
className="text-info align-middle" className="text-info align-middle"
@ -93,16 +107,25 @@ const AlertHeader = ({ alertSummary, repoModel, issueTrackers }) => {
) : ( ) : (
{ bugNumber } { bugNumber }
)} )}
</span> <span>·</span>
</Col>
)} )}
</span> <Col className="p-0" xs="auto">
</div> <Assignee
assigneeUsername={alertSummary.assignee_username}
updateAssignee={updateAssignee}
user={user}
/>
</Col>
</Row>
</Container>
); );
}; };
AlertHeader.propTypes = { AlertHeader.propTypes = {
alertSummary: PropTypes.shape({}).isRequired, alertSummary: PropTypes.shape({}).isRequired,
repoModel: PropTypes.shape({}).isRequired, repoModel: PropTypes.shape({}).isRequired,
user: PropTypes.shape({}).isRequired,
issueTrackers: PropTypes.arrayOf(PropTypes.shape({})), issueTrackers: PropTypes.arrayOf(PropTypes.shape({})),
}; };

Просмотреть файл

@ -9,7 +9,11 @@ import {
errorMessageClass, errorMessageClass,
} from '../../helpers/constants'; } from '../../helpers/constants';
import RepositoryModel from '../../models/repository'; import RepositoryModel from '../../models/repository';
import { getInitializedAlerts, containsText } from '../helpers'; import {
getInitializedAlerts,
containsText,
updateAlertSummary,
} from '../helpers';
import TruncatedText from '../../shared/TruncatedText'; import TruncatedText from '../../shared/TruncatedText';
import ErrorBoundary from '../../shared/ErrorBoundary'; import ErrorBoundary from '../../shared/ErrorBoundary';
@ -119,6 +123,32 @@ export default class AlertTable extends React.Component {
this.setState({ filteredAlerts }); this.setState({ filteredAlerts });
}; };
updateAssignee = async newAssigneeUsername => {
const {
updateAlertSummary,
updateViewState,
fetchAlertSummaries,
} = this.props;
const { alertSummary } = this.state;
const { data, failureStatus } = await updateAlertSummary(alertSummary.id, {
assignee_username: newAssigneeUsername,
});
if (!failureStatus) {
// now refresh UI, by syncing with backend
fetchAlertSummaries(alertSummary.id);
} else {
updateViewState({
errorMessages: [
`Failed to set new assignee "${newAssigneeUsername}". (${data})`,
],
});
}
return { failureStatus };
};
render() { render() {
const { const {
user, user,
@ -180,6 +210,8 @@ export default class AlertTable extends React.Component {
alertSummary={alertSummary} alertSummary={alertSummary}
repoModel={repoModel} repoModel={repoModel}
issueTrackers={issueTrackers} issueTrackers={issueTrackers}
user={user}
updateAssignee={this.updateAssignee}
/> />
</Label> </Label>
</FormGroup> </FormGroup>
@ -280,7 +312,7 @@ export default class AlertTable extends React.Component {
AlertTable.propTypes = { AlertTable.propTypes = {
alertSummary: PropTypes.shape({}), alertSummary: PropTypes.shape({}),
user: PropTypes.shape({}), user: PropTypes.shape({}).isRequired,
alertSummaries: PropTypes.arrayOf(PropTypes.shape({})).isRequired, alertSummaries: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
issueTrackers: PropTypes.arrayOf(PropTypes.shape({})), issueTrackers: PropTypes.arrayOf(PropTypes.shape({})),
optionCollectionMap: PropTypes.shape({}).isRequired, optionCollectionMap: PropTypes.shape({}).isRequired,
@ -293,13 +325,16 @@ AlertTable.propTypes = {
updateViewState: PropTypes.func.isRequired, updateViewState: PropTypes.func.isRequired,
bugTemplate: PropTypes.shape({}), bugTemplate: PropTypes.shape({}),
modifyAlert: PropTypes.func, modifyAlert: PropTypes.func,
updateAlertSummary: PropTypes.func,
projects: PropTypes.arrayOf(PropTypes.shape({})).isRequired, projects: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
}; };
AlertTable.defaultProps = { AlertTable.defaultProps = {
alertSummary: null, alertSummary: null,
user: null,
issueTrackers: [], issueTrackers: [],
bugTemplate: null, bugTemplate: null,
modifyAlert: undefined, modifyAlert: undefined,
// leverage dependency injection
// to improve code testability
updateAlertSummary,
}; };

Просмотреть файл

@ -48,8 +48,14 @@ export default class AlertsViewControls extends React.Component {
}; };
render() { render() {
const {
alertSummaries,
dropdownOptions,
fetchAlertSummaries,
user,
} = this.props;
const { hideImprovements, hideDownstream } = this.state; const { hideImprovements, hideDownstream } = this.state;
const { dropdownOptions, alertSummaries } = this.props;
const alertFilters = [ const alertFilters = [
{ {
text: 'Hide improvements', text: 'Hide improvements',
@ -78,7 +84,9 @@ export default class AlertsViewControls extends React.Component {
filters={this.state} filters={this.state}
key={alertSummary.id} key={alertSummary.id}
alertSummary={alertSummary} alertSummary={alertSummary}
fetchAlertSummaries={fetchAlertSummaries}
{...this.props} {...this.props}
user={user}
/> />
))} ))}
</React.Fragment> </React.Fragment>
@ -91,7 +99,9 @@ AlertsViewControls.propTypes = {
updateParams: PropTypes.func, updateParams: PropTypes.func,
}).isRequired, }).isRequired,
dropdownOptions: PropTypes.arrayOf(PropTypes.shape({})), dropdownOptions: PropTypes.arrayOf(PropTypes.shape({})),
fetchAlertSummaries: PropTypes.func.isRequired,
alertSummaries: PropTypes.arrayOf(PropTypes.shape({})), alertSummaries: PropTypes.arrayOf(PropTypes.shape({})),
user: PropTypes.shape({}).isRequired,
}; };
AlertsViewControls.defaultProps = { AlertsViewControls.defaultProps = {

Просмотреть файл

@ -0,0 +1,145 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Input, InputGroup } from 'reactstrap';
export default class Assignee extends React.Component {
constructor(props) {
super(props);
const { assigneeUsername } = props;
this.state = {
assigneeUsername,
inEditMode: false,
newAssigneeUsername: null,
};
}
componentDidMount() {
// React's onKeyPress isn't able to listen to 'Escape'.
// This is a workaround.
document.addEventListener('keydown', this.keydownListener);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.keydownListener);
}
goToEditMode = () => {
const { user } = this.props;
const { assigneeUsername } = this.state;
if (user.isStaff) {
this.setState({
inEditMode: true,
// input prefills with this field, so
// we must have it prepared
newAssigneeUsername: assigneeUsername,
});
}
};
editUsername = newAssigneeUsername => {
this.setState({ newAssigneeUsername });
};
pressedEnter = async event => {
if (event.key === 'Enter') {
const { updateAssignee } = this.props;
const newAssigneeUsername = event.target.value;
const { failureStatus } = await updateAssignee(newAssigneeUsername);
if (!failureStatus) {
this.setState({
assigneeUsername: newAssigneeUsername,
inEditMode: false,
});
}
}
};
prefillWithLoggedInUsername = () => {
const { user } = this.props;
this.setState({
newAssigneeUsername: user.username,
inEditMode: true,
});
};
keydownListener = event => {
if (event.key === 'Escape') {
this.setState({ inEditMode: false });
}
};
extractNicknameAndPlaceholder = assigneeUsername => {
let nickname = 'Unassigned';
const placeholder = 'nobody@mozilla.org';
if (!assigneeUsername) {
return { nickname, placeholder };
}
const nicknameRegex = /\/(\w+)@/g;
// eslint-disable-next-line prefer-destructuring
nickname = nicknameRegex.exec(assigneeUsername)[1];
return { nickname, placeholder };
};
render() {
const { user } = this.props;
const { assigneeUsername, newAssigneeUsername, inEditMode } = this.state;
const { nickname, placeholder } = this.extractNicknameAndPlaceholder(
assigneeUsername,
);
return !inEditMode ? (
<React.Fragment>
<Button
className="ml-1"
color="outline-info"
size="xs"
onClick={this.goToEditMode}
title="Click to change assignee"
>
{nickname}
</Button>
{!assigneeUsername && (
<Button
className="ml-1"
size="xs"
disabled={!user.isStaff}
onClick={this.prefillWithLoggedInUsername}
>
Take
</Button>
)}
</React.Fragment>
) : (
<InputGroup size="sm">
<Input
disabled={!user.isStaff}
placeholder={placeholder}
value={newAssigneeUsername}
aria-label="Set assignee"
onChange={event => this.editUsername(event.target.value)}
onKeyPress={event => this.pressedEnter(event)}
autoFocus
/>
</InputGroup>
);
}
}
Assignee.propTypes = {
updateAssignee: PropTypes.func.isRequired,
user: PropTypes.shape({}).isRequired,
assigneeUsername: PropTypes.string,
};
Assignee.defaultProps = {
assigneeUsername: null,
};

Просмотреть файл

@ -12,10 +12,15 @@ import moment from 'moment';
import template from 'lodash/template'; import template from 'lodash/template';
import templateSettings from 'lodash/templateSettings'; import templateSettings from 'lodash/templateSettings';
import { getTextualSummary, getTitle, getStatus } from '../helpers'; import {
import { getData, update } from '../../helpers/http'; getTextualSummary,
getTitle,
getStatus,
updateAlertSummary,
} from '../helpers';
import { getData } from '../../helpers/http';
import { getApiUrl, bzBaseUrl, createQueryParams } from '../../helpers/url'; import { getApiUrl, bzBaseUrl, createQueryParams } from '../../helpers/url';
import { endpoints, summaryStatusMap } from '../constants'; import { summaryStatusMap } from '../constants';
import DropdownMenuItems from '../../shared/DropdownMenuItems'; import DropdownMenuItems from '../../shared/DropdownMenuItems';
import AlertModal from './AlertModal'; import AlertModal from './AlertModal';
@ -111,10 +116,11 @@ export default class StatusDropdown extends React.Component {
changeAlertSummary = async params => { changeAlertSummary = async params => {
const { alertSummary, updateState, updateViewState } = this.props; const { alertSummary, updateState, updateViewState } = this.props;
const { data, failureStatus } = await update( const { data, failureStatus } = await updateAlertSummary(
getApiUrl(`${endpoints.alertSummary}${alertSummary.id}/`), alertSummary.id,
params, params,
); );
if (failureStatus) { if (failureStatus) {
return updateViewState({ return updateViewState({
errorMessages: [ errorMessages: [

Просмотреть файл

@ -527,6 +527,9 @@ export const getTitle = alertSummary => {
return title; return title;
}; };
export const updateAlertSummary = async (alertSummaryId, params) =>
update(getApiUrl(`${endpoints.alertSummary}${alertSummaryId}/`), params);
export const convertParams = (params, value) => export const convertParams = (params, value) =>
Boolean(params[value] !== undefined && parseInt(params[value], 10)); Boolean(params[value] !== undefined && parseInt(params[value], 10));