From 15721f009c325256b754bd3f8ce29c16041478af Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Wed, 13 Jun 2018 15:40:38 -0700 Subject: [PATCH] Bug 1450022 - Convert the rest of Details Panel to ReactJS (#3621) --- .eslintignore | 1 - docs/ui/index.rst | 1 - docs/ui/plugin.rst | 96 --- package.json | 1 + tests/selenium/pages/treeherder.py | 30 +- .../test_filter_jobs_by_failure_result.py | 2 +- .../selenium/test_filter_jobs_by_keywords.py | 4 +- .../test_filter_jobs_by_result_status.py | 12 +- tests/selenium/test_info_panel.py | 6 +- tests/selenium/test_log_viewer.py | 2 +- tests/selenium/test_pin_jobs.py | 4 +- tests/ui/unit/controllers/pinboard.tests.js | 39 - tests/ui/unit/init.js | 2 - tests/ui/unit/react/groups.tests.jsx | 40 +- ...panel.css => treeherder-details-panel.css} | 246 ++++--- ui/css/treeherder-global.css | 2 +- ui/css/treeherder-pinboard.css | 33 +- ui/entry-index.js | 14 +- ui/helpers/job.js | 61 +- ui/helpers/revision.js | 4 + ui/index.html | 11 +- ui/job-view/JobButton.jsx | 1 - ui/job-view/JobGroup.jsx | 1 - ui/job-view/Push.jsx | 8 +- ui/job-view/PushActionMenu.jsx | 12 +- ui/job-view/PushHeader.jsx | 43 +- ui/job-view/PushList.jsx | 43 +- ui/job-view/Revision.jsx | 1 - ui/job-view/RevisionList.jsx | 3 +- ui/job-view/details/DetailsPanel.jsx | 514 +++++++++++++ ui/job-view/details/PinBoard.jsx | 548 ++++++++++++++ ui/job-view/details/summary/ActionBar.jsx | 406 +++++++++++ .../details/summary/ClassificationsPanel.jsx | 43 +- ui/job-view/details/summary/LogUrls.jsx | 89 +++ ui/job-view/details/summary/StatusPanel.jsx | 34 +- ui/job-view/details/summary/SummaryPanel.jsx | 406 ++++------- ui/job-view/details/tabs/AnnotationsTab.jsx | 51 +- ui/job-view/details/tabs/JobDetailsTab.jsx | 14 +- ui/job-view/details/tabs/PerformanceTab.jsx | 8 +- ui/job-view/details/tabs/SimilarJobsTab.jsx | 33 +- ui/job-view/details/tabs/TabsPanel.jsx | 240 +++++++ .../tabs/autoclassify/AutoclassifyTab.jsx | 64 +- .../tabs/autoclassify/AutoclassifyToolbar.jsx | 4 +- .../details/tabs/autoclassify/ErrorLine.jsx | 53 +- .../details/tabs/autoclassify/LineOption.jsx | 17 +- .../tabs/autoclassify/StaticLineOption.jsx | 20 +- .../tabs/failureSummary/BugListItem.jsx | 11 +- .../tabs/failureSummary/ErrorsList.jsx | 2 +- .../tabs/failureSummary/FailureSummaryTab.jsx | 175 +++-- .../failureSummary/SuggestionsListItem.jsx | 25 +- ui/js/auth/auth-utils.js | 2 +- ui/js/components/auth.js | 6 +- ui/js/constants.js | 2 + ui/js/controllers/bugfiler.js | 6 - ui/js/controllers/filters.js | 27 +- ui/js/controllers/main.js | 74 +- .../directives/treeherder/bottom_nav_panel.js | 39 - ui/js/models/resultsets_store.js | 6 +- ui/js/services/pinboard.js | 176 ----- ui/js/treeherder_app.js | 19 - ui/models/user.js | 1 + ui/partials/main/tcjobactions.html | 4 +- ui/partials/main/thGlobalTopNavPanel.html | 2 +- ui/partials/main/thPinboardPanel.html | 112 --- ui/partials/main/thPinnedJob.html | 11 - ui/partials/main/thRelatedBugQueued.html | 9 - ui/partials/perf/alertsctrl.html | 22 +- ui/partials/perf/graphsctrl.html | 6 +- ui/plugins/annotations/main.html | 7 - ui/plugins/auto_classification/main.html | 9 - ui/plugins/controller.js | 676 ------------------ ui/plugins/failure_summary/main.html | 14 - ui/plugins/job_details/main.html | 4 - ui/plugins/perf_details/main.html | 10 - ui/plugins/pinboard.js | 307 -------- ui/plugins/pluginpanel.html | 203 ------ ui/plugins/similar_jobs/main.html | 3 - ui/plugins/tabs.js | 74 -- ui/vendor/resizer.js | 48 -- yarn.lock | 11 +- 80 files changed, 2603 insertions(+), 2767 deletions(-) delete mode 100644 .eslintignore delete mode 100644 docs/ui/plugin.rst delete mode 100644 tests/ui/unit/controllers/pinboard.tests.js rename ui/css/{treeherder-info-panel.css => treeherder-details-panel.css} (72%) create mode 100644 ui/job-view/details/DetailsPanel.jsx create mode 100644 ui/job-view/details/PinBoard.jsx create mode 100644 ui/job-view/details/summary/ActionBar.jsx create mode 100644 ui/job-view/details/summary/LogUrls.jsx create mode 100644 ui/job-view/details/tabs/TabsPanel.jsx delete mode 100644 ui/js/directives/treeherder/bottom_nav_panel.js delete mode 100644 ui/partials/main/thPinboardPanel.html delete mode 100644 ui/partials/main/thPinnedJob.html delete mode 100644 ui/partials/main/thRelatedBugQueued.html delete mode 100644 ui/plugins/annotations/main.html delete mode 100644 ui/plugins/auto_classification/main.html delete mode 100644 ui/plugins/controller.js delete mode 100644 ui/plugins/failure_summary/main.html delete mode 100755 ui/plugins/job_details/main.html delete mode 100755 ui/plugins/perf_details/main.html delete mode 100644 ui/plugins/pinboard.js delete mode 100755 ui/plugins/pluginpanel.html delete mode 100755 ui/plugins/similar_jobs/main.html delete mode 100644 ui/plugins/tabs.js delete mode 100644 ui/vendor/resizer.js diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 79a6c997b5e..00000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -ui/vendor/ diff --git a/docs/ui/index.rst b/docs/ui/index.rst index 6dc58618a49..15838c12ee6 100644 --- a/docs/ui/index.rst +++ b/docs/ui/index.rst @@ -12,4 +12,3 @@ Contents: :maxdepth: 2 installation - plugin diff --git a/docs/ui/plugin.rst b/docs/ui/plugin.rst deleted file mode 100644 index 78a9e7776e2..00000000000 --- a/docs/ui/plugin.rst +++ /dev/null @@ -1,96 +0,0 @@ -Writing a Plugin -================ - -When a job is selected, a bottom tabbed panel is activated which shows details -of that job. You can add your own tab to that panel in the form of a -``plugin``. - -The existing ``Jobs Detail`` tab is, itself, a plugin. So it is a good example -to follow. See ``ui/plugins/jobdetail``. - -To create a new plugin the following steps are required: - - * Create your plugin folder - * Create a ``controller`` in your plugin folder - * Create a ``partial`` HTML file in your plugin folder - * Register the ``controller`` - * Register the ``partial`` - - -Create your plugin folder -------------------------- - -Your folder can have whatever name you choose, but it should reside beneath -``app/plugins``. For example: ``app/plugins/jobfoo``. - - -Create a controller -------------------- - -The most basic of controllers would look like this:: - - treeherder.controller('JobFooPluginCtrl', - function JobFooPluginCtrl($scope) { - - $scope.$watch('selectedJob', function(newValue, oldValue) { - // preferred way to get access to the selected job - if (newValue) { - $scope.job = newValue; - } - }, true); - } - ); - -This controller just watches the value of ``selectedJob`` to see when it gets -a value. ``selectedJob`` is set by the ui when a job is... well... selected. - - -Create a partial ----------------- - -The ``partial`` is the portion of HTML that will be displayed in your plugin's -tab. A very simple partial would look like this:: - -
-

I pitty the foo that don't like job_guid: {{ job.job_guid }}

-
- - -Register the controller ------------------------ - -Due to a limitation of jqlite, you must register your ``controller.js`` in -the main application's ``index.html`` file. You can see at the end of the file -that many ``.js`` files are registered. Simply add yours to the list:: - - - - -Register the partial --------------------- - -The plugins controller needs to be told to use your plugin. So edit the file: -``app/plugins/controller.js`` and add an entry to the ``tabs`` array with the -information about your plugin:: - - $scope.tabs = [ - { - title: "Jobs Detail", - content: "plugins/jobdetail/main.html", - active: true - }, - { - title: "Jobs Foo", - content: "plugins/jobfoo/main.html" - } - ]; - -It may be obvious, but ``title`` is the title of the tab to display. And -``content`` is the path to your partial. - - -Profit ------- - -That's it! Reload your page, and you should now have a tab to your plugin! -Rejoice in the profit! \ No newline at end of file diff --git a/package.json b/package.json index d11c60615aa..2fd35d763b0 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "react-router-dom": "4.2.2", "react-select": "1.2.1", "react-table": "6.8.6", + "react-tabs": "2.2.2", "react2angular": "4.0.2", "reactstrap": "6.0.1", "redux": "4.0.0", diff --git a/tests/selenium/pages/treeherder.py b/tests/selenium/pages/treeherder.py index 371f7817897..24186ca5442 100644 --- a/tests/selenium/pages/treeherder.py +++ b/tests/selenium/pages/treeherder.py @@ -95,8 +95,8 @@ def get_next_50(self): self._get_next(50) @property - def info_panel(self): - return self.InfoPanel(self) + def details_panel(self): + return self.DetailsPanel(self) def _keyboard_shortcut(self, shortcut): self.find_element(By.CSS_SELECTOR, 'body').send_keys(shortcut) @@ -123,14 +123,14 @@ def select_next_job(self): def select_next_unclassified_job(self): self._keyboard_shortcut('n') - self.wait.until(lambda _: self.info_panel.is_open) + self.wait.until(lambda _: self.details_panel.is_open) def select_previous_job(self): self._keyboard_shortcut(Keys.ARROW_LEFT) def select_previous_unclassified_job(self): self._keyboard_shortcut('p') - self.wait.until(lambda _: self.info_panel.is_open) + self.wait.until(lambda _: self.details_panel.is_open) def select_repository(self, name): self.find_element(*self._repo_menu_locator).click() @@ -230,8 +230,8 @@ class ResultSet(Region): _job_groups_locator = (By.CSS_SELECTOR, '.job-group') _jobs_locator = (By.CSS_SELECTOR, '.job-btn.filter-shown') _pin_all_jobs_locator = (By.CLASS_NAME, 'pin-all-jobs-btn') - _set_bottom_of_range_locator = (By.CSS_SELECTOR, 'ul.dropdown-menu > li:nth-child(6)') - _set_top_of_range_locator = (By.CSS_SELECTOR, 'ul.dropdown-menu > li:nth-child(5)') + _set_bottom_of_range_locator = (By.CLASS_NAME, 'bottom-of-range-menu-item') + _set_top_of_range_locator = (By.CLASS_NAME, 'top-of-range-menu-item') @property def author(self): @@ -288,7 +288,7 @@ class Job(Region): def click(self): self.root.click() - self.wait.until(lambda _: self.page.info_panel.is_open) + self.wait.until(lambda _: self.page.details_panel.is_open) @property def selected(self): @@ -334,10 +334,10 @@ def author(self): def comment(self): return self.find_element(*self._comment_locator).text - class InfoPanel(Region): + class DetailsPanel(Region): - _root_locator = (By.ID, 'info-panel') - _close_locator = (By.CSS_SELECTOR, '.info-panel-navbar-controls a') + _root_locator = (By.ID, 'details-panel') + _close_locator = (By.CSS_SELECTOR, '.details-panel-close-btn') _loading_locator = (By.CSS_SELECTOR, '.overlay') def close(self, method='pointer'): @@ -355,13 +355,13 @@ def is_open(self): @property def job_details(self): - return self.JobDetails(self.page) + return self.SummaryPanel(self.page) - class JobDetails(Region): + class SummaryPanel(Region): - _root_locator = (By.ID, 'job-details-panel') + _root_locator = (By.ID, 'summary-panel') _keywords_locator = (By.CSS_SELECTOR, 'a[title="Filter jobs containing these keywords"]') - _log_viewer_locator = (By.ID, 'logviewer-btn') + _log_viewer_locator = (By.CLASS_NAME, 'logviewer-btn') _pin_job_locator = (By.ID, 'pin-job-btn') _result_locator = (By.CSS_SELECTOR, '#result-status-pane div:nth-of-type(1) span') @@ -399,7 +399,7 @@ def open_log_viewer(self, method='pointer'): class Pinboard(Region): _root_locator = (By.ID, 'pinboard-panel') - _clear_all_locator = (By.CSS_SELECTOR, '#pinboard-controls .dropdown-menu li:nth-child(5) a') + _clear_all_locator = (By.CSS_SELECTOR, '#pinboard-controls .dropdown-menu li:nth-child(3) a') _jobs_locator = (By.CLASS_NAME, 'pinned-job') _save_menu_locator = (By.CSS_SELECTOR, '#pinboard-controls .save-btn-dropdown') diff --git a/tests/selenium/test_filter_jobs_by_failure_result.py b/tests/selenium/test_filter_jobs_by_failure_result.py index 8c2f171724a..97fabcb3210 100644 --- a/tests/selenium/test_filter_jobs_by_failure_result.py +++ b/tests/selenium/test_filter_jobs_by_failure_result.py @@ -25,4 +25,4 @@ def test_filter_jobs_by_failure_result(base_url, selenium, test_jobs, result): getattr(filters, 'toggle_{}_jobs'.format(result))() assert len(page.all_jobs) == 1 page.all_jobs[0].click() - assert page.info_panel.job_details.result == result + assert page.details_panel.job_details.result == result diff --git a/tests/selenium/test_filter_jobs_by_keywords.py b/tests/selenium/test_filter_jobs_by_keywords.py index 3c15b79021c..27b569ac6b3 100644 --- a/tests/selenium/test_filter_jobs_by_keywords.py +++ b/tests/selenium/test_filter_jobs_by_keywords.py @@ -22,8 +22,8 @@ def test_filter_jobs_by_keywords_from_job_panel(base_url, selenium, test_jobs): page = Treeherder(selenium, base_url).open() page.wait.until(lambda _: len(page.all_jobs) == len(test_jobs)) page.all_jobs[0].click() - keywords = page.info_panel.job_details.keywords - page.info_panel.job_details.filter_by_keywords() + keywords = page.details_panel.job_details.keywords + page.details_panel.job_details.filter_by_keywords() page.wait.until(lambda _: len(page.all_jobs) < len(test_jobs)) assert page.quick_filter_term == keywords.lower() diff --git a/tests/selenium/test_filter_jobs_by_result_status.py b/tests/selenium/test_filter_jobs_by_result_status.py index c5d056ac338..64583123642 100644 --- a/tests/selenium/test_filter_jobs_by_result_status.py +++ b/tests/selenium/test_filter_jobs_by_result_status.py @@ -37,7 +37,7 @@ def test_filter_failures(base_url, selenium, test_jobs): page.toggle_in_progress() page.wait.until(lambda _: len(page.all_jobs) == 1) page.all_jobs[0].click() - assert page.info_panel.job_details.result == 'testfailed' + assert page.details_panel.job_details.result == 'testfailed' def test_filter_success(base_url, selenium, test_jobs): @@ -49,7 +49,7 @@ def test_filter_success(base_url, selenium, test_jobs): page.toggle_in_progress() page.wait.until(lambda _: len(page.all_jobs) == 1) page.all_jobs[0].click() - assert page.info_panel.job_details.result == 'success' + assert page.details_panel.job_details.result == 'success' def test_filter_retry(base_url, selenium, test_jobs): @@ -61,7 +61,7 @@ def test_filter_retry(base_url, selenium, test_jobs): page.toggle_in_progress() page.wait.until(lambda _: len(page.all_jobs) == 1) page.all_jobs[0].click() - assert page.info_panel.job_details.result == 'retry' + assert page.details_panel.job_details.result == 'retry' def test_filter_usercancel(base_url, selenium, test_jobs): @@ -73,7 +73,7 @@ def test_filter_usercancel(base_url, selenium, test_jobs): page.toggle_in_progress() page.wait.until(lambda _: len(page.all_jobs) == 1) page.all_jobs[0].click() - assert page.info_panel.job_details.result == 'usercancel' + assert page.details_panel.job_details.result == 'usercancel' def test_filter_superseded(base_url, selenium, test_jobs): @@ -87,7 +87,7 @@ def test_filter_superseded(base_url, selenium, test_jobs): page.toggle_in_progress() page.wait.until(lambda _: len(page.all_jobs) == 1) page.all_jobs[0].click() - assert page.info_panel.job_details.result == 'superseded' + assert page.details_panel.job_details.result == 'superseded' def test_filter_in_progress(base_url, selenium, test_jobs): @@ -99,4 +99,4 @@ def test_filter_in_progress(base_url, selenium, test_jobs): page.toggle_usercancel() page.wait.until(lambda _: len(page.all_jobs) == 1) page.all_jobs[0].click() - assert page.info_panel.job_details.result == 'unknown' + assert page.details_panel.job_details.result == 'unknown' diff --git a/tests/selenium/test_info_panel.py b/tests/selenium/test_info_panel.py index ac131294b40..03cc8eab780 100644 --- a/tests/selenium/test_info_panel.py +++ b/tests/selenium/test_info_panel.py @@ -8,6 +8,6 @@ def test_close_info_panel(base_url, selenium, test_job, method): page = Treeherder(selenium, base_url).open() page.wait.until(lambda _: page.all_jobs) page.all_jobs[0].click() - assert page.info_panel.is_open - page.info_panel.close(method) - assert not page.info_panel.is_open + assert page.details_panel.is_open + page.details_panel.close(method) + assert not page.details_panel.is_open diff --git a/tests/selenium/test_log_viewer.py b/tests/selenium/test_log_viewer.py index 1154f909cfd..4d2f41c27ae 100644 --- a/tests/selenium/test_log_viewer.py +++ b/tests/selenium/test_log_viewer.py @@ -24,5 +24,5 @@ def test_open_log_viewer(base_url, selenium, log): page = Treeherder(selenium, base_url).open() page.wait.until(lambda _: page.all_jobs) page.all_jobs[0].click() - log_viewer = page.info_panel.job_details.open_log_viewer() + log_viewer = page.details_panel.job_details.open_log_viewer() assert log_viewer.seed_url in selenium.current_url diff --git a/tests/selenium/test_pin_jobs.py b/tests/selenium/test_pin_jobs.py index 616d39ea94b..f89dc572b07 100644 --- a/tests/selenium/test_pin_jobs.py +++ b/tests/selenium/test_pin_jobs.py @@ -23,7 +23,7 @@ def test_pin_job(base_url, selenium, test_jobs, method): page.wait.until(lambda _: len(page.all_jobs) == len(test_jobs)) page.all_jobs[0].click() assert len(page.pinboard.jobs) == 0 - page.info_panel.job_details.pin_job(method) + page.details_panel.job_details.pin_job(method) assert len(page.pinboard.jobs) == 1 assert page.pinboard.jobs[0].symbol == page.all_jobs[0].symbol @@ -32,7 +32,7 @@ def test_clear_pinboard(base_url, selenium, test_jobs): page = Treeherder(selenium, base_url).open() page.wait.until(lambda _: len(page.all_jobs) == len(test_jobs)) page.all_jobs[0].click() - page.info_panel.job_details.pin_job() + page.details_panel.job_details.pin_job() assert len(page.pinboard.jobs) == 1 page.pinboard.clear() assert len(page.pinboard.jobs) == 0 diff --git a/tests/ui/unit/controllers/pinboard.tests.js b/tests/ui/unit/controllers/pinboard.tests.js deleted file mode 100644 index 007c7288f72..00000000000 --- a/tests/ui/unit/controllers/pinboard.tests.js +++ /dev/null @@ -1,39 +0,0 @@ -/* jasmine specs for controllers go here */ - -describe('PinboardCtrl', function(){ - var $httpBackend, controller, pinboardScope; - - beforeEach(angular.mock.module('treeherder.app')); - - beforeEach(inject(function ($injector, $rootScope, $controller) { - $httpBackend = $injector.get('$httpBackend'); - jasmine.getJSONFixtures().fixturesPath='base/tests/ui/mock'; - - pinboardScope = $rootScope.$new(); - $controller('PinboardCtrl', { - '$scope': pinboardScope, - }); - })); - - /* - Tests PinboardCtrl - */ - it('should determine sha or commit url', function() { - // Blatantly not a sha or commit - var str = "banana"; - expect(pinboardScope.isSHAorCommit(str)).toBe(false); - // This contains a legit 12-char SHA but includes a space - str = "c00b13480420 8c2652ebd4f45a1d37277c54e60b"; - expect(pinboardScope.isSHAorCommit(str)).toBe(false); - // This is a valid commit URL - str = "https://hg.mozilla.org/integration/mozilla-inbound/rev/c00b134804208c2652ebd4f45a1d37277c54e60b"; - expect(pinboardScope.isSHAorCommit(str)).toBe(true); - // Valid 40-char SHA - str = "c00b134804208c2652ebd4f45a1d37277c54e60b"; - expect(pinboardScope.isSHAorCommit(str)).toBe(true); - // Valid 12-char SHA - str = "c00b13480420"; - expect(pinboardScope.isSHAorCommit(str)).toBe(true); - }); - -}); diff --git a/tests/ui/unit/init.js b/tests/ui/unit/init.js index 2f0ee78006f..eb32f04d49c 100644 --- a/tests/ui/unit/init.js +++ b/tests/ui/unit/init.js @@ -28,8 +28,6 @@ const serviceContext = require.context('../../../ui/js/services', true, /^\.\/.* serviceContext.keys().forEach(serviceContext); const componentContext = require.context('../../../ui/js/components', true, /^\.\/.*\.jsx?$/); componentContext.keys().forEach(componentContext); -const pluginContext = require.context('../../../ui/plugins', true, /^\.\/.*\.jsx?$/); -pluginContext.keys().forEach(pluginContext); const testContext = require.context('./', true, /^\.\/.*\.tests\.jsx?$/); testContext.keys().forEach(testContext); diff --git a/tests/ui/unit/react/groups.tests.jsx b/tests/ui/unit/react/groups.tests.jsx index 81b9321fb0f..0be103d2827 100644 --- a/tests/ui/unit/react/groups.tests.jsx +++ b/tests/ui/unit/react/groups.tests.jsx @@ -30,9 +30,9 @@ describe('JobGroup component', () => { ); expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + - '' + + '' + '' + '' ); @@ -50,9 +50,9 @@ describe('JobGroup component', () => { jobGroup.setState({ expanded: false }); expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + - '' + + '' + '' + '' ); @@ -69,12 +69,12 @@ describe('JobGroup component', () => { jobGroup.setState({ expanded: true }); expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '' ); @@ -92,12 +92,12 @@ describe('JobGroup component', () => { $rootScope.$emit(thEvents.groupStateChanged, 'expanded'); expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '' ); @@ -114,9 +114,9 @@ describe('JobGroup component', () => { expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + - '' + + '' + '' + '' + '' + @@ -136,10 +136,10 @@ describe('JobGroup component', () => { jobGroup.setState({ showDuplicateJobs: true }); expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + - '' + - '' + + '' + + '' + '' + '' + '' + @@ -159,10 +159,10 @@ describe('JobGroup component', () => { $rootScope.$emit(thEvents.duplicateJobsVisibilityChanged); expect(jobGroup.html()).toEqual( '' + - '' + + '' + '' + - '' + - '' + + '' + + '' + '' + '' + '' + diff --git a/ui/css/treeherder-info-panel.css b/ui/css/treeherder-details-panel.css similarity index 72% rename from ui/css/treeherder-info-panel.css rename to ui/css/treeherder-details-panel.css index 61b60393d8b..1b70e827ea0 100644 --- a/ui/css/treeherder-info-panel.css +++ b/ui/css/treeherder-details-panel.css @@ -2,39 +2,39 @@ strong { font-weight: bold; } -div#info-panel { - background-color: #AAAAAA; +details-panel { font-size: 12px; height: 35%; max-height: 75%; - flex: none; - display: flex; - flex-flow: column; } -.info-panel-slide { - animation: info-panel-slide 0.4s; +.details-panel-slide { + animation: details-panel-slide 0.4s; + height: 100%; } -@keyframes info-panel-slide { +@keyframes details-panel-slide { 0% { transform: translateY(100%); } 100% { transform: translateY(0%); } } -div#info-panel .navbar { +div#details-panel .navbar, +div#tabs-panel .tab-headers { border-radius: 0; - border-style: solid; - border-color: #42484F; - border-width: 1px 0; height: 33px; + width: 100%; margin: 0; font-size: 12px; min-height: 33px; min-width: initial; z-index: 100; + background-color: #252C33; + border: 1px solid transparent; + color: #CED3D9; + justify-content: space-between; } -div#info-panel-resizer { +div#details-panel-resizer { display: flex; flex: none; background-color: #919dad; @@ -42,49 +42,53 @@ div#info-panel-resizer { height: 2px; } -div#info-panel .navbar-nav > ul { +#tab-header-buttons > span > span { + padding-left: 5px; +} + +div#details-panel .navbar-nav > ul { height: 32px; } -div#info-panel .navbar-nav.actionbar-nav > li > a, -div#info-panel .navbar-nav.actionbar-nav > li > button { +div#details-panel .navbar-nav.actionbar-nav > li > a, +div#details-panel .navbar-nav.actionbar-nav > li > button { padding: 8px 15px; line-height: 16px; } -div#info-panel .navbar-nav.tab-headers > li > a, -div#info-panel .navbar-nav.tab-headers > li > button { +div#details-panel .navbar-nav.tab-headers > li > a, +div#details-panel .navbar-nav.tab-headers > li > button { padding: 8px 15px; line-height: 30px; } -div#info-panel .navbar-nav > li > button { +div#details-panel .navbar-nav > li > button { border: none; background: transparent; } /* Use a loaded image, rather than an icon, so it needs to be slightly shorter */ -div#info-panel .navbar-nav > li > a#logviewer-btn { +div#details-panel .navbar-nav > li > a#logviewer-btn { line-height: 18px; } -div#info-panel .navbar-nav > li > a.disabled, -div#info-panel .navbar-nav > li > button.disabled, +div#details-panel .navbar-nav > li > a.disabled, +div#details-panel .navbar-nav > li > button.disabled, ul.actionbar-menu > li.disabled { cursor: not-allowed; text-decoration: none; } -div#info-panel .navbar-nav > li.active a, -div#info-panel .navbar-nav > li.active a:hover, -div#info-panel .navbar-nav > li.active a:focus { +div#details-panel .navbar-nav > li.active a, +div#details-panel .navbar-nav > li.active a:hover, +div#details-panel .navbar-nav > li.active a:focus { outline: 0; } -div#info-panel .info-panel-navbar > ul.tab-headers > li { +div#details-panel .details-panel-navbar > ul.tab-headers > li { border-right: 1px solid #42484F; } -.info-panel-navbar { +.details-panel-navbar { background-color: #252C33; border: 1px solid transparent; color: #CED3D9; @@ -94,18 +98,34 @@ div#info-panel .info-panel-navbar > ul.tab-headers > li { height: 33px; } -.info-panel-navbar li { +.details-panel-navbar li { align-self: center; } -.info-panel-navbar-tabs { - justify-content: space-between; +.tab-header-tabs { flex-direction: row; + display: flex; +} + +.tab-header-tabs > li { + padding: 1px 15px; + line-height: 30px; + cursor: pointer; + color: #9FA3A5; } -.tab-headers { +#details-panel ul.tab-headers { + list-style: none; flex-direction: row; min-width: 550px; + display: flex; + padding-left: 0; + font-size: 12px; +} + +.details-panel-close-btn { + padding-top: 3px; + font-size: 12px; } .perf-job-selected { @@ -113,80 +133,107 @@ div#info-panel .info-panel-navbar > ul.tab-headers > li { min-width: 646px !important; } -.info-panel-navbar-controls { +.details-panel-navbar-controls { flex-wrap: nowrap; } -.info-panel-navbar .navbar-nav { +.details-panel-navbar .navbar-nav { display: flex; flex-direction: row; } -.info-panel-navbar .navbar-nav > li { +.details-panel-navbar .navbar-nav > li { white-space: nowrap; } -.info-panel-navbar .navbar-nav > li > a, -.info-panel-navbar .navbar-nav > li > button { +.details-panel-navbar .navbar-nav > li > a, +.details-panel-navbar .navbar-nav > li > .btn { color: #9FA3A5; - padding: 7px 15px; + padding: 4px 15px; } -div#info-panel .navbar-nav > li > a:hover, -div#info-panel .navbar-nav > li > a:focus, -div#info-panel .navbar-nav > li > button:hover, -div#info-panel .navbar-nav > li > button:focus +div#details-panel .navbar-nav > li > a:hover, +div#details-panel .navbar-nav > li > a:focus, +div#details-panel .navbar-nav > li > button:hover, +div#details-panel .navbar-nav > li > button:focus { background-color: #1E252B; color: #D3D8DA; } -div#info-panel .navbar-nav > li > a:active, -div#info-panel .navbar-nav > li > button:active +div#details-panel .navbar-nav > li > a:active, +div#details-panel .navbar-nav > li > button:active { background-color: #000; } -div#info-panel .navbar-nav > li > a.disabled:active, -div#info-panel .navbar-nav > li > button.disabled:active +div#details-panel .navbar-nav > li > a.disabled:active, +div#details-panel .navbar-nav > li > button.disabled:active { background-color: #1E252B; } -.info-panel-navbar .actionbar-nav { +.details-panel-navbar .actionbar-nav { flex: auto; } -div#info-panel .info-panel-navbar .navbar-nav > li.active a, -div#info-panel .info-panel-navbar .navbar-nav > li.active a:hover, -div#info-panel .info-panel-navbar .navbar-nav > li.active a:focus, -div#info-panel .info-panel-navbar > li.active a, -div#info-panel .info-panel-navbar > li.active a:hover, -div#info-panel .info-panel-navbar > li.active a:focus { +div#details-panel .details-panel-navbar .navbar-nav > li.active a, +div#details-panel .details-panel-navbar .navbar-nav > li.active a:hover, +div#details-panel .details-panel-navbar .navbar-nav > li.active a:focus, +div#details-panel .details-panel-navbar > li.active a, +div#details-panel .details-panel-navbar > li.active a:hover, +div#details-panel .details-panel-navbar > li.active a:focus { background-color: #1A4666; color: #EEF0F2; } -#info-panel-content { +.tab-header-tabs > li.selected-tab { + background-color: #1A4666; + color: #EEF0F2; +} + +.react-tabs { + height: 100%; + width: 100%; +} + +.react-tabs__tab-panel--selected { + height: 100%; +} + +#tabs-panel { + height: 100%; + max-height: calc(100% - 35px); + width: 100%; +} + +#details-panel #job-details-list, +#details-panel .failure-summary-list, +#details-panel .similar-jobs > .similar-job-list tbody { + overflow-y: auto; + height: 100%; +} + +#details-panel { position: relative; /* So we can absolutely position the loading overlay */ - height: 60%; + height: 100%; flex: auto; display: flex; flex-flow: row; } -#job-details-panel, #job-tabs-panel { +#summary-panel { background-color: #fff; display: flex; flex-flow: column; } -#job-details-actionbar, #job-tabs-navbar { +#job-details-actionbar { min-height: 33px; } /* - * Job details action bar + * action bar */ .action-bar-spin { @@ -202,6 +249,10 @@ div#info-panel .info-panel-navbar > li.active a:focus { flex-direction: row; } +.actionbar-nav .btn { + cursor: pointer; +} + .actionbar-nav > li { /* Override padding on all icons to keep compact */ padding: 0 !important; @@ -245,24 +296,29 @@ div#info-panel .info-panel-navbar > li.active a:focus { * Job details panel (left side) */ -#job-details-panel { +#summary-panel-content { + overflow-y: auto; +} + +#summary-panel { width: 260px; min-width: 260px; + height: 100%; } -#job-details-panel .content-spacer { +#summary-panel .content-spacer { padding-top: 2px; padding-bottom: 4px; } -#job-details-panel ul li { +#summary-panel ul li { padding: 0 0 0 5px; line-height: 15px; word-wrap: break-word; } -#job-details-panel ul li label { - padding: 0; +#summary-panel ul li label { + padding: 0 3px 0 0; margin: 2px 0; font-weight: bold; } @@ -312,7 +368,7 @@ div#info-panel .info-panel-navbar > li.active a:focus { color: grey; } -#job-details-panel em.testfail { +#summary-panel em.testfail { color: red; } @@ -326,6 +382,11 @@ div#info-panel .info-panel-navbar > li.active a:focus { overflow: auto; } +#result-status-pane { + width: 100%; + padding: 4px; +} + #result-status-pane div { display: inline-block; } @@ -342,50 +403,24 @@ div#info-panel .info-panel-navbar > li.active a:focus { border-left: 1px solid lightgrey; } -.job-tabs-content { - padding: 2px 4px 0; -} - -#job-tabs-panel { - flex: 1 6; - padding: 0; - min-width: 565px; -} - -#job-tabs-pane { - max-height: calc(100% - 33px); - flex: 1; - display: flex; - overflow: auto; -} - -#job-tabs-pane > * { - flex: 1; - display: flex; -} - -#job-tabs-pane > * > * { - flex: 1; - display: flex; -} - -#job-tabs-pane > * > * > * { - flex: 1; - display: flex; +#job-details-list label { + margin-left: 2px; } /* * Failure summary */ -#job-tabs-panel ul.failure-summary-list { +ul.failure-summary-list { width: 100%; margin-bottom: 0; + height: 100%; } ul.failure-summary-list li { font-size: 11px; background: #ccfaff; + padding: 1px 0 0 2px; } ul.failure-summary-list li .btn-xs { @@ -486,48 +521,49 @@ annotations-tab { font-size: 12px; } -.similar_jobs { +.similar-jobs { display: flex; flex-flow: row; + height: 100%; } -div.similar_jobs .right_panel { +div.similar-jobs .similar-job-detail-panel { border-left: 1px solid #101010; margin-right: 1px; overflow-y: auto; flex: 1 1; } -div.similar_jobs .right_panel form { +div.similar-jobs .similar-job-detail-panel form { overflow: hidden; background-color: #D3D3D3; } -div.similar_jobs .right_panel form .checkbox input[type="checkbox"] { +div.similar-jobs .similar-job-detail-panel form .checkbox input[type="checkbox"] { margin-left: 0; position: relative; } -div.similar_jobs .right_panel .similar_job_detail { +div.similar-jobs .similar-job-detail-panel .similar_job_detail { border-top: 1px solid #101010; } -div.similar_jobs .right_panel .similar_job_detail table { +div.similar-jobs .similar-job-detail-panel .similar_job_detail table { width: 100%; overflow: hidden; } -div.similar_jobs .left_panel { +div.similar-jobs .similar-job-list { overflow: auto; flex: 1 1; } -div.similar_jobs .left_panel table { +div.similar-jobs .similar-job-list table { margin-bottom: 7px; } /* We override bootstrap table style for cleaner layout */ -div.similar_jobs .left_panel table tr > td { +div.similar-jobs .similar-job-list table tr > td { vertical-align: middle; border-top: 1px solid lightgrey; border-bottom: 0; @@ -536,14 +572,14 @@ div.similar_jobs .left_panel table tr > td { } /* Selected Similar Job row in blue */ -div.similar_jobs .left_panel table tr.active > td { +div.similar-jobs .similar-job-list table tr.active > td { background: #e2ebfa; border-top: 1px solid darkgrey; border-bottom: 1px solid darkgrey; } /* Avoid using the hand pointer unless we are on a link */ -div.similar_jobs .left_panel table tr { +div.similar-jobs .similar-job-list table tr { cursor: default; } diff --git a/ui/css/treeherder-global.css b/ui/css/treeherder-global.css index 295377fdca5..ee0f4ed352c 100755 --- a/ui/css/treeherder-global.css +++ b/ui/css/treeherder-global.css @@ -13,7 +13,7 @@ html, body { height: 100%; font-size: 14px; line-height: 1.42; - overflow-x: hidden; + overflow: hidden; } body { diff --git a/ui/css/treeherder-pinboard.css b/ui/css/treeherder-pinboard.css index de86e9bc3ba..55da9c9135c 100644 --- a/ui/css/treeherder-pinboard.css +++ b/ui/css/treeherder-pinboard.css @@ -1,13 +1,15 @@ -#job-tabs-navbar .info-panel-navbar #pinboard-btn { - margin-top: -2px; +#pinboard-btn { + margin-top: 0; + margin-bottom: 1px; background-color: #e6eef5; color: #252c33; - line-height: 30px; + line-height: 22px; cursor: pointer; + border-radius: 0; + padding: 3px 10px 4px 14px; } .pinboard-btn-text { - margin: 0 15px; font-size: 12px; } @@ -50,12 +52,13 @@ line-height: 18px; } -#pinboard-panel { +#pinboard-contents { background-color: #e6eef5; color: #252c33; flex: auto; display: flex; flex-flow: row; + min-height: 100px; } #pinboard-panel .header { @@ -132,6 +135,15 @@ .add-related-bugs-input { width: 12em; + margin: -3px 0 0 -3px; + font-size: 12px; + line-height: 12px; +} + +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; } .pinboard-related-bug-preload-txt { @@ -165,8 +177,17 @@ color: black; } -#pinboard-classification select { +#pinboard-classification-content .form-group { + margin-bottom: 5px; + margin-top: 5px; +} + +select#pinboard-classification-select.classification-select, +select#pinboard-revision-select.classification-select { width: 177px; + height: 20px; + font-size: 12px; + padding: 0; } .add-classification-input { diff --git a/ui/entry-index.js b/ui/entry-index.js index 45bfe845a73..fed62eb9779 100644 --- a/ui/entry-index.js +++ b/ui/entry-index.js @@ -13,7 +13,7 @@ import './css/treeherder-global.css'; import './css/treeherder-navbar.css'; import './css/treeherder-navbar-panels.css'; import './css/treeherder-notifications.css'; -import './css/treeherder-info-panel.css'; +import './css/treeherder-details-panel.css'; import './css/treeherder-job-buttons.css'; import './css/treeherder-resultsets.css'; import './css/treeherder-pinboard.css'; @@ -25,12 +25,12 @@ import './js/treeherder_app'; // Treeherder React UI import './job-view/PushList'; +import './job-view/details/DetailsPanel'; // Treeherder JS import './js/components/auth'; import './js/directives/treeherder/main'; import './js/directives/treeherder/top_nav_bar'; -import './js/directives/treeherder/bottom_nav_panel'; import './js/services/main'; import './js/services/buildapi'; import './js/services/taskcluster'; @@ -48,14 +48,4 @@ import './js/controllers/notification'; import './js/controllers/filters'; import './js/controllers/bugfiler'; import './js/controllers/tcjobactions'; -import './plugins/tabs'; -import './plugins/controller'; -import './plugins/pinboard'; -import './job-view/details/summary/SummaryPanel'; -import './job-view/details/tabs/JobDetailsTab'; -import './job-view/details/tabs/failureSummary/FailureSummaryTab'; -import './job-view/details/tabs/autoclassify/AutoclassifyTab'; -import './job-view/details/tabs/AnnotationsTab'; -import './job-view/details/tabs/SimilarJobsTab'; -import './job-view/details/tabs/PerformanceTab'; import './js/filters'; diff --git a/ui/helpers/job.js b/ui/helpers/job.js index 4229d97b04a..fe6d549c1e0 100644 --- a/ui/helpers/job.js +++ b/ui/helpers/job.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import _ from 'lodash'; import { thPlatformMap } from '../js/constants'; +import { toDateStr } from './display'; +import { getSlaveHealthUrl, getWorkerExplorerUrl } from './url'; const btnClasses = { busted: "btn-red", @@ -17,6 +19,14 @@ const btnClasses = { 'in progress': "btn-dkgray", }; +// The result will be unknown unless the state is complete, so much check both. +// TODO: We should consider storing either pending or running in the result, +// even when the job isn't complete. It would simplify a lot of UI code and +// I can't think of a reason that would hurt anything. +export const getStatus = function getStatus(job) { + return job.state === 'completed' ? job.result : job.state; +}; + // Get the CSS class for job buttons as well as jobs that show in the pinboard. // These also apply to result "groupings" like ``failures`` and ``in progress`` // for the colored filter chicklets on the nav bar. @@ -35,12 +45,8 @@ export const getBtnClass = function getBtnClass(resultState, failureClassificati return btnClass; }; -// The result will be unknown unless the state is complete, so much check both. -// TODO: We should consider storing either pending or running in the result, -// even when the job isn't complete. It would simplify a lot of UI code and -// I can't think of a reason that would hurt anything. -export const getStatus = function getStatus(job) { - return job.state === 'completed' ? job.result : job.state; +export const getJobBtnClass = function getJobBtnClass(job) { + return getBtnClass(getStatus(job), job.failure_classification_id); }; export const isReftest = function isReftest(job) { @@ -75,7 +81,7 @@ const isOnScreen = function isOnScreen(el) { viewport.top = filterbarheight > 0 ? viewport.top + filterbarheight : viewport.top; const updatebarheight = $('.update-alert-panel').height(); viewport.top = updatebarheight > 0 ? viewport.top + updatebarheight : viewport.top; - viewport.bottom = $(window).height() - $('#info-panel').height() - 20; + viewport.bottom = $(window).height() - $('#details-panel').height() - 20; const bounds = {}; bounds.top = el.offset().top; bounds.bottom = bounds.top + el.outerHeight(); @@ -129,5 +135,46 @@ export const getSearchStr = function getSearchStr(job) { job.job_type_name, `${symbolInfo}(${job.job_type_symbol})` ].filter(item => typeof item !== 'undefined').join(' '); +}; + +export const getHoverText = function getHoverText(job) { + const duration = Math.round((job.end_timestamp - job.start_timestamp) / 60); + + return `${job.job_type_name} - ${getStatus(job)} - ${duration} mins`; +}; + +export const getJobMachineUrl = function getJobMachineUrl(job) { + const { build_system_type, machine_name } = job; + const machineUrl = (machine_name !== 'unknown' && build_system_type === 'buildbot') ? + getSlaveHealthUrl(machine_name) : + getWorkerExplorerUrl(job.taskcluster_metadata.task_id); + + return machineUrl; +}; + +export const getTimeFields = function getTimeFields(job) { + // time fields to show in detail panel, but that should be grouped together + const timeFields = { + requestTime: toDateStr(job.submit_timestamp) + }; + + // If start time is 0, then duration should be from requesttime to now + // If we have starttime and no endtime, then duration should be starttime to now + // If we have both starttime and endtime, then duration will be between those two + const endtime = job.end_timestamp || Date.now() / 1000; + const starttime = job.start_timestamp || job.submit_timestamp; + const duration = `${Math.round((endtime - starttime)/60, 0)} minute(s)`; + + if (job.start_timestamp) { + timeFields.startTime = toDateStr(job.start_timestamp); + timeFields.duration = duration; + } else { + timeFields.duration = `Not started (queued for ${duration})`; + } + + if (job.end_timestamp) { + timeFields.endTime = toDateStr(job.end_timestamp); + } + return timeFields; }; diff --git a/ui/helpers/revision.js b/ui/helpers/revision.js index 8eb7ff62d8c..34b19c61ca6 100644 --- a/ui/helpers/revision.js +++ b/ui/helpers/revision.js @@ -21,3 +21,7 @@ export const isSHA = function isSHA(str) { } return true; }; + +export const isSHAorCommit = function isSHAorCommit(str) { + return /^[a-f\d]{12,40}$/.test(str) || str.includes('hg.mozilla.org'); +}; diff --git a/ui/index.html b/ui/index.html index ba8f6bc35e9..38b937bdd06 100755 --- a/ui/index.html +++ b/ui/index.html @@ -35,11 +35,12 @@ /> -
-
+ diff --git a/ui/job-view/JobButton.jsx b/ui/job-view/JobButton.jsx index 39935650179..ee4fe5b4fe5 100644 --- a/ui/job-view/JobButton.jsx +++ b/ui/job-view/JobButton.jsx @@ -101,7 +101,6 @@ export default class JobButtonComponent extends React.Component { const classes = ['btn', btnClass, 'filter-shown']; const attributes = { 'data-job-id': id, - 'data-ignore-job-clear-on-click': true, title }; diff --git a/ui/job-view/JobGroup.jsx b/ui/job-view/JobGroup.jsx index 82ae65a0bb6..e2c0d07ba8d 100644 --- a/ui/job-view/JobGroup.jsx +++ b/ui/job-view/JobGroup.jsx @@ -16,7 +16,6 @@ class GroupSymbol extends React.PureComponent { return ( diff --git a/ui/job-view/Push.jsx b/ui/job-view/Push.jsx index 9ac1a995f79..52cc0642713 100644 --- a/ui/job-view/Push.jsx +++ b/ui/job-view/Push.jsx @@ -116,7 +116,7 @@ export default class Push extends React.Component { } render() { - const { push, loggedIn, isStaff, $injector, repoName } = this.props; + const { push, isLoggedIn, isStaff, $injector, repoName } = this.props; const { watched, runnableVisible } = this.state; const { currentRepo, urlBasePath } = this.$rootScope; const { id, push_timestamp, revision, job_counts, author } = push; @@ -130,7 +130,7 @@ export default class Push extends React.Component { revision={revision} jobCounts={job_counts} watchState={watched} - loggedIn={loggedIn} + isLoggedIn={isLoggedIn} isStaff={isStaff} repoName={repoName} urlBasePath={urlBasePath} @@ -165,13 +165,13 @@ export default class Push extends React.Component { Push.propTypes = { push: PropTypes.object.isRequired, $injector: PropTypes.object.isRequired, - loggedIn: PropTypes.bool, + isLoggedIn: PropTypes.bool, isStaff: PropTypes.bool, repoName: PropTypes.string, }; Push.defaultProps = { - loggedIn: false, + isLoggedIn: false, isStaff: false, repoName: 'mozilla-inbound', }; diff --git a/ui/job-view/PushActionMenu.jsx b/ui/job-view/PushActionMenu.jsx index a53f745d289..3fa06001cb8 100644 --- a/ui/job-view/PushActionMenu.jsx +++ b/ui/job-view/PushActionMenu.jsx @@ -106,7 +106,7 @@ export default class PushActionMenu extends React.PureComponent { } render() { - const { loggedIn, isStaff, repoName, revision, pushId, runnableVisible, + const { isLoggedIn, isStaff, repoName, revision, pushId, runnableVisible, hideRunnableJobsCb, showRunnableJobsCb } = this.props; const { topOfRangeUrl, bottomOfRangeUrl } = this.state; @@ -132,8 +132,8 @@ export default class PushActionMenu extends React.PureComponent { onClick={() => hideRunnableJobsCb()} >Hide Runnable Jobs :
  • showRunnableJobsCb()} >Add new jobs
  • } @@ -168,11 +168,11 @@ export default class PushActionMenu extends React.PureComponent { title="View/Edit/Submit Action tasks for this push" >Custom Push Action...
  • Set as top of range
  • Set as bottom of range
  • @@ -184,7 +184,7 @@ export default class PushActionMenu extends React.PureComponent { PushActionMenu.propTypes = { runnableVisible: PropTypes.bool.isRequired, isStaff: PropTypes.bool.isRequired, - loggedIn: PropTypes.bool.isRequired, + isLoggedIn: PropTypes.bool.isRequired, revision: PropTypes.string.isRequired, repoName: PropTypes.string.isRequired, pushId: PropTypes.number.isRequired, diff --git a/ui/job-view/PushHeader.jsx b/ui/job-view/PushHeader.jsx index 6278800f97c..fb352725d8c 100644 --- a/ui/job-view/PushHeader.jsx +++ b/ui/job-view/PushHeader.jsx @@ -4,7 +4,7 @@ import { Alert } from 'reactstrap'; import PushActionMenu from './PushActionMenu'; import { toDateStr } from '../helpers/display'; import { formatModelError, formatTaskclusterError } from '../helpers/errorMessage'; -import { thPinboardCountError, thEvents } from "../js/constants"; +import { thEvents } from '../js/constants'; function Author(props) { const authorMatch = props.author.match(/\<(.*?)\>+/); @@ -12,7 +12,7 @@ function Author(props) { return ( - {authorEmail} + {authorEmail} ); } @@ -58,7 +58,6 @@ export default class PushHeader extends React.PureComponent { this.$rootScope = $injector.get('$rootScope'); this.thJobFilters = $injector.get('thJobFilters'); this.thNotify = $injector.get('thNotify'); - this.thPinboard = $injector.get('thPinboard'); this.thBuildApi = $injector.get('thBuildApi'); this.ThResultSetStore = $injector.get('ThResultSetStore'); this.ThResultSetModel = $injector.get('ThResultSetModel'); @@ -112,13 +111,13 @@ export default class PushHeader extends React.PureComponent { } triggerNewJobs() { - const { loggedIn, pushId } = this.props; + const { isLoggedIn, pushId } = this.props; if (!window.confirm( 'This will trigger all selected jobs. Click "OK" if you want to proceed.')) { return; } - if (loggedIn) { + if (isLoggedIn) { const builderNames = this.ThResultSetStore.getSelectedRunnableJobs(pushId); this.ThResultSetStore.getGeckoDecisionTaskId(pushId).then((decisionTaskID) => { this.ThResultSetModel.triggerNewJobs(builderNames, decisionTaskID).then((result) => { @@ -136,10 +135,10 @@ export default class PushHeader extends React.PureComponent { } cancelAllJobs() { - const { repoName, revision, loggedIn, pushId } = this.props; + const { repoName, revision, isLoggedIn, pushId } = this.props; this.setState({ showConfirmCancelAll: false }); - if (!loggedIn) return; + if (!isLoggedIn) return; this.ThResultSetModel.cancelAll(pushId).then(() => ( this.thBuildApi.cancelAll(repoName, revision) @@ -153,16 +152,8 @@ export default class PushHeader extends React.PureComponent { } pinAllShownJobs() { - if (!this.thPinboard.spaceRemaining()) { - this.thNotify.send(thPinboardCountError, 'danger'); - return; - } - const shownJobs = this.ThResultSetStore.getAllShownJobs( - this.thPinboard.spaceRemaining(), - thPinboardCountError, - this.props.pushId - ); - this.thPinboard.pinJobs(shownJobs); + const shownJobs = this.ThResultSetStore.getAllShownJobs(this.props.pushId); + this.$rootScope.$emit(thEvents.pinJobs, shownJobs); if (!this.$rootScope.selectedJob) { this.$rootScope.$emit(thEvents.jobClick, shownJobs[0]); @@ -170,11 +161,11 @@ export default class PushHeader extends React.PureComponent { } render() { - const { repoName, loggedIn, pushId, isStaff, jobCounts, author, + const { repoName, isLoggedIn, pushId, isStaff, jobCounts, author, revision, runnableVisible, $injector, watchState, showRunnableJobsCb, hideRunnableJobsCb, cycleWatchState } = this.props; const { filterParams } = this.state; - const cancelJobsTitle = loggedIn ? + const cancelJobsTitle = isLoggedIn ? "Cancel all jobs" : "Must be logged in to cancel jobs"; const counts = jobCounts || { pending: 0, running: 0, completed: 0 }; @@ -194,7 +185,6 @@ export default class PushHeader extends React.PureComponent { {this.pushDateStr} -
    @@ -221,40 +211,35 @@ export default class PushHeader extends React.PureComponent { rel="noopener" title="View details on failed test results for this push" >View Tests - {loggedIn && + {isLoggedIn && } {this.state.runnableJobsSelected && runnableVisible && } { - this.$location.search('selectedJob', null); + this.clearSelectedJobUnlisten = this.$rootScope.$on(thEvents.clearSelectedJob, (ev, target) => { + this.closeJob(target); }); this.changeSelectionUnlisten = this.$rootScope.$on( @@ -188,7 +187,7 @@ export default class PushList extends React.Component { // on the component directly. // // Filter the list of possible jobs down to ONLY ones in the .th-view-content - // div (excluding pinboard) and then to the specific selector passed + // div (excluding pinBoard) and then to the specific selector passed // in. And then to only VISIBLE (not filtered away) jobs. The exception // is for the .selected-job. If that's not visible, we still want to // include it, because it is the anchor from which we find @@ -232,14 +231,14 @@ export default class PushList extends React.Component { // if there was no new job selected, then ensure that we clear any job that // was previously selected. if ($('.selected-job').css('display') === 'none') { - this.$rootScope.closeJob(); + this.$rootScope.$emit(thEvents.clearSelectedJob); } } noMoreUnclassifiedFailures() { this.$timeout(() => { this.thNotify.send("No unclassified failures to select."); - this.$rootScope.closeJob(); + this.$rootScope.$emit(thEvents.clearSelectedJob); }); } @@ -254,33 +253,39 @@ export default class PushList extends React.Component { }, 200); } - // Clear the job if it occurs in a particular area - clearJobOnClick(event) { - // Suppress for various UI elements so selection is preserved - const ignoreClear = event.target.hasAttribute("data-ignore-job-clear-on-click"); - - if (!ignoreClear && !this.thPinboard.hasPinnedJobs()) { + // Clear the selectedJob + closeJob() { + // TODO: Should block clearing the selected job if there are pinned jobs + // But can't get the pinned jobs at this time. When we're completely on React, + // or at least have a shared parent between PushList and DetailsPanel, we can share + // a PinBoardModel or Context so they both have access. + if (!this.$rootScope.countPinnedJobs()) { const selected = findSelectedInstance(); if (selected) { selected.setSelected(false); } - this.$timeout(this.$rootScope.closeJob); + } + } + + clearIfEligibleTarget(target) { + if (target.hasAttribute("data-job-clear-on-click")) { + this.$rootScope.$emit(thEvents.clearSelectedJob, target); } } render() { const { $injector, user, repoName, revision, currentRepo } = this.props; const { pushList, loadingPushes, jobsReady } = this.state; - const { loggedin, is_staff } = user; + const { isLoggedIn, isStaff } = user; return ( -
    +
    this.clearIfEligibleTarget(evt.target)}> {jobsReady && } {repoName && pushList.map(push => ( } -
    +
    get next:
    {[10, 20, 50].map(count => ( diff --git a/ui/job-view/Revision.jsx b/ui/job-view/Revision.jsx index f987882eabc..d557601cc1a 100644 --- a/ui/job-view/Revision.jsx +++ b/ui/job-view/Revision.jsx @@ -55,7 +55,6 @@ export class Revision extends React.PureComponent { {commitRevision.substring(0, 12)} diff --git a/ui/job-view/RevisionList.jsx b/ui/job-view/RevisionList.jsx index 3d677e55ebe..98f1ab66916 100644 --- a/ui/job-view/RevisionList.jsx +++ b/ui/job-view/RevisionList.jsx @@ -14,7 +14,7 @@ export class RevisionList extends React.PureComponent { const { push, repo } = this.props; return ( - +
      {push.revisions.map(revision => ( {`\u2026and more`} diff --git a/ui/job-view/details/DetailsPanel.jsx b/ui/job-view/details/DetailsPanel.jsx new file mode 100644 index 00000000000..ac3a496e35d --- /dev/null +++ b/ui/job-view/details/DetailsPanel.jsx @@ -0,0 +1,514 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular/index.es2015'; +import { chunk } from 'lodash'; +import $ from 'jquery'; + +import treeherder from '../../js/treeherder'; +import { + thEvents, + thBugSuggestionLimit, + thPinboardCountError, + thPinboardMaxSize, +} from '../../js/constants'; +import { getLogViewerUrl, getReftestUrl } from '../../helpers/url'; +import BugJobMapModel from '../../models/bugJobMap'; +import BugSuggestionsModel from '../../models/bugSuggestions'; +import JobClassificationModel from '../../models/classification'; +import JobModel from '../../models/job'; +import JobDetailModel from '../../models/jobDetail'; +import JobLogUrlModel from '../../models/jobLogUrl'; +import TextLogStepModel from '../../models/textLogStep'; + +import PinBoard from './PinBoard'; +import SummaryPanel from './summary/SummaryPanel'; +import TabsPanel from './tabs/TabsPanel'; + +class DetailsPanel extends React.Component { + constructor(props) { + super(props); + + const { $injector } = this.props; + + this.PhSeries = $injector.get('PhSeries'); + this.ThResultSetStore = $injector.get('ThResultSetStore'); + this.thClassificationTypes = $injector.get('thClassificationTypes'); + this.thNotify = $injector.get('thNotify'); + this.$rootScope = $injector.get('$rootScope'); + this.$location = $injector.get('$location'); + this.$timeout = $injector.get('$timeout'); + + // used to cancel all the ajax requests triggered by selectJob + this.selectJobController = null; + + this.state = { + isPinBoardVisible: false, + jobDetails: [], + jobLogUrls: [], + jobDetailLoading: false, + jobLogsAllParsed: false, + logViewerUrl: null, + logViewerFullUrl: null, + reftestUrl: null, + perfJobDetail: [], + jobRevision: null, + logParseStatus: 'unavailable', + classifications: [], + bugs: [], + suggestions: [], + errors: [], + bugSuggestionsLoading: false, + pinnedJobs: {}, + pinnedJobBugs: {}, + }; + } + + componentDidMount() { + this.pinJob = this.pinJob.bind(this); + this.unPinJob = this.unPinJob.bind(this); + this.unPinAll = this.unPinAll.bind(this); + this.addBug = this.addBug.bind(this); + this.removeBug = this.removeBug.bind(this); + this.closeJob = this.closeJob.bind(this); + this.countPinnedJobs = this.countPinnedJobs.bind(this); + // give access to this count to components that don't have a common ancestor in React + // TODO: remove this once we're fully on ReactJS: Bug 1450042 + this.$rootScope.countPinnedJobs = this.countPinnedJobs; + + this.jobClickUnlisten = this.$rootScope.$on(thEvents.jobClick, (evt, job) => { + this.setState({ + jobDetailLoading: true, + jobDetails: [], + suggestions: [], + isPinBoardVisible: !!this.countPinnedJobs(), + }, () => this.selectJob(job)); + }); + + this.clearSelectedJobUnlisten = this.$rootScope.$on(thEvents.clearSelectedJob, () => { + if (this.selectJobController !== null) { + this.selectJobController.abort(); + } + if (!this.countPinnedJobs()) { + this.closeJob(); + } + }); + + this.toggleJobPinUnlisten = this.$rootScope.$on(thEvents.toggleJobPin, (event, job) => { + this.toggleJobPin(job); + }); + + this.jobPinUnlisten = this.$rootScope.$on(thEvents.jobPin, (event, job) => { + this.pinJob(job); + }); + + this.jobsClassifiedUnlisten = this.$rootScope.$on(thEvents.jobsClassified, () => { + this.updateClassifications(this.props.selectedJob); + }); + + this.pinAllShownJobsUnlisten = this.$rootScope.$on(thEvents.pinJobs, (event, jobs) => { + this.pinJobs(jobs); + }); + + this.clearPinboardUnlisten = this.$rootScope.$on(thEvents.clearPinboard, () => { + if (this.state.isPinBoardVisible) { + this.unPinAll(); + } + }); + + this.pulsePinCountUnlisten = this.$rootScope.$on(thEvents.pulsePinCount, () => { + this.pulsePinCount(); + }); + } + + componentWillUnmount() { + this.jobClickUnlisten(); + this.clearSelectedJobUnlisten(); + this.toggleJobPinUnlisten(); + this.jobPinUnlisten(); + this.jobsClassifiedUnlisten(); + this.clearPinboardUnlisten(); + this.pulsePinCountUnlisten(); + this.pinAllShownJobsUnlisten(); + } + + getRevisionTips() { + return this.ThResultSetStore.getPushArray().map(push => ({ + revision: push.revision, + author: push.author, + title: push.revisions[0].comments.split('\n')[0] + })); + } + + togglePinBoardVisibility() { + this.setState({ isPinBoardVisible: !this.state.isPinBoardVisible }); + } + + loadBugSuggestions(job) { + const { repoName } = this.props; + + BugSuggestionsModel.get(job.id).then((suggestions) => { + suggestions.forEach((suggestion) => { + suggestion.bugs.too_many_open_recent = ( + suggestion.bugs.open_recent.length > thBugSuggestionLimit + ); + suggestion.bugs.too_many_all_others = ( + suggestion.bugs.all_others.length > thBugSuggestionLimit + ); + suggestion.valid_open_recent = ( + suggestion.bugs.open_recent.length > 0 && + !suggestion.bugs.too_many_open_recent + ); + suggestion.valid_all_others = ( + suggestion.bugs.all_others.length > 0 && + !suggestion.bugs.too_many_all_others && + // If we have too many open_recent bugs, we're unlikely to have + // relevant all_others bugs, so don't show them either. + !suggestion.bugs.too_many_open_recent + ); + }); + + // if we have no bug suggestions, populate with the raw errors from + // the log (we can do this asynchronously, it should normally be + // fast) + if (!suggestions.length) { + TextLogStepModel.get(job.id).then((textLogSteps) => { + const errors = textLogSteps + .filter(step => step.result !== 'success') + .map(step => ({ + name: step.name, + result: step.result, + logViewerUrl: getLogViewerUrl(job.id, repoName, step.finished_line_number) + })); + this.setState({ errors }); + }); + } + + this.setState({ bugSuggestionsLoading: false, suggestions }); + }); + } + + async updateClassifications(job) { + const classifications = await JobClassificationModel.getList({ job_id: job.id }); + const bugs = await BugJobMapModel.getList({ job_id: job.id }); + this.setState({ classifications, bugs }); + } + + selectJob(newJob) { + const { repoName } = this.props; + + if (this.selectJobController !== null) { + // Cancel the in-progress fetch requests. + this.selectJobController.abort(); + } + // eslint-disable-next-line no-undef + this.selectJobController = new AbortController(); + + let jobDetails = []; + const jobPromise = JobModel.get( + repoName, newJob.id, + this.selectJobController.signal); + + const jobDetailPromise = JobDetailModel.getJobDetails( + { job_guid: newJob.job_guid }, + this.selectJobController.signal); + + const jobLogUrlPromise = JobLogUrlModel.getList( + { job_id: newJob.id }, + this.selectJobController.signal); + + const phSeriesPromise = this.PhSeries.getSeriesData( + repoName, { job_id: newJob.id }); + + Promise.all([ + jobPromise, + jobDetailPromise, + jobLogUrlPromise, + phSeriesPromise + ]).then(async (results) => { + + // The first result comes from the job promise. + // This version of the job has more information than what we get in the main job list. This + // is what we'll pass to the rest of the details panel. It has extra fields like + // taskcluster_metadata. + const job = results[0]; + const jobRevision = this.ThResultSetStore.getPush(job.result_set_id).revision; + + // the second result comes from the job detail promise + jobDetails = results[1]; + + // incorporate the buildername into the job details if this is a buildbot job + // (i.e. it has a buildbot request id) + const buildbotRequestIdDetail = jobDetails.find(detail => detail.title === 'buildbot_request_id'); + if (buildbotRequestIdDetail) { + jobDetails = [...jobDetails, { title: 'Buildername', value: job.ref_data_name }]; + } + + // the third result comes from the jobLogUrl promise + // exclude the json log URLs + const jobLogUrls = results[2].filter(log => !log.name.endsWith('_json')); + + let logParseStatus = 'unavailable'; + // Provide a parse status as a scope variable for logviewer shortcut + if (jobLogUrls.length && jobLogUrls[0].parse_status) { + logParseStatus = jobLogUrls[0].parse_status; + } + + // Provide a parse status for the model + const jobLogsAllParsed = (jobLogUrls ? + jobLogUrls.every(jlu => jlu.parse_status !== 'pending') : + false); + + const logViewerUrl = getLogViewerUrl(job.id, repoName); + const logViewerFullUrl = `${location.origin}/${logViewerUrl}`; + const reftestUrl = jobLogUrls.length ? + `${getReftestUrl(jobLogUrls[0].url)}&only_show_unexpected=1` : + ''; + const performanceData = Object.values(results[3]).reduce((a, b) => [...a, ...b], []); + + let perfJobDetail = []; + if (performanceData) { + const signatureIds = [...new Set(performanceData.map(perf => perf.signature_id))]; + const seriesListList = await Promise.all(chunk(signatureIds, 20).map( + signatureIdChunk => this.PhSeries.getSeriesList(repoName, { id: signatureIdChunk }) + )); + const seriesList = seriesListList.reduce((a, b) => [...a, ...b], []); + + perfJobDetail = performanceData.map(d => ({ + series: seriesList.find(s => d.signature_id === s.id), + ...d + })).filter(d => !d.series.parentSignature).map(d => ({ + url: `/perf.html#/graphs?series=${[repoName, d.signature_id, 1, d.series.frameworkId]}&selected=${[repoName, d.signature_id, job.result_set_id, d.id]}`, + value: d.value, + title: d.series.name + })); + } + + this.setState({ + job, + jobLogUrls, + jobDetails, + jobLogsAllParsed, + logParseStatus, + logViewerUrl, + logViewerFullUrl, + reftestUrl, + perfJobDetail, + jobRevision, + }, async () => { + await this.updateClassifications(job); + await this.loadBugSuggestions(job); + this.setState({ jobDetailLoading: false }); + }); + }).finally(() => { + this.selectJobController = null; + }); + } + + closeJob() { + this.$rootScope.selectedJob = null; + this.ThResultSetStore.setSelectedJob(); + this.$location.search('selectedJob', null); + if (this.selectJobController) { + this.selectJobController.abort(); + } + + this.setState({ isPinboardVisible: false }); + } + + toggleJobPin(job) { + const { pinnedJobs } = this.state; + + if (pinnedJobs.includes(job)) { + this.unPinJob(job); + } else { + this.pinJob(job); + } + if (!this.selectedJob) { + this.selectJob(job); + } + } + + pulsePinCount() { + $('.pin-count-group').addClass('pin-count-pulse'); + window.setTimeout(() => { + $('.pin-count-group').removeClass('pin-count-pulse'); + }, 700); + } + + pinJob(job) { + const { pinnedJobs } = this.state; + + if (thPinboardMaxSize - this.countPinnedJobs() > 0) { + this.setState({ + pinnedJobs: { ...pinnedJobs, [job.id]: job }, + isPinBoardVisible: true, + }); + this.pulsePinCount(); + } else { + this.thNotify.send(thPinboardCountError, 'danger'); + } + if (!this.state.selectedJob) { + this.selectJob(job); + } + } + + unPinJob(id) { + const { pinnedJobs } = this.state; + + delete pinnedJobs[id]; + this.setState({ pinnedJobs: { ...pinnedJobs } }); + } + + pinJobs(jobsToPin) { + const { pinnedJobs } = this.state; + const spaceRemaining = thPinboardMaxSize - this.countPinnedJobs(); + const showError = jobsToPin.length > spaceRemaining; + const newPinnedJobs = jobsToPin.slice(0, spaceRemaining).reduce((acc, job) => ({ ...acc, [job.id]: job }), {}); + + if (!spaceRemaining) { + this.thNotify.send(thPinboardCountError, 'danger', { sticky: true }); + this.$rootScope.$apply(); + return; + } + + this.setState({ + pinnedJobs: { ...pinnedJobs, ...newPinnedJobs }, + isPinBoardVisible: true, + }, () => { + if (!this.props.selectedJob) { + this.$rootScope.$emit(thEvents.jobClick, jobsToPin[0]); + } + if (showError) { + this.thNotify.send(thPinboardCountError, 'danger', { sticky: true }); + this.$rootScope.$apply(); + } + }); + } + + countPinnedJobs() { + return Object.keys(this.state.pinnedJobs).length; + } + + addBug(bug, job) { + const { pinnedJobBugs } = this.state; + + pinnedJobBugs[bug.id] = bug; + this.setState({ pinnedJobBugs: { ...pinnedJobBugs } }); + if (job) { + this.pinJob(job); + } + } + + removeBug(id) { + const { pinnedJobBugs } = this.state; + + delete pinnedJobBugs[id]; + this.setState({ pinnedJobBugs: { ...pinnedJobBugs } }); + } + + unPinAll() { + this.setState({ + pinnedJobs: {}, + pinnedJobBugs: {}, + }); + } + + render() { + const { + repoName, $injector, user, currentRepo, + } = this.props; + const { + job, isPinBoardVisible, jobDetails, jobRevision, jobLogUrls, jobDetailLoading, + perfJobDetail, suggestions, errors, bugSuggestionsLoading, logParseStatus, + classifications, logViewerUrl, logViewerFullUrl, pinnedJobs, pinnedJobBugs, bugs, reftestUrl, + } = this.state; + + return ( +
      +
      + + {!!job &&
      + + + this.togglePinBoardVisibility()} + logViewerFullUrl={logViewerFullUrl} + reftestUrl={reftestUrl} + user={user} + $injector={$injector} + /> +
      } +
      diff --git a/ui/plugins/similar_jobs/main.html b/ui/plugins/similar_jobs/main.html deleted file mode 100755 index 1ed079d8bf8..00000000000 --- a/ui/plugins/similar_jobs/main.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/ui/plugins/tabs.js b/ui/plugins/tabs.js deleted file mode 100644 index 6354760a7fb..00000000000 --- a/ui/plugins/tabs.js +++ /dev/null @@ -1,74 +0,0 @@ -import angular from 'angular'; - -import treeherder from '../js/treeherder'; - -treeherder.factory('thTabs', [ - function () { - const thTabs = { - tabs: { - jobDetails: { - title: "Job Details", - description: "additional job information", - content: "plugins/job_details/main.html", - enabled: true - }, - failureSummary: { - title: "Failure Summary", - description: "failure summary", - content: "plugins/failure_summary/main.html", - enabled: true - }, - autoClassification: { - title: "Failure Classification", - description: "intermittent classification interface", - content: "plugins/auto_classification/main.html", - enabled: false - }, - annotations: { - title: "Annotations", - description: "annotations", - content: "plugins/annotations/main.html", - enabled: true - }, - similarJobs: { - title: "Similar Jobs", - description: "similar jobs", - content: "plugins/similar_jobs/main.html", - enabled: true - }, - perfDetails: { - title: "Performance", - description: "performance details", - content: "plugins/perf_details/main.html", - enabled: false - } - }, - tabOrder: [ - "jobDetails", - "failureSummary", - "autoClassification", - "annotations", - "similarJobs", - "perfDetails" - ], - selectedTab: "jobDetails", - showTab: function (tab, contentId) { - thTabs.selectedTab = tab; - if (!thTabs.tabs[thTabs.selectedTab].enabled) { - thTabs.selectedTab = 'jobDetails'; - } - // if the tab exposes an update function, call it - // only refresh the tab if the content hasn't been loaded yet - // or we don't have an identifier for the content loaded - if (angular.isUndefined(thTabs.tabs[thTabs.selectedTab].contentId) || - thTabs.tabs[thTabs.selectedTab].contentId !== contentId) { - if (angular.isFunction(thTabs.tabs[thTabs.selectedTab].update)) { - thTabs.tabs[thTabs.selectedTab].contentId = contentId; - thTabs.tabs[thTabs.selectedTab].update(); - } - } - } - }; - return thTabs; - } -]); diff --git a/ui/vendor/resizer.js b/ui/vendor/resizer.js deleted file mode 100644 index 4f282bf1f86..00000000000 --- a/ui/vendor/resizer.js +++ /dev/null @@ -1,48 +0,0 @@ -import angular from 'angular'; - -/* From: http://stackoverflow.com/a/22253161/295132 (author: Mario Campa) */ -angular.module('mc.resizer', []).directive('resizer', ['$document', function($document) { - return function($scope, $element, $attrs) { - $element.on('mousedown', function(event) { - event.preventDefault(); - $document.on('mousemove', mousemove); - $document.on('mouseup', mouseup); - }); - function mousemove(event) { - if ($attrs.resizer == 'vertical') { - // Handle vertical resizer - var x = event.pageX; - if ($attrs.resizerMax && x > $attrs.resizerMax) { - x = parseInt($attrs.resizerMax); - } - $element.css({ - left: x + 'px' - }); - $($attrs.resizerLeft).css({ - width: x + 'px' - }); - $($attrs.resizerRight).css({ - left: (x + parseInt($attrs.resizerWidth)) + 'px' - }); - } else { - // Handle horizontal resizer - var y = window.innerHeight - event.pageY; - $element.css({ - bottom: y + 'px' - }); - $($attrs.resizerTop).css({ - bottom: (y + parseInt($attrs.resizerHeight)) + 'px' - }); - $($attrs.resizerBottom).css({ - height: y + 'px' - }); - } - } - function mouseup() { - $document.unbind('mousemove', mousemove); - $document.unbind('mouseup', mouseup); - } - }; -}]); - -export default 'mc.resizer'; diff --git a/yarn.lock b/yarn.lock index 9539e4b649d..022fcaabcd9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1577,7 +1577,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5: +classnames@^2.2.0, classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -5963,7 +5963,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@15.6.1, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1: +prop-types@15.6.1, prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1: version "15.6.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca" dependencies: @@ -6242,6 +6242,13 @@ react-table@6.8.6: dependencies: classnames "^2.2.5" +react-tabs@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-2.2.2.tgz#2f2935da379889484751d1df47c1b639e5ee835d" + dependencies: + classnames "^2.2.0" + prop-types "^15.5.0" + react-test-renderer@^16.0.0-0: version "16.4.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.4.1.tgz#f2fb30c2c7b517db6e5b10ed20bb6b0a7ccd8d70"