Bug 1510280 - Convert Notifications to Redux (#5030)

This introduces Redux back into Treeherder.  While redux is often considered
unnecessary for simple apps in favor of Context, Treeherder is not simple.

Using Redux here allows us to abstract out certain functionality into
libraries that can be self-contained.  And it does a better job of managing
the functions needed to modify the state in these areas.  This is the first
context I am converting and will likely have minimal (if any) noticeable
performance gain.  But converting SelectedJob and Pusehs should end up having
a larger impact.
This commit is contained in:
Cameron Dawson 2019-06-06 14:39:50 -07:00 коммит произвёл GitHub
Родитель fd47449017
Коммит 0ddd5a80e1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
34 изменённых файлов: 429 добавлений и 288 удалений

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

@ -60,7 +60,7 @@
"react-hotkeys": "1.1.4",
"react-lazylog": "3.2.1",
"react-linkify": "0.2.2",
"react-redux": "6.0.1",
"react-redux": "7.0.3",
"react-router-dom": "5.0.0",
"react-select": "2.4.3",
"react-split-pane": "0.1.87",

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

@ -0,0 +1,22 @@
export const MAX_TRANSIENT_AGE = 4000;
/*
* Clear any expired transient notifications
*/
export const clearExpiredTransientNotifications = notifications => {
const cleanedNotifications = notifications.reduce((acc, note) => {
return note.sticky || Date.now() - note.created < MAX_TRANSIENT_AGE
? [...acc, note]
: acc;
}, []);
return cleanedNotifications.length !== notifications.length
? { notifications: cleanedNotifications }
: { notifications };
};
export const clearNotificationAtIndex = (notifications, index) => {
notifications.splice(index, 1);
return { notifications: [...notifications] };
};

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

@ -3,18 +3,19 @@ import { hot } from 'react-hot-loader/root';
import SplitPane from 'react-split-pane';
import pick from 'lodash/pick';
import isEqual from 'lodash/isEqual';
import { Provider } from 'react-redux';
import { thFavicons } from '../helpers/constants';
import { Notifications } from '../shared/context/Notifications';
import NotificationList from '../shared/NotificationList';
import ShortcutTable from '../shared/ShortcutTable';
import { allFilterParams, matchesDefaults } from '../helpers/filter';
import { getAllUrlParams, getRepo } from '../helpers/location';
import { MAX_TRANSIENT_AGE } from '../helpers/notifications';
import { deployedRevisionUrl, parseQueryParams } from '../helpers/url';
import ClassificationTypeModel from '../models/classificationType';
import FilterModel from '../models/filter';
import RepositoryModel from '../models/repository';
import Notifications from './Notifications';
import { Pushes } from './context/Pushes';
import { SelectedJob } from './context/SelectedJob';
import { PinnedJobs } from './context/PinnedJobs';
@ -25,6 +26,8 @@ import { PUSH_HEALTH_VISIBILITY } from './headerbars/HealthMenu';
import DetailsPanel from './details/DetailsPanel';
import PushList from './pushes/PushList';
import KeyboardShortcuts from './KeyboardShortcuts';
import { store } from './redux/store';
import { CLEAR_EXPIRED_TRANSIENTS } from './redux/stores/notifications';
const DEFAULT_DETAILS_PCT = 40;
const REVISION_POLL_INTERVAL = 1000 * 60 * 5;
@ -140,6 +143,11 @@ class App extends React.Component {
});
}, REVISION_POLL_INTERVAL);
});
// clear expired notifications
this.notificationInterval = setInterval(() => {
store.dispatch({ type: CLEAR_EXPIRED_TRANSIENTS });
}, MAX_TRANSIENT_AGE);
}
componentWillUnmount() {
@ -300,7 +308,7 @@ class App extends React.Component {
return (
<div id="global-container" className="height-minus-navbars">
<Notifications>
<Provider store={store}>
<Pushes filterModel={filterModel}>
<PinnedJobs>
<SelectedJob>
@ -369,7 +377,7 @@ class App extends React.Component {
classificationMap={classificationMap}
/>
</SplitPane>
<NotificationList />
<Notifications />
{showShortCuts && (
<div
id="onscreen-overlay"
@ -386,7 +394,7 @@ class App extends React.Component {
</SelectedJob>
</PinnedJobs>
</Pushes>
</Notifications>
</Provider>
</div>
);
}

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

@ -1,4 +1,5 @@
import React from 'react';
import { connect } from 'react-redux';
import Select from 'react-select';
import PropTypes from 'prop-types';
import Ajv from 'ajv';
@ -22,9 +23,9 @@ import { faCheckSquare } from '@fortawesome/free-regular-svg-icons';
import { formatTaskclusterError } from '../helpers/errorMessage';
import TaskclusterModel from '../models/taskcluster';
import { withNotifications } from '../shared/context/Notifications';
import { withPushes } from './context/Pushes';
import { notify } from './redux/stores/notifications';
class CustomJobActions extends React.PureComponent {
constructor(props) {
@ -302,4 +303,7 @@ CustomJobActions.defaultProps = {
job: null,
};
export default withNotifications(withPushes(CustomJobActions));
export default connect(
null,
{ notify },
)(withPushes(CustomJobActions));

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

@ -1,12 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { HotKeys } from 'react-hotkeys';
import { connect } from 'react-redux';
import { thEvents } from '../helpers/constants';
import { withNotifications } from '../shared/context/Notifications';
import { withPinnedJobs } from './context/PinnedJobs';
import { withSelectedJob } from './context/SelectedJob';
import { clearAllOnScreenNotifications } from './redux/stores/notifications';
const keyMap = {
addRelatedBug: 'b',
@ -49,11 +50,11 @@ class KeyboardShortcuts extends React.Component {
clearSelectedJob,
showOnScreenShortcuts,
notifications,
clearOnScreenNotifications,
clearAllOnScreenNotifications,
} = this.props;
if (notifications.length) {
clearOnScreenNotifications();
clearAllOnScreenNotifications();
} else {
clearSelectedJob();
showOnScreenShortcuts(false);
@ -243,7 +244,7 @@ KeyboardShortcuts.propTypes = {
sticky: PropTypes.bool,
}),
).isRequired,
clearOnScreenNotifications: PropTypes.func.isRequired,
clearAllOnScreenNotifications: PropTypes.func.isRequired,
selectedJob: PropTypes.object,
};
@ -251,6 +252,11 @@ KeyboardShortcuts.defaultProps = {
selectedJob: null,
};
export default withPinnedJobs(
withSelectedJob(withNotifications(KeyboardShortcuts)),
);
const mapStateToProps = ({ notifications: { notifications } }) => ({
notifications,
});
export default connect(
mapStateToProps,
{ clearAllOnScreenNotifications },
)(withPinnedJobs(withSelectedJob(KeyboardShortcuts)));

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

@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import NotificationList from '../shared/NotificationList';
import { clearNotification } from './redux/stores/notifications';
const Notifications = props => {
const { notifications, clearNotification } = props;
return (
<NotificationList
notifications={notifications}
clearNotification={clearNotification}
/>
);
};
Notifications.propTypes = {
notifications: PropTypes.arrayOf(
PropTypes.shape({
created: PropTypes.number.isRequired,
message: PropTypes.string.isRequired,
severity: PropTypes.oneOf(['danger', 'warning', 'info', 'success']),
sticky: PropTypes.bool,
}),
).isRequired,
clearNotification: PropTypes.func.isRequired,
};
const mapStateToProps = ({ notifications: { notifications } }) => ({
notifications,
});
export default connect(
mapStateToProps,
{ clearNotification },
)(Notifications);

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

@ -1,7 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withNotifications } from '../../shared/context/Notifications';
import { notify } from '../redux/stores/notifications';
const COUNT_ERROR = 'Max pinboard size of 500 reached.';
const MAX_SIZE = 500;
@ -165,7 +166,10 @@ PinnedJobsClass.propTypes = {
children: PropTypes.object.isRequired,
};
export const PinnedJobs = withNotifications(PinnedJobsClass);
export const PinnedJobs = connect(
null,
{ notify },
)(PinnedJobsClass);
export function withPinnedJobs(Component) {
return function PinBoardComponent(props) {

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import pick from 'lodash/pick';
import keyBy from 'lodash/keyBy';
import isEqual from 'lodash/isEqual';
@ -22,7 +23,7 @@ import { isUnclassifiedFailure } from '../../helpers/job';
import PushModel from '../../models/push';
import JobModel from '../../models/job';
import { reloadOnChangeParameters } from '../../helpers/filter';
import { withNotifications } from '../../shared/context/Notifications';
import { notify } from '../redux/stores/notifications';
const PushesContext = React.createContext({});
const defaultPushCount = 10;
@ -414,7 +415,10 @@ PushesClass.propTypes = {
notify: PropTypes.func.isRequired,
};
export const Pushes = withNotifications(PushesClass);
export const Pushes = connect(
null,
{ notify },
)(PushesClass);
export function withPushes(Component) {
return function PushesComponent(props) {

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import intersection from 'lodash/intersection';
import $ from 'jquery';
@ -15,7 +16,7 @@ import { getUrlParam, setUrlParam } from '../../helpers/location';
import { getJobsUrl } from '../../helpers/url';
import JobModel from '../../models/job';
import PushModel from '../../models/push';
import { withNotifications } from '../../shared/context/Notifications';
import { notify } from '../redux/stores/notifications';
import { withPinnedJobs } from './PinnedJobs';
import { withPushes } from './Pushes';
@ -287,9 +288,10 @@ SelectedJobClass.propTypes = {
children: PropTypes.object.isRequired,
};
export const SelectedJob = withNotifications(
withPushes(withPinnedJobs(SelectedJobClass)),
);
export const SelectedJob = connect(
null,
{ notify },
)(withPushes(withPinnedJobs(SelectedJobClass)));
export function withSelectedJob(Component) {
return function SelectedJobComponent(props) {

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Modal,
@ -27,7 +28,7 @@ import {
hgBaseUrl,
} from '../../helpers/url';
import { create } from '../../helpers/http';
import { withNotifications } from '../../shared/context/Notifications';
import { notify } from '../redux/stores/notifications';
const crashRegex = /application crashed \[@ (.+)\]$/g;
const omittedLeads = [
@ -862,4 +863,7 @@ BugFilerClass.propTypes = {
notify: PropTypes.func.isRequired,
};
export default withNotifications(BugFilerClass);
export default connect(
null,
{ notify },
)(BugFilerClass);

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

@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, FormGroup, Input, FormFeedback } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlusSquare, faTimes } from '@fortawesome/free-solid-svg-icons';
import { thEvents } from '../../helpers/constants';
import { withNotifications } from '../../shared/context/Notifications';
import { formatModelError } from '../../helpers/errorMessage';
import {
getJobBtnClass,
@ -20,6 +20,7 @@ import JobModel from '../../models/job';
import { withPinnedJobs } from '../context/PinnedJobs';
import { withSelectedJob } from '../context/SelectedJob';
import { withPushes } from '../context/Pushes';
import { notify } from '../redux/stores/notifications';
class PinBoard extends React.Component {
constructor(props) {
@ -657,6 +658,7 @@ PinBoard.defaultProps = {
revisionTips: [],
};
export default withNotifications(
withPushes(withSelectedJob(withPinnedJobs(PinBoard))),
);
export default connect(
null,
{ notify },
)(withPushes(withSelectedJob(withPinnedJobs(PinBoard))));

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button } from 'reactstrap';
import $ from 'jquery';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -21,7 +22,7 @@ import CustomJobActions from '../../CustomJobActions';
import { withSelectedJob } from '../../context/SelectedJob';
import { withPinnedJobs } from '../../context/PinnedJobs';
import { withPushes } from '../../context/Pushes';
import { withNotifications } from '../../../shared/context/Notifications';
import { notify } from '../../redux/stores/notifications';
import LogUrls from './LogUrls';
@ -476,6 +477,7 @@ ActionBar.defaultProps = {
jobLogUrls: [],
};
export default withNotifications(
withPushes(withSelectedJob(withPinnedJobs(ActionBar))),
);
export default connect(
null,
{ notify },
)(withPushes(withSelectedJob(withPinnedJobs(ActionBar))));

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons';
import {
@ -11,8 +12,8 @@ import { thEvents } from '../../../helpers/constants';
import { getBugUrl } from '../../../helpers/url';
import { withSelectedJob } from '../../context/SelectedJob';
import { withPushes } from '../../context/Pushes';
import { withNotifications } from '../../../shared/context/Notifications';
import { longDateFormat } from '../../../helpers/display';
import { notify } from '../../redux/stores/notifications';
function RelatedBugSaved(props) {
const { deleteBug, bug } = props;
@ -254,4 +255,7 @@ AnnotationsTab.defaultProps = {
selectedJob: null,
};
export default withNotifications(withPushes(withSelectedJob(AnnotationsTab)));
export default connect(
null,
{ notify },
)(withPushes(withSelectedJob(AnnotationsTab)));

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
@ -11,7 +12,7 @@ import JobModel from '../../../models/job';
import PushModel from '../../../models/push';
import TextLogStepModel from '../../../models/textLogStep';
import { withSelectedJob } from '../../context/SelectedJob';
import { withNotifications } from '../../../shared/context/Notifications';
import { notify } from '../../redux/stores/notifications';
class SimilarJobsTab extends React.Component {
constructor(props) {
@ -334,4 +335,7 @@ SimilarJobsTab.defaultProps = {
selectedJob: null,
};
export default withNotifications(withSelectedJob(SimilarJobsTab));
export default connect(
null,
{ notify },
)(withSelectedJob(SimilarJobsTab));

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

@ -1,6 +1,7 @@
import $ from 'jquery';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
@ -9,7 +10,7 @@ import { getProjectJobUrl } from '../../../../helpers/location';
import TextLogErrorsModel from '../../../../models/textLogErrors';
import { withSelectedJob } from '../../../context/SelectedJob';
import { withPinnedJobs } from '../../../context/PinnedJobs';
import { withNotifications } from '../../../../shared/context/Notifications';
import { notify } from '../../../redux/stores/notifications';
import AutoclassifyToolbar from './AutoclassifyToolbar';
import ErrorLine from './ErrorLine';
@ -371,6 +372,7 @@ AutoclassifyTab.propTypes = {
repoName: PropTypes.string.isRequired,
};
export default withNotifications(
withSelectedJob(withPinnedJobs(AutoclassifyTab)),
);
export default connect(
null,
{ notify },
)(withSelectedJob(withPinnedJobs(AutoclassifyTab)));

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell } from '@fortawesome/free-regular-svg-icons';
import {
@ -10,7 +11,7 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { shortDateFormat } from '../../helpers/display';
import { withNotifications } from '../../shared/context/Notifications';
import { clearStoredNotifications } from '../redux/stores/notifications';
class NotificationsMenu extends React.Component {
getIcon(severity) {
@ -117,4 +118,11 @@ NotificationsMenu.propTypes = {
clearStoredNotifications: PropTypes.func.isRequired,
};
export default withNotifications(NotificationsMenu);
const mapStateToProps = ({ notifications: { storedNotifications } }) => ({
storedNotifications,
});
export default connect(
mapStateToProps,
{ clearStoredNotifications },
)(NotificationsMenu);

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

@ -1,10 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import isEqual from 'lodash/isEqual';
import Logo from '../../img/treeherder-logo.png';
import Login from '../../shared/auth/Login';
import LogoMenu from '../../shared/LogoMenu';
import { notify } from '../redux/stores/notifications';
import NotificationsMenu from './NotificationsMenu';
import InfraMenu from './InfraMenu';
@ -15,7 +17,7 @@ import HelpMenu from './HelpMenu';
import SecondaryNavBar from './SecondaryNavBar';
import HealthMenu from './HealthMenu';
export default class PrimaryNavBar extends React.Component {
class PrimaryNavBar extends React.Component {
shouldComponentUpdate(prevProps) {
const {
filterModel,
@ -52,6 +54,7 @@ export default class PrimaryNavBar extends React.Component {
toggleFieldFilterVisible,
pushHealthVisibility,
setPushHealthVisibility,
notify,
} = this.props;
return (
@ -71,7 +74,7 @@ export default class PrimaryNavBar extends React.Component {
setPushHealthVisibility={setPushHealthVisibility}
/>
<HelpMenu />
<Login user={user} setUser={setUser} />
<Login user={user} setUser={setUser} notify={notify} />
</span>
</div>
<SecondaryNavBar
@ -104,4 +107,10 @@ PrimaryNavBar.propTypes = {
groupCountsExpanded: PropTypes.bool.isRequired,
pushHealthVisibility: PropTypes.string.isRequired,
setPushHealthVisibility: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
export default connect(
null,
{ notify },
)(PrimaryNavBar);

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Col,
@ -15,9 +16,9 @@ import {
import Fuse from 'fuse.js';
import PushModel from '../../models/push';
import { withNotifications } from '../../shared/context/Notifications';
import { formatTaskclusterError } from '../../helpers/errorMessage';
import { sortAlphaNum } from '../../helpers/sort';
import { notify } from '../redux/stores/notifications';
class FuzzyJobFinder extends React.Component {
constructor(props) {
@ -321,4 +322,7 @@ FuzzyJobFinder.defaultProps = {
decisionTaskId: '',
};
export default withNotifications(FuzzyJobFinder);
export default connect(
null,
{ notify },
)(FuzzyJobFinder);

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import sortBy from 'lodash/sortBy';
import {
@ -12,9 +13,9 @@ import { getGroupMapKey } from '../../helpers/aggregateId';
import { getAllUrlParams, getUrlParam } from '../../helpers/location';
import JobModel from '../../models/job';
import RunnableJobModel from '../../models/runnableJob';
import { withNotifications } from '../../shared/context/Notifications';
import { getRevisionTitle } from '../../helpers/revision';
import { getPercentComplete } from '../../helpers/display';
import { notify } from '../redux/stores/notifications';
import FuzzyJobFinder from './FuzzyJobFinder';
import { Revision } from './Revision';
@ -549,4 +550,7 @@ Push.propTypes = {
pushHealthVisibility: PropTypes.string.isRequired,
};
export default withNotifications(withPushes(Push));
export default connect(
null,
{ notify },
)(withPushes(Push));

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

@ -1,13 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getUrlParam } from '../../helpers/location';
import { formatTaskclusterError } from '../../helpers/errorMessage';
import CustomJobActions from '../CustomJobActions';
import PushModel from '../../models/push';
import { withPushes } from '../context/Pushes';
import { withNotifications } from '../../shared/context/Notifications';
import { getPushHealthUrl } from '../../helpers/url';
import { notify } from '../redux/stores/notifications';
// Trigger missing jobs is dangerous on repos other than these (see bug 1335506)
const triggerMissingRepos = ['mozilla-inbound', 'autoland'];
@ -284,4 +285,7 @@ PushActionMenu.propTypes = {
notify: PropTypes.func.isRequired,
};
export default withNotifications(withPushes(PushActionMenu));
export default connect(
null,
{ notify },
)(withPushes(PushActionMenu));

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

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import isEqual from 'lodash/isEqual';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
@ -20,9 +21,9 @@ import JobModel from '../../models/job';
import { withPinnedJobs } from '../context/PinnedJobs';
import { withSelectedJob } from '../context/SelectedJob';
import { withPushes } from '../context/Pushes';
import { withNotifications } from '../../shared/context/Notifications';
import PushHealthStatus from '../../shared/PushHealthStatus';
import { getUrlParam, setUrlParam } from '../../helpers/location';
import { notify } from '../redux/stores/notifications';
import PushActionMenu from './PushActionMenu';
@ -407,6 +408,7 @@ PushHeader.defaultProps = {
watchState: 'none',
};
export default withNotifications(
withPushes(withSelectedJob(withPinnedJobs(PushHeader))),
);
export default connect(
null,
{ notify },
)(withPushes(withSelectedJob(withPinnedJobs(PushHeader))));

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

@ -0,0 +1,12 @@
import { createStore, combineReducers } from 'redux';
import * as notificationStore from './stores/notifications';
export default () => {
const reducers = combineReducers({
notifications: notificationStore.reducer,
});
const store = createStore(reducers);
return { store };
};

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

@ -0,0 +1,4 @@
import configureStore from './configureStore';
// eslint-disable-next-line import/prefer-default-export
export const { store } = configureStore();

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

@ -0,0 +1,103 @@
import {
clearNotificationAtIndex,
clearExpiredTransientNotifications,
} from '../../../helpers/notifications';
const MAX_STORED_NOTIFICATIONS = 40;
const LOCAL_STORAGE_KEY = 'notifications';
// *** Event types ***
export const NOTIFY = 'NOTIFY';
export const CLEAR = 'CLEAR';
export const CLEAR_EXPIRED_TRANSIENTS = 'CLEAR_EXPIRED_TRANSIENTS';
export const CLEAR_ALL_ON_SCREEN = 'CLEAR_ALL_ON_SCREEN';
export const CLEAR_STORED = 'CLEAR_STORED';
// *** Action creators ***
export const clearAllOnScreenNotifications = () => ({
type: CLEAR_ALL_ON_SCREEN,
});
export const clearNotification = index => ({
type: CLEAR,
index,
});
export const clearStoredNotifications = () => ({
type: CLEAR_STORED,
});
export const notify = (message, severity, options) => ({
type: NOTIFY,
message,
severity,
options,
});
// *** Implementation ***
const doNotify = (
{ notifications, storedNotifications },
message,
severity = 'info',
options = {},
) => {
const notification = {
...options,
message,
severity,
created: Date.now(),
};
const newNotifications = [notification, ...notifications];
storedNotifications.unshift(notification);
storedNotifications.splice(MAX_STORED_NOTIFICATIONS);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(storedNotifications));
return {
notifications: newNotifications,
storedNotifications: [...storedNotifications],
};
};
const doClearStoredNotifications = () => {
const storedNotifications = [];
localStorage.setItem(LOCAL_STORAGE_KEY, storedNotifications);
return { storedNotifications };
};
const doClearAllOnScreenNotifications = () => {
return { notifications: [] };
};
const initialState = {
notifications: [],
storedNotifications: JSON.parse(
localStorage.getItem(LOCAL_STORAGE_KEY) || '[]',
),
};
export const reducer = (state = initialState, action) => {
const { message, severity, options, index } = action;
switch (action.type) {
case NOTIFY:
return { ...state, ...doNotify(state, message, severity, options) };
case CLEAR:
return {
...state,
...clearNotificationAtIndex(state.notifications, index),
};
case CLEAR_EXPIRED_TRANSIENTS:
return {
...state,
...clearExpiredTransientNotifications(state.notifications),
};
case CLEAR_ALL_ON_SCREEN:
return { ...state, ...doClearAllOnScreenNotifications() };
case CLEAR_STORED:
return { ...state, ...doClearStoredNotifications() };
default:
return state;
}
};

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

@ -17,7 +17,6 @@ import {
DropdownItem,
} from 'reactstrap';
import { withNotifications } from '../shared/context/Notifications';
import JobModel from '../models/job';
import TestFailure from './TestFailure';
@ -68,6 +67,7 @@ class ClassificationGroup extends React.PureComponent {
headerColor,
user,
hasRetriggerAll,
notify,
} = this.props;
const expandIcon = detailsShowing ? faMinusSquare : faPlusSquare;
@ -125,6 +125,7 @@ class ClassificationGroup extends React.PureComponent {
repo={repo}
revision={revision}
user={user}
notify={notify}
/>
))}
</div>
@ -154,4 +155,4 @@ ClassificationGroup.defaultProps = {
hasRetriggerAll: false,
};
export default withNotifications(ClassificationGroup);
export default ClassificationGroup;

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

@ -4,15 +4,18 @@ import { Table, Container, Spinner } from 'reactstrap';
import ErrorMessages from '../shared/ErrorMessages';
import NotificationList from '../shared/NotificationList';
import { Notifications } from '../shared/context/Notifications';
import { getJobsUrl } from '../helpers/url';
import {
clearNotificationAtIndex,
clearExpiredTransientNotifications,
} from '../helpers/notifications';
import PushModel from '../models/push';
import { resultColorMap } from './helpers';
import Metric from './Metric';
import Navigation from './Navigation';
export default class Health extends React.Component {
export default class Health extends React.PureComponent {
constructor(props) {
super(props);
@ -24,6 +27,7 @@ export default class Health extends React.Component {
repo: params.get('repo'),
healthData: null,
failureMessage: null,
notifications: [],
};
}
@ -33,6 +37,11 @@ export default class Health extends React.Component {
// Update the tests every two minutes.
this.testTimerId = setInterval(() => this.updatePushHealth(), 120000);
this.notificationsId = setInterval(() => {
const { notifications } = this.state;
this.setState(clearExpiredTransientNotifications(notifications));
}, 4000);
}
componentWillUnmount() {
@ -53,61 +62,87 @@ export default class Health extends React.Component {
this.setState(newState);
};
notify = (message, severity, options = {}) => {
const { notifications } = this.state;
const notification = {
...options,
message,
severity: severity || 'info',
created: Date.now(),
};
const newNotifications = [notification, ...notifications];
this.setState({
notifications: newNotifications,
});
};
clearNotification = index => {
const { notifications } = this.state;
this.setState(clearNotificationAtIndex(notifications, index));
};
render() {
const { healthData, user, repo, revision, failureMessage } = this.state;
const {
healthData,
user,
repo,
revision,
failureMessage,
notifications,
} = this.state;
const overallResult = healthData
? resultColorMap[healthData.result]
: 'none';
return (
<Notifications>
<React.Fragment>
<Navigation user={user} setUser={this.setUser} />
<Container fluid className="mt-2">
<NotificationList />
{healthData && (
<div className="d-flex flex-column">
<h3 className="text-center">
<span
className={`badge badge-xl mb-3 badge-${overallResult}`}
<React.Fragment>
<Navigation user={user} setUser={this.setUser} notify={this.notify} />
<Container fluid className="mt-2">
<NotificationList
notifications={notifications}
clearNotification={this.clearNotification}
/>
{healthData && (
<div className="d-flex flex-column">
<h3 className="text-center">
<span className={`badge badge-xl mb-3 badge-${overallResult}`}>
<a
href={getJobsUrl({ repo, revision })}
className="text-white"
target="_blank"
rel="noopener noreferrer"
>
<a
href={getJobsUrl({ repo, revision })}
className="text-white"
target="_blank"
rel="noopener noreferrer"
>
{repo} - {revision}
</a>
</span>
</h3>
<Table size="sm" className="table-fixed">
<tbody>
{healthData.metrics.map(metric => (
<tr key={metric.name}>
<Metric
name={metric.name}
result={metric.result}
value={metric.value}
details={metric.details}
failures={metric.failures}
repo={repo}
revision={revision}
user={user}
/>
</tr>
))}
</tbody>
</Table>
</div>
)}
{failureMessage && (
<ErrorMessages failureMessage={failureMessage} />
)}
{!failureMessage && !healthData && <Spinner />}
</Container>
</React.Fragment>
</Notifications>
{repo} - {revision}
</a>
</span>
</h3>
<Table size="sm" className="table-fixed">
<tbody>
{healthData.metrics.map(metric => (
<tr key={metric.name}>
<Metric
name={metric.name}
result={metric.result}
value={metric.value}
details={metric.details}
failures={metric.failures}
repo={repo}
revision={revision}
user={user}
notify={this.notify}
/>
</tr>
))}
</tbody>
</Table>
</div>
)}
{failureMessage && <ErrorMessages failureMessage={failureMessage} />}
{!failureMessage && !healthData && <Spinner />}
</Container>
</React.Fragment>
);
}
}

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

@ -35,6 +35,7 @@ export default class Metric extends React.PureComponent {
repo,
revision,
user,
notify,
} = this.props;
const resultColor = resultColorMap[result];
const expandIcon = detailsShowing ? faMinusSquare : faPlusSquare;
@ -68,6 +69,7 @@ export default class Metric extends React.PureComponent {
repo={repo}
revision={revision}
user={user}
notify={notify}
/>
)}
{details &&
@ -92,6 +94,7 @@ Metric.propTypes = {
result: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
user: PropTypes.object.isRequired,
notify: PropTypes.func.isRequired,
details: PropTypes.array,
failures: PropTypes.object,
};

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

@ -8,7 +8,7 @@ import SimpleTooltip from '../shared/SimpleTooltip';
export default class Navigation extends React.PureComponent {
render() {
const { user, setUser } = this.props;
const { user, setUser, notify } = this.props;
return (
<Navbar dark color="dark">
@ -27,7 +27,7 @@ export default class Navigation extends React.PureComponent {
</div>
}
/>
<Login user={user} setUser={setUser} />
<Login user={user} setUser={setUser} notify={notify} />
</Navbar>
);
}
@ -36,4 +36,5 @@ export default class Navigation extends React.PureComponent {
Navigation.propTypes = {
user: PropTypes.object.isRequired,
setUser: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};

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

@ -5,7 +5,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRedo } from '@fortawesome/free-solid-svg-icons';
import JobModel from '../models/job';
import { withNotifications } from '../shared/context/Notifications';
import Job from './Job';
@ -32,7 +31,9 @@ class TestFailure extends React.PureComponent {
const { user, repo, notify } = this.props;
if (!user.isLoggedIn) {
notify('Must be logged in to retrigger a job', 'danger');
notify('Must be logged in to retrigger a job', 'danger', {
sticky: true,
});
return;
}
JobModel.retrigger([job], repo, notify);
@ -178,4 +179,4 @@ TestFailure.propTypes = {
notify: PropTypes.func.isRequired,
};
export default withNotifications(TestFailure);
export default TestFailure;

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

@ -5,7 +5,7 @@ import ClassificationGroup from './ClassificationGroup';
export default class TestFailures extends React.PureComponent {
render() {
const { failures, repo, revision, user } = this.props;
const { failures, repo, revision, user, notify } = this.props;
const { needInvestigation, intermittent } = failures;
const needInvestigationLength = Object.keys(needInvestigation).length;
@ -20,6 +20,7 @@ export default class TestFailures extends React.PureComponent {
headerColor={needInvestigationLength ? 'danger' : 'secondary'}
user={user}
hasRetriggerAll
notify={notify}
/>
<ClassificationGroup
group={intermittent}
@ -30,6 +31,7 @@ export default class TestFailures extends React.PureComponent {
headerColor="secondary"
expanded={false}
user={user}
notify={notify}
/>
</div>
);
@ -41,4 +43,5 @@ TestFailures.propTypes = {
user: PropTypes.object.isRequired,
repo: PropTypes.string.isRequired,
revision: PropTypes.string.isRequired,
notify: PropTypes.func.isRequired,
};

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

@ -8,8 +8,6 @@ import {
faExclamationTriangle,
} from '@fortawesome/free-solid-svg-icons';
import { withNotifications } from './context/Notifications';
class NotificationList extends React.Component {
static getIcon(severity) {
// TODO: Move this and the usage in NotificationsMenu to a shared component.
@ -26,7 +24,7 @@ class NotificationList extends React.Component {
}
render() {
const { notifications, removeNotification } = this.props;
const { notifications, clearNotification } = this.props;
return (
<ul id="notification-box" className="list-unstyled">
@ -37,7 +35,7 @@ class NotificationList extends React.Component {
icon={NotificationList.getIcon(notification.severity)}
title={notification.severity}
/>
<span>{notification.message}</span>
<span className="ml-1">{notification.message}</span>
{notification.url && notification.linkText && (
<span>
<a href={notification.url}>{notification.linkText}</a>
@ -46,7 +44,7 @@ class NotificationList extends React.Component {
{notification.sticky && (
<button
type="button"
onClick={() => removeNotification(idx)}
onClick={() => clearNotification(idx)}
className="close"
>
x
@ -65,12 +63,11 @@ NotificationList.propTypes = {
PropTypes.shape({
created: PropTypes.number.isRequired,
message: PropTypes.string.isRequired,
// TODO: Enforce specific severity strings
severity: PropTypes.string.isRequired,
severity: PropTypes.oneOf(['danger', 'warning', 'info', 'success']),
sticky: PropTypes.bool,
}),
).isRequired,
removeNotification: PropTypes.func.isRequired,
clearNotification: PropTypes.func.isRequired,
};
export default withNotifications(NotificationList);
export default NotificationList;

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

@ -9,7 +9,6 @@ import { loggedOutUser } from '../../helpers/auth';
import taskcluster from '../../helpers/taskcluster';
import { getApiUrl, loginCallbackUrl } from '../../helpers/url';
import UserModel from '../../models/user';
import { withNotifications } from '../context/Notifications';
import AuthService from './AuthService';
@ -167,4 +166,4 @@ Login.defaultProps = {
notify: msg => console.error(msg), // eslint-disable-line no-console
};
export default withNotifications(Login);
export default Login;

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

@ -1,156 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import findLastIndex from 'lodash/findLastIndex';
export const NotificationsContext = React.createContext({});
const maxTransientNotifications = 5;
const maxStoredNotifications = 40;
export class Notifications extends React.Component {
constructor(props) {
super(props);
this.state = {
notifications: [],
storedNotifications: JSON.parse(
localStorage.getItem('notifications') || '[]',
),
};
this.value = {
...this.state,
notify: this.notify,
removeNotification: this.removeNotification,
clearStoredNotifications: this.clearStoredNotifications,
clearOnScreenNotifications: this.clearOnScreenNotifications,
};
}
componentDidMount() {
window.addEventListener('storage', this.handleStorageEvent);
}
componentWillUnmount() {
window.removeEventListener('storage', this.handleStorageEvent);
}
setValue = (newState, callback) => {
this.value = { ...this.value, ...newState };
this.setState(newState, callback);
};
handleStorageEvent = e => {
if (e.key === 'notifications') {
this.setValue({
storedNotifications: JSON.parse(
localStorage.getItem('notifications') || '[]',
),
});
}
};
notify = (message, severity, opts) => {
opts = opts || {};
severity = severity || 'info';
const { notifications, storedNotifications } = this.state;
const notification = { ...opts, message, severity, created: Date.now() };
const trimmedNotifications =
notifications >= maxTransientNotifications
? this.withoutOldestTransient(notifications)
: notifications;
const newNotifications = [notification, ...trimmedNotifications];
storedNotifications.unshift(notification);
storedNotifications.splice(maxStoredNotifications);
localStorage.setItem('notifications', JSON.stringify(storedNotifications));
this.setValue(
{
notifications: newNotifications,
storedNotifications: [...storedNotifications],
},
() => {
if (!opts.sticky) {
setTimeout(this.shift, 4000, true);
}
},
);
};
/*
* remove an arbitrary element from the notifications queue
*/
removeNotification = (index, delay = 0) => {
const { notifications } = this.state;
notifications.splice(index, 1);
setTimeout(
() => this.setValue({ notifications: [...notifications] }),
delay,
);
};
/*
* Delete the first non-sticky element from the notifications queue
*/
shift = delay => {
const { notifications } = this.state;
this.removeNotification(notifications.findIndex(n => !n.sticky), delay);
};
withoutOldestTransient = notifications => {
const last = findLastIndex(notifications, n => !n.sticky);
if (last) {
notifications.splice(last, 1);
}
return notifications;
};
/*
* Clear the list of stored notifications
*/
clearStoredNotifications = () => {
const storedNotifications = [];
localStorage.setItem('notifications', storedNotifications);
this.setValue({ storedNotifications });
};
clearOnScreenNotifications = () => {
this.setValue({ notifications: [] });
};
render() {
return (
<NotificationsContext.Provider value={this.value}>
{this.props.children}
</NotificationsContext.Provider>
);
}
}
Notifications.propTypes = {
children: PropTypes.object.isRequired,
};
export function withNotifications(Component) {
return function NotificationComponent(props) {
return (
<NotificationsContext.Consumer>
{context => (
<Component
{...props}
notifications={context.notifications}
storedNotifications={context.storedNotifications}
notify={context.notify}
removeNotification={context.removeNotification}
clearStoredNotifications={context.clearStoredNotifications}
clearOnScreenNotifications={context.clearOnScreenNotifications}
/>
)}
</NotificationsContext.Consumer>
);
};
}

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

@ -670,7 +670,7 @@
"@babel/plugin-transform-react-jsx-self" "^7.0.0"
"@babel/plugin-transform-react-jsx-source" "^7.0.0"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.3":
"@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.3":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.4.tgz#dc2e34982eb236803aa27a07fea6857af1b9171d"
integrity sha512-w0+uT71b6Yi7i5SE0co4NioIpSYS6lLiXvCzWzGSKvpK5vdQtCbICHMj+gbAKAOtxiV6HsVh/MBdaF9EQ6faSg==
@ -7599,7 +7599,7 @@ react-input-autosize@^2.2.1:
dependencies:
prop-types "^15.5.8"
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.2, react-is@^16.8.4, react-is@^16.8.6:
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==
@ -7640,17 +7640,17 @@ react-popper@^0.10.4:
popper.js "^1.14.1"
prop-types "^15.6.1"
react-redux@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-6.0.1.tgz#0d423e2c1cb10ada87293d47e7de7c329623ba4d"
integrity sha512-T52I52Kxhbqy/6TEfBv85rQSDz6+Y28V/pf52vDWs1YRXG19mcFOGfHnY2HsNFHyhP+ST34Aih98fvt6tqwVcQ==
react-redux@7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.0.3.tgz#983c5a6de81cb1e696bd1c090ba826545f9170f1"
integrity sha512-vYZA7ftOYlDk3NetitsI7fLjryt/widNl1SLXYvFenIpm7vjb4ryK0EeFrgn62usg5fYkyIAWNUPKnwWPevKLg==
dependencies:
"@babel/runtime" "^7.3.1"
"@babel/runtime" "^7.4.3"
hoist-non-react-statics "^3.3.0"
invariant "^2.2.4"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^16.8.2"
react-is "^16.8.6"
react-router-dom@5.0.0:
version "5.0.0"