From 49368ca7ffd35c441e57de21e2400b816c7eda99 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Fri, 12 Oct 2018 17:16:20 -0700 Subject: [PATCH] Bug 1450024 - Convert Notifications to ReactJS (#4132) --- tests/ui/unit/context/pushes.tests.jsx | 12 +- tests/ui/unit/init.js | 9 -- tests/ui/unit/react/bugfiler.tests.jsx | 10 +- ui/entry-index.js | 2 - ui/entry-logviewer.js | 2 - ui/entry-perf.js | 1 - ui/index.html | 2 - ui/job-view/CustomJobActions.jsx | 13 +- ui/job-view/JobView.jsx | 128 ++++++++-------- ui/job-view/context/PinnedJobs.jsx | 21 +-- ui/job-view/context/Pushes.jsx | 15 +- ui/job-view/context/SelectedJob.jsx | 11 +- ui/job-view/details/BugFiler.jsx | 17 ++- ui/job-view/details/PinBoard.jsx | 33 +++-- ui/job-view/details/summary/ActionBar.jsx | 66 ++++----- ui/job-view/details/tabs/AnnotationsTab.jsx | 25 ++-- ui/job-view/details/tabs/SimilarJobsTab.jsx | 12 +- ui/job-view/details/tabs/TabsPanel.jsx | 1 - .../tabs/autoclassify/AutoclassifyTab.jsx | 8 +- .../details/tabs/autoclassify/LineOption.jsx | 2 - .../tabs/failureSummary/FailureSummaryTab.jsx | 2 - ui/job-view/headerbars/NotificationsMenu.jsx | 49 ++----- ui/job-view/headerbars/PrimaryNavBar.jsx | 16 +- ui/job-view/pushes/JobsAndGroups.jsx | 5 +- ui/job-view/pushes/Platform.jsx | 4 +- ui/job-view/pushes/Push.jsx | 27 ++-- ui/job-view/pushes/PushActionMenu.jsx | 26 ++-- ui/job-view/pushes/PushHeader.jsx | 35 ++--- ui/job-view/pushes/PushJobs.jsx | 4 +- ui/job-view/pushes/PushList.jsx | 4 +- ui/js/controllers/logviewer.js | 7 +- ui/js/controllers/main.js | 9 +- ui/js/directives/treeherder/main.js | 15 -- ui/js/services/main.js | 95 ------------ ui/logviewer.html | 3 +- ui/models/job.js | 39 ++--- ui/partials/main/thNotificationsBox.html | 16 -- ui/shared/Login.jsx | 20 ++- ui/shared/NotificationList.jsx | 51 +++++++ ui/shared/context/Notifications.jsx | 138 ++++++++++++++++++ 40 files changed, 466 insertions(+), 489 deletions(-) delete mode 100755 ui/js/directives/treeherder/main.js delete mode 100755 ui/js/services/main.js delete mode 100644 ui/partials/main/thNotificationsBox.html create mode 100644 ui/shared/NotificationList.jsx create mode 100644 ui/shared/context/Notifications.jsx diff --git a/tests/ui/unit/context/pushes.tests.jsx b/tests/ui/unit/context/pushes.tests.jsx index 8cb6a29cc..52d33f6ad 100644 --- a/tests/ui/unit/context/pushes.tests.jsx +++ b/tests/ui/unit/context/pushes.tests.jsx @@ -4,7 +4,7 @@ import * as fetchMock from 'fetch-mock'; import { mount } from 'enzyme'; import { getProjectUrl } from '../../../../ui/helpers/url'; -import { Pushes } from '../../../../ui/job-view/context/Pushes'; +import { PushesClass } from '../../../../ui/job-view/context/Pushes'; import FilterModel from '../../../../ui/models/filter'; const { getJSONFixture, inject } = window; @@ -42,10 +42,11 @@ describe('Pushes context', () => { */ it('should have 2 pushes', async () => { const pushes = mount( -
, + notify={() => {}} + >
, ); await pushes.instance().fetchPushes(10); expect(pushes.state('pushList').length).toBe(2); @@ -53,10 +54,11 @@ describe('Pushes context', () => { it('should have id of 1 in current repo', async () => { const pushes = mount( -
, + notify={() => {}} + >
, ); await pushes.instance().fetchPushes(10); expect(pushes.state('pushList')[0].id).toBe(1); diff --git a/tests/ui/unit/init.js b/tests/ui/unit/init.js index 0c1662a89..69e83cfd7 100644 --- a/tests/ui/unit/init.js +++ b/tests/ui/unit/init.js @@ -18,14 +18,5 @@ configure({ adapter: new Adapter() }); const jsContext = require.context('../../../ui/js', true, /^\.\/.*\.jsx?$/); jsContext('./filters.js'); -const controllerContext = require.context('../../../ui/js/controllers', true, /^\.\/.*\.jsx?$/); -controllerContext.keys().forEach(controllerContext); -const directiveContext = require.context('../../../ui/js/directives', true, /^\.\/.*\.jsx?$/); -directiveContext.keys().forEach(directiveContext); -const serviceContext = require.context('../../../ui/js/services', true, /^\.\/.*\.jsx?$/); -serviceContext.keys().forEach(serviceContext); -const componentContext = require.context('../../../ui/js/components', true, /^\.\/.*\.jsx?$/); -componentContext.keys().forEach(componentContext); - const testContext = require.context('./', true, /^\.\/.*\.tests\.jsx?$/); testContext.keys().forEach(testContext); diff --git a/tests/ui/unit/react/bugfiler.tests.jsx b/tests/ui/unit/react/bugfiler.tests.jsx index 58ff09b0c..187640738 100644 --- a/tests/ui/unit/react/bugfiler.tests.jsx +++ b/tests/ui/unit/react/bugfiler.tests.jsx @@ -4,7 +4,7 @@ import * as fetchMock from 'fetch-mock'; import { hgBaseUrl, bzBaseUrl } from '../../../../ui/helpers/url'; import { isReftest } from '../../../../ui/helpers/job'; -import BugFiler from '../../../../ui/job-view/details/BugFiler'; +import { BugFilerClass } from '../../../../ui/job-view/details/BugFiler'; describe('BugFiler', () => { const fullLog = 'https://queue.taskcluster.net/v1/task/AGs4CgN_RnCTb943uQn8NQ/runs/0/artifacts/public/logs/live_backing.log'; @@ -70,7 +70,7 @@ describe('BugFiler', () => { }; return mount( - { reftestUrl={isReftest(selectedJob) ? reftest : ''} successCallback={successCallback} jobGroupName={selectedJob.job_group_name} - notify={{}} + notify={() => {}} />, ); }; @@ -211,7 +211,7 @@ describe('BugFiler', () => { search: 'REFTEST TEST-UNEXPECTED-PASS | flee | floo' }, ]; const bugFiler = mount( - { reftestUrl={isReftest(selectedJob) ? reftest : ''} successCallback={successCallback} jobGroupName={selectedJob.job_group_name} - notify={{}} + notify={() => {}} />, ); diff --git a/ui/entry-index.js b/ui/entry-index.js index 911b5d895..a51479405 100644 --- a/ui/entry-index.js +++ b/ui/entry-index.js @@ -30,7 +30,5 @@ import './job-view/JobView'; import './shared/ShortcutTable'; // Treeherder JS -import './js/directives/treeherder/main'; -import './js/services/main'; import './js/models/perf/series'; import './js/controllers/main'; diff --git a/ui/entry-logviewer.js b/ui/entry-logviewer.js index 0f3399d53..a9c8721db 100644 --- a/ui/entry-logviewer.js +++ b/ui/entry-logviewer.js @@ -16,8 +16,6 @@ import './js/logviewer'; // Logviewer JS import './js/directives/treeherder/log_viewer_steps'; -import './js/directives/treeherder/main'; import './js/components/logviewer/logviewer'; -import './js/services/main'; import './js/filters'; import './js/controllers/logviewer'; diff --git a/ui/entry-perf.js b/ui/entry-perf.js index 3aabf97df..d67c5c2f2 100644 --- a/ui/entry-perf.js +++ b/ui/entry-perf.js @@ -27,7 +27,6 @@ import './shared/Login'; // Perf JS import './js/filters'; -import './js/services/main'; import './js/models/perf/series'; import './js/models/perf/issue_tracker'; import './js/models/perf/performance_framework'; diff --git a/ui/index.html b/ui/index.html index 9f6bc6cd9..4cfe53a0a 100755 --- a/ui/index.html +++ b/ui/index.html @@ -22,7 +22,5 @@
- - diff --git a/ui/job-view/CustomJobActions.jsx b/ui/job-view/CustomJobActions.jsx index 8803b497a..d4df80df3 100644 --- a/ui/job-view/CustomJobActions.jsx +++ b/ui/job-view/CustomJobActions.jsx @@ -12,6 +12,7 @@ import { import { formatTaskclusterError } from '../helpers/errorMessage'; import TaskclusterModel from '../models/taskcluster'; import { withPushes } from './context/Pushes'; +import { withNotifications } from '../shared/context/Notifications'; class CustomJobActions extends React.Component { constructor(props) { @@ -97,13 +98,13 @@ class CustomJobActions extends React.Component { input = jsyaml.safeLoad(payload); } catch (e) { this.setState({ triggering: false }); - notify.send(`YAML Error: ${e.message}`, 'danger'); + notify(`YAML Error: ${e.message}`, 'danger'); return; } const valid = validate(input); if (!valid) { this.setState({ triggering: false }); - notify.send(ajv.errorsText(validate.errors), 'danger'); + notify(ajv.errorsText(validate.errors), 'danger'); return; } } @@ -130,10 +131,10 @@ class CustomJobActions extends React.Component { message = 'Visit Taskcluster Tools site to access loaner:'; url = `${url}/connect`; } - notify.send(message, 'success', { linkText: 'Open in Taskcluster', url }); + notify(message, 'success', { linkText: 'Open in Taskcluster', url }); this.close(); }, (e) => { - notify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); this.setState({ triggering: false }); this.close(); }); @@ -229,7 +230,7 @@ class CustomJobActions extends React.Component { CustomJobActions.propTypes = { pushId: PropTypes.number.isRequired, isLoggedIn: PropTypes.bool.isRequired, - notify: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, toggle: PropTypes.func.isRequired, getGeckoDecisionTaskId: PropTypes.func.isRequired, job: PropTypes.object, @@ -239,4 +240,4 @@ CustomJobActions.defaultProps = { job: null, }; -export default withPushes(CustomJobActions); +export default withNotifications(withPushes(CustomJobActions)); diff --git a/ui/job-view/JobView.jsx b/ui/job-view/JobView.jsx index 3b4b6906b..60e56f2d7 100644 --- a/ui/job-view/JobView.jsx +++ b/ui/job-view/JobView.jsx @@ -8,6 +8,7 @@ import { thEvents, thFavicons } from '../helpers/constants'; import { Pushes } from './context/Pushes'; import { SelectedJob } from './context/SelectedJob'; import { PinnedJobs } from './context/PinnedJobs'; +import { Notifications } from '../shared/context/Notifications'; import { matchesDefaults } from '../helpers/filter'; import { getAllUrlParams, getRepo } from '../helpers/location'; import { deployedRevisionUrl } from '../helpers/url'; @@ -20,6 +21,7 @@ import UpdateAvailable from './headerbars/UpdateAvailable'; import DetailsPanel from './details/DetailsPanel'; import PushList from './pushes/PushList'; import KeyboardShortcuts from './KeyboardShortcuts'; +import NotificationList from '../shared/NotificationList'; const DEFAULT_DETAILS_PCT = 40; const REVISION_POLL_INTERVAL = 1000 * 60 * 5; @@ -42,7 +44,6 @@ class JobView extends React.Component { const { $injector } = this.props; this.$rootScope = $injector.get('$rootScope'); - this.thNotify = $injector.get('thNotify'); const filterModel = new FilterModel(); this.$rootScope.filterModel = filterModel; @@ -231,75 +232,74 @@ class JobView extends React.Component { ), []); return ( - - - - - + + + + - this.handleSplitChange(size)} > -
- {(isFieldFilterVisible || !!filterBarFilters.length) && } - {serverChangedDelayed && } -
- - - -
-
- -
-
-
-
-
+ this.handleSplitChange(size)} + > +
+ {(isFieldFilterVisible || !!filterBarFilters.length) && } + {serverChangedDelayed && } +
+ + + +
+
+ +
+ +
+
+
+
+ ); } } diff --git a/ui/job-view/context/PinnedJobs.jsx b/ui/job-view/context/PinnedJobs.jsx index a55a90584..1a3e50768 100644 --- a/ui/job-view/context/PinnedJobs.jsx +++ b/ui/job-view/context/PinnedJobs.jsx @@ -1,11 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { withNotifications } from '../../shared/context/Notifications'; const COUNT_ERROR = 'Max pinboard size of 500 reached.'; const MAX_SIZE = 500; const PinnedJobsContext = React.createContext({}); -export class PinnedJobs extends React.Component { +export class PinnedJobsClass extends React.Component { constructor(props) { super(props); @@ -73,7 +74,7 @@ export class PinnedJobs extends React.Component { }, () => { if (callback) callback(); }); this.pulsePinCount(); } else { - notify.send(COUNT_ERROR, 'danger'); + notify(COUNT_ERROR, 'danger'); } } @@ -92,7 +93,7 @@ export class PinnedJobs extends React.Component { const newPinnedJobs = jobsToPin.slice(0, spaceRemaining).reduce((acc, job) => ({ ...acc, [job.id]: job }), {}); if (!spaceRemaining) { - notify.send(COUNT_ERROR, 'danger', { sticky: true }); + notify(COUNT_ERROR, 'danger', { sticky: true }); return; } @@ -101,7 +102,7 @@ export class PinnedJobs extends React.Component { isPinBoardVisible: true, }, () => { if (showError) { - notify.send(COUNT_ERROR, 'danger', { sticky: true }); + notify(COUNT_ERROR, 'danger', { sticky: true }); } }); } @@ -160,6 +161,13 @@ export class PinnedJobs extends React.Component { } } +PinnedJobsClass.propTypes = { + notify: PropTypes.func.isRequired, + children: PropTypes.object.isRequired, +}; + +export const PinnedJobs = withNotifications(PinnedJobsClass); + export function withPinnedJobs(Component) { return function PinBoardComponent(props) { return ( @@ -184,8 +192,3 @@ export function withPinnedJobs(Component) { ); }; } - -PinnedJobs.propTypes = { - notify: PropTypes.object.isRequired, - children: PropTypes.object.isRequired, -}; diff --git a/ui/job-view/context/Pushes.jsx b/ui/job-view/context/Pushes.jsx index 58c6fb54b..a4e54f47b 100644 --- a/ui/job-view/context/Pushes.jsx +++ b/ui/job-view/context/Pushes.jsx @@ -17,6 +17,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'; const PushesContext = React.createContext({}); const defaultPushCount = 10; @@ -26,13 +27,12 @@ const pushPollingKeys = ['tochange', 'enddate', 'revision', 'author']; const pushFetchKeys = [...pushPollingKeys, 'fromchange', 'startdate']; const pushPollInterval = 60000; -export class Pushes extends React.Component { +export class PushesClass extends React.Component { constructor(props) { super(props); const { $injector } = this.props; this.$rootScope = $injector.get('$rootScope'); - this.thNotify = $injector.get('thNotify'); this.skipNextPageReload = false; @@ -208,6 +208,7 @@ export class Pushes extends React.Component { poll() { this.pushIntervalId = setInterval(() => { + const { notify } = this.props; const { pushList } = this.state; // these params will be passed in each time we poll to remain // within the constraints of the URL params @@ -221,7 +222,7 @@ export class Pushes extends React.Component { const data = await resp.json(); this.addPushes(data); } else { - this.thNotify.send('Error fetching new push data', 'danger', { sticky: true }); + notify('Error fetching new push data', 'danger', { sticky: true }); } }); @@ -262,6 +263,7 @@ export class Pushes extends React.Component { * @param count How many to fetch */ fetchPushes(count) { + const { notify } = this.props; const { oldestPushTimestamp } = this.state; // const isAppend = (repoData.pushes.length > 0); // Only pass supported query string params to this endpoint. @@ -285,7 +287,7 @@ export class Pushes extends React.Component { this.addPushes(data.results.length ? data : { results: [] }); } else { - this.thNotify.send('Error retrieving push data!', 'danger', { sticky: true }); + notify('Error retrieving push data!', 'danger', { sticky: true }); } }).then(() => this.setValue({ loadingPushes: false })); } @@ -370,12 +372,15 @@ export class Pushes extends React.Component { } -Pushes.propTypes = { +PushesClass.propTypes = { children: PropTypes.object.isRequired, filterModel: PropTypes.object.isRequired, $injector: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, }; +export const Pushes = withNotifications(PushesClass); + export function withPushes(Component) { return function PushesComponent(props) { return ( diff --git a/ui/job-view/context/SelectedJob.jsx b/ui/job-view/context/SelectedJob.jsx index 664914ddf..437e1e5b3 100644 --- a/ui/job-view/context/SelectedJob.jsx +++ b/ui/job-view/context/SelectedJob.jsx @@ -16,6 +16,7 @@ import JobModel from '../../models/job'; import PushModel from '../../models/push'; import { withPinnedJobs } from './PinnedJobs'; import { withPushes } from './Pushes'; +import { withNotifications } from '../../shared/context/Notifications'; const SelectedJobContext = React.createContext({}); @@ -96,7 +97,7 @@ class SelectedJobClass extends React.Component { // the job exists, but isn't in any loaded push. // provide a message and link to load the right push - notify.send( + notify( `Selected job id: ${selectedJobId} not within current push range.`, 'danger', { sticky: true, linkText: 'Load push', url: newPushUrl }); @@ -108,7 +109,7 @@ class SelectedJobClass extends React.Component { // the job wasn't found in the db. Either never existed, // or was expired and deleted. this.clearSelectedJob(); - notify.send(`Selected Job - ${error}`, + notify(`Selected Job - ${error}`, 'danger', { sticky: true }); }); @@ -169,7 +170,7 @@ class SelectedJobClass extends React.Component { noMoreUnclassifiedFailures() { const { pinnedJobs, notify } = this.props; - notify.send('No unclassified failures to select.'); + notify('No unclassified failures to select.'); this.clearSelectedJob(Object.keys(pinnedJobs).length); } @@ -257,13 +258,13 @@ class SelectedJobClass extends React.Component { SelectedJobClass.propTypes = { jobsLoaded: PropTypes.bool.isRequired, - notify: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, pinnedJobs: PropTypes.object.isRequired, jobMap: PropTypes.object.isRequired, children: PropTypes.object.isRequired, }; -export const SelectedJob = withPushes(withPinnedJobs(SelectedJobClass)); +export const SelectedJob = withNotifications(withPushes(withPinnedJobs(SelectedJobClass))); export function withSelectedJob(Component) { return function SelectedJobComponent(props) { diff --git a/ui/job-view/details/BugFiler.jsx b/ui/job-view/details/BugFiler.jsx index 9a7568732..b79568681 100644 --- a/ui/job-view/details/BugFiler.jsx +++ b/ui/job-view/details/BugFiler.jsx @@ -13,6 +13,7 @@ import { hgBaseUrl, } from '../../helpers/url'; import { create } from '../../helpers/http'; +import { withNotifications } from '../../shared/context/Notifications'; const crashRegex = /application crashed \[@ (.+)\]$/g; const omittedLeads = ['TEST-UNEXPECTED-FAIL', 'PROCESS-CRASH', 'TEST-UNEXPECTED-ERROR', 'REFTEST ERROR']; @@ -79,7 +80,7 @@ const parseSummary = (suggestion) => { return [summaryParts, possibleFilename]; }; -export default class BugFiler extends React.Component { +export class BugFilerClass extends React.Component { constructor(props) { super(props); @@ -294,12 +295,12 @@ export default class BugFiler extends React.Component { const [product, component] = selectedProduct.split(' :: '); if (!selectedProduct) { - notify.send('Please select (or search and select) a product/component pair to continue', 'danger'); + notify('Please select (or search and select) a product/component pair to continue', 'danger'); return; } if (summary.length > 255) { - notify.send('Please ensure the summary is no more than 255 characters', 'danger'); + notify('Please ensure the summary is no more than 255 characters', 'danger'); return; } @@ -353,7 +354,7 @@ export default class BugFiler extends React.Component { this.submitFailure('Bugzilla', productResp.status, productResp.statusText, productData); } } catch (e) { - notify.send(`Error filing bug: ${e.toString()}`, 'danger', { sticky: true }); + notify(`Error filing bug: ${e.toString()}`, 'danger', { sticky: true }); } } @@ -367,7 +368,7 @@ export default class BugFiler extends React.Component { if (status === 403) { failureString += '\n\nAuthentication failed. Has your Treeherder session expired?'; } - notify.send(failureString, 'danger', { sticky: true }); + notify(failureString, 'danger', { sticky: true }); } toggleTooltip(key) { @@ -601,7 +602,7 @@ export default class BugFiler extends React.Component { } } -BugFiler.propTypes = { +BugFilerClass.propTypes = { isOpen: PropTypes.bool.isRequired, toggle: PropTypes.func.isRequired, suggestion: PropTypes.object.isRequired, @@ -611,5 +612,7 @@ BugFiler.propTypes = { reftestUrl: PropTypes.string.isRequired, successCallback: PropTypes.func.isRequired, jobGroupName: PropTypes.string.isRequired, - notify: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, }; + +export default withNotifications(BugFilerClass); diff --git a/ui/job-view/details/PinBoard.jsx b/ui/job-view/details/PinBoard.jsx index 5dc2a2909..1b3a9f35e 100644 --- a/ui/job-view/details/PinBoard.jsx +++ b/ui/job-view/details/PinBoard.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { FormGroup, Input, FormFeedback } from 'reactstrap'; import { thEvents } from '../../helpers/constants'; +import { withNotifications } from '../../shared/context/Notifications'; import { formatModelError } from '../../helpers/errorMessage'; import { getJobBtnClass, getHoverText } from '../../helpers/job'; import { isSHAorCommit } from '../../helpers/revision'; @@ -19,7 +20,6 @@ class PinBoard extends React.Component { super(props); const { $injector } = this.props; - this.thNotify = $injector.get('thNotify'); this.$rootScope = $injector.get('$rootScope'); this.state = { @@ -67,7 +67,7 @@ class PinBoard extends React.Component { } save() { - const { isLoggedIn, pinnedJobs, recalculateUnclassifiedCounts } = this.props; + const { isLoggedIn, pinnedJobs, recalculateUnclassifiedCounts, notify } = this.props; let errorFree = true; if (this.state.enteringBugNumber) { @@ -75,15 +75,15 @@ class PinBoard extends React.Component { // just forgot to hit enter. Returns false if invalid errorFree = this.saveEnteredBugNumber(); if (!errorFree) { - this.thNotify.send('Please enter a valid bug number', 'danger'); + notify('Please enter a valid bug number', 'danger'); } } if (!this.canSaveClassifications() && isLoggedIn) { - this.thNotify.send('Please classify this failure before saving', 'danger'); + notify('Please classify this failure before saving', 'danger'); errorFree = false; } if (!isLoggedIn) { - this.thNotify.send('Must be logged in to save job classifications', 'danger'); + notify('Must be logged in to save job classifications', 'danger'); errorFree = false; } if (errorFree) { @@ -122,7 +122,7 @@ class PinBoard extends React.Component { } saveClassification(job) { - const { recalculateUnclassifiedCounts } = this.props; + const { recalculateUnclassifiedCounts, notify } = this.props; const classification = this.createNewClassification(); // classification can be left unset making this a no-op @@ -133,16 +133,16 @@ class PinBoard extends React.Component { classification.job_id = job.id; return classification.create().then(() => { - this.thNotify.send(`Classification saved for ${job.platform} ${job.job_type_name}`, 'success'); + notify(`Classification saved for ${job.platform} ${job.job_type_name}`, 'success'); }).catch((response) => { const message = `Error saving classification for ${job.platform} ${job.job_type_name}`; - this.thNotify.send(formatModelError(response, message), 'danger'); + notify(formatModelError(response, message), 'danger'); }); } } saveBugs(job) { - const { pinnedJobBugs } = this.props; + const { pinnedJobBugs, notify } = this.props; Object.values(pinnedJobBugs).forEach((bug) => { const bjm = new BugJobMapModel({ @@ -153,11 +153,11 @@ class PinBoard extends React.Component { bjm.create() .then(() => { - this.thNotify.send(`Bug association saved for ${job.platform} ${job.job_type_name}`, 'success'); + notify(`Bug association saved for ${job.platform} ${job.job_type_name}`, 'success'); }) .catch((response) => { const message = `Error saving bug association for ${job.platform} ${job.job_type_name}`; - this.thNotify.send(formatModelError(response, message), 'danger'); + notify(formatModelError(response, message), 'danger'); }); }); } @@ -191,7 +191,7 @@ class PinBoard extends React.Component { } cancelAllPinnedJobs() { - const { getGeckoDecisionTaskId } = this.props; + const { getGeckoDecisionTaskId, notify } = this.props; if (window.confirm('This will cancel all the selected jobs. Are you sure?')) { const jobIds = Object.keys(this.props.pinnedJobs); @@ -200,7 +200,7 @@ class PinBoard extends React.Component { jobIds, this.$rootScope.repoName, getGeckoDecisionTaskId, - this.thNotify, + notify, ); this.unPinAll(); @@ -332,12 +332,12 @@ class PinBoard extends React.Component { } retriggerAllPinnedJobs() { - const { getGeckoDecisionTaskId } = this.props; + const { getGeckoDecisionTaskId, notify } = this.props; JobModel.retrigger( Object.keys(this.props.pinnedJobs), this.$rootScope.repoName, getGeckoDecisionTaskId, - this.thNotify, + notify, ); } @@ -548,6 +548,7 @@ PinBoard.propTypes = { unPinAll: PropTypes.func.isRequired, getGeckoDecisionTaskId: PropTypes.func.isRequired, setSelectedJob: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, selectedJob: PropTypes.object, email: PropTypes.string, revisionTips: PropTypes.array, @@ -559,4 +560,4 @@ PinBoard.defaultProps = { revisionTips: [], }; -export default withPushes(withSelectedJob(withPinnedJobs(PinBoard))); +export default withNotifications(withPushes(withSelectedJob(withPinnedJobs(PinBoard)))); diff --git a/ui/job-view/details/summary/ActionBar.jsx b/ui/job-view/details/summary/ActionBar.jsx index a3b6ec083..06d94e48f 100644 --- a/ui/job-view/details/summary/ActionBar.jsx +++ b/ui/job-view/details/summary/ActionBar.jsx @@ -13,15 +13,13 @@ import LogUrls from './LogUrls'; import { withSelectedJob } from '../../context/SelectedJob'; import { withPinnedJobs } from '../../context/PinnedJobs'; import { withPushes } from '../../context/Pushes'; +import { withNotifications } from '../../../shared/context/Notifications'; class ActionBar extends React.Component { constructor(props) { super(props); const { $injector } = this.props; - - this.thNotify = $injector.get('thNotify'); - this.$interpolate = $injector.get('$interpolate'); this.$rootScope = $injector.get('$rootScope'); this.state = { @@ -32,15 +30,15 @@ class ActionBar extends React.Component { componentDidMount() { // Open the logviewer and provide notifications if it isn't available this.openLogViewerUnlisten = this.$rootScope.$on(thEvents.openLogviewer, () => { - const { logParseStatus } = this.props; + const { logParseStatus, notify } = this.props; switch (logParseStatus) { case 'pending': - this.thNotify.send('Log parsing in progress, log viewer not yet available', 'info'); break; + notify('Log parsing in progress, log viewer not yet available', 'info'); break; case 'failed': - this.thNotify.send('Log parsing has failed, log viewer is unavailable', 'warning'); break; + notify('Log parsing has failed, log viewer is unavailable', 'warning'); break; case 'unavailable': - this.thNotify.send('No logs available for this job', 'info'); break; + notify('No logs available for this job', 'info'); break; case 'parsed': $('.logviewer-btn')[0].click(); } @@ -66,9 +64,9 @@ class ActionBar extends React.Component { } createGeckoProfile() { - const { user, selectedJob, getGeckoDecisionTaskId } = this.props; + const { user, selectedJob, getGeckoDecisionTaskId, notify } = this.props; if (!user.isLoggedIn) { - return this.thNotify.send('Must be logged in to create a gecko profile', 'danger'); + return notify('Must be logged in to create a gecko profile', 'danger'); } getGeckoDecisionTaskId( @@ -77,7 +75,7 @@ class ActionBar extends React.Component { const geckoprofile = results.actions.find(result => result.name === 'geckoprofile'); if (geckoprofile === undefined || !geckoprofile.hasOwnProperty('kind')) { - return this.thNotify.send('Job was scheduled without taskcluster support for GeckoProfiles'); + return notify('Job was scheduled without taskcluster support for GeckoProfiles'); } TaskclusterModel.submit({ @@ -88,13 +86,13 @@ class ActionBar extends React.Component { input: {}, staticActionVariables: results.staticActionVariables, }).then(() => { - this.thNotify.send( + notify( 'Request sent to collect gecko profile job via actions.json', 'success'); }, (e) => { // The full message is too large to fit in a Treeherder // notification box. - this.thNotify.send( + notify( formatTaskclusterError(e), 'danger', { sticky: true }); @@ -104,11 +102,11 @@ class ActionBar extends React.Component { } retriggerJob(jobs) { - const { user, repoName, getGeckoDecisionTaskId } = this.props; + const { user, repoName, getGeckoDecisionTaskId, notify } = this.props; const jobIds = jobs.map(({ id }) => id); if (!user.isLoggedIn) { - return this.thNotify.send('Must be logged in to retrigger a job', 'danger'); + return notify('Must be logged in to retrigger a job', 'danger'); } // Spin the retrigger button when retriggers happen @@ -119,24 +117,24 @@ class ActionBar extends React.Component { }); }); - JobModel.retrigger(jobIds, repoName, getGeckoDecisionTaskId, this.thNotify); + JobModel.retrigger(jobIds, repoName, getGeckoDecisionTaskId, notify); } backfillJob() { - const { user, selectedJob, getGeckoDecisionTaskId } = this.props; + const { user, selectedJob, getGeckoDecisionTaskId, notify } = this.props; if (!this.canBackfill()) { return; } if (!user.isLoggedIn) { - this.thNotify.send('Must be logged in to backfill a job', 'danger'); + notify('Must be logged in to backfill a job', 'danger'); return; } if (!selectedJob.id) { - this.thNotify.send('Job not yet loaded for backfill', 'warning'); + notify('Job not yet loaded for backfill', 'warning'); return; } @@ -154,21 +152,16 @@ class ActionBar extends React.Component { input: {}, staticActionVariables: results.staticActionVariables, }).then(() => { - this.thNotify.send( - 'Request sent to backfill job via actions.json', - 'success'); + notify('Request sent to backfill job via actions.json', 'success'); }, (e) => { // The full message is too large to fit in a Treeherder // notification box. - this.thNotify.send( - formatTaskclusterError(e), - 'danger', - { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); }) )); } else { - this.thNotify.send('Unable to backfill this job type!', 'danger', { sticky: true }); + notify('Unable to backfill this job type!', 'danger', { sticky: true }); } } @@ -203,11 +196,11 @@ class ActionBar extends React.Component { } async createInteractiveTask() { - const { user, selectedJob, repoName, getGeckoDecisionTaskId } = this.props; + const { user, selectedJob, repoName, getGeckoDecisionTaskId, notify } = this.props; const jobId = selectedJob.id; if (!user.isLoggedIn) { - return this.thNotify.send('Must be logged in to create an interactive task', 'danger'); + return notify('Must be logged in to create an interactive task', 'danger'); } const job = await JobModel.get(repoName, jobId); @@ -226,29 +219,26 @@ class ActionBar extends React.Component { staticActionVariables: results.staticActionVariables, }); - this.thNotify.send( + notify( `Request sent to create an interactive job via actions.json. You will soon receive an email containing a link to interact with the task.`, 'success'); } catch (e) { // The full message is too large to fit in a Treeherder // notification box. - this.thNotify.send( - formatTaskclusterError(e), - 'danger', - { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); } } cancelJobs(jobs) { - const { user, repoName, getGeckoDecisionTaskId } = this.props; + const { user, repoName, getGeckoDecisionTaskId, notify } = this.props; const jobIds = jobs.filter(({ state }) => state === 'pending' || state === 'running').map(({ id }) => id); if (!user.isLoggedIn) { - return this.thNotify.send('Must be logged in to cancel a job', 'danger'); + return notify('Must be logged in to cancel a job', 'danger'); } - JobModel.cancel(jobIds, repoName, getGeckoDecisionTaskId, this.thNotify); + JobModel.cancel(jobIds, repoName, getGeckoDecisionTaskId, notify); } cancelJob() { @@ -363,7 +353,6 @@ class ActionBar extends React.Component { job={selectedJob} pushId={selectedJob.push_id} isLoggedIn={user.isLoggedIn} - notify={this.thNotify} toggle={this.toggleCustomJobActions} />}
@@ -379,6 +368,7 @@ ActionBar.propTypes = { selectedJob: PropTypes.object.isRequired, logParseStatus: PropTypes.string.isRequired, getGeckoDecisionTaskId: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, jobLogUrls: PropTypes.array, isTryRepo: PropTypes.bool, logViewerUrl: PropTypes.string, @@ -392,4 +382,4 @@ ActionBar.defaultProps = { jobLogUrls: [], }; -export default withPushes(withSelectedJob(withPinnedJobs(ActionBar))); +export default withNotifications(withPushes(withSelectedJob(withPinnedJobs(ActionBar)))); diff --git a/ui/job-view/details/tabs/AnnotationsTab.jsx b/ui/job-view/details/tabs/AnnotationsTab.jsx index 0a141bd50..f13e6e4a0 100644 --- a/ui/job-view/details/tabs/AnnotationsTab.jsx +++ b/ui/job-view/details/tabs/AnnotationsTab.jsx @@ -5,6 +5,7 @@ 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'; function RelatedBugSaved(props) { const { deleteBug, bug } = props; @@ -144,7 +145,6 @@ class AnnotationsTab extends React.Component { super(props); const { $injector } = props; - this.thNotify = $injector.get('thNotify'); this.$rootScope = $injector.get('$rootScope'); this.deleteBug = this.deleteBug.bind(this); @@ -152,7 +152,7 @@ class AnnotationsTab extends React.Component { } componentDidMount() { - const { classifications, bugs } = this.props; + const { classifications, bugs, notify } = this.props; this.deleteClassificationUnlisten = this.$rootScope.$on(thEvents.deleteClassification, () => { if (classifications.length) { @@ -160,7 +160,7 @@ class AnnotationsTab extends React.Component { // Delete any number of bugs if they exist bugs.forEach((bug) => { this.deleteBug(bug); }); } else { - this.thNotify.send('No classification on this job to delete', 'warning'); + notify('No classification on this job to delete', 'warning'); } }); } @@ -170,14 +170,14 @@ class AnnotationsTab extends React.Component { } deleteClassification(classification) { - const { selectedJob, recalculateUnclassifiedCounts } = this.props; + const { selectedJob, recalculateUnclassifiedCounts, notify } = this.props; selectedJob.failure_classification_id = 1; recalculateUnclassifiedCounts(); classification.destroy().then( () => { - this.thNotify.send('Classification successfully deleted', 'success'); + notify('Classification successfully deleted', 'success'); // also be sure the job object in question gets updated to the latest // classification state (in case one was added or removed). this.$rootScope.$emit( @@ -186,23 +186,19 @@ class AnnotationsTab extends React.Component { ); }, () => { - this.thNotify.send( - 'Classification deletion failed', - 'danger', - { sticky: true }, - ); + notify('Classification deletion failed', 'danger', { sticky: true }); }); } deleteBug(bug) { - const { selectedJob } = this.props; + const { selectedJob, notify } = this.props; bug.destroy() .then(() => { - this.thNotify.send(`Association to bug ${bug.bug_id} successfully deleted`, 'success'); + notify(`Association to bug ${bug.bug_id} successfully deleted`, 'success'); this.$rootScope.$emit(thEvents.jobsClassified, { jobs: { [selectedJob.id]: selectedJob } }); }, () => { - this.thNotify.send(`Association to bug ${bug.bug_id} deletion failed`, 'danger', { sticky: true }); + notify(`Association to bug ${bug.bug_id} deletion failed`, 'danger', { sticky: true }); }); } @@ -247,6 +243,7 @@ AnnotationsTab.propTypes = { bugs: PropTypes.array.isRequired, classifications: PropTypes.array.isRequired, recalculateUnclassifiedCounts: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, selectedJob: PropTypes.object, }; @@ -254,4 +251,4 @@ AnnotationsTab.defaultProps = { selectedJob: null, }; -export default withPushes(withSelectedJob(AnnotationsTab)); +export default withNotifications(withPushes(withSelectedJob(AnnotationsTab))); diff --git a/ui/job-view/details/tabs/SimilarJobsTab.jsx b/ui/job-view/details/tabs/SimilarJobsTab.jsx index 58d5346bb..69f6f4a10 100644 --- a/ui/job-view/details/tabs/SimilarJobsTab.jsx +++ b/ui/job-view/details/tabs/SimilarJobsTab.jsx @@ -9,14 +9,12 @@ 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'; class SimilarJobsTab extends React.Component { constructor(props) { super(props); - const { $injector } = this.props; - this.thNotify = $injector.get('thNotify'); - this.pageSize = 20; this.state = { @@ -46,7 +44,7 @@ class SimilarJobsTab extends React.Component { async getSimilarJobs() { const { page, similarJobs, selectedSimilarJob } = this.state; - const { repoName, selectedJob } = this.props; + const { repoName, selectedJob, notify } = this.props; const options = { // get one extra to detect if there are more jobs that can be loaded (hasNextPage) count: this.pageSize + 1, @@ -89,7 +87,7 @@ class SimilarJobsTab extends React.Component { this.showJobInfo(newSimilarJobs[0]); } } else { - this.thNotify.send(`Error fetching similar jobs push data: ${resp.message}`, 'danger', { sticky: true }); + notify(`Error fetching similar jobs push data: ${resp.message}`, 'danger', { sticky: true }); } } this.setState({ isLoading: false }); @@ -284,9 +282,9 @@ class SimilarJobsTab extends React.Component { } SimilarJobsTab.propTypes = { - $injector: PropTypes.object.isRequired, repoName: PropTypes.string.isRequired, classificationMap: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, selectedJob: PropTypes.object, }; @@ -294,4 +292,4 @@ SimilarJobsTab.defaultProps = { selectedJob: null, }; -export default withSelectedJob(SimilarJobsTab); +export default withNotifications(withSelectedJob(SimilarJobsTab)); diff --git a/ui/job-view/details/tabs/TabsPanel.jsx b/ui/job-view/details/tabs/TabsPanel.jsx index 7f8422273..2cb970b40 100644 --- a/ui/job-view/details/tabs/TabsPanel.jsx +++ b/ui/job-view/details/tabs/TabsPanel.jsx @@ -180,7 +180,6 @@ class TabsPanel extends React.Component { {!!perfJobDetail.length && diff --git a/ui/job-view/details/tabs/autoclassify/AutoclassifyTab.jsx b/ui/job-view/details/tabs/autoclassify/AutoclassifyTab.jsx index 6e2057335..603bbeb79 100644 --- a/ui/job-view/details/tabs/autoclassify/AutoclassifyTab.jsx +++ b/ui/job-view/details/tabs/autoclassify/AutoclassifyTab.jsx @@ -11,6 +11,7 @@ import ErrorLine from './ErrorLine'; import ErrorLineData from './ErrorLineModel'; import { withSelectedJob } from '../../../context/SelectedJob'; import { withPinnedJobs } from '../../../context/PinnedJobs'; +import { withNotifications } from '../../../../shared/context/Notifications'; class AutoclassifyTab extends React.Component { constructor(props) { @@ -18,7 +19,6 @@ class AutoclassifyTab extends React.Component { const { $injector } = this.props; - this.thNotify = $injector.get('thNotify'); this.$rootScope = $injector.get('$rootScope'); this.state = { @@ -235,6 +235,7 @@ class AutoclassifyTab extends React.Component { return Promise.reject('No lines to save'); } const { errorLines } = this.state; + const { notify } = this.props; const data = Object.values(lines).map(input => ({ id: input.id, best_classification: input.classifiedFailureId || null, @@ -255,7 +256,7 @@ class AutoclassifyTab extends React.Component { .catch((err) => { const prefix = 'Error saving classifications: '; const msg = err.stack ? `${prefix}${err}${err.stack}` : `${prefix}${err.statusText} - ${err.data.detail}`; - this.thNotify.send(msg, 'danger', { sticky: true }); + notify(msg, 'danger', { sticky: true }); }); } @@ -391,6 +392,7 @@ AutoclassifyTab.propTypes = { selectedJob: PropTypes.object.isRequired, hasLogs: PropTypes.bool.isRequired, pinJob: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, autoclassifyStatus: PropTypes.string, logsParsed: PropTypes.bool, logParseStatus: PropTypes.string, @@ -402,4 +404,4 @@ AutoclassifyTab.defaultProps = { logParseStatus: 'pending', }; -export default withSelectedJob(withPinnedJobs(AutoclassifyTab)); +export default withNotifications(withSelectedJob(withPinnedJobs(AutoclassifyTab))); diff --git a/ui/job-view/details/tabs/autoclassify/LineOption.jsx b/ui/job-view/details/tabs/autoclassify/LineOption.jsx index 2b69523c6..e7c59bbf2 100644 --- a/ui/job-view/details/tabs/autoclassify/LineOption.jsx +++ b/ui/job-view/details/tabs/autoclassify/LineOption.jsx @@ -22,7 +22,6 @@ class LineOption extends React.Component { const { $injector } = props; this.$rootScope = $injector.get('$rootScope'); - this.thNotify = $injector.get('thNotify'); this.state = { isBugFilerOpen: false, @@ -189,7 +188,6 @@ class LineOption extends React.Component { reftestUrl={isReftest(selectedJob) ? getReftestUrl(logUrl) : ''} successCallback={this.bugFilerCallback} jobGroupName={selectedJob.job_group_name} - notify={this.thNotify} />}
); diff --git a/ui/job-view/details/tabs/failureSummary/FailureSummaryTab.jsx b/ui/job-view/details/tabs/failureSummary/FailureSummaryTab.jsx index 64195526d..6950862a1 100644 --- a/ui/job-view/details/tabs/failureSummary/FailureSummaryTab.jsx +++ b/ui/job-view/details/tabs/failureSummary/FailureSummaryTab.jsx @@ -18,7 +18,6 @@ class FailureSummaryTab extends React.Component { const { $injector } = this.props; this.$rootScope = $injector.get('$rootScope'); - this.thNotify = $injector.get('thNotify'); this.state = { isBugFilerOpen: false, @@ -122,7 +121,6 @@ class FailureSummaryTab extends React.Component { reftestUrl={isReftest(selectedJob) ? reftestUrl : ''} successCallback={this.bugFilerCallback} jobGroupName={selectedJob.job_group_name} - notify={this.thNotify} />}
); diff --git a/ui/job-view/headerbars/NotificationsMenu.jsx b/ui/job-view/headerbars/NotificationsMenu.jsx index b0bfc4f24..0d49a2c4f 100644 --- a/ui/job-view/headerbars/NotificationsMenu.jsx +++ b/ui/job-view/headerbars/NotificationsMenu.jsx @@ -2,35 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { toShortDateStr } from '../../helpers/display'; +import { withNotifications } from '../../shared/context/Notifications'; -export default class NotificationsMenu extends React.Component { - constructor(props) { - super(props); - - const { $injector } = this.props; - this.thNotify = $injector.get('thNotify'); - - this.state = { - notifications: [], - }; - } - - componentDidMount() { - this.unlistenStorage = window.addEventListener('storage', (e) => { - if (e.key === 'notifications') { - this.changeCallback(JSON.parse(localStorage.getItem('notifications') || '[]')); - } - }); - - this.changeCallback = this.changeCallback.bind(this); - this.thNotify.setChangeCallback(this.changeCallback); - this.changeCallback(this.thNotify.storedNotifications); - } - - componentWillUnmount() { - this.unlistenStorage(); - } - +class NotificationsMenu extends React.Component { getSeverityClass(severity) { switch (severity) { case 'danger': return 'fa fa-ban text-danger'; @@ -40,12 +14,8 @@ export default class NotificationsMenu extends React.Component { return 'fa fa-info-circle text-info'; } - changeCallback(notifications) { - this.setState({ notifications }); - } - render() { - const { notifications } = this.state; + const { storedNotifications, clearStoredNotifications } = this.props; return ( @@ -67,14 +37,14 @@ export default class NotificationsMenu extends React.Component { className="dropdown-header" title="Notifications" >Recent notifications - {!!notifications.length && } - {notifications.length ? - notifications.map(notification => ( + {storedNotifications.length ? + storedNotifications.map(notification => (
  • - + - - + + - + ( { - this.thNotify.send(`${event.target.title}: ${event.target.body}`, 'danger'); + notify(`${event.target.title}: ${event.target.body}`, 'danger'); }; notification.onclick = (event) => { @@ -274,7 +272,7 @@ class Push extends React.Component { } async showRunnableJobs() { - const { push, repoName, getGeckoDecisionTaskId } = this.props; + const { push, repoName, getGeckoDecisionTaskId, notify } = this.props; try { const decisionTaskId = await getGeckoDecisionTaskId(push.id, repoName); @@ -286,12 +284,12 @@ class Push extends React.Component { job.id = escapeId(job.push_id + job.ref_data_name); }); if (jobList.length === 0) { - this.thNotify.send('No new jobs available'); + notify('No new jobs available'); } this.mapPushJobs(jobList, true); this.setState({ runnableVisible: jobList.length > 0 }); } catch (error) { - this.thNotify.send(`Error fetching runnable jobs: Failed to fetch task ID (${error})`, 'danger'); + notify(`Error fetching runnable jobs: Failed to fetch task ID (${error})`, 'danger'); } } @@ -307,6 +305,8 @@ class Push extends React.Component { } async cycleWatchState() { + const { notify } = this.props; + if (!this.props.notificationSupported) { return; } @@ -317,7 +317,7 @@ class Push extends React.Component { const result = await Notification.requestPermission(); if (result === 'denied') { - this.thNotify.send('Notification permission denied', 'danger'); + notify('Notification permission denied', 'danger'); next = 'none'; } @@ -327,7 +327,7 @@ class Push extends React.Component { render() { const { - push, isLoggedIn, $injector, repoName, currentRepo, duplicateJobsVisible, + push, isLoggedIn, repoName, currentRepo, duplicateJobsVisible, filterModel, notificationSupported, getAllShownJobs, groupCountsExpanded, } = this.props; const { @@ -349,7 +349,6 @@ class Push extends React.Component { isLoggedIn={isLoggedIn} repoName={repoName} filterModel={filterModel} - $injector={$injector} runnableVisible={runnableVisible} showRunnableJobs={this.showRunnableJobs} hideRunnableJobs={this.hideRunnableJobs} @@ -364,7 +363,6 @@ class Push extends React.Component { {currentRepo && } @@ -379,7 +377,6 @@ class Push extends React.Component { runnableVisible={runnableVisible} duplicateJobsVisible={duplicateJobsVisible} groupCountsExpanded={groupCountsExpanded} - $injector={$injector} /> @@ -391,7 +388,6 @@ class Push extends React.Component { Push.propTypes = { push: PropTypes.object.isRequired, currentRepo: PropTypes.object.isRequired, - $injector: PropTypes.object.isRequired, filterModel: PropTypes.object.isRequired, repoName: PropTypes.string.isRequired, isLoggedIn: PropTypes.bool.isRequired, @@ -402,6 +398,7 @@ Push.propTypes = { getGeckoDecisionTaskId: PropTypes.func.isRequired, duplicateJobsVisible: PropTypes.bool.isRequired, groupCountsExpanded: PropTypes.bool.isRequired, + notify: PropTypes.func.isRequired, }; -export default withPushes(withSelectedJob(Push)); +export default withNotifications(withPushes(withSelectedJob(Push))); diff --git a/ui/job-view/pushes/PushActionMenu.jsx b/ui/job-view/pushes/PushActionMenu.jsx index 68e4790f5..e83dcf33d 100644 --- a/ui/job-view/pushes/PushActionMenu.jsx +++ b/ui/job-view/pushes/PushActionMenu.jsx @@ -5,14 +5,11 @@ 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'; class PushActionMenu extends React.PureComponent { - constructor(props) { super(props); - const { $injector } = this.props; - - this.thNotify = $injector.get('thNotify'); this.revision = this.props.revision; this.pushId = this.props.pushId; @@ -56,7 +53,7 @@ class PushActionMenu extends React.PureComponent { } triggerMissingJobs() { - const { getGeckoDecisionTaskId } = this.props; + const { getGeckoDecisionTaskId, notify } = this.props; if (!window.confirm(`This will trigger all missing jobs for revision ${this.revision}!\n\nClick "OK" if you want to proceed.`)) { return; @@ -66,17 +63,17 @@ class PushActionMenu extends React.PureComponent { .then((decisionTaskID) => { PushModel.triggerMissingJobs(decisionTaskID) .then((msg) => { - this.thNotify.send(msg, 'success'); + notify(msg, 'success'); }).catch((e) => { - this.thNotify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); }).catch((e) => { - this.thNotify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); } triggerAllTalosJobs() { - const { getGeckoDecisionTaskId } = this.props; + const { getGeckoDecisionTaskId, notify } = this.props; if (!window.confirm(`This will trigger all Talos jobs for revision ${this.revision}!\n\nClick "OK" if you want to proceed.`)) { return; @@ -91,12 +88,12 @@ class PushActionMenu extends React.PureComponent { .then((decisionTaskID) => { PushModel.triggerAllTalosJobs(times, decisionTaskID) .then((msg) => { - this.thNotify.send(msg, 'success'); + notify(msg, 'success'); }).catch((e) => { - this.thNotify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); }).catch((e) => { - this.thNotify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); } @@ -175,7 +172,6 @@ class PushActionMenu extends React.PureComponent { job={null} pushId={pushId} isLoggedIn={isLoggedIn} - notify={this.thNotify} toggle={this.toggleCustomJobActions} />} @@ -192,7 +188,7 @@ PushActionMenu.propTypes = { hideRunnableJobs: PropTypes.func.isRequired, showRunnableJobs: PropTypes.func.isRequired, getGeckoDecisionTaskId: PropTypes.func.isRequired, - $injector: PropTypes.object.isRequired, + notify: PropTypes.func.isRequired, }; -export default withPushes(PushActionMenu); +export default withNotifications(withPushes(PushActionMenu)); diff --git a/ui/job-view/pushes/PushHeader.jsx b/ui/job-view/pushes/PushHeader.jsx index 5e4970429..7a6ab6d79 100644 --- a/ui/job-view/pushes/PushHeader.jsx +++ b/ui/job-view/pushes/PushHeader.jsx @@ -9,6 +9,7 @@ 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'; // url params we don't want added from the current querystring to the revision // and author links. @@ -63,10 +64,7 @@ PushCounts.propTypes = { class PushHeader extends React.PureComponent { constructor(props) { super(props); - const { $injector, pushTimestamp } = this.props; - - this.$rootScope = $injector.get('$rootScope'); - this.thNotify = $injector.get('thNotify'); + const { pushTimestamp } = this.props; this.pushDateStr = toDateStr(pushTimestamp); } @@ -90,7 +88,10 @@ class PushHeader extends React.PureComponent { } triggerNewJobs() { - const { isLoggedIn, pushId, getGeckoDecisionTaskId, selectedRunnableJobs, hideRunnableJobs } = this.props; + const { + isLoggedIn, pushId, getGeckoDecisionTaskId, selectedRunnableJobs, + hideRunnableJobs, notify, + } = this.props; if (!window.confirm( 'This will trigger all selected jobs. Click "OK" if you want to proceed.')) { @@ -101,21 +102,23 @@ class PushHeader extends React.PureComponent { getGeckoDecisionTaskId(pushId) .then((decisionTaskID) => { PushModel.triggerNewJobs(builderNames, decisionTaskID).then((result) => { - this.thNotify.send(result, 'success'); + notify(result, 'success'); hideRunnableJobs(pushId); this.props.hideRunnableJobs(); }).catch((e) => { - this.thNotify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); }).catch((e) => { - this.thNotify.send(formatTaskclusterError(e), 'danger', { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); }); } else { - this.thNotify.send('Must be logged in to trigger a job', 'danger'); + notify('Must be logged in to trigger a job', 'danger'); } } cancelAllJobs() { + const { notify, repoName } = this.props; + if (window.confirm('This will cancel all pending and running jobs for this push. It cannot be undone! Are you sure?')) { const { push, isLoggedIn, getGeckoDecisionTaskId } = this.props; // Any job Id inside the push will do @@ -123,12 +126,7 @@ class PushHeader extends React.PureComponent { if (!isLoggedIn) return; - JobModel.cancelAll( - jobId, - this.$rootScope.repoName, - getGeckoDecisionTaskId, - this.thNotify, - ); + JobModel.cancelAll(jobId, repoName, getGeckoDecisionTaskId, notify); } } @@ -148,7 +146,7 @@ class PushHeader extends React.PureComponent { render() { const { repoName, isLoggedIn, pushId, jobCounts, author, - revision, runnableVisible, $injector, watchState, + revision, runnableVisible, watchState, showRunnableJobs, hideRunnableJobs, cycleWatchState, notificationSupported, selectedRunnableJobs } = this.props; const cancelJobsTitle = isLoggedIn ? @@ -235,7 +233,6 @@ class PushHeader extends React.PureComponent { revision={revision} repoName={repoName} pushId={pushId} - $injector={$injector} showRunnableJobs={showRunnableJobs} hideRunnableJobs={hideRunnableJobs} /> @@ -253,7 +250,6 @@ PushHeader.propTypes = { author: PropTypes.string.isRequired, revision: PropTypes.string.isRequired, repoName: PropTypes.string.isRequired, - $injector: PropTypes.object.isRequired, filterModel: PropTypes.object.isRequired, runnableVisible: PropTypes.bool.isRequired, showRunnableJobs: PropTypes.func.isRequired, @@ -267,6 +263,7 @@ PushHeader.propTypes = { getAllShownJobs: PropTypes.func.isRequired, getGeckoDecisionTaskId: PropTypes.func.isRequired, selectedRunnableJobs: PropTypes.array.isRequired, + notify: PropTypes.func.isRequired, jobCounts: PropTypes.object, watchState: PropTypes.string, selectedJob: PropTypes.object, @@ -278,4 +275,4 @@ PushHeader.defaultProps = { watchState: 'none', }; -export default withPushes(withSelectedJob(withPinnedJobs(PushHeader))); +export default withNotifications(withPushes(withSelectedJob(withPinnedJobs(PushHeader)))); diff --git a/ui/job-view/pushes/PushJobs.jsx b/ui/job-view/pushes/PushJobs.jsx index f4c535713..4a429b5b2 100644 --- a/ui/job-view/pushes/PushJobs.jsx +++ b/ui/job-view/pushes/PushJobs.jsx @@ -138,7 +138,7 @@ class PushJobs extends React.Component { render() { const filteredPlatforms = this.state.filteredPlatforms || []; const { - $injector, repoName, filterModel, pushGroupState, duplicateJobsVisible, + repoName, filterModel, pushGroupState, duplicateJobsVisible, groupCountsExpanded, } = this.props; @@ -150,7 +150,6 @@ class PushJobs extends React.Component { { $scope.loading = false; $scope.jobExists = false; - thNotify.send(`${error}`, 'danger', { sticky: true }); + $scope.jobError = error.toString(); + $scope.$apply(); }); }; diff --git a/ui/js/controllers/main.js b/ui/js/controllers/main.js index a9feccb9e..b13bfbead 100644 --- a/ui/js/controllers/main.js +++ b/ui/js/controllers/main.js @@ -5,14 +5,9 @@ import { thTitleSuffixLimit, thDefaultRepo } from '../../helpers/constants'; import { getUrlParam } from '../../helpers/location'; treeherderApp.controller('MainCtrl', [ - '$scope', '$rootScope', 'thNotify', + '$scope', '$rootScope', function MainController( - $scope, $rootScope, thNotify) { - - if (window.navigator.userAgent.indexOf('Firefox/52') !== -1) { - thNotify.send('Firefox ESR52 is not supported. Please update to ESR60 or ideally release/beta/nightly.', - 'danger', { sticky: true }); - } + $scope, $rootScope) { // set to the default repo if one not specified const repoName = getUrlParam('repo'); diff --git a/ui/js/directives/treeherder/main.js b/ui/js/directives/treeherder/main.js deleted file mode 100755 index 0141db5af..000000000 --- a/ui/js/directives/treeherder/main.js +++ /dev/null @@ -1,15 +0,0 @@ -import treeherder from '../../treeherder'; -import thNotificationsBoxTemplate from '../../../partials/main/thNotificationsBox.html'; - -treeherder.directive('thNotificationBox', [ - 'thNotify', - function (thNotify) { - return { - restrict: 'E', - template: thNotificationsBoxTemplate, - link: function (scope) { - scope.notifier = thNotify; - scope.alert_class_prefix = 'alert-'; - }, - }; - }]); diff --git a/ui/js/services/main.js b/ui/js/services/main.js deleted file mode 100755 index 9b2120aa8..000000000 --- a/ui/js/services/main.js +++ /dev/null @@ -1,95 +0,0 @@ -import _ from 'lodash'; - -import treeherder from '../treeherder'; - -/* Services */ -treeherder.factory('thNotify', [ - '$timeout', - function ($timeout) { - // a growl-like notification system - - const thNotify = { - // message queue - notifications: [], - - // Long-term storage for notifications - storedNotifications: JSON.parse(localStorage.getItem('notifications') || '[]'), - - // Callback for any updates. Listening to window for 'storage' - // events won't work for this because those events are only fired - // for storage events made in OTHER windows/tabs. Not the current - // one. Default to dummy function. - // TODO: We should be able to remove this once this service is - // converted to a class for direct usage in ReactJS. - changeCallback: () => {}, - - /* - * send a message to the notification queue - * @severity can be one of success|info|warning|danger - * @opts is an object with up to three entries: - * sticky -- Keeps notification visible until cleared if true - * linkText -- Text to display as a link if exists - * url -- Location the link should point to if exists - */ - send: function (message, severity, opts) { - if (opts !== undefined && !_.isPlainObject(opts)) { - throw new Error('Must pass an object as last argument to thNotify.send!'); - } - opts = opts || {}; - severity = severity || 'info'; - - const maxNsNotifications = 5; - const notification = { - ...opts, - message, - severity, - created: Date.now(), - }; - $timeout(thNotify.notifications.unshift(notification)); - thNotify.storedNotifications.unshift(notification); - thNotify.storedNotifications.splice(40); - localStorage.setItem('notifications', JSON.stringify(thNotify.storedNotifications)); - thNotify.changeCallback(thNotify.storedNotifications); - - if (!opts.sticky) { - if (thNotify.notifications.length > maxNsNotifications) { - $timeout(thNotify.shift); - return; - } - $timeout(thNotify.shift, 4000, true); - } - }, - - /* - * Delete the first non-sticky element from the notifications queue - */ - shift: function () { - for (let i = 0; i < thNotify.notifications.length; i++) { - if (!thNotify.notifications[i].sticky) { - thNotify.remove(i); - return; - } - } - }, - /* - * remove an arbitrary element from the notifications queue - */ - remove: function (index) { - thNotify.notifications.splice(index, 1); - }, - - /* - * Clear the list of stored notifications - */ - clear: function () { - thNotify.storedNotifications = []; - localStorage.setItem('notifications', thNotify.storedNotifications); - thNotify.changeCallback(thNotify.storedNotifications); - }, - setChangeCallback: function (cb) { - thNotify.changeCallback = cb; - }, - }; - return thNotify; - - }]); diff --git a/ui/logviewer.html b/ui/logviewer.html index 4263312dc..2fc630e36 100644 --- a/ui/logviewer.html +++ b/ui/logviewer.html @@ -40,7 +40,7 @@
  • - Unavailable + Unavailable: {{jobError}}
  • @@ -128,7 +128,6 @@ - diff --git a/ui/models/job.js b/ui/models/job.js index 91e944fbe..9cff7e8ad 100644 --- a/ui/models/job.js +++ b/ui/models/job.js @@ -97,13 +97,11 @@ export default class JobModel { return JobModel.getList(repoName, options, config); } - static async retrigger(jobIds, repoName, getGeckoDecisionTaskId, thNotify) { + static async retrigger(jobIds, repoName, getGeckoDecisionTaskId, notify) { const jobTerm = jobIds.length > 1 ? 'jobs' : 'job'; try { - thNotify.send( - `Attempting to retrigger ${jobTerm} via actions.json`, - 'info'); + notify(`Attempting to retrigger ${jobTerm} via actions.json`, 'info'); /* eslint-disable no-await-in-loop */ for (const id of jobIds) { @@ -123,22 +121,19 @@ export default class JobModel { } catch (e) { // The full message is too large to fit in a Treeherder // notification box. - thNotify.send( - formatTaskclusterError(e), - 'danger', - { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); } } /* eslint-enable no-await-in-loop */ - thNotify.send(`Request sent to retrigger ${jobTerm} via action.json`, 'success'); + notify(`Request sent to retrigger ${jobTerm} via action.json`, 'success'); } catch (e) { - thNotify.send(`Unable to retrigger ${jobTerm}`, 'danger', { sticky: true }); + notify(`Unable to retrigger ${jobTerm}`, 'danger', { sticky: true }); } } // Any jobId inside the push will do - static async cancelAll(jobId, repoName, getGeckoDecisionTaskId, thNotify) { + static async cancelAll(jobId, repoName, getGeckoDecisionTaskId, notify) { const job = await JobModel.get(repoName, jobId); const decisionTaskId = await getGeckoDecisionTaskId(job.push_id); const results = await TaskclusterModel.load(decisionTaskId); @@ -154,22 +149,17 @@ export default class JobModel { } catch (e) { // The full message is too large to fit in a Treeherder // notification box. - thNotify.send( - formatTaskclusterError(e), - 'danger', - { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); } - thNotify.send('Request sent to cancel all jobs via action.json', 'success'); + notify('Request sent to cancel all jobs via action.json', 'success'); } - static async cancel(jobIds, repoName, getGeckoDecisionTaskId, thNotify) { + static async cancel(jobIds, repoName, getGeckoDecisionTaskId, notify) { const jobTerm = jobIds.length > 1 ? 'jobs' : 'job'; try { - thNotify.send( - `Attempting to cancel selected ${jobTerm} via actions.json`, - 'info'); + notify(`Attempting to cancel selected ${jobTerm} via actions.json`, 'info'); /* eslint-disable no-await-in-loop */ for (const id of jobIds) { @@ -189,17 +179,14 @@ export default class JobModel { } catch (e) { // The full message is too large to fit in a Treeherder // notification box. - thNotify.send( - formatTaskclusterError(e), - 'danger', - { sticky: true }); + notify(formatTaskclusterError(e), 'danger', { sticky: true }); } } /* eslint-enable no-await-in-loop */ - thNotify.send(`Request sent to cancel ${jobTerm} via action.json`, 'success'); + notify(`Request sent to cancel ${jobTerm} via action.json`, 'success'); } catch (e) { - thNotify.send(`Unable to cancel ${jobTerm}`, 'danger', { sticky: true }); + notify(`Unable to cancel ${jobTerm}`, 'danger', { sticky: true }); } } } diff --git a/ui/partials/main/thNotificationsBox.html b/ui/partials/main/thNotificationsBox.html deleted file mode 100644 index 888e509d2..000000000 --- a/ui/partials/main/thNotificationsBox.html +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/ui/shared/Login.jsx b/ui/shared/Login.jsx index 76d5fb000..04e3b44cd 100644 --- a/ui/shared/Login.jsx +++ b/ui/shared/Login.jsx @@ -9,19 +9,17 @@ import { loggedOutUser } from '../js/auth/auth-utils'; import taskcluster from '../helpers/taskcluster'; import { getApiUrl, loginCallbackUrl } from '../helpers/url'; import UserModel from '../models/user'; +import { withNotifications } from './context/Notifications'; /** * This component handles logging in to Taskcluster Authentication * * See: https://docs.taskcluster.net/manual/3rdparty */ -export default class Login extends React.Component { +class Login extends React.Component { constructor(props) { super(props); - const { $injector } = props; - this.thNotify = $injector.get('thNotify'); - this.authService = new AuthService(); } @@ -89,13 +87,15 @@ export default class Login extends React.Component { } logout() { + const { notify } = this.props; + fetch(getApiUrl('/auth/logout/')) .then(async (resp) => { if (resp.ok) { this.setLoggedOut(); } else { - const msg = await resp.json(); - this.thNotify.send(`Logout failed: ${msg}`, 'danger', { sticky: true }); + const msg = await resp.text(); + notify(`Logout failed: ${msg}`, 'danger', { sticky: true }); } }); } @@ -140,12 +140,16 @@ export default class Login extends React.Component { Login.propTypes = { setUser: PropTypes.func.isRequired, - $injector: PropTypes.object.isRequired, user: PropTypes.object, + notify: PropTypes.func, }; Login.defaultProps = { user: { isLoggedIn: false }, + notify: msg => console.error(msg), // eslint-disable-line no-console }; -treeherder.component('login', react2angular(Login, ['user', 'setUser'], ['$injector'])); +export default withNotifications(Login); + +treeherder.component('login', react2angular( + withNotifications(Login), ['user', 'setUser'], [])); diff --git a/ui/shared/NotificationList.jsx b/ui/shared/NotificationList.jsx new file mode 100644 index 000000000..f3f6dfdc0 --- /dev/null +++ b/ui/shared/NotificationList.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { withNotifications } from './context/Notifications'; + +class NotificationList extends React.Component { + static getSeverityClass(severity) { + switch (severity) { + case 'danger': + return 'fa fa-ban'; + case 'warning': + return 'fa fa-warning'; + case 'info': + return 'fa fa-circle'; + case 'success': + return 'fa fa-check'; + } + } + + render() { + const { notifications, removeNotification } = this.props; + + return ( +
      + {notifications.map((notification, idx) => ( +
    • +
      + + {notification.message} + {notification.url && notification.linkText && + {notification.linkText} + } + {notification.sticky && } +
      +
    • ))} +
    + ); + } +} + +NotificationList.propTypes = { + notifications: PropTypes.array.isRequired, + removeNotification: PropTypes.func.isRequired, +}; + +export default withNotifications(NotificationList); diff --git a/ui/shared/context/Notifications.jsx b/ui/shared/context/Notifications.jsx new file mode 100644 index 000000000..0f89b068c --- /dev/null +++ b/ui/shared/context/Notifications.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export const NotificationsContext = React.createContext({}); + +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, + }; + } + + componentDidMount() { + this.notify = this.notify.bind(this); + this.removeNotification = this.removeNotification.bind(this); + this.shift = this.shift.bind(this); + this.clearStoredNotifications = this.clearStoredNotifications.bind(this); + + this.unlistenStorage = window.addEventListener('storage', (e) => { + if (e.key === 'notifications') { + this.setValue({ + storedNotifications: JSON.parse(localStorage.getItem('notifications') || '[]'), + }); + } + }); + + this.value = { + ...this.state, + notify: this.notify, + removeNotification: this.removeNotification, + clearStoredNotifications: this.clearStoredNotifications, + }; + } + + componentWillUnmount() { + this.unlistenStorage(); + } + + setValue(newState, callback) { + this.value = { ...this.value, ...newState }; + this.setState(newState, callback); + } + + notify(message, severity, opts) { + opts = opts || {}; + severity = severity || 'info'; + const { notifications, storedNotifications } = this.state; + const maxNsNotifications = 5; + const notification = { ...opts, message, severity, created: Date.now() }; + const newNotifications = [notification, ...notifications]; + + storedNotifications.unshift(notification); + storedNotifications.splice(40); + localStorage.setItem('notifications', JSON.stringify(storedNotifications)); + + this.setValue( + { notifications: newNotifications, storedNotifications: [...storedNotifications] }, + () => { + if (!opts.sticky) { + if (notifications.length > maxNsNotifications) { + this.shift(); + return; + } + 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); + } + + /* + * Clear the list of stored notifications + */ + clearStoredNotifications() { + const storedNotifications = []; + + localStorage.setItem('notifications', storedNotifications); + this.setValue({ storedNotifications }); + } + + render() { + return ( + + {this.props.children} + + ); + } +} + +Notifications.propTypes = { + children: PropTypes.object.isRequired, +}; + +export function withNotifications(Component) { + return function NotificationComponent(props) { + return ( + + {context => ( + + )} + + ); + }; +}