diff --git a/neutrino-custom/lint.js b/neutrino-custom/lint.js index 11efd1efa..c9e6b1fde 100644 --- a/neutrino-custom/lint.js +++ b/neutrino-custom/lint.js @@ -16,7 +16,7 @@ module.exports = neutrino => { .loader('eslint', props => merge(props, { options: { plugins: ['react'], - envs: ['browser', 'es6', 'commonjs'], + envs: ['browser', 'es6', 'commonjs', 'jasmine'], baseConfig: { extends: ['airbnb'] }, diff --git a/tests/jenkins/pages/treeherder.py b/tests/jenkins/pages/treeherder.py index 383c22a0f..75b3c7701 100644 --- a/tests/jenkins/pages/treeherder.py +++ b/tests/jenkins/pages/treeherder.py @@ -16,7 +16,7 @@ class TreeherderPage(Page): _get_next_20_locator = (By.CSS_SELECTOR, 'div.btn:nth-child(2)') _get_next_50_locator = (By.CSS_SELECTOR, 'div.btn:nth-child(3)') _quick_filter_locator = (By.ID, 'quick-filter') - _result_sets_locator = (By.CSS_SELECTOR, '.result-set:not(.row)') + _pushes_locator = (By.CSS_SELECTOR, '.push:not(.row)') _unchecked_repos_links_locator = (By.CSS_SELECTOR, '#repoLabel + .dropdown-menu .dropdown-checkbox:not([checked]) + .dropdown-link') _unclassified_failure_count_locator = (By.ID, 'unclassified-failure-count') _unclassified_failure_filter_locator = (By.CSS_SELECTOR, '.btn-unclassified-failures') @@ -31,12 +31,12 @@ class TreeherderPage(Page): @property def all_emails(self): return list(itertools.chain.from_iterable( - r.emails for r in self.result_sets)) + r.emails for r in self.pushes)) @property def all_jobs(self): return list(itertools.chain.from_iterable( - r.jobs for r in self.result_sets)) + r.jobs for r in self.pushes)) @property def info_panel(self): @@ -52,8 +52,8 @@ class TreeherderPage(Page): return random_email_name.get_name @property - def result_sets(self): - return [self.ResultSet(self, el) for el in self.find_elements(*self._result_sets_locator)] + def pushes(self): + return [self.ResultSet(self, el) for el in self.find_elements(*self._pushes_locator)] @property def search_term(self): @@ -92,7 +92,7 @@ class TreeherderPage(Page): el = self.selenium.find_element(*self._quick_filter_locator) el.send_keys(term) el.send_keys(Keys.RETURN) - self.wait.until(lambda s: self.result_sets) + self.wait.until(lambda s: self.pushes) elif method == 'keyboard': self.find_element(By.CSS_SELECTOR, 'body').send_keys( 'f' + term + Keys.RETURN) @@ -103,10 +103,10 @@ class TreeherderPage(Page): self.find_element(*self._unclassified_failure_filter_locator).click() def _get_next(self, count): - before = len(self.result_sets) + before = len(self.pushes) locator = getattr(self, '_get_next_{}_locator'.format(count)) self.find_element(*locator).click() - self.wait.until(lambda s: len(self.result_sets) == before + count) + self.wait.until(lambda s: len(self.pushes) == before + count) def get_next_10(self): self._get_next(10) @@ -145,8 +145,8 @@ class TreeherderPage(Page): class ResultSet(Region): _busted_jobs_locator = (By.CSS_SELECTOR, '.job-btn.filter-shown.btn-red') - _datestamp_locator = (By.CSS_SELECTOR, '.result-set-title-left > span a') - _email_locator = (By.CSS_SELECTOR, '.result-set-title-left > th-author > span > a') + _datestamp_locator = (By.CSS_SELECTOR, '.push-title-left > span a') + _email_locator = (By.CSS_SELECTOR, '.push-title-left > .push-author > span > a') _exception_jobs_locator = (By.CSS_SELECTOR, '.job-btn.filter-shown.btn-purple') _job_groups_locator = (By.CSS_SELECTOR, '.job-group') _jobs_locator = (By.CSS_SELECTOR, '.job-btn.filter-shown') diff --git a/tests/jenkins/tests/test_expanding_group_count.py b/tests/jenkins/tests/test_expanding_group_count.py index fe0ab66d9..58f158ea4 100644 --- a/tests/jenkins/tests/test_expanding_group_count.py +++ b/tests/jenkins/tests/test_expanding_group_count.py @@ -9,7 +9,7 @@ from pages.treeherder import TreeherderPage def test_expanding_group_count(base_url, selenium): page = TreeherderPage(selenium, base_url).open() all_groups = list(itertools.chain.from_iterable( - r.job_groups for r in page.result_sets)) + r.job_groups for r in page.pushes)) group = next(g for g in all_groups if not g.expanded) jobs = group.jobs group.expand() diff --git a/tests/jenkins/tests/test_filter_job_by_email.py b/tests/jenkins/tests/test_filter_job_by_email.py index bc5db3d29..ed5e8bfe2 100644 --- a/tests/jenkins/tests/test_filter_job_by_email.py +++ b/tests/jenkins/tests/test_filter_job_by_email.py @@ -10,7 +10,7 @@ def test_filter_by_email(base_url, selenium): page = TreeherderPage(selenium, base_url).open() page.select_random_email() - filtered_emails_name = page.result_sets[0].email_name + filtered_emails_name = page.pushes[0].email_name random_email_name = page.random_email_name assert filtered_emails_name == random_email_name diff --git a/tests/jenkins/tests/test_filter_jobs.py b/tests/jenkins/tests/test_filter_jobs.py index ec90020c7..23ed1369e 100644 --- a/tests/jenkins/tests/test_filter_jobs.py +++ b/tests/jenkins/tests/test_filter_jobs.py @@ -7,9 +7,9 @@ from pages.treeherder import TreeherderPage def test_filter_jobs(base_url, selenium): """Open resultset page and filter for platform""" page = TreeherderPage(selenium, base_url).open() - assert any(r.contains_platform('linux') for r in page.result_sets) - assert any(r.contains_platform('windows') for r in page.result_sets) + assert any(r.contains_platform('linux') for r in page.pushes) + assert any(r.contains_platform('windows') for r in page.pushes) page.filter_by('linux') - assert any(r.contains_platform('linux') for r in page.result_sets) - assert not any(r.contains_platform('windows') for r in page.result_sets) + assert any(r.contains_platform('linux') for r in page.pushes) + assert not any(r.contains_platform('windows') for r in page.pushes) diff --git a/tests/jenkins/tests/test_get_next_results.py b/tests/jenkins/tests/test_get_next_results.py index a350d2544..6b5795d1c 100644 --- a/tests/jenkins/tests/test_get_next_results.py +++ b/tests/jenkins/tests/test_get_next_results.py @@ -7,6 +7,6 @@ from pages.treeherder import TreeherderPage @pytest.mark.parametrize('count', ((10), (20), (50))) def test_get_next_results(base_url, selenium, count): page = TreeherderPage(selenium, base_url).open() - assert len(page.result_sets) == 10 + assert len(page.pushes) == 10 getattr(page, 'get_next_{}'.format(count))() - assert len(page.result_sets) == 10 + count + assert len(page.pushes) == 10 + count diff --git a/tests/jenkins/tests/test_load_page_results.py b/tests/jenkins/tests/test_load_page_results.py index 9b58f2dba..17cc98140 100644 --- a/tests/jenkins/tests/test_load_page_results.py +++ b/tests/jenkins/tests/test_load_page_results.py @@ -6,14 +6,14 @@ from pages.treeherder import TreeherderPage @pytest.mark.nondestructive def test_load_next_results(base_url, selenium): page = TreeherderPage(selenium, base_url).open() - assert len(page.result_sets) == 10 + assert len(page.pushes) == 10 page.get_next_10() - assert len(page.result_sets) == 20 + assert len(page.pushes) == 20 page.get_next_20() page.wait_for_page_to_load() - assert len(page.result_sets) == 40 + assert len(page.pushes) == 40 page.get_next_50() - assert len(page.result_sets) == 90 + assert len(page.pushes) == 90 diff --git a/tests/jenkins/tests/test_pin_job.py b/tests/jenkins/tests/test_pin_job.py index 735d78f19..4155133ef 100644 --- a/tests/jenkins/tests/test_pin_job.py +++ b/tests/jenkins/tests/test_pin_job.py @@ -39,6 +39,6 @@ def test_clear_pinboard(base_url, selenium): def test_pin_all_jobs(base_url, selenium): """Open treeherder page, pin all jobs, confirm no more than 500 pins in pinboard""" page = TreeherderPage(selenium, base_url).open() - result_set = next(r for r in page.result_sets if len(r.jobs) > 1) + result_set = next(r for r in page.pushes if len(r.jobs) > 1) result_set.pin_all_jobs() assert len(result_set.jobs) <= len(page.pinboard.jobs) <= 500 diff --git a/tests/selenium/pages/treeherder.py b/tests/selenium/pages/treeherder.py index 8d77640e5..ca2b5f187 100644 --- a/tests/selenium/pages/treeherder.py +++ b/tests/selenium/pages/treeherder.py @@ -24,7 +24,7 @@ class Treeherder(Base): _quick_filter_locator = (By.ID, 'quick-filter') _repo_locator = (By.CSS_SELECTOR, '#repo-dropdown a[href*="repo={}"]') _repo_menu_locator = (By.ID, 'repoLabel') - _result_sets_locator = (By.CSS_SELECTOR, '.result-set:not(.row)') + _pushes_locator = (By.CSS_SELECTOR, '.push:not(.row)') _watched_repos_locator = (By.CSS_SELECTOR, '#watched-repo-navbar th-watched-repo') def wait_for_page_to_load(self): @@ -38,7 +38,7 @@ class Treeherder(Base): @property def all_jobs(self): return list(itertools.chain.from_iterable( - r.jobs for r in self.result_sets)) + r.jobs for r in self.pushes)) @contextmanager def filters_menu(self): @@ -52,8 +52,8 @@ class Treeherder(Base): return self.InfoPanel(self) @property - def result_sets(self): - return [self.ResultSet(self, el) for el in self.find_elements(*self._result_sets_locator)] + def pushes(self): + return [self.ResultSet(self, el) for el in self.find_elements(*self._pushes_locator)] @property def quick_filter_term(self): @@ -130,13 +130,13 @@ class Treeherder(Base): class ResultSet(Region): - _author_locator = (By.CSS_SELECTOR, '.result-set-title-left th-author a') - _datestamp_locator = (By.CSS_SELECTOR, '.result-set-title-left > span a') + _author_locator = (By.CSS_SELECTOR, '.push-title-left .push-author a') + _datestamp_locator = (By.CSS_SELECTOR, '.push-title-left > span a') _dropdown_toggle_locator = (By.CLASS_NAME, 'dropdown-toggle') _commits_locator = (By.CSS_SELECTOR, '.revision-list .revision') _jobs_locator = (By.CSS_SELECTOR, '.job-btn.filter-shown') - _set_bottom_of_range_locator = (By.CSS_SELECTOR, 'ul.dropdown-menu > li:nth-child(9) > a') - _set_top_of_range_locator = (By.CSS_SELECTOR, 'ul.dropdown-menu > li:nth-child(8) > a') + _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)') @property def author(self): diff --git a/tests/selenium/test_range.py b/tests/selenium/test_range.py index 990ebde0d..60152b933 100644 --- a/tests/selenium/test_range.py +++ b/tests/selenium/test_range.py @@ -3,17 +3,17 @@ from pages.treeherder import Treeherder def test_set_as_top_of_range(base_url, selenium, test_job): page = Treeherder(selenium, base_url).open() - result_sets = page.result_sets - datestamp = result_sets[1].datestamp - assert result_sets[0].datestamp != datestamp - result_sets[1].set_as_top_of_range() - assert page.result_sets[0].datestamp == datestamp + pushes = page.pushes + datestamp = pushes[1].datestamp + assert pushes[0].datestamp != datestamp + pushes[1].set_as_top_of_range() + assert page.pushes[0].datestamp == datestamp def test_set_as_bottom_of_range(base_url, selenium, test_job): page = Treeherder(selenium, base_url).open() - result_sets = page.result_sets - datestamp = result_sets[-2].datestamp - assert result_sets[-1].datestamp != datestamp - page.result_sets[-2].set_as_bottom_of_range() - assert page.result_sets[-1].datestamp == datestamp + pushes = page.pushes + datestamp = pushes[-2].datestamp + assert pushes[-1].datestamp != datestamp + page.pushes[-2].set_as_bottom_of_range() + assert page.pushes[-1].datestamp == datestamp diff --git a/tests/selenium/test_view_single_result.py b/tests/selenium/test_view_single_result.py index 2475a11cf..11ce8aac3 100644 --- a/tests/selenium/test_view_single_result.py +++ b/tests/selenium/test_view_single_result.py @@ -3,11 +3,11 @@ from pages.treeherder import Treeherder def test_open_single_result(base_url, selenium, test_commit): page = Treeherder(selenium, base_url).open() - page.wait.until(lambda _: 1 == len(page.result_sets)) - page.result_sets[0].view() - assert 1 == len(page.result_sets) - assert test_commit.author == page.result_sets[0].author - assert test_commit.push.time.strftime('%a %b %-d, %H:%M:%S') == page.result_sets[0].datestamp - assert 1 == len(page.result_sets[0].commits) - assert test_commit.revision[:12] == page.result_sets[0].commits[0].revision - assert test_commit.comments == page.result_sets[0].commits[0].comment + page.wait.until(lambda _: 1 == len(page.pushes)) + page.pushes[0].view() + assert 1 == len(page.pushes) + assert test_commit.author == page.pushes[0].author + assert test_commit.push.time.strftime('%a, %b %-d, %H:%M:%S') == page.pushes[0].datestamp + assert 1 == len(page.pushes[0].commits) + assert test_commit.revision[:12] == page.pushes[0].commits[0].revision + assert test_commit.comments == page.pushes[0].commits[0].comment diff --git a/tests/ui/mock/resultset_list.json b/tests/ui/mock/push_list.json similarity index 100% rename from tests/ui/mock/resultset_list.json rename to tests/ui/mock/push_list.json diff --git a/tests/ui/unit/controllers/jobs.tests.js b/tests/ui/unit/controllers/jobs.tests.js deleted file mode 100644 index 0f30f64fa..000000000 --- a/tests/ui/unit/controllers/jobs.tests.js +++ /dev/null @@ -1,129 +0,0 @@ -/* jasmine specs for controllers go here */ - -describe('JobsCtrl', function(){ - var $httpBackend, controller, jobsScope; - - beforeEach(angular.mock.module('treeherder.app')); - - beforeEach(inject(function ($injector, $rootScope, $controller) { - var activeRepo = 'mozilla-central'; - var projectPrefix = '/api/project/' + activeRepo + '/'; - - $httpBackend = $injector.get('$httpBackend'); - jasmine.getJSONFixtures().fixturesPath='base/tests/ui/mock'; - - $httpBackend.whenGET('/api/repository/').respond( - getJSONFixture('repositories.json') - ); - - $httpBackend.whenGET(projectPrefix + 'resultset/?count=10&full=true').respond( - getJSONFixture('resultset_list.json') - ); - - $httpBackend.whenGET(projectPrefix + 'jobs/?count=2000&result_set_id=1&return_type=list').respond( - getJSONFixture('job_list/job_1.json') - ); - - $httpBackend.whenGET(projectPrefix + 'jobs/?count=2000&result_set_id=2&return_type=list').respond( - getJSONFixture('job_list/job_2.json') - ); - - $httpBackend.whenGET('https://treestatus.mozilla-releng.net/trees/mozilla-central').respond( - { - "result": { - "status": "closed", - "message_of_the_day": "See the Inbound tree rules before pushing. Sheriff notes/current issues.", - "tree": "mozilla-central", - "reason": "Bustage" - } - } - ); - - $httpBackend.whenGET('/api/project/mozilla-central/jobs/0/unclassified_failure_count/').respond( - { - "unclassified_failure_count": 1152, - "repository": "mozilla-central" - } - ); - - $httpBackend.whenGET('/api/jobtype/').respond( - getJSONFixture('job_type_list.json') - ); - - $httpBackend.whenGET('/api/jobgroup/').respond( - getJSONFixture('job_group_list.json') - ); - - jobsScope = $rootScope.$new(); - jobsScope.repoName = activeRepo; - jobsScope.setRepoPanelShowing = function(tf) { - // no op in the tests. - }; - $controller('JobsCtrl', {'$scope': jobsScope}); - })); - - /* - Tests JobsCtrl - */ - it('should have 2 resultsets', function() { - $httpBackend.flush(); - expect(jobsScope.result_sets).toBeDefined(); - expect(jobsScope.result_sets.length).toBe(2); - }); - - it('should have a job_map', function(){ - $httpBackend.flush(); - expect(jobsScope.job_map).toBeDefined(); - }); - - it('should know when it\'s loading the initial data', function() { - expect(jobsScope.isLoadingRsBatch).toEqual( - {appending: true, prepending: false } - ); - $httpBackend.flush(); - expect(jobsScope.isLoadingRsBatch).toEqual( - {appending: false, prepending: false } - ); - }); - - it('should have 2 platforms in resultset 1', function() { - // because some http requests are deferred to after first - // ingestion, we need to flush twice - $httpBackend.flush(); - $httpBackend.flush(); - expect(jobsScope.result_sets[0].platforms.length).toBe(2); - }); - - /* - Tests ResultSetCtrl - */ - describe('ResultSetCtrl', function(){ - var resultSetScopeList; - - beforeEach(inject(function ($rootScope, $controller) { - /* - * ResultSetCtrl is created insided a ng-repeat - * so we should have a list of resultSetscope - */ - // because some http requests are deferred to after first - // ingestion, we need to flush twice - $httpBackend.flush(); - $httpBackend.flush(); - resultSetScopeList = []; - for(var i=0; i { + let $injector, $httpBackend, pushListEl, pushList; + const repoName = 'mozilla-central'; + + beforeEach(angular.mock.module('treeherder')); + beforeEach(inject((_$injector_) => { + const projectPrefix = '/api/project/' + repoName + '/'; + $injector = _$injector_; + + // TODO: Once we switch away from angular for fetching data, we may want to + // switch to using something like this for mocking test data: http://www.wheresrhys.co.uk/fetch-mock/ + + $httpBackend = $injector.get('$httpBackend'); + const ThResultSetStore = $injector.get('ThResultSetStore'); + + jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock'; + + $httpBackend.whenGET('/api/repository/').respond( + getJSONFixture('repositories.json') + ); + + $httpBackend.whenGET(projectPrefix + 'resultset/?count=10&full=true').respond( + getJSONFixture('push_list.json') + ); + + $httpBackend.whenGET(projectPrefix + 'resultset/?count=11&full=true&push_timestamp__lte=1424272126').respond( + getJSONFixture('push_list.json') + ); + + $httpBackend.whenGET(projectPrefix + 'jobs/?count=2000&result_set_id=1&return_type=list').respond( + getJSONFixture('job_list/job_1.json') + ); + + $httpBackend.whenGET(projectPrefix + 'jobs/?count=2000&result_set_id=2&return_type=list').respond( + getJSONFixture('job_list/job_2.json') + ); + + $httpBackend.whenGET('https://treestatus.mozilla-releng.net/trees/mozilla-central').respond( + { + result: { + status: "closed", + message_of_the_day: "This is a message nstuff...", + tree: "mozilla-central", + reason: "Bustage" + } + } + ); + + ThResultSetStore.addRepository(repoName); + ThResultSetStore.fetchResultSets(repoName, 10); + $httpBackend.flush(); + pushList = ThResultSetStore.getResultSetsArray(repoName); + })); + + /* + Tests Jobs view + */ + it('should have 2 Pushes', () => { + pushListEl = mount( + + ); + $httpBackend.flush(); + pushListEl.setState({ pushList }); + expect(pushListEl.find(Push).length).toEqual(2); + }); +}); + +describe('PushJobs component', () => { + let $injector, pushJobsEl, pushList, ThResultSetStore; + const repoName = 'mozilla-central'; + const projectPrefix = '/api/project/' + repoName + '/'; + + beforeEach(angular.mock.module('treeherder')); + beforeEach(inject((_$injector_) => { + $injector = _$injector_; + jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock'; + const $httpBackend = $injector.get('$httpBackend'); + ThResultSetStore = $injector.get('ThResultSetStore'); + + jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock'; + + $httpBackend.whenGET('/api/repository/').respond( + getJSONFixture('repositories.json') + ); + $httpBackend.whenGET(projectPrefix + 'resultset/?count=10&full=true').respond( + getJSONFixture('push_list.json') + ); + $httpBackend.whenGET(projectPrefix + 'jobs/?count=2000&result_set_id=1&return_type=list').respond( + getJSONFixture('job_list/job_1.json') + ); + $httpBackend.whenGET(projectPrefix + 'jobs/?count=2000&result_set_id=2&return_type=list').respond( + getJSONFixture('job_list/job_2.json') + ); + $httpBackend.whenGET('https://treestatus.mozilla-releng.net/trees/mozilla-central').respond( + { + result: { + status: "closed", + message_of_the_day: "This is a message nstuff...", + tree: "mozilla-central", + reason: "Bustage" + } + } + ); + + ThResultSetStore.addRepository(repoName); + ThResultSetStore.fetchResultSets(repoName, 10); + + $httpBackend.flush(); + pushList = ThResultSetStore.getResultSetsArray(repoName); + pushJobsEl = mount( + + ); + + })); + + it('should have platforms', () => { + expect(pushJobsEl.find(Platform).length).toEqual(2); + }); + + it('should set the selected job when calling selectJob()', () => { + const jobEl = pushJobsEl.find(JobButton).first(); + const jobInst = jobEl.instance(); + jobInst.ThResultSetStore = ThResultSetStore; + jobEl.simulate('mouseDown'); + expect(jobInst.state.isSelected).toEqual(true); + }); + +}); diff --git a/tests/ui/unit/react/revisions.tests.jsx b/tests/ui/unit/react/revisions.tests.jsx index 5e8d3fac2..c9415c9fc 100644 --- a/tests/ui/unit/react/revisions.tests.jsx +++ b/tests/ui/unit/react/revisions.tests.jsx @@ -1,58 +1,61 @@ import { mount } from 'enzyme'; import { Revision, Initials } from '../../../../ui/job-view/Revision'; -import { RevisionList, MoreRevisionsLink } from '../../../../ui/job-view/RevisionList'; +import { + RevisionList, + MoreRevisionsLink +} from '../../../../ui/job-view/RevisionList'; describe('Revision list component', () => { - let $injector, mockData; - beforeEach(angular.mock.module('treeherder')); - beforeEach(inject((_$injector_) => { - $injector = _$injector_; + let $injector, mockData; + beforeEach(angular.mock.module('treeherder')); + beforeEach(inject((_$injector_) => { + $injector = _$injector_; - const repo = { - "id": 2, - "repository_group": { - "name": "development", - "description": "" - }, - "name": "mozilla-inbound", - "dvcs_type": "hg", - "url": "https://hg.mozilla.org/integration/mozilla-inbound", - "branch": null, - "codebase": "gecko", - "description": "", - "active_status": "active", - "performance_alerts_enabled": true, - "pushlogURL": "https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml" - }; - // Mock these simple functions so we don't have to call ThRepositoryModel.load() first to use them - repo.getRevisionHref = () => `${repo.url}/rev/${push.revision}`; - repo.getPushLogHref = (revision) => `${repo.pushlogURL}?changeset=${revision}`; + const repo = { + id: 2, + repository_group: { + name: "development", + description: "" + }, + name: "mozilla-inbound", + dvcs_type: "hg", + url: "https://hg.mozilla.org/integration/mozilla-inbound", + branch: null, + codebase: "gecko", + description: "", + active_status: "active", + performance_alerts_enabled: true, + pushlogURL: "https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml" + }; + // Mock these simple functions so we don't have to call ThRepositoryModel.load() first to use them + repo.getRevisionHref = () => `${repo.url}/rev/${push.revision}`; + repo.getPushLogHref = revision => `${repo.pushlogURL}?changeset=${revision}`; const push = { - "id": 151371, - "revision": "5a110ad242ead60e71d2186bae78b1fb766ad5ff", - "revision_count": 3, - "author": "ryanvm@gmail.com", - "push_timestamp": 1481326280, - "repository_id": 2, - "revisions": [{ - "result_set_id": 151371, - "repository_id": 2, - "revision": "5a110ad242ead60e71d2186bae78b1fb766ad5ff", - "author": "André Bargull ", - "comments": "Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem" + id: 151371, + revision: "5a110ad242ead60e71d2186bae78b1fb766ad5ff", + revision_count: 3, + author: "ryanvm@gmail.com", + push_timestamp: 1481326280, + repository_id: 2, + revisions: [{ + result_set_id: 151371, + repository_id: 2, + revision: "5a110ad242ead60e71d2186bae78b1fb766ad5ff", + author: "André Bargull ", + comments: "Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem" }, { - "result_set_id": 151371, - "repository_id": 2, - "revision": "07d6bf74b7a2552da91b5e2fce0fa0bc3b457394", - "author": "André Bargull ", - "comments": "Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem" + result_set_id: 151371, + repository_id: 2, + revision: "07d6bf74b7a2552da91b5e2fce0fa0bc3b457394", + author: "André Bargull ", + comments: "Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem" }, { - "result_set_id": 151371, - "repository_id": 2, - "revision": "e83eaf2380c65400dc03c6f3615d4b2cef669af3", - "author": "Frédéric Wang ", - "comments": "Bug 1322743 - Add STIX Two Math to the list of math fonts. r=karlt" + result_set_id: 151371, + repository_id: 2, + revision: "e83eaf2380c65400dc03c6f3615d4b2cef669af3", + author: "Frédéric Wang ", + comments: "Bug 1322743 - Add STIX Two Math to the list of math fonts. r=karlt" }] }; mockData = { @@ -61,142 +64,168 @@ describe('Revision list component', () => { }; })); - it('renders the correct number of revisions in a list', () => { - const wrapper = mount(); - expect(wrapper.find(Revision).length).toEqual(mockData['push']['revision_count']); - }); + it('renders the correct number of revisions in a list', () => { + const wrapper = mount( + + ); + expect(wrapper.find(Revision).length).toEqual(mockData.push.revision_count); + }); - it('renders an "...and more" link if the revision count is higher than the max display count of 20', () => { - mockData.push.revision_count = 21; + it('renders an "...and more" link if the revision count is higher than the max display count of 20', () => { + mockData.push.revision_count = 21; - const wrapper = mount(); - expect(wrapper.find(MoreRevisionsLink).length).toEqual(1); - }); + const wrapper = mount( + + ); + expect(wrapper.find(MoreRevisionsLink).length).toEqual(1); + }); }); describe('Revision item component', () => { - let $injector, linkifyBugsFilter, mockData; - beforeEach(angular.mock.module('treeherder')); - beforeEach(inject((_$injector_, $filter) => { - $injector = _$injector_; - linkifyBugsFilter = $filter('linkifyBugs'); + let linkifyBugsFilter, mockData; + beforeEach(angular.mock.module('treeherder')); + beforeEach(inject((_$injector_, $filter) => { + linkifyBugsFilter = $filter('linkifyBugs'); - const repo = { - "id": 2, - "repository_group": { - "name": "development", - "description": "" - }, - "name": "mozilla-inbound", - "dvcs_type": "hg", - "url": "https://hg.mozilla.org/integration/mozilla-inbound", - "branch": null, - "codebase": "gecko", - "description": "", - "active_status": "active", - "performance_alerts_enabled": true, - "pushlogURL": "https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml" - }; - const revision = { - "result_set_id": 151371, - "repository_id": 2, - "revision": "5a110ad242ead60e71d2186bae78b1fb766ad5ff", - "author": "André Bargull ", - "comments": "Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem" - }; - // Mock these simple functions so we don't have to call ThRepositoryModel.load() first to use them - repo.getRevisionHref = () => `${repo.url}/rev/${revision.revision}`; - repo.getPushLogHref = (revision) => `${repo.pushlogURL}?changeset=${revision}`; + const repo = { + id: 2, + repository_group: { + name: "development", + description: "" + }, + name: "mozilla-inbound", + dvcs_type: "hg", + url: "https://hg.mozilla.org/integration/mozilla-inbound", + branch: null, + codebase: "gecko", + description: "", + active_status: "active", + performance_alerts_enabled: true, + pushlogURL: "https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml" + }; + const revision = { + result_set_id: 151371, + repository_id: 2, + revision: "5a110ad242ead60e71d2186bae78b1fb766ad5ff", + author: "André Bargull ", + comments: "Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem" + }; + // Mock these simple functions so we don't have to call ThRepositoryModel.load() first to use them + repo.getRevisionHref = () => `${repo.url}/rev/${revision.revision}`; + repo.getPushLogHref = revision => `${repo.pushlogURL}?changeset=${revision}`; - mockData = { - revision, - repo - }; - })); + mockData = { + revision, + repo + }; + })); - it('renders a linked revision', () => { - const wrapper = mount(); - const link = wrapper.find('a'); - expect(link.length).toEqual(1); - expect(link.props().href).toEqual(mockData.repo.getRevisionHref()); - expect(link.props().title).toEqual(`Open revision ${mockData.revision.revision} on ${mockData.repo.url}`); - }); + it('renders a linked revision', () => { + const wrapper = mount( + ); + const link = wrapper.find('a'); + expect(link.length).toEqual(1); + expect(link.props().href).toEqual(mockData.repo.getRevisionHref()); + expect(link.props().title).toEqual(`Open revision ${mockData.revision.revision} on ${mockData.repo.url}`); + }); - it(`renders the contributors' initials`, () => { - const wrapper = mount(); - const initials = wrapper.find('.user-push-initials'); - expect(initials.length).toEqual(1); - expect(initials.text()).toEqual('AB'); - }); + it(`renders the contributors' initials`, () => { + const wrapper = mount( + ); + const initials = wrapper.find('.user-push-initials'); + expect(initials.length).toEqual(1); + expect(initials.text()).toEqual('AB'); + }); - it('linkifies bug IDs in the comments', () => { - const wrapper = mount(); - const escapedComment = _.escape(mockData.revision.comments.split('\n')[0]); - const linkifiedCommentText = linkifyBugsFilter(escapedComment); + it('linkifies bug IDs in the comments', () => { + const wrapper = mount( + ); + const escapedComment = _.escape(mockData.revision.comments.split('\n')[0]); + const linkifiedCommentText = linkifyBugsFilter(escapedComment); - const comment = wrapper.find('.revision-comment em'); - expect(comment.html()).toEqual(`${linkifiedCommentText}`); - }); + const comment = wrapper.find('.revision-comment em'); + expect(comment.html()).toEqual(`${linkifiedCommentText}`); + }); - it('marks the revision as backed out if the words "Back/Backed out" appear in the comments', () => { - mockData.revision.comments = "Backed out changeset a6e2d96c1274 (bug 1322565) for eslint failure"; - let wrapper = mount(); - expect(wrapper.find({'data-tags': 'backout'}).length).toEqual(1); + it('marks the revision as backed out if the words "Back/Backed out" appear in the comments', () => { + mockData.revision.comments = "Backed out changeset a6e2d96c1274 (bug 1322565) for eslint failure"; + let wrapper = mount( + ); + expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1); - mockData.revision.comments = "Back out changeset a6e2d96c1274 (bug 1322565) for eslint failure"; - wrapper = mount(); - expect(wrapper.find({'data-tags': 'backout'}).length).toEqual(1); - }); + mockData.revision.comments = "Back out changeset a6e2d96c1274 (bug 1322565) for eslint failure"; + wrapper = mount( + ); + expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1); + }); }); describe('More revisions link component', () => { - it('renders an "...and more" link', () => { - const wrapper = mount(); - const link = wrapper.find('a'); - expect(link.props().href).toEqual('http://more.link/'); - expect(link.text()).toEqual('\u2026and more'); - }); + it('renders an "...and more" link', () => { + const wrapper = mount(); + const link = wrapper.find('a'); + expect(link.props().href).toEqual('http://more.link/'); + expect(link.text()).toEqual('\u2026and more'); + }); - it('has an external link icon', () => { - const wrapper = mount(); - expect(wrapper.find('i.fa.fa-external-link-square').length).toEqual(1); - }); + it('has an external link icon', () => { + const wrapper = mount(); + expect(wrapper.find('i.fa.fa-external-link-square').length).toEqual(1); + }); }); -describe('initials filter', function() { - const email = "foo@bar.baz"; - it('initializes a one-word name', function() { - const name = 'Starscream'; - const initials = mount(); - expect(initials.html()).toEqual('
S
'); - }); +describe('initials filter', function () { + const email = "foo@bar.baz"; + it('initializes a one-word name', function () { + const name = 'Starscream'; + const initials = mount(); + expect(initials.html()).toEqual('
S
'); + }); - it('initializes a two-word name', function() { - const name = 'Optimus Prime'; - const initials = mount(); - const userPushInitials = initials.find('.user-push-initials'); - expect(userPushInitials.html()).toEqual('
OP
'); - }); + it('initializes a two-word name', function () { + const name = 'Optimus Prime'; + const initials = mount(); + const userPushInitials = initials.find('.user-push-initials'); + expect(userPushInitials.html()).toEqual('
OP
'); + }); - it('initializes a three-word name', function() { - const name = 'Some Other Transformer'; - const initials = mount(); - const userPushInitials = initials.find('.user-push-initials'); - expect(userPushInitials.html()).toEqual('
ST
'); - }); + it('initializes a three-word name', function () { + const name = 'Some Other Transformer'; + const initials = mount(); + const userPushInitials = initials.find('.user-push-initials'); + expect(userPushInitials.html()).toEqual('
ST
'); + }); }); diff --git a/ui/css/treeherder-global.css b/ui/css/treeherder-global.css index 27df2bdce..2750fd8ec 100755 --- a/ui/css/treeherder-global.css +++ b/ui/css/treeherder-global.css @@ -85,6 +85,10 @@ input[type="checkbox"] { cursor: not-allowed; } +.dropdown-item { + cursor: pointer; +} + /* For cases where the base Bootstrap 4 equivalent doesn't work, due to our special uses.*/ .nav-dropdown-menu-right { @@ -241,7 +245,7 @@ input[type="checkbox"] { /* * Buttons * - * Currently used in resultsets only but could be + * Currently used in pushes only but could be * potentially used elsewhere. */ diff --git a/ui/css/treeherder-job-buttons.css b/ui/css/treeherder-job-buttons.css index 245dd200a..9a63e09ac 100644 --- a/ui/css/treeherder-job-buttons.css +++ b/ui/css/treeherder-job-buttons.css @@ -32,10 +32,6 @@ background-color: rgba(208, 228, 250, 0.51); } -.group-btn.btn.job-group-count.selected-count:hover { - background-color: rgb(208, 228, 250); -} - .group-symbol { background: transparent; padding: 0 2px 0 2px; @@ -100,10 +96,6 @@ background-color: #fff; } -.selected-count.btn-lg-xform { - background-color: #fff; -} - .btn-lg-xform { margin: -2px !important; border: 2px solid; diff --git a/ui/css/treeherder-resultsets.css b/ui/css/treeherder-resultsets.css index b292c8b0c..3cb3af639 100644 --- a/ui/css/treeherder-resultsets.css +++ b/ui/css/treeherder-resultsets.css @@ -2,7 +2,7 @@ * Resultset bar */ -.result-set-bar { +.push-bar { border-top: 1px solid black; padding: 2px 0 0 34px; white-space: nowrap; @@ -12,31 +12,31 @@ justify-content: space-between; } -.result-set-left { +.push-left { display: flex; flex-flow: row wrap; flex: auto; align-items: center; } -.result-set-title-left { +.push-title-left { flex: 0 0 24.2em; overflow: hidden; text-overflow: ellipsis; padding-right: 10px; } -.result-set-body-divider { +.push-body-divider { margin-left: 34px; border-bottom: 1px solid lightgrey; } -.result-set-body { +.push-body { padding-left: 15px; padding-right: 25px; } -.result-set { +.push { padding-bottom: 0; margin-left: 0; margin-top: 2px; @@ -44,76 +44,76 @@ width: 100%; } -.result-counts { +.push-counts { flex: none; padding-right: 10px; } -.result-set-progress { +.push-progress { color: #6f6d70; font-style: italic; margin-left: 10px; } -.result-set-buttons { +.push-buttons { margin-right: 25px; } -.btn-resultset { +.btn-push { color: #666; background-color: transparent; padding-left: 9px; padding-right: 10px; } -.btn-resultset.btn:focus { +.btn-push.btn:focus { -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); } -.btn-resultset:hover { +.btn-push:hover { background-color: #6f6d70; color: white; } -.btn-resultset.disabled:hover, -.btn-resultset.disabled:active, -.btn-resultset.disabled.active, -.btn-resultset:disabled:hover, -.btn-resultset:disabled:active, -.btn-resultset:disabled.active, -fieldset[disabled] .btn-resultset:hover { +.btn-push.disabled:hover, +.btn-push.disabled:active, +.btn-push.disabled.active, +.btn-push:disabled:hover, +.btn-push:disabled:active, +.btn-push:disabled.active, +fieldset[disabled] .btn-push:hover { background-color: #7c7a7d; border-color: #7c7a7d; color: white; } -th-action-button .btn-resultset { +th-action-button .btn-push { padding-left: 6px; font-size: 14px; } -/* Encompasses unknown push,resultset,repo */ +/* Encompasses unknown push,repo */ .unknown-message-body { padding-top: 10px; } -.result-set .job-list-pad { +.push .job-list-pad { padding-right: 0; } -.result-set .job-list-nopad { +.push .job-list-nopad { padding-left: 20px; padding-right: 0; display: block; } -.result-set .revision-list { +.push .revision-list { padding-left: 15px; } -.result-set .cancel-all-jobs-confirm { - padding: 8px 34px 0 34px; +.cancel-all-jobs-confirm-btn { + margin-left: 20px; } /* diff --git a/ui/entry-index.js b/ui/entry-index.js index 0199b7615..f0e2115e5 100644 --- a/ui/entry-index.js +++ b/ui/entry-index.js @@ -33,8 +33,7 @@ require('jquery.scrollto'); require('./vendor/resizer.js'); // Treeherder React UI -require('./job-view/Repo'); -require('./job-view/Push'); +require('./job-view/PushList'); // Treeherder JS require('./js/services/log.js'); @@ -42,8 +41,6 @@ require('./js/providers.js'); require('./js/values.js'); require('./js/components/auth.js'); require('./js/directives/treeherder/main.js'); -require('./js/directives/treeherder/clonejobs.js'); -require('./js/directives/treeherder/resultsets.js'); require('./js/directives/treeherder/top_nav_bar.js'); require('./js/directives/treeherder/bottom_nav_panel.js'); require('./js/services/main.js'); @@ -80,7 +77,6 @@ require('./js/controllers/settings.js'); require('./js/controllers/repository.js'); require('./js/controllers/notification.js'); require('./js/controllers/filters.js'); -require('./js/controllers/jobs.js'); require('./js/controllers/bugfiler.js'); require('./js/controllers/tcjobactions.js'); require('./plugins/tabs.js'); diff --git a/ui/helpers/jobHelper.js b/ui/helpers/jobHelper.js new file mode 100644 index 000000000..e2ac448fd --- /dev/null +++ b/ui/helpers/jobHelper.js @@ -0,0 +1,108 @@ +const btnClasses = { + busted: "btn-red", + exception: "btn-purple", + testfailed: "btn-orange", + usercancel: "btn-pink", + retry: "btn-dkblue", + success: "btn-green", + running: "btn-dkgray", + pending: "btn-ltgray", + superseded: "btn-ltblue", + failures: "btn-red", + 'in progress': "btn-dkgray", +}; + +// 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. +export const getBtnClass = function getBtnClass(resultState, failureClassificationId) { + let btnClass = btnClasses[resultState] || "btn-default"; + + // handle if a job is classified + const classificationId = parseInt(failureClassificationId, 10); + if (classificationId > 1) { + btnClass += "-classified"; + // autoclassification-only case + if (classificationId === 7) { + btnClass += " autoclassified"; + } + } + 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; +}; + +// Fetch the React instance of an object from a DOM element. +// Credit for this approach goes to SO: https://stackoverflow.com/a/48335220/333614 +export const findInstance = function findInstance(el) { + const key = Object.keys(el).find(key => key.startsWith('__reactInternalInstance$')); + if (key) { + const fiberNode = el[key]; + return fiberNode && fiberNode.return && fiberNode.return.stateNode; + } + return null; +}; + +// Fetch the React instance of the currently selected job. +export const findSelectedInstance = function findSelectedInstance() { + const selectedEl = $('.th-view-content').find(".job-btn.selected-job").first(); + if (selectedEl.length) { + return findInstance(selectedEl[0]); + } +}; + +// Fetch the React instance based on the jobId, and if scrollTo is true, then +// scroll it into view. +export const findJobInstance = function findJobInstance(jobId, scrollTo) { + const jobEl = $('.th-view-content') + .find(`button[data-job-id='${jobId}']`) + .first(); + + if (jobEl.length) { + if (scrollTo) { + scrollToElement(jobEl); + } + return findInstance(jobEl[0]); + } +}; + +// Scroll the element into view. +// TODO: see if Element.scrollIntoView() can be used here. (bug 1434679) +export const scrollToElement = function scrollToElement(el, duration) { + if (_.isUndefined(duration)) { + duration = 50; + } + if (el.position() !== undefined) { + let scrollOffset = -50; + if (window.innerHeight >= 500 && window.innerHeight < 1000) { + scrollOffset = -100; + } else if (window.innerHeight >= 1000) { + scrollOffset = -200; + } + if (!isOnScreen(el)) { + $('.th-global-content').scrollTo(el, duration, { offset: scrollOffset }); + } + } +}; + +// Check if the element is visible on screen or not. +const isOnScreen = function isOnScreen(el) { + const viewport = {}; + viewport.top = $(window).scrollTop() + $('#global-navbar-container').height() + 30; + const filterbarheight = $('.active-filters-bar').height(); + 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; + const bounds = {}; + bounds.top = el.offset().top; + bounds.bottom = bounds.top + el.outerHeight(); + return ((bounds.top <= viewport.bottom) && (bounds.bottom >= viewport.top)); +}; + diff --git a/ui/helpers/revisionHelper.js b/ui/helpers/revisionHelper.js new file mode 100644 index 000000000..db97b2d7c --- /dev/null +++ b/ui/helpers/revisionHelper.js @@ -0,0 +1,8 @@ +export const parseAuthor = function parseAuthor(author) { + const userTokens = author.split(/[<>]+/); + const name = userTokens[0].trim().replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1)); + const email = userTokens.length > 1 ? userTokens[1] : ''; + return { name, email }; +}; + +export default parseAuthor; diff --git a/ui/index.html b/ui/index.html index b2cee1029..e4d869076 100755 --- a/ui/index.html +++ b/ui/index.html @@ -25,10 +25,15 @@ -
+
- +
pushes; - -class JobButtonComponent extends React.Component { +export default class JobButtonComponent extends React.Component { constructor(props) { super(props); - this.$rootScope = this.props.$injector.get('$rootScope'); - this.thEvents = this.props.$injector.get('thEvents'); - this.thResultStatus = this.props.$injector.get('thResultStatus'); - this.thResultStatusInfo = this.props.$injector.get('thResultStatusInfo'); - this.ThJobModel = this.props.$injector.get('ThJobModel'); - this.ThResultSetStore = this.props.$injector.get('ThResultSetStore'); + const { $injector } = this.props; + + this.$rootScope = $injector.get('$rootScope'); + this.thEvents = $injector.get('thEvents'); + this.ThResultSetStore = $injector.get('ThResultSetStore'); this.state = { - failureClassificationId: null + isSelected: false, + isRunnableSelected: false, }; } componentWillMount() { - this.$rootScope.$on(this.thEvents.jobsClassified, (ev, { jobs }) => { - const ids = Object.keys(jobs).map(job => parseInt(job)); - if (ids.includes(this.props.job.id)) { - const job = jobs[this.props.job.id]; - this.setState({ failureClassificationId: job.failure_classification_id }); - } - }); + const { job } = this.props; + const { id } = job; + const urlSelectedJob = new URLSearchParams( + location.hash.split('?')[1]).get('selectedJob'); + + if (parseInt(urlSelectedJob) === id) { + this.setState({ isSelected: true }); + } + } + + componentDidMount() { + if (this.state.isSelected) { + // scroll to make this job if it's selected + findJobInstance(this.props.job.id, true); + } } /** @@ -36,66 +43,85 @@ class JobButtonComponent extends React.Component { * logic on shouldComponentUpdate is a little more complex than a simple * shallow compare would allow. */ - shouldComponentUpdate(nextProps) { - return (this.props.job.id === nextProps.selectedJobId || - this.props.job.id === this.props.selectedJobId || - this.props.visible !== nextProps.visible || - this.props.job.selected !== nextProps.selected); + shouldComponentUpdate(nextProps, nextState) { + const { visible, status, failureClassificationId } = this.props; + const { isSelected, isRunnableSelected } = this.state; + + return ( + visible !== nextProps.visible || + status !== nextProps.status || + failureClassificationId !== nextProps.failureClassificationId || + isSelected !== nextState.isSelected || + isRunnableSelected !== nextState.isRunnableSelected + ); + } + + componentWillUnmount() { + this.setState({ isRunnableSelected: false, isSelected: false }); + } + + setSelected(isSelected) { + this.setState({ isSelected }); + } + + toggleRunnableSelected() { + this.setState({ isRunnableSelected: !this.state.isRunnableSelected }); } render() { - if (!this.props.job.visible) return null; - const status = this.thResultStatus(this.props.job); - const runnable = this.props.job.state === 'runnable'; - const statusInfo = this.thResultStatusInfo(status, this.props.job.failure_classification_id); - let title = `${this.props.job.job_type_name} - ${status}`; + const { job } = this.props; + const { isSelected, isRunnableSelected } = this.state; + const { state, job_type_name, failure_classification_id, end_timestamp, + start_timestamp, ref_data_name, visible, id, + job_type_symbol, result } = job; - if (this.props.job.state === 'completed') { - const duration = Math.round((this.props.job.end_timestamp - this.props.job.start_timestamp) / 60); + if (!visible) return null; + const resultState = state === "completed" ? result : state; + const runnable = state === 'runnable'; + const btnClass = getBtnClass(resultState, failure_classification_id); + let title = `${job_type_name} - ${status}`; + + if (state === 'completed') { + const duration = Math.round((end_timestamp - start_timestamp) / 60); title += ` (${duration} mins)`; } - const classes = ['btn', statusInfo.btnClass]; + const classes = ['btn', btnClass, 'filter-shown']; + const attributes = { + 'data-job-id': id, + 'data-ignore-job-clear-on-click': true, + title + }; if (runnable) { classes.push('runnable-job-btn', 'runnable'); + attributes['data-buildername'] = ref_data_name; + if (isRunnableSelected) { + classes.push('runnable-job-btn-selected'); + } } else { classes.push('job-btn'); } - if (runnable) { - if (runnable && this.ThResultSetStore.isRunnableJobSelected(this.$rootScope.repoName, - this.props.job.result_set_id, - this.props.job.ref_data_name)) { - classes.push('runnable-job-btn-selected'); - } - } - if (this.state.selected || this.props.job.id === this.props.selectedJobId) { + if (isSelected) { classes.push('selected-job btn-lg-xform'); } else { classes.push('btn-xs'); } - if (this.props.job.visible) classes.push('filter-shown'); - - const attributes = { - className: classes.join(' '), - 'data-job-id': this.props.job.id, - 'data-ignore-job-clear-on-click': true, - title - }; - if (runnable) { - attributes['data-buildername'] = this.props.job.ref_data_name; - } - return ; + attributes.className = classes.join(' '); + return ( + + ); } } JobButtonComponent.propTypes = { - $injector: PropTypes.object.isRequired, job: PropTypes.object.isRequired, + $injector: PropTypes.object.isRequired, + repoName: PropTypes.string.isRequired, visible: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + failureClassificationId: PropTypes.number, // runnable jobs won't have this hasGroup: PropTypes.bool.isRequired, }; - -export default connect(mapStateToProps)(JobButtonComponent); diff --git a/ui/job-view/JobCount.jsx b/ui/job-view/JobCount.jsx index 0c5953993..7ab375c9b 100644 --- a/ui/job-view/JobCount.jsx +++ b/ui/job-view/JobCount.jsx @@ -1,5 +1,8 @@ +import React from 'react'; + export default (props) => { const classes = [props.className, 'btn group-btn btn-xs job-group-count filter-shown']; + return ( @@ -109,8 +118,11 @@ export default class JobGroup extends React.Component { {this.items.buttons.map((job, i) => ( - {this.props.groups.map((group, i) => { + {groups.map((group, i) => { if (group.symbol !== '?') { return ( group.visible && ); } @@ -24,10 +27,13 @@ export default class JobsAndGroups extends React.Component { group.jobs.map(job => ( @@ -41,5 +47,6 @@ export default class JobsAndGroups extends React.Component { JobsAndGroups.propTypes = { groups: PropTypes.array.isRequired, + repoName: PropTypes.string.isRequired, $injector: PropTypes.object.isRequired, }; diff --git a/ui/job-view/Platform.jsx b/ui/job-view/Platform.jsx index 9a05e01e7..a00981a6a 100644 --- a/ui/job-view/Platform.jsx +++ b/ui/job-view/Platform.jsx @@ -13,12 +13,15 @@ const PlatformName = (props) => { export default class Platform extends React.Component { render() { + const { platform, $injector, repoName } = this.props; + return ( - - + + ); @@ -27,6 +30,7 @@ export default class Platform extends React.Component { Platform.propTypes = { platform: PropTypes.object.isRequired, + repoName: PropTypes.string.isRequired, $injector: PropTypes.object.isRequired, }; diff --git a/ui/job-view/Push.jsx b/ui/job-view/Push.jsx index 0a88bb7d0..40cbaa2c2 100644 --- a/ui/job-view/Push.jsx +++ b/ui/job-view/Push.jsx @@ -1,47 +1,91 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Provider } from 'react-redux'; import PushJobs from './PushJobs'; +import PushHeader from './PushHeader'; import { RevisionList } from './RevisionList'; -import { store } from './redux/store'; import * as aggregateIds from './aggregateIds'; -class Push extends React.Component { +export default class Push extends React.Component { constructor(props) { super(props); - this.$rootScope = this.props.$injector.get('$rootScope'); - this.aggregateId = aggregateIds.getResultsetTableId( - this.$rootScope.repoName, this.props.push.id, this.props.push.revision + const { $injector, repoName, push } = props; + + this.$rootScope = $injector.get('$rootScope'); + this.thEvents = $injector.get('thEvents'); + this.ThResultSetStore = $injector.get('ThResultSetStore'); + + this.aggregateId = aggregateIds.getPushTableId( + repoName, push.id, push.revision ); + this.showRunnableJobs = this.showRunnableJobs.bind(this); + this.hideRunnableJobs = this.hideRunnableJobs.bind(this); + + this.state = { + runnableVisible: false + }; + } + + showRunnableJobs() { + this.$rootScope.$emit(this.thEvents.showRunnableJobs, this.props.push.id); + this.setState({ runnableVisible: true }); + } + + hideRunnableJobs() { + this.ThResultSetStore.deleteRunnableJobs(this.props.repoName, this.props.push.id); + this.$rootScope.$emit(this.thEvents.deleteRunnableJobs, this.props.push.id); + this.setState({ runnableVisible: false }); } render() { + const { push, loggedIn, isStaff, isTryRepo, $injector, repoName } = this.props; + const { currentRepo, urlBasePath } = this.$rootScope; + const { id, push_timestamp, revision, job_counts, author } = push; + return ( - -
- {this.$rootScope.currentRepo && - } +
+ +
+
+ {currentRepo && + + }
- +
); } } Push.propTypes = { push: PropTypes.object.isRequired, + isTryRepo: PropTypes.bool, + loggedIn: PropTypes.bool, + isStaff: PropTypes.bool, + repoName: PropTypes.string, $injector: PropTypes.object.isRequired, }; - -treeherder.directive('push', ['reactDirective', '$injector', (reactDirective, $injector) => - reactDirective(Push, ['push'], {}, { $injector, store })]); - diff --git a/ui/job-view/PushActionMenu.jsx b/ui/job-view/PushActionMenu.jsx new file mode 100644 index 000000000..b25988af8 --- /dev/null +++ b/ui/job-view/PushActionMenu.jsx @@ -0,0 +1,171 @@ +import React from "react"; +import PropTypes from 'prop-types'; + +export default class PushActionMenu extends React.PureComponent { + + constructor(props) { + super(props); + const { $injector } = this.props; + + this.$rootScope = $injector.get('$rootScope'); + this.thEvents = $injector.get('thEvents'); + this.thNotify = $injector.get('thNotify'); + this.thJobFilters = $injector.get('thJobFilters'); + this.ThResultSetStore = $injector.get('ThResultSetStore'); + this.ThModelErrors = $injector.get('ThModelErrors'); + this.ThTaskclusterErrors = $injector.get('ThTaskclusterErrors'); + + // customPushActions uses $uibModal which doesn't work well in the + // unit tests. So if we fail to inject here, that's OK. + // Bug 1437736 + try { + this.customPushActions = $injector.get('customPushActions'); + } catch (ex) { + this.customPushActions = {}; + } + + this.revision = this.props.revision; + this.pushId = this.props.pushId; + this.repoName = this.props.repoName; + + this.triggerMissingJobs = this.triggerMissingJobs.bind(this); + this.triggerAllTalosJobs = this.triggerAllTalosJobs.bind(this); + + // Trigger missing jobs is dangerous on repos other than these (see bug 1335506) + this.triggerMissingRepos = ['mozilla-inbound', 'autoland']; + } + + triggerMissingJobs() { + if (!window.confirm(`This will trigger all missing jobs for revision ${this.revision}!\n\nClick "OK" if you want to proceed.`)) { + return; + } + + this.ThResultSetStore.getGeckoDecisionTaskId(this.repoName, this.pushId) + .then((decisionTaskID) => { + this.ThResultSetModel.triggerMissingJobs(decisionTaskID) + .then((msg) => { + this.thNotify.send(msg, "success"); + }, (e) => { + this.thNotify.send( + this.ThModelErrors.format(e, "The action 'trigger missing jobs' failed"), + 'danger', true + ); + }); + }); + } + + triggerAllTalosJobs() { + if (!window.confirm(`This will trigger all Talos jobs for revision ${this.revision}!\n\nClick "OK" if you want to proceed.`)) { + return; + } + + let times = parseInt(window.prompt("Enter number of instances to have for each talos job", 6)); + while (times < 1 || times > 6 || isNaN(times)) { + times = window.prompt("We only allow instances of each talos job to be between 1 to 6 times. Enter again", 6); + } + + this.ThResultSetStore.getGeckoDecisionTaskId(this.repoName, this.pushId) + .then((decisionTaskID) => { + this.ThResultSetModel.triggerAllTalosJobs(times, decisionTaskID) + .then((msg) => { + this.thNotify.send(msg, "success"); + }, (e) => { + this.thNotify.send( + this.ThTaskclusterErrors.format(e), + 'danger', + { sticky: true } + ); + }); + }); + } + + + render() { + const { loggedIn, isStaff, repoName, revision, pushId, runnableVisible, + hideRunnableJobsCb, showRunnableJobsCb } = this.props; + const { addFilter } = this.thJobFilters; + + return ( + + + +
    + {runnableVisible ? +
  • hideRunnableJobsCb()} + >Hide Runnable Jobs
  • : +
  • showRunnableJobsCb()} + >Add new jobs
  • + } +
  • BuildAPI
  • + {isStaff && this.triggerMissingRepos.includes(repoName) && +
  • this.triggerMissingJobs(revision)} + >Trigger missing jobs
  • + } + {isStaff && +
  • this.triggerAllTalosJobs(revision)} + >Trigger all Talos jobs
  • + } +
  • + Mark with Bugherder
  • +
  • this.customPushActions.open(repoName, pushId)} + title="View/Edit/Submit Action tasks for this push" + >Custom Push Action...
  • +
  • addFilter('tochange', revision)} + >Set as top of range
  • +
  • addFilter('fromchange', revision)} + >Set as bottom of range
  • +
+
+ ); + } +} + +PushActionMenu.propTypes = { + runnableVisible: PropTypes.bool.isRequired, + isStaff: PropTypes.bool.isRequired, + loggedIn: PropTypes.bool.isRequired, + revision: PropTypes.string.isRequired, +}; diff --git a/ui/job-view/PushHeader.jsx b/ui/job-view/PushHeader.jsx new file mode 100644 index 000000000..4fcc42b85 --- /dev/null +++ b/ui/job-view/PushHeader.jsx @@ -0,0 +1,268 @@ +import React from "react"; +import PropTypes from 'prop-types'; +import { Alert } from 'reactstrap'; +import PushActionMenu from './PushActionMenu'; + +const Author = (props) => { + const authorMatch = props.author.match(/\<(.*?)\>+/); + const authorEmail = authorMatch ? authorMatch[1] : props.author; + + return ( + + {authorEmail} + + ); +}; + +const PushCounts = (props) => { + const { pending, running, completed } = props; + const inProgress = pending + running; + const total = completed + inProgress; + const percentComplete = total > 0 ? + Math.floor(((completed / total) * 100)) : undefined; + + return ( + + {percentComplete === 100 ? + - Complete - : + {percentComplete}% - {inProgress} in progress + } + + ); +}; + +export default class PushHeader extends React.PureComponent { + + constructor(props) { + super(props); + const { $injector, pushTimestamp, urlBasePath, repoName, revision, author } = this.props; + + this.$rootScope = $injector.get('$rootScope'); + this.thEvents = $injector.get('thEvents'); + this.thJobFilters = $injector.get('thJobFilters'); + this.thNotify = $injector.get('thNotify'); + this.thPinboard = $injector.get('thPinboard'); + this.thPinboardCountError = $injector.get('thPinboardCountError'); + this.thBuildApi = $injector.get('thBuildApi'); + this.ThResultSetStore = $injector.get('ThResultSetStore'); + this.ThResultSetModel = $injector.get('ThResultSetModel'); + this.ThModelErrors = $injector.get('ThModelErrors'); + this.ThTaskclusterErrors = $injector.get('ThTaskclusterErrors'); + + const dateFormat = { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false }; + this.pushDateStr = new Date(pushTimestamp*1000).toLocaleString("en-US", dateFormat); + this.revisionPushFilterUrl = `${urlBasePath}?repo=${repoName}&revision=${revision}`; + this.authorPushFilterUrl = `${urlBasePath}?repo=${repoName}&author=${encodeURIComponent(author)}`; + + this.pinAllShownJobs = this.pinAllShownJobs.bind(this); + this.triggerNewJobs = this.triggerNewJobs.bind(this); + this.cancelAllJobs = this.cancelAllJobs.bind(this); + + this.state = { + showConfirmCancelAll: false, + runnableJobsSelected: false, + }; + } + + componentWillMount() { + this.toggleRunnableJobUnlisten = this.$rootScope.$on( + this.thEvents.selectRunnableJob, (ev, runnableJobs, pushId) => { + if (this.props.pushId === pushId) { + this.setState({ runnableJobsSelected: runnableJobs.length > 0 }); + } + } + ); + } + + componentWillUnmount() { + this.toggleRunnableJobUnlisten(); + } + + filterParams() { + return Object.entries(this.thJobFilters.getActiveFilters()) + .reduce((acc, [key, value]) => `&${key}=${value}`, ""); + } + + triggerNewJobs() { + const { repoName, loggedIn, pushId } = this.props; + + if (!window.confirm( + 'This will trigger all selected jobs. Click "OK" if you want to proceed.')) { + return; + } + if (loggedIn) { + const builderNames = this.ThResultSetStore.getSelectedRunnableJobs(repoName, pushId); + this.ThResultSetStore.getGeckoDecisionTaskId(repoName, pushId).then((decisionTaskID) => { + this.ThResultSetModel.triggerNewJobs(builderNames, decisionTaskID).then((result) => { + this.thNotify.send(result, "success"); + this.ThResultSetStore.deleteRunnableJobs(repoName, pushId); + this.props.hideRunnableJobsCb(); + this.setState({ runnableJobsSelected: false }); + }, (e) => { + this.thNotify.send(this.ThTaskclusterErrors.format(e), 'danger', { sticky: true }); + }); + }); + } else { + this.thNotify.send("Must be logged in to trigger a job", 'danger'); + } + } + + cancelAllJobs() { + const { repoName, revision, isTryRepo, isStaff, pushId } = this.props; + + this.setState({ showConfirmCancelAll: false }); + if (!(isTryRepo || isStaff)) return; + + this.ThResultSetModel.cancelAll(pushId, repoName).then(() => ( + this.thBuildApi.cancelAll(repoName, revision) + )).catch((e) => { + this.thNotify.send( + this.ThModelErrors.format(e, "Failed to cancel all jobs"), + 'danger', true + ); + }); + } + + pinAllShownJobs() { + if (!this.thPinboard.spaceRemaining()) { + this.thNotify.send(this.thPinboardCountError, 'danger'); + return; + } + const shownJobs = this.ThResultSetStore.getAllShownJobs( + this.props.repoName, + this.thPinboard.spaceRemaining(), + this.thPinboardCountError, + this.props.pushId + ); + this.thPinboard.pinJobs(shownJobs); + + if (!this.$rootScope.selectedJob) { + this.$rootScope.$emit(this.thEvents.jobClick, shownJobs[0]); + } + } + + render() { + const { repoName, loggedIn, pushId, isTryRepo, isStaff, jobCounts, author, + revision, runnableVisible, $injector, + showRunnableJobsCb, hideRunnableJobsCb } = this.props; + + const cancelJobsTitle = loggedIn ? + "Cancel all jobs" : + "Must be logged in to cancel jobs"; + const canCancelJobs = isTryRepo || isStaff; + const counts = jobCounts || { pending: 0, running: 0, completed: 0 }; + + return ( +
+
+ + + + {this.pushDateStr} + - + + + + + + View Tests + {canCancelJobs && + + } + + {this.state.runnableJobsSelected && runnableVisible && + + } + + +
+ {this.state.showConfirmCancelAll && +
+ this.setState({ showConfirmCancelAll: false })}> + + This action will cancel all pending and running jobs for this push. It cannot be undone! + + + +
+ } +
+ ); + } +} + +PushHeader.propTypes = { + pushId: PropTypes.number.isRequired, + pushTimestamp: PropTypes.number.isRequired, + author: PropTypes.string.isRequired, + revision: PropTypes.string.isRequired, + jobCounts: PropTypes.object, + loggedIn: PropTypes.bool, + isStaff: PropTypes.bool, + repoName: PropTypes.string.isRequired, + isTryRepo: PropTypes.bool, + urlBasePath: PropTypes.string, + $injector: PropTypes.object.isRequired, + runnableVisible: PropTypes.bool.isRequired, + showRunnableJobsCb: PropTypes.func.isRequired, + hideRunnableJobsCb: PropTypes.func.isRequired, +}; + diff --git a/ui/job-view/PushJobs.jsx b/ui/job-view/PushJobs.jsx index 050519913..ce47b82c4 100644 --- a/ui/job-view/PushJobs.jsx +++ b/ui/job-view/PushJobs.jsx @@ -1,43 +1,46 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as _ from 'lodash'; -import { actions, store } from './redux/store'; import { platformMap } from '../js/constants'; import * as aggregateIds from './aggregateIds'; import Platform from './Platform'; +import { findInstance, findSelectedInstance, findJobInstance } from '../helpers/jobHelper'; export default class PushJobs extends React.Component { constructor(props) { super(props); - this.$rootScope = this.props.$injector.get('$rootScope'); - this.$location = this.props.$injector.get('$location'); - this.thEvents = this.props.$injector.get('thEvents'); - this.ThResultSetStore = this.props.$injector.get('ThResultSetStore'); - this.ThJobModel = this.props.$injector.get('ThJobModel'); - this.thUrl = this.props.$injector.get('thUrl'); - this.thJobFilters = this.props.$injector.get('thJobFilters'); - this.thResultStatus = this.props.$injector.get('thResultStatus'); - this.thResultStatusInfo = this.props.$injector.get('thResultStatusInfo'); + const { $injector, push, repoName } = this.props; - this.rsMap = null; - this.pushId = this.props.push.id; - this.aggregateId = aggregateIds.getResultsetTableId( - this.$rootScope.repoName, + this.$rootScope = $injector.get('$rootScope'); + this.$location = $injector.get('$location'); + this.thEvents = $injector.get('thEvents'); + this.ThResultSetStore = $injector.get('ThResultSetStore'); + this.ThJobModel = $injector.get('ThJobModel'); + this.thUrl = $injector.get('thUrl'); + this.thJobFilters = $injector.get('thJobFilters'); + + this.pushId = push.id; + this.aggregateId = aggregateIds.getPushTableId( + repoName, this.pushId, - this.props.push.revision + push.revision ); - this.state = { platforms: null, isRunnableVisible: false }; + this.onMouseDown = this.onMouseDown.bind(this); this.selectJob = this.selectJob.bind(this); - const showDuplicateJobs = this.$location.search().duplicate_jobs === 'visible'; - const expanded = this.$location.search().group_state === 'expanded'; - store.dispatch(actions.pushes.setCountExpanded(expanded)); - store.dispatch(actions.pushes.setShowDuplicates(showDuplicateJobs)); + this.state = { + platforms: null, + isRunnableVisible: false, + }; + } + + componentWillMount() { + this.applyNewJobs(); } componentDidMount() { - this.$rootScope.$on( + this.applyNewJobsUnlisten = this.$rootScope.$on( this.thEvents.applyNewJobs, (ev, appliedpushId) => { if (appliedpushId === this.pushId) { this.applyNewJobs(); @@ -45,54 +48,61 @@ export default class PushJobs extends React.Component { } ); - this.$rootScope.$on( - this.thEvents.clearSelectedJob, () => { - store.dispatch(actions.pushes.setSelectedJobId(null)); - } - ); - - this.$rootScope.$on( + this.globalFilterChangedUnlisten = this.$rootScope.$on( this.thEvents.globalFilterChanged, () => { this.filterJobs(); } ); - this.$rootScope.$on( + this.groupStateChangedUnlisten = this.$rootScope.$on( this.thEvents.groupStateChanged, () => { this.filterJobs(); } ); - this.$rootScope.$on( + this.jobsClassifiedUnlisten = this.$rootScope.$on( this.thEvents.jobsClassified, () => { this.filterJobs(); } ); - this.$rootScope.$on( + this.searchPageUnlisten = this.$rootScope.$on( this.thEvents.searchPage, () => { this.filterJobs(); } ); - this.$rootScope.$on(this.thEvents.showRunnableJobs, (ev, push) => { - if (this.props.push.id === push.id) { + this.showRunnableJobsUnlisten = this.$rootScope.$on(this.thEvents.showRunnableJobs, (ev, pushId) => { + const { push, repoName } = this.props; + + if (push.id === pushId) { push.isRunnableVisible = true; this.setState({ isRunnableVisible: true }); - this.ThResultSetStore.addRunnableJobs(this.$rootScope.repoName, push); + this.ThResultSetStore.addRunnableJobs(repoName, push); } }); - this.$rootScope.$on(this.thEvents.deleteRunnableJobs, (ev, push) => { - if (this.props.push.id === push.id) { + this.deleteRunnableJobsUnlisten = this.$rootScope.$on(this.thEvents.deleteRunnableJobs, (ev, pushId) => { + const { push } = this.props; + + if (push.id === pushId) { push.isRunnableVisible = false; this.setState({ isRunnableVisible: false }); - store.dispatch(actions.pushes.setSelectedRunnableJobs(null)); this.applyNewJobs(); } }); } + componentWillUnmount() { + this.applyNewJobsUnlisten(); + this.globalFilterChangedUnlisten(); + this.groupStateChangedUnlisten(); + this.jobsClassifiedUnlisten(); + this.searchPageUnlisten(); + this.showRunnableJobsUnlisten(); + this.deleteRunnableJobsUnlisten(); + } + onMouseDown(ev) { const jobElem = ev.target.attributes.getNamedItem('data-job-id'); if (jobElem) { @@ -105,14 +115,14 @@ export default class PushJobs extends React.Component { } else if (job.state === 'runnable') { // Toggle runnable this.handleRunnableClick(job); } else { - this.selectJob(job); // Left click + this.selectJob(job, ev.target); // Left click } } } getIdForPlatform(platform) { return aggregateIds.getPlatformRowId( - this.$rootScope.repoName, + this.props.repoName, this.props.push.id, platform.name, platform.option @@ -120,7 +130,7 @@ export default class PushJobs extends React.Component { } getJobFromId(jobId) { - const jobMap = this.ThResultSetStore.getJobMap(this.$rootScope.repoName); + const jobMap = this.ThResultSetStore.getJobMap(this.props.repoName); return jobMap[`${jobId}`].job_obj; } @@ -132,18 +142,22 @@ export default class PushJobs extends React.Component { this.setState({ platforms }); } - selectJob(job) { - store.dispatch(actions.pushes.setSelectedJobId(job.id)); + selectJob(job, el) { + const selected = findSelectedInstance(); + if (selected) selected.setSelected(false); + const jobInstance = findInstance(el); + jobInstance.setSelected(true); this.$rootScope.$emit(this.thEvents.jobClick, job); } applyNewJobs() { - this.rsMap = this.ThResultSetStore.getResultSetsMap(this.$rootScope.repoName); - if (!this.rsMap[this.pushId] || !this.rsMap[this.pushId].rs_obj.platforms) { + const { push } = this.props; + + if (!push.platforms) { return; } - const rsPlatforms = this.rsMap[this.pushId].rs_obj.platforms; + const rsPlatforms = push.platforms; const platforms = rsPlatforms.reduce((acc, platform) => { const thisPlatform = { ...platform }; thisPlatform.id = this.getIdForPlatform(platform); @@ -162,7 +176,7 @@ export default class PushJobs extends React.Component { handleLogViewerClick(jobId) { // Open logviewer in a new window this.ThJobModel.get( - this.$rootScope.repoName, + this.props.repoName, jobId ).then((data) => { if (data.logs.length > 0) { @@ -173,12 +187,12 @@ export default class PushJobs extends React.Component { } handleRunnableClick(job) { - const selected = this.ThResultSetStore.toggleSelectedRunnableJob( - this.$rootScope.repoName, + this.ThResultSetStore.toggleSelectedRunnableJob( + this.props.repoName, this.pushId, job.ref_data_name ); - store.dispatch(actions.pushes.setSelectedRunnableJobs({ selectedRunnableJobs: selected })); + findJobInstance(job.id, false).toggleRunnableSelected(); } filterPlatform(platform) { @@ -187,9 +201,8 @@ export default class PushJobs extends React.Component { group.visible = false; group.jobs.forEach((job) => { job.visible = this.thJobFilters.showJob(job); - if (this.rsMap && job.state === 'runnable') { - job.visible = job.visible && - this.rsMap[job.result_set_id].rs_obj.isRunnableVisible; + if (job.state === 'runnable') { + job.visible = job.visible && this.props.push.isRunnableVisible; } job.selected = this.$rootScope.selectedJob ? job.id === this.$rootScope.selectedJob.id : false; if (job.visible) { @@ -203,6 +216,8 @@ export default class PushJobs extends React.Component { render() { const platforms = this.state.platforms || {}; + const { $injector, repoName } = this.props; + return ( @@ -210,7 +225,8 @@ export default class PushJobs extends React.Component { platforms[id].visible && { + const pushList = this.ThResultSetStore.getResultSetsArray(repoName); + this.$timeout(() => { + this.setState({ pushList, loadingPushes: false }); + }, 0); + }); + + this.jobsLoadedUnlisten = this.$rootScope.$on(this.thEvents.jobsLoaded, () => { + const pushList = this.ThResultSetStore.getResultSetsArray(repoName); + this.$timeout(() => { + this.setState({ pushList, jobsReady: true }); + }, 0); + }); + + this.jobClickUnlisten = this.$rootScope.$on(this.thEvents.jobClick, (ev, job) => { + this.$location.search('selectedJob', job.id); + const { repoName } = this.props; + if (repoName) { + this.ThResultSetStore.setSelectedJob(repoName, job); + } + }); + + this.clearSelectedJobUnlisten = this.$rootScope.$on(this.thEvents.clearSelectedJob, () => { + this.$location.search('selectedJob', null); + }); + + this.changeSelectionUnlisten = this.$rootScope.$on( + this.thEvents.changeSelection, (ev, direction, jobNavSelector) => { + this.changeSelectedJob(ev, direction, jobNavSelector); + } + ); + + this.jobsLoadedUnlisten = this.$rootScope.$on( + this.thEvents.jobsLoaded, () => { + const selectedJobId = parseInt(this.$location.search().selectedJob); + if (selectedJobId) { + this.setSelectedJobFromQueryString(selectedJobId); + } + } + ); + } + + componentWillUnmount() { + this.pushesLoadedUnlisten(); + this.jobsLoadedUnlisten(); + this.jobClickUnlisten(); + this.clearSelectedJobUnlisten(); + this.changeSelectionUnlisten(); + this.jobsLoadedUnlisten(); + } + + getNextPushes(count, keepFilters) { + this.setState({ loadingPushes: true }); + const revision = this.$location.search().revision; + if (revision) { + this.$rootScope.skipNextPageReload = true; + this.$location.search('revision', null); + this.$location.search('tochange', revision); + } + this.ThResultSetStore.fetchResultSets(this.props.repoName, count, keepFilters) + .then(this.updateUrlFromchange); + + } + + /** + * If the URL has a query string param of ``selectedJob`` then select + * that job on load. + * + * If that job isn't in any of the loaded pushes, then throw + * an error and provide a link to load it with the right push. + */ + setSelectedJobFromQueryString(selectedJobId) { + const { repoName } = this.props; + const { urlBasePath } = this.$rootScope; + const jobMap = this.ThResultSetStore.getJobMap(repoName); + const selectedJobEl = jobMap[`${selectedJobId}`]; + + // select the job in question + if (selectedJobEl) { + this.$rootScope.$emit(this.thEvents.jobClick, selectedJobEl.job_obj); + } else { + // If the ``selectedJob`` was not mapped, then we need to notify + // the user it's not in the range of the current result set list. + this.ThJobModel.get(repoName, selectedJobId).then((job) => { + this.ThResultSetModel.getResultSet(repoName, job.result_set_id).then((push) => { + const url = `${urlBasePath}?repo=${repoName}&revision=${push.data.revision}&selectedJob=${selectedJobId}`; + + // the job exists, but isn't in any loaded push. + // provide a message and link to load the right push + this.thNotify.send(`Selected job id: ${selectedJobId} not within current push range.`, + 'danger', + { sticky: true, linkText: 'Load push', url }); + + }); + }, function () { + // the job wasn't found in the db. Either never existed, + // or was expired and deleted. + this.thNotify.send( + `Unable to find job with id ${selectedJobId}`, + 'danger', + { sticky: true }); + }); + } + } + + updateUrlFromchange() { + // since we fetched more pushes, we need to persist the + // push state in the URL. + const rsArray = this.ThResultSetStore.getResultSetsArray(this.props.repoName); + const updatedLastRevision = _.last(rsArray).revision; + if (this.$location.search().fromchange !== updatedLastRevision) { + this.$rootScope.skipNextPageReload = true; + this.$location.search('fromchange', updatedLastRevision); + } + } + + changeSelectedJob(ev, direction, jobNavSelector) { + const jobMap = this.ThResultSetStore.getJobMap(this.props.repoName); + // Get the appropriate next index based on the direction and current job + // selection (if any). Must wrap end to end. + const getIndex = direction === 'next' ? + (idx, jobs) => (idx + 1 > jobs.length - 1 ? 0 : idx + 1) : + (idx, jobs) => (idx - 1 < 0 ? jobs.length - 1 : idx - 1); + + // TODO: Move from using jquery here to using the ReactJS state tree (bug 1434679) + // to find the next/prev component to select so that setState can be called + // 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 + // 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 + // the next/previous job. + // + // The .selected-job can be invisible, for instance, when filtered to + // unclassified failures only, and you then classify the selected job. + // It's still selected, but no longer visible. + const jobs = $('.th-view-content') + .find(jobNavSelector.selector) + .filter(':visible, .selected-job'); + + if (jobs.length) { + const selectedEl = jobs.filter('.selected-job').first(); + const selIdx = jobs.index(selectedEl); + const idx = getIndex(selIdx, jobs); + const jobEl = $(jobs[idx]); + + if (selectedEl.length) { + const selected = findInstance(selectedEl[0]); + selected.setSelected(false); + } + + const nextSelected = findInstance(jobEl[0]); + nextSelected.setSelected(true); + + const jobId = jobEl.attr('data-job-id'); + + if (jobMap && jobMap[jobId] && selIdx !== idx) { + this.selectJob(jobMap[jobId].job_obj, jobEl); + return; + } + } + // 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(); + } + } + + selectJob(job, jobEl) { + // Delay switching jobs right away, in case the user is switching rapidly between jobs + scrollToElement(jobEl); + if (this.jobChangedTimeout) { + window.clearTimeout(this.jobChangedTimeout); + } + this.jobChangedTimeout = window.setTimeout(() => { + this.$rootScope.$emit(this.thEvents.jobClick, job); + }, 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()) { + const selected = findSelectedInstance(); + if (selected) { + selected.setSelected(false); + } + this.$timeout(this.$rootScope.closeJob); + } + } + + render() { + const { $injector, user, repoName, revision } = this.props; + const currentRepo = this.props.currentRepo || {}; + const { pushList, loadingPushes, jobsReady } = this.state; + const { loggedin, is_staff } = user; + + return ( +
+ {jobsReady && } + {repoName && pushList.map(push => ( + + ))} + {loadingPushes && +
+ } + {pushList.length === 0 && !loadingPushes && + + } +
+ get next: +
+ {[10, 20, 50].map(count => ( +
(this.getNextPushes(count, true))} + key={count} + >{count}
+ ))} +
+
+
+ ); + } +} + +treeherder.directive('pushList', ['reactDirective', '$injector', + (reactDirective, $injector) => reactDirective( + PushList, + ['repoName', 'user', 'revision', 'currentRepo'], + {}, + { $injector } + ) +]); diff --git a/ui/job-view/PushLoadErrors.jsx b/ui/job-view/PushLoadErrors.jsx new file mode 100644 index 000000000..c0ae1a10f --- /dev/null +++ b/ui/job-view/PushLoadErrors.jsx @@ -0,0 +1,56 @@ +const PushLoadErrors = (props) => { + const { loadingPushes, currentRepo, revision, repoName } = props; + const urlParams = new URLSearchParams(location.hash.split('?')[1]); + urlParams.delete("revision"); + + return ( +
+ {loadingPushes && revision && currentRepo && currentRepo.url && +
+ + {revision && + + Waiting for a push with revision {revision} + (view pushlog) + +
If the push exists, it will appear in a few minutes once it has been processed.
+
+ } +
+
+ } + {!loadingPushes && revision && +
+ This is an invalid or unknown revision. Please change it, or click + here to reload the latest revisions from {repoName}. +
+ } + {!loadingPushes && !revision && currentRepo && +
+ +
No pushes found.
+ No commit information could be loaded for this repository. + More information about this repository can be found here. +
+
+ } + {!loadingPushes && !Object.keys(currentRepo).length && +
+ +
Unknown repository.
+ This repository is either unknown to Treeherder or it does not exist. + If this repository does exist, please + file a bug against the Treeherder product in Bugzilla to get it added to the system. + +
+
+ } +
+ ); +}; + +export default PushLoadErrors; diff --git a/ui/job-view/Repo.jsx b/ui/job-view/Repo.jsx deleted file mode 100644 index c30d2c32e..000000000 --- a/ui/job-view/Repo.jsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { actions, store } from './redux/store'; - -class Repo extends React.PureComponent { - - // While this component doesn't render anything, it acts as a top-level, - // single-point for some events so that they don't happen for each push - // object that is rendered. - - // TODO: Once we migrate the ng-repeat to ReactJS for the list of - // pushes, this component will do the rendering of that array. - // This component could arguably be renamed as PushList at that time. - // (bug 1434677). - - constructor(props) { - super(props); - this.$rootScope = this.props.$injector.get('$rootScope'); - this.$location = this.props.$injector.get('$location'); - this.thEvents = this.props.$injector.get('thEvents'); - this.ThResultSetStore = this.props.$injector.get('ThResultSetStore'); - this.thNotify = this.props.$injector.get('thNotify'); - this.thNotify = this.props.$injector.get('thNotify'); - this.ThJobModel = this.props.$injector.get('ThJobModel'); - this.ThResultSetModel = this.props.$injector.get('ThResultSetModel'); - } - - componentDidMount() { - this.$rootScope.$on(this.thEvents.jobClick, (ev, job) => { - this.$location.search('selectedJob', job.id); - this.ThResultSetStore.setSelectedJob(this.$rootScope.repoName, job); - store.dispatch(actions.pushes.setSelectedJobId(job.id)); - }); - - this.$rootScope.$on(this.thEvents.clearSelectedJob, () => { - this.$location.search('selectedJob', null); - }); - - this.$rootScope.$on( - this.thEvents.changeSelection, (ev, direction, jobNavSelector) => { - this.changeSelectedJob(ev, direction, jobNavSelector); - } - ); - - this.$rootScope.$on( - this.thEvents.jobsLoaded, () => { - const selectedJobId = parseInt(this.$location.search().selectedJob); - if (selectedJobId) { - this.setSelectedJobFromQueryString(selectedJobId); - } - } - ); - } - - /** - * If the URL has a query string param of ``selectedJob`` then select - * that job on load. - * - * If that job isn't in any of the loaded resultsets, then throw - * an error and provide a link to load it with the right resultset. - */ - setSelectedJobFromQueryString(selectedJobId) { - const jobMap = this.ThResultSetStore.getJobMap(this.$rootScope.repoName); - const selectedJobEl = jobMap[`${selectedJobId}`]; - - // select the job in question - if (selectedJobEl) { - const jobSelector = `button[data-job-id='${selectedJobId}']`; - this.$rootScope.$emit(this.thEvents.jobClick, selectedJobEl.job_obj); - // scroll to make it visible - this.scrollToElement($('.th-view-content').find(jobSelector).first()); - } else { - // If the ``selectedJob`` was not mapped, then we need to notify - // the user it's not in the range of the current result set list. - this.ThJobModel.get(this.$rootScope.repoName, selectedJobId).then((job) => { - this.ThResultSetModel.getResultSet(this.$rootScope.repoName, job.result_set_id).then(function (resultset) { - const url = `${this.$rootScope.urlBasePath}?repo=${this.$rootScope.repoName}&revision=${resultset.data.revision}&selectedJob=${selectedJobId}`; - - // the job exists, but isn't in any loaded resultset. - // provide a message and link to load the right resultset - this.thNotify.send(`Selected job id: ${selectedJobId} not within current push range.`, - 'danger', - { sticky: true, linkText: 'Load push', url }); - - }); - }, function () { - // the job wasn't found in the db. Either never existed, - // or was expired and deleted. - this.thNotify.send(`Unable to find job with id ${selectedJobId}`, 'danger', { sticky: true }); - }); - } - } - - changeSelectedJob(ev, direction, jobNavSelector) { - const jobMap = this.ThResultSetStore.getJobMap(this.$rootScope.repoName); - // Get the appropriate next index based on the direction and current job - // selection (if any). Must wrap end to end. - const getIndex = direction === 'next' ? - (idx, jobs) => (idx + 1 > jobs.length - 1 ? 0 : idx + 1) : - (idx, jobs) => (idx - 1 < 0 ? jobs.length - 1 : idx - 1); - - // TODO: Move from using jquery here to using the ReactJS state tree (bug 1434679) - // to find the next/prev component to select so that setState can be called - // 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 - // 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 - // the next/previous job. - // - // The .selected-job can be invisible, for instance, when filtered to - // unclassified failures only, and you then classify the selected job. - // It's still selected, but no longer visible. - const jobs = $('.th-view-content').find(jobNavSelector.selector).filter(':visible, .selected-job, .selected-count'); - - if (jobs.length) { - const selIdx = jobs.index(jobs.filter('.selected-job, .selected-count').first()); - const idx = getIndex(selIdx, jobs); - const jobEl = $(jobs[idx]); - const jobId = jobEl.attr('data-job-id'); - - if (jobMap && jobMap[jobId] && selIdx !== idx) { - this.selectJob(jobMap[jobId].job_obj, jobEl); - return; - } - } - // 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(); - } - } - - // TODO: see if Element.scrollIntoView() can be used here. (bug 1434679) - scrollToElement(el, duration) { - if (_.isUndefined(duration)) { - duration = 50; - } - if (el.position() !== undefined) { - var scrollOffset = -50; - if (window.innerHeight >= 500 && window.innerHeight < 1000) { - scrollOffset = -100; - } else if (window.innerHeight >= 1000) { - scrollOffset = -200; - } - if (!this.isOnScreen(el)) { - $('.th-global-content').scrollTo(el, duration, { offset: scrollOffset }); - } - } - } - - isOnScreen(el) { - const viewport = {}; - viewport.top = $(window).scrollTop() + $('#global-navbar-container').height() + 30; - const filterbarheight = $('.active-filters-bar').height(); - 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; - const bounds = {}; - bounds.top = el.offset().top; - bounds.bottom = bounds.top + el.outerHeight(); - return ((bounds.top <= viewport.bottom) && (bounds.bottom >= viewport.top)); - } - - selectJob(job, jobEl) { - // Delay switching jobs right away, in case the user is switching rapidly between jobs - store.dispatch(actions.pushes.setSelectedJobId(job.id)); - this.scrollToElement(jobEl); - if (this.jobChangedTimeout) { - window.clearTimeout(this.jobChangedTimeout); - } - this.jobChangedTimeout = window.setTimeout(() => { - this.$rootScope.$emit(this.thEvents.jobClick, job); - }, 200); - } - - render() { - return null; - } -} - -Repo.propTypes = { - $injector: PropTypes.object.isRequired, -}; - -// Need store here because this React component is not wrapped in a . -// Once we're completely on React, the entire app will be wrapped in a singular -// so all components will get the store. -treeherder.constant('store', store); -treeherder.directive('repo', ['reactDirective', '$injector', (reactDirective, $injector) => - reactDirective(Repo, ['repo'], {}, { $injector })]); diff --git a/ui/job-view/Revision.jsx b/ui/job-view/Revision.jsx index 935e29528..312fe3633 100644 --- a/ui/job-view/Revision.jsx +++ b/ui/job-view/Revision.jsx @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import { parseAuthor } from '../helpers/revisionHelper'; export const Initials = (props) => { const str = props.author || ''; @@ -27,34 +28,31 @@ export const Initials = (props) => { export class Revision extends React.PureComponent { constructor(props) { super(props); - this.userTokens = this.props.revision.author.split(/[<>]+/); - this.name = this.userTokens[0].trim().replace(/\w\S*/g, - txt => txt.charAt(0).toUpperCase() + txt.substr(1)); - if (this.userTokens.length > 1) this.email = this.userTokens[1]; - const comment = this.props.revision.comments.split('\n')[0]; - const escapedComment = _.escape(comment); - this.escapedCommentHTML = { __html: this.props.linkifyBugsFilter(escapedComment) }; + const { revision, linkifyBugsFilter } = this.props; - this.tags = ''; - if (escapedComment.search('Backed out') >= 0 || - escapedComment.search('Back out') >= 0) { - this.tags += 'backout'; - } + const escapedComment = _.escape(revision.comments.split('\n')[0]); + this.escapedCommentHTML = { __html: linkifyBugsFilter(escapedComment) }; + this.tags = escapedComment.search('Backed out') >= 0 || escapedComment.search('Back out') >= 0 ? + 'backout' : ''; } render() { + const { revision, repo } = this.props; + const { name, email } = parseAuthor(revision.author); + const commitRevision = revision.revision; + return (
  • {this.props.revision.revision.substring(0, 12)} + >{commitRevision.substring(0, 12)} - diff --git a/ui/job-view/RevisionList.jsx b/ui/job-view/RevisionList.jsx index e109df31c..e528ba842 100644 --- a/ui/job-view/RevisionList.jsx +++ b/ui/job-view/RevisionList.jsx @@ -9,21 +9,23 @@ export class RevisionList extends React.PureComponent { } render() { + const { push, repo } = this.props; + return (
      - {this.props.push.revisions.map((revision, i) => + {push.revisions.map((revision, i) => () )} {this.hasMore && }
    @@ -43,9 +45,7 @@ export const MoreRevisionsLink = props => ( {`\u2026and more`} - - + >{`\u2026and more`}
  • ); diff --git a/ui/job-view/aggregateIds.js b/ui/job-view/aggregateIds.js index c03d36fdd..bf832b14b 100644 --- a/ui/job-view/aggregateIds.js +++ b/ui/job-view/aggregateIds.js @@ -1,20 +1,17 @@ -export const escape = function (id) { - return id.replace(/(:|\[|\]|\?|,|\.|\s+)/g, '-'); -}; +export const escape = id => ( + id.replace(/(:|\[|\]|\?|,|\.|\s+)/g, '-') +); -export const getPlatformRowId = function (repoName, resultsetId, platformName, platformOptions) { +export const getPlatformRowId = (repoName, pushId, platformName, platformOptions) => ( // ensure there are no invalid characters in the id (like spaces, etc) - return escape(repoName + - resultsetId + - platformName + - platformOptions); -}; + escape(repoName + pushId + platformName + platformOptions) +); -export const getResultsetTableId = function (repoName, resultsetId, revision) { - return escape(repoName + resultsetId + revision); -}; +export const getPushTableId = (repoName, pushId, revision) => ( + escape(repoName + pushId + revision) +); -export const getGroupMapKey = function (result_set_id, grSymbol, grTier, plName, plOpt) { +export const getGroupMapKey = (pushId, grSymbol, grTier, plName, plOpt) => ( //Build string key for groupMap entries - return escape(result_set_id + grSymbol + grTier + plName + plOpt); -}; + escape(pushId + grSymbol + grTier + plName + plOpt) +); diff --git a/ui/job-view/redux/configureStore.js b/ui/job-view/redux/configureStore.js deleted file mode 100644 index f7b7befb4..000000000 --- a/ui/job-view/redux/configureStore.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createStore, bindActionCreators, combineReducers } from 'redux'; -import * as pushes from './modules/pushes'; - -export default () => { - const reducer = combineReducers({ - pushes: pushes.reducer, - }); - const store = createStore(reducer); - const actions = { - pushes: bindActionCreators(pushes.actions, store.dispatch), - }; - - return { store, actions }; -}; diff --git a/ui/job-view/redux/modules/pushes.js b/ui/job-view/redux/modules/pushes.js deleted file mode 100644 index 404b2388a..000000000 --- a/ui/job-view/redux/modules/pushes.js +++ /dev/null @@ -1,60 +0,0 @@ -export const types = { - SET_COUNTS_EXPANDED: 'SET_COUNTS_EXPANDED', - SET_SHOW_DUPLICATES: 'SET_SHOW_DUPLICATES', - SET_SELECTED_JOB: 'SET_SELECTED_JOB', - SET_SELECTED_RUNNABLE_JOBS: 'SET_SELECTED_RUNNABLE_JOBS', -}; - -export const actions = { - setSelectedJobId: jobId => ({ - type: types.SET_SELECTED_JOB, - payload: { - jobId, - } - }), - setSelectedRunnableJobs: selectedRunnableJobs => ({ - type: types.SET_SELECTED_RUNNABLE_JOBS, - payload: { - selectedRunnableJobs, - } - }), - setCountExpanded: countsExpanded => ({ - type: types.SET_COUNTS_EXPANDED, - payload: { - countsExpanded - } - }), - setShowDuplicates: showDuplicates => ({ - type: types.SET_SHOW_DUPLICATES, - payload: { - showDuplicates - } - }), - -}; - -const initialState = { - selectedJobId: null, - selectedRunnableJobs: null, - countsExpanded: false, - showDuplicates: false, -}; - -export const reducer = (state = initialState, action) => { - switch (action.type) { - case types.SET_SELECTED_JOB: - return { - ...state, - selectedJobId: action.payload.jobId - }; - case types.SET_SELECTED_RUNNABLE_JOBS: - case types.SET_SHOW_DUPLICATES: - case types.SET_COUNTS_EXPANDED: - return { - ...state, - ...action.payload - }; - default: - return state; - } -}; diff --git a/ui/job-view/redux/store.js b/ui/job-view/redux/store.js deleted file mode 100644 index 3c3631c29..000000000 --- a/ui/job-view/redux/store.js +++ /dev/null @@ -1,3 +0,0 @@ -import configureStore from './configureStore'; - -export const { store, actions } = configureStore(); diff --git a/ui/js/controllers/jobs.js b/ui/js/controllers/jobs.js deleted file mode 100644 index 09314d374..000000000 --- a/ui/js/controllers/jobs.js +++ /dev/null @@ -1,272 +0,0 @@ -treeherderApp.controller('JobsCtrl', [ - '$scope', '$rootScope', '$routeParams', - 'thDefaultRepo', - 'ThResultSetStore', '$location', 'thEvents', - 'thNotify', - function JobsCtrl( - $scope, $rootScope, $routeParams, - thDefaultRepo, - ThResultSetStore, $location, thEvents, thNotify) { - - // load our initial set of resultsets - // scope needs this function so it can be called directly by the user, too. - $scope.getNextResultSets = function (count, keepFilters) { - var revision = $location.search().revision; - if (revision) { - $rootScope.skipNextPageReload = true; - $location.search('revision', null); - $location.search('tochange', revision); - } - ThResultSetStore.fetchResultSets($scope.repoName, count, keepFilters) - .then(function () { - - // since we fetched more resultsets, we need to persist the - // resultset state in the URL. - var rsArray = ThResultSetStore.getResultSetsArray($scope.repoName); - var updatedLastRevision = _.last(rsArray).revision; - if ($location.search().fromchange !== updatedLastRevision) { - $rootScope.skipNextPageReload = true; - $location.search('fromchange', updatedLastRevision); - } - }); - }; - - // set to the default repo if one not specified - if ($routeParams.hasOwnProperty("repo") && - $routeParams.repo !== "") { - $rootScope.repoName = $routeParams.repo; - } else { - $rootScope.repoName = thDefaultRepo; - $location.search("repo", $rootScope.repoName); - } - - ThResultSetStore.addRepository($scope.repoName); - - $scope.isLoadingRsBatch = ThResultSetStore.getLoadingStatus($scope.repoName); - $scope.result_sets = ThResultSetStore.getResultSetsArray($scope.repoName); - $scope.job_map = ThResultSetStore.getJobMap($scope.repoName); - - $scope.searchParams = $location.search(); - $scope.locationHasSearchParam = function (prop) { - return _.has($scope.searchParams, prop); - }; - - $scope.getSearchParamValue = function (param) { - var params = $location.search(); - var searchParamValue = params[param]; - // in the event the user manually strips off the search - // parameter and its = sign, which would return true - if (searchParamValue === true) { - return ""; - } - return searchParamValue; - }; - - if ($location.search().revision === 'undefined') { - thNotify.send("Invalid value for revision parameter.", 'danger'); - } - - if (ThResultSetStore.isNotLoaded($scope.repoName)) { - // get our first set of resultsets - ThResultSetStore.fetchResultSets( - $scope.repoName, - ThResultSetStore.defaultResultSetCount, - true); - } - } -]); - - -treeherderApp.controller('ResultSetCtrl', [ - '$scope', '$rootScope', - 'thResultStatusInfo', 'thDateFormat', - 'ThResultSetStore', 'thEvents', 'thNotify', - 'thBuildApi', 'thPinboard', 'ThResultSetModel', 'dateFilter', - 'ThModelErrors', 'ThTaskclusterErrors', '$uibModal', 'thPinboardCountError', - function ResultSetCtrl( - $scope, $rootScope, - thResultStatusInfo, thDateFormat, - ThResultSetStore, thEvents, thNotify, - thBuildApi, thPinboard, ThResultSetModel, dateFilter, ThModelErrors, - ThTaskclusterErrors, $uibModal, thPinboardCountError) { - - $scope.getCountClass = function (resultStatus) { - return thResultStatusInfo(resultStatus).btnClass; - }; - $scope.getCountText = function (resultStatus) { - return thResultStatusInfo(resultStatus).countText; - }; - $scope.viewJob = function (job) { - // set the selected job - $rootScope.selectedJob = job; - }; - - /** - * Pin all jobs that pass the global filters. - * - * If optional resultsetId is passed in, then only pin jobs from that - * resultset. - */ - $scope.pinAllShownJobs = function () { - if (!thPinboard.spaceRemaining()) { - thNotify.send(thPinboardCountError, 'danger'); - return; - } - var shownJobs = ThResultSetStore.getAllShownJobs( - $rootScope.repoName, - thPinboard.spaceRemaining(), - thPinboardCountError, - $scope.resultset.id - ); - thPinboard.pinJobs(shownJobs); - - if (!$rootScope.selectedJob) { - $scope.viewJob(shownJobs[0]); - } - - }; - - $scope.showRunnableJobs = function () { - if ($scope.user.loggedin) { - $rootScope.$emit(thEvents.showRunnableJobs, $scope.resultset); - } - }; - - $scope.deleteRunnableJobs = function () { - $rootScope.$emit(thEvents.deleteRunnableJobs, $scope.resultset); - }; - - $scope.getCancelJobsTitle = function () { - if (!$scope.user || !$scope.user.loggedin) { - return "Must be logged in to cancel jobs"; - } - return "Cancel all jobs"; - }; - - $scope.canCancelJobs = function () { - return $scope.user && $scope.user.loggedin; - }; - - $scope.confirmCancelAllJobs = function () { - $scope.showConfirmCancelAll = true; - }; - - $scope.hideConfirmCancelAll = function () { - $scope.showConfirmCancelAll = false; - }; - - $scope.cancelAllJobs = function (revision) { - $scope.showConfirmCancelAll = false; - if (!$scope.canCancelJobs()) return; - - ThResultSetModel.cancelAll($scope.resultset.id, $scope.repoName).then(function () { - return thBuildApi.cancelAll($scope.repoName, revision); - }).catch(function (e) { - thNotify.send( - ThModelErrors.format(e, "Failed to cancel all jobs"), - 'danger', true - ); - }); - }; - - $scope.customPushAction = function () { - $uibModal.open({ - templateUrl: 'partials/main/tcjobactions.html', - controller: 'TCJobActionsCtrl', - size: 'lg', - resolve: { - job: () => null, - repoName: function () { - return $scope.repoName; - }, - resultsetId: function () { - return $scope.resultset.id; - } - } - }); - }; - - $scope.triggerMissingJobs = function (revision) { - if (!window.confirm('This will trigger all missing jobs for revision ' + revision + '!\n\nClick "OK" if you want to proceed.')) { - return; - } - - ThResultSetStore.getGeckoDecisionTaskId( - $scope.repoName, - $scope.resultset.id - ).then(function (decisionTaskID) { - ThResultSetModel.triggerMissingJobs( - decisionTaskID - ).then(function (msg) { - thNotify.send(msg, "success"); - }, function (e) { - thNotify.send( - ThModelErrors.format(e, "The action 'trigger missing jobs' failed"), - 'danger', true - ); - }); - }); - }; - - $scope.triggerAllTalosJobs = function (revision) { - if (!window.confirm('This will trigger all talos jobs for revision ' + revision + '!\n\nClick "OK" if you want to proceed.')) { - return; - } - - var times = parseInt(window.prompt("Enter number of instances to have for each talos job", 6)); - while (times < 1 || times > 6 || isNaN(times)) { - times = window.prompt("We only allow instances of each talos job to be between 1 to 6 times. Enter again", 6); - } - - ThResultSetStore.getGeckoDecisionTaskId( - $scope.repoName, - $scope.resultset.id - ).then(function (decisionTaskID) { - ThResultSetModel.triggerAllTalosJobs( - times, - decisionTaskID - ).then(function (msg) { - thNotify.send(msg, "success"); - }, function (e) { - thNotify.send(ThTaskclusterErrors.format(e), 'danger', { sticky: true }); - }); - }); - }; - - $scope.showTriggerButton = function () { - var buildernames = ThResultSetStore.getSelectedRunnableJobs($rootScope.repoName, $scope.resultset.id); - return buildernames.length > 0; - }; - - $scope.triggerNewJobs = function () { - if (!window.confirm( - 'This will trigger all selected jobs. Click "OK" if you want to proceed.')) { - return; - } - if ($scope.user.loggedin) { - var buildernames = ThResultSetStore.getSelectedRunnableJobs($rootScope.repoName, $scope.resultset.id); - ThResultSetStore.getGeckoDecisionTaskId($rootScope.repoName, $scope.resultset.id).then(function (decisionTaskID) { - ThResultSetModel.triggerNewJobs(buildernames, decisionTaskID).then(function (result) { - thNotify.send(result, "success"); - ThResultSetStore.deleteRunnableJobs($scope.repoName, $scope.resultset); - }, function (e) { - thNotify.send(ThTaskclusterErrors.format(e), 'danger', { sticky: true }); - }); - }); - } else { - thNotify.send("Must be logged in to trigger a job", 'danger'); - } - }; - - $scope.revisionResultsetFilterUrl = $scope.urlBasePath + "?repo=" + - $scope.repoName + "&revision=" + - $scope.resultset.revision; - - $scope.resultsetDateStr = dateFilter($scope.resultset.push_timestamp*1000, - thDateFormat); - - $scope.authorResultsetFilterUrl = $scope.urlBasePath + "?repo=" + - $scope.repoName + "&author=" + - encodeURIComponent($scope.resultset.author); - } -]); diff --git a/ui/js/controllers/main.js b/ui/js/controllers/main.js index 0297154fe..3cfdadd2d 100644 --- a/ui/js/controllers/main.js +++ b/ui/js/controllers/main.js @@ -26,6 +26,18 @@ treeherderApp.controller('MainCtrl', [ // Ensure user is available on initial page load $rootScope.user = {}; + // set to the default repo if one not specified + const repoName = $location.search().repo; + if (repoName) { + $rootScope.repoName = repoName; + } else { + $rootScope.repoName = thDefaultRepo; + $location.search("repo", $rootScope.repoName); + } + $rootScope.revision = $location.search().revision; + ThResultSetStore.addRepository($rootScope.repoName); + + thClassificationTypes.load(); var checkServerRevision = function () { @@ -41,9 +53,6 @@ treeherderApp.controller('MainCtrl', [ }); }; - // Trigger missing jobs is dangerous on repos other than these (see bug 1335506) - $scope.triggerMissingRepos = ['mozilla-inbound', 'autoland']; - $scope.updateButtonClick = function () { if (window.confirm("Reload the page to pick up Treeherder updates?")) { window.location.reload(true); @@ -138,23 +147,12 @@ treeherderApp.controller('MainCtrl', [ $rootScope.selectedJob = null; // Clear the selected job display style - $rootScope.$emit(thEvents.clearSelectedJob, $rootScope.selectedJob); + $rootScope.$emit(thEvents.clearSelectedJob); // Reset selected job to null to initialize nav position ThResultSetStore.setSelectedJob($rootScope.repoName); }; - // Clear the job if it occurs in a particular area - $scope.clearJobOnClick = function (event) { - var element = event.target; - // Suppress for various UI elements so selection is preserved - var ignoreClear = element.hasAttribute("data-ignore-job-clear-on-click"); - - if (!ignoreClear && !thPinboard.hasPinnedJobs()) { - $scope.closeJob(); - } - }; - $scope.repoModel = ThRepositoryModel; /** diff --git a/ui/js/directives/treeherder/bottom_nav_panel.js b/ui/js/directives/treeherder/bottom_nav_panel.js index ede5b842c..e89b00ad3 100644 --- a/ui/js/directives/treeherder/bottom_nav_panel.js +++ b/ui/js/directives/treeherder/bottom_nav_panel.js @@ -1,31 +1,31 @@ -treeherder.directive('thPinnedJob', [ - 'thResultStatusInfo', 'thResultStatus', - function (thResultStatusInfo, thResultStatus) { +import { getBtnClass, getStatus } from "../../../helpers/jobHelper"; - var getHoverText = function (job) { - var duration = Math.round((job.end_timestamp - job.start_timestamp) / 60); - var status = thResultStatus(job); - return job.job_type_name + " - " + status + " - " + duration + "mins"; - }; +treeherder.directive('thPinnedJob', function () { - return { - restrict: "E", - link: function (scope) { - var unbindWatcher = scope.$watch("job", function () { - var resultState = thResultStatus(scope.job); - scope.job.display = thResultStatusInfo(resultState, scope.job.failure_classification_id); - scope.hoverText = getHoverText(scope.job); + var getHoverText = function (job) { + var duration = Math.round((job.end_timestamp - job.start_timestamp) / 60); + var status = getStatus(job); + return job.job_type_name + " - " + status + " - " + duration + "mins"; + }; - if (scope.job.state === "completed") { - //Remove watchers when a job has a completed status - unbindWatcher(); - } + return { + restrict: "E", + link: function (scope) { + var unbindWatcher = scope.$watch("job", function () { + var resultState = getStatus(scope.job); + scope.job.btnClass = getBtnClass(resultState, scope.job.failure_classification_id); + scope.hoverText = getHoverText(scope.job); - }, true); - }, - templateUrl: 'partials/main/thPinnedJob.html' - }; - }]); + if (scope.job.state === "completed") { + //Remove watchers when a job has a completed status + unbindWatcher(); + } + + }, true); + }, + templateUrl: 'partials/main/thPinnedJob.html' + }; +}); treeherder.directive('thRelatedBugQueued', function () { diff --git a/ui/js/directives/treeherder/clonejobs.js b/ui/js/directives/treeherder/clonejobs.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/ui/js/directives/treeherder/resultsets.js b/ui/js/directives/treeherder/resultsets.js deleted file mode 100644 index 593fb3eb2..000000000 --- a/ui/js/directives/treeherder/resultsets.js +++ /dev/null @@ -1,48 +0,0 @@ -treeherder.directive('thActionButton', function () { - return { - restrict: "E", - templateUrl: 'partials/main/thActionButton.html' - }; -}); - -treeherder.directive('thResultCounts', [ - 'thEvents', '$rootScope', function (thEvents, $rootScope) { - - return { - restrict: "E", - link: function (scope) { - var setTotalCount = function () { - if (scope.resultset.job_counts) { - scope.inProgress = scope.resultset.job_counts.pending + - scope.resultset.job_counts.running; - var total = scope.resultset.job_counts.completed + scope.inProgress; - scope.percentComplete = total > 0 ? - Math.floor(((scope.resultset.job_counts.completed / total) * 100)) : undefined; - } - }; - - $rootScope.$on(thEvents.applyNewJobs, function (evt, resultSetId) { - if (resultSetId === scope.resultset.id) { - setTotalCount(); - } - }); - - }, - templateUrl: 'partials/main/thResultCounts.html' - }; - }]); - -treeherder.directive('thAuthor', function () { - - return { - restrict: "E", - link: function (scope, element, attrs) { - var authorMatch = attrs.author.match(/\<(.*?)\>+/); - scope.authorEmail = authorMatch ? authorMatch[1] : attrs.author; - }, - template: '' + - '{{authorEmail}}' - }; -}); - diff --git a/ui/js/directives/treeherder/top_nav_bar.js b/ui/js/directives/treeherder/top_nav_bar.js index 28c2c7264..529c63d3d 100644 --- a/ui/js/directives/treeherder/top_nav_bar.js +++ b/ui/js/directives/treeherder/top_nav_bar.js @@ -1,3 +1,5 @@ +import { getBtnClass } from '../../../helpers/jobHelper'; + treeherder.directive('thWatchedRepo', [ 'ThLog', 'ThRepositoryModel', function (ThLog, ThRepositoryModel) { @@ -152,15 +154,12 @@ treeherder.directive('thRepoMenuItem', }; }); -treeherder.directive('thResultStatusChicklet', [ - 'thResultStatusInfo', function (thResultStatusInfo) { - return { - restrict: "E", - link: function (scope) { - scope.chickletClass = thResultStatusInfo(scope.filterName).btnClass + - "-filter-chicklet"; - }, - templateUrl: 'partials/main/thResultStatusChicklet.html' - }; - } -]); +treeherder.directive('thResultStatusChicklet', function () { + return { + restrict: "E", + link: function (scope) { + scope.chickletClass = `${getBtnClass(scope.filterName)}-filter-chicklet`; + }, + templateUrl: 'partials/main/thResultStatusChicklet.html' + }; +}); diff --git a/ui/js/models/resultsets_store.js b/ui/js/models/resultsets_store.js index 2d6a68d0b..154a248b4 100644 --- a/ui/js/models/resultsets_store.js +++ b/ui/js/models/resultsets_store.js @@ -348,9 +348,11 @@ treeherder.factory('ThResultSetStore', [ }); }; - var deleteRunnableJobs = function (repoName, resultSet) { - repositories[repoName].rsMap[resultSet.id].selected_runnable_jobs = []; - resultSet.isRunnableVisible = false; + var deleteRunnableJobs = function (repoName, pushId) { + const push = repositories[repoName].rsMap[pushId]; + push.selected_runnable_jobs = []; + push.rs_obj.isRunnableVisible = false; + $rootScope.$emit(thEvents.selectRunnableJob, []); $rootScope.$emit(thEvents.globalFilterChanged); }; @@ -671,7 +673,7 @@ treeherder.factory('ThResultSetStore', [ revision = repositories[repoName].rsMap[resultsetId].rs_obj.revision; - resultsetAggregateId = thAggregateIds.getResultsetTableId( + resultsetAggregateId = thAggregateIds.getPushTableId( $rootScope.repoName, resultsetId, revision ); @@ -806,21 +808,18 @@ treeherder.factory('ThResultSetStore', [ isInResultSetRange(repoName, data.results[i].push_timestamp) && repositories[repoName].rsMap[data.results[i].id] === undefined) { - $log.debug("prepending resultset: ", data.results[i].id); repositories[repoName].resultSets.push(data.results[i]); added.push(data.results[i]); - } else { - $log.debug("not prepending. timestamp is older"); } } mapResultSets(repoName, added); repositories[repoName].loadingStatus.prepending = false; + $rootScope.$emit(thEvents.pushesLoaded); }; var appendResultSets = function (repoName, data) { - if (data.results.length > 0) { $log.debug("appendResultSets", data.results); @@ -850,6 +849,7 @@ treeherder.factory('ThResultSetStore', [ } repositories[repoName].loadingStatus.appending = false; + $rootScope.$emit(thEvents.pushesLoaded); }; /** @@ -883,19 +883,18 @@ treeherder.factory('ThResultSetStore', [ return repositories[repoName].resultSets; }; - var getResultSetsMap = function (repoName) { - return repositories[repoName].rsMap; - }; - var getResultSet = function (repoName, resultsetId) { return repositories[repoName].rsMap[resultsetId].rs_obj; }; - var getSelectedRunnableJobs = function (repoName, resultsetId) { - if (!repositories[repoName].rsMap[resultsetId].selected_runnable_jobs) { - repositories[repoName].rsMap[resultsetId].selected_runnable_jobs = []; + var getSelectedRunnableJobs = function (repoName, pushId) { + if (!repositories[repoName].rsMap[pushId]) { + return 0; } - return repositories[repoName].rsMap[resultsetId].selected_runnable_jobs; + if (!repositories[repoName].rsMap[pushId].selected_runnable_jobs) { + repositories[repoName].rsMap[pushId].selected_runnable_jobs = []; + } + return repositories[repoName].rsMap[pushId].selected_runnable_jobs; }; var getGeckoDecisionJob = function (repoName, resultsetId) { @@ -948,27 +947,14 @@ treeherder.factory('ThResultSetStore', [ } else { selectedRunnableJobs.splice(jobIndex, 1); } + $rootScope.$emit(thEvents.selectRunnableJob, selectedRunnableJobs, resultsetId); return selectedRunnableJobs; }; - var isRunnableJobSelected = function (repoName, resultsetId, buildername) { - var selectedRunnableJobs = getSelectedRunnableJobs(repoName, resultsetId); - return selectedRunnableJobs.indexOf(buildername) !== -1; - }; - var getJobMap = function (repoName) { // this is a "watchable" for jobs return repositories[repoName].jobMap; }; - var getGroupMap = function (repoName) { - return repositories[repoName].grpMap; - }; - var getLoadingStatus = function (repoName) { - return repositories[repoName].loadingStatus; - }; - var isNotLoaded = function (repoName) { - return _.isEmpty(repositories[repoName].rsMap); - }; var fetchResultSets = function (repoName, count, keepFilters) { /** @@ -1184,22 +1170,15 @@ treeherder.factory('ThResultSetStore', [ fetchResultSets: fetchResultSets, getAllShownJobs: getAllShownJobs, getJobMap: getJobMap, - getGroupMap: getGroupMap, - getLoadingStatus: getLoadingStatus, - getPlatformKey: getPlatformKey, addRunnableJobs: addRunnableJobs, - isRunnableJobSelected: isRunnableJobSelected, getSelectedRunnableJobs: getSelectedRunnableJobs, - getGeckoDecisionJob: getGeckoDecisionJob, getGeckoDecisionTaskId: getGeckoDecisionTaskId, toggleSelectedRunnableJob: toggleSelectedRunnableJob, getResultSet: getResultSet, getResultSetsArray: getResultSetsArray, - getResultSetsMap: getResultSetsMap, getSelectedJob: getSelectedJob, getFilteredUnclassifiedFailureCount: getFilteredUnclassifiedFailureCount, getAllUnclassifiedFailureCount: getAllUnclassifiedFailureCount, - isNotLoaded: isNotLoaded, setSelectedJob: setSelectedJob, updateUnclassifiedFailureMap: updateUnclassifiedFailureMap, defaultResultSetCount: defaultResultSetCount, diff --git a/ui/js/providers.js b/ui/js/providers.js index 7a2f0bfa5..d3b28ae39 100644 --- a/ui/js/providers.js +++ b/ui/js/providers.js @@ -25,17 +25,6 @@ treeherder.provider('thResultStatusList', function () { }; }); -treeherder.provider('thResultStatus', function () { - this.$get = function () { - return function (job) { - if (job.state === "completed") { - return job.result; - } - return job.state; - }; - }; -}); - treeherder.provider('thResultStatusObject', function () { var getResultStatusObject = function () { return { @@ -52,95 +41,6 @@ treeherder.provider('thResultStatusObject', function () { }; }); -treeherder.provider('thResultStatusInfo', function () { - this.$get = function () { - return function (resultState, failure_classification_id) { - // default if there is no match, used for pending - var resultStatusInfo = { - btnClass: "btn-default" - }; - - switch (resultState) { - case "busted": - case "failures": - resultStatusInfo = { - btnClass: "btn-red", - countText: "busted" - }; - break; - case "exception": - resultStatusInfo = { - btnClass: "btn-purple", - countText: "exception" - }; - break; - case "testfailed": - resultStatusInfo = { - btnClass: "btn-orange", - countText: "failed" - }; - break; - case "unknown": - resultStatusInfo = { - btnClass: "btn-yellow", - countText: "unknown" - }; - break; - case "usercancel": - resultStatusInfo = { - btnClass: "btn-pink", - countText: "cancel" - }; - break; - case "retry": - resultStatusInfo = { - btnClass: "btn-dkblue", - countText: "retry" - }; - break; - case "success": - resultStatusInfo = { - btnClass: "btn-green", - countText: "success" - }; - break; - case "running": - case "in progress": - resultStatusInfo = { - btnClass: "btn-dkgray", - countText: "running" - }; - break; - case "pending": - resultStatusInfo = { - btnClass: "btn-ltgray", - countText: "pending" - }; - break; - case "superseded": - resultStatusInfo = { - btnClass: "btn-ltblue", - countText: "superseded" - }; - break; - } - - // handle if a job is classified - var classificationId = parseInt(failure_classification_id, 10); - if (classificationId > 1) { - resultStatusInfo.btnClass += "-classified"; - // autoclassification-only case - if (classificationId === 7) { - resultStatusInfo.btnClass += " autoclassified"; - } - resultStatusInfo.countText = "classified " + resultStatusInfo.countText; - } - return resultStatusInfo; - }; - - }; -}); - /** * The set of custom Treeherder events. * @@ -183,6 +83,9 @@ treeherder.provider('thEvents', function () { // after loading a group of jobs jobsLoaded: "jobs-loaded-EVT", + // when new pushes are prepended, or appended + pushesLoaded: "pushes-loaded-EVT", + // after deselecting a job via click outside/esc clearSelectedJob: "clear-selected-job-EVT", @@ -239,8 +142,9 @@ treeherder.provider('thEvents', function () { autoclassifyToggleExpandOptions: "ac-toggle-expand-options-EVT", - autoclassifyToggleEdit: "ac-toggle-edit-EVT" + autoclassifyToggleEdit: "ac-toggle-edit-EVT", + selectRunnableJob: "select-runnable-job-EVT", }; }; }); @@ -249,7 +153,7 @@ treeherder.provider('thAggregateIds', function () { this.$get = function () { return { getPlatformRowId: aggregateIds.getPlatformRowId, - getResultsetTableId: aggregateIds.getResultsetTableId, + getPushTableId: aggregateIds.getPushTableId, getGroupMapKey: aggregateIds.getGroupMapKey, escape: aggregateIds.escape }; diff --git a/ui/js/reactrevisions.jsx b/ui/js/reactrevisions.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/ui/js/services/jobfilters.js b/ui/js/services/jobfilters.js index 33eb7b1f1..1dbba7c31 100644 --- a/ui/js/services/jobfilters.js +++ b/ui/js/services/jobfilters.js @@ -1,3 +1,4 @@ +import { getStatus } from '../../helpers/jobHelper'; /** This service handles whether or not a job, job group or platform row should be displayed based on the filter settings. @@ -18,13 +19,13 @@ */ treeherder.factory('thJobFilters', [ 'thResultStatusList', 'ThLog', '$rootScope', '$location', - 'thEvents', 'thFailureResults', - 'thResultStatus', 'thClassificationTypes', + 'thEvents', 'thFailureResults', '$timeout', + 'thClassificationTypes', 'thPlatformName', function ( thResultStatusList, ThLog, $rootScope, $location, - thEvents, thFailureResults, - thResultStatus, thClassificationTypes, + thEvents, thFailureResults, $timeout, + thClassificationTypes, thPlatformName) { const $log = new ThLog("thJobFilters"); @@ -193,9 +194,10 @@ treeherder.factory('thJobFilters', [ function showJob(job) { // when runnable jobs have been added to a resultset, they should be // shown regardless of settings for classified or result state - if (job.result !== "runnable") { + const status = getStatus(job); + if (status !== "runnable") { // test against resultStatus and classifiedState - if (cachedResultStatusFilters.indexOf(thResultStatus(job)) === -1) { + if (cachedResultStatusFilters.indexOf(status) === -1) { return false; } if (!_checkClassifiedStateFilters(job)) { @@ -281,7 +283,9 @@ treeherder.factory('thJobFilters', [ newQsVal = null; } $log.debug("add set " + _withPrefix(field) + " from " + oldQsVal + " to " + newQsVal); - $location.search(_withPrefix(field), newQsVal); + $timeout(() => { + $location.search(_withPrefix(field), newQsVal); + }, 0); } function removeFilter(field, value) { diff --git a/ui/js/services/log.js b/ui/js/services/log.js index 3e1012dc7..e9f06a9ad 100644 --- a/ui/js/services/log.js +++ b/ui/js/services/log.js @@ -41,11 +41,11 @@ treeherder.factory('ThLog', [ /** * You can use this to configure which debug lines you want to see in your - * ``local.conf.js`` file. You can see ONLY ``ResultSetCtrl`` lines by adding + * ``local.conf.js`` file. You can see ONLY ``foo`` lines by adding * a line like: * * ThLogConfigProvider.setWhitelist([ - * 'ResultSetCtrl' + * 'foo' * ]); * * Note: even though this is called ThLogConfig, when you configure it, you must diff --git a/ui/js/services/main.js b/ui/js/services/main.js index 401680345..8b2dc9d95 100755 --- a/ui/js/services/main.js +++ b/ui/js/services/main.js @@ -198,6 +198,32 @@ treeherder.factory('Ajv', [ return require('ajv'); }]); +// The Custom Actions modal is accessible from both the PushActionMenu and the +// job-details-actionbar. So leave this as Angular for now, till we +// migrate job-details-actionbar to React. +treeherder.factory('customPushActions', [ + '$uibModal', + function ($uibModal) { + return { + open(repoName, pushId) { + $uibModal.open({ + templateUrl: 'partials/main/tcjobactions.html', + controller: 'TCJobActionsCtrl', + size: 'lg', + resolve: { + job: () => null, + repoName: function () { + return repoName; + }, + resultsetId: function () { + return pushId; + } + } + }); + } + }; + }]); + treeherder.factory('jsonSchemaDefaults', [ function () { return require('json-schema-defaults'); diff --git a/ui/js/treeherder_app.js b/ui/js/treeherder_app.js index ca10ee269..fd995d315 100644 --- a/ui/js/treeherder_app.js +++ b/ui/js/treeherder_app.js @@ -32,15 +32,11 @@ treeherderApp.config(['$compileProvider', '$locationProvider', '$routeProvider', $routeProvider .when('/jobs', { - controller: 'JobsCtrl', - templateUrl: 'partials/main/jobs.html', // see controllers/filters.js ``skipNextSearchChangeReload`` for // why we set this to false. reloadOnSearch: false }) .when('/jobs/:tree', { - controller: 'JobsCtrl', - templateUrl: 'partials/main/jobs.html', reloadOnSearch: false }) .otherwise({ redirectTo: '/jobs' }); diff --git a/ui/js/values.js b/ui/js/values.js index 9dce830d1..fd79d10c6 100644 --- a/ui/js/values.js +++ b/ui/js/values.js @@ -159,12 +159,11 @@ treeherder.value("thJobNavSelectors", { ALL_JOBS: { name: "jobs", - selector: ".job-btn, .selected-job, .selected-count" + selector: ".job-btn, .selected-job" }, UNCLASSIFIED_FAILURES: { name: "unclassified failures", selector: ".selected-job, " + - ".selected-count, " + ".job-btn.btn-red, " + ".job-btn.btn-orange, " + ".job-btn.btn-purple, " + diff --git a/ui/partials/main/jobs.html b/ui/partials/main/jobs.html deleted file mode 100755 index ae324ac0d..000000000 --- a/ui/partials/main/jobs.html +++ /dev/null @@ -1,131 +0,0 @@ - - - -
    -
    -
    - - -
    - -
    - - - - {{::resultsetDateStr}} - - - - - - - - View Tests - - - - - -
    -
    -
    -
    - This action will cancel all pending and running jobs for this push. - It cannot be undone! -   - -
    -
    - -
    - - -
    - - - Waiting for a push with revision {{revision}} - (view pushlog) - -
    If the push exists, it will appear in a few minutes once it has been processed.
    -
    - - This is an invalid revision parameter. Please change it, or click - here to reload the latest revisions from {{repoName}}. - - -
    -
    - -
    - -
    No pushes found.
    - No commit information could be loaded for this repository. - More information about this repository can be found here. -
    -
    - -
    - -
    Unknown repository.
    - This repository is either unknown to Treeherder or it doesn't exist. - If this repository does exist, please - - file a bug against the Treeherder product in Bugzilla to get it added to the system. - -
    -
    - - - -
    -
    -
    - - -
    - get next: -
    -
    {{::count}}
    -
    -
    diff --git a/ui/partials/main/thActionButton.html b/ui/partials/main/thActionButton.html deleted file mode 100644 index 5c60a025f..000000000 --- a/ui/partials/main/thActionButton.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - diff --git a/ui/partials/main/thFilterCheckbox.html b/ui/partials/main/thFilterCheckbox.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/ui/partials/main/thPinnedJob.html b/ui/partials/main/thPinnedJob.html index dd8d107cd..a7e92154c 100644 --- a/ui/partials/main/thPinnedJob.html +++ b/ui/partials/main/thPinnedJob.html @@ -1,5 +1,5 @@ - - - Complete - - {{percentComplete}}% - {{inProgress}} in progress - diff --git a/ui/plugins/controller.js b/ui/plugins/controller.js index 0a71e7558..b955e546c 100644 --- a/ui/plugins/controller.js +++ b/ui/plugins/controller.js @@ -1,11 +1,12 @@ import { Queue, slugid } from 'taskcluster-client-web'; import thTaskcluster from '../js/services/taskcluster'; +import { getStatus } from '../helpers/jobHelper'; treeherder.controller('PluginCtrl', [ '$scope', '$rootScope', '$location', '$http', '$interpolate', '$uibModal', 'thUrl', 'ThJobClassificationModel', 'thClassificationTypes', 'ThJobModel', 'thEvents', 'dateFilter', 'thDateFormat', - 'numberFilter', 'ThBugJobMapModel', 'thResultStatus', 'thJobFilters', + 'numberFilter', 'ThBugJobMapModel', 'thJobFilters', 'ThLog', '$q', 'thPinboard', 'ThJobDetailModel', 'thBuildApi', 'thNotify', 'ThJobLogUrlModel', 'ThModelErrors', 'ThTaskclusterErrors', 'thTabs', '$timeout', 'thReftestStatus', 'ThResultSetStore', @@ -14,7 +15,7 @@ treeherder.controller('PluginCtrl', [ $scope, $rootScope, $location, $http, $interpolate, $uibModal, thUrl, ThJobClassificationModel, thClassificationTypes, ThJobModel, thEvents, dateFilter, thDateFormat, - numberFilter, ThBugJobMapModel, thResultStatus, thJobFilters, + numberFilter, ThBugJobMapModel, thJobFilters, ThLog, $q, thPinboard, ThJobDetailModel, thBuildApi, thNotify, ThJobLogUrlModel, ThModelErrors, ThTaskclusterErrors, thTabs, $timeout, thReftestStatus, ThResultSetStore, PhSeries, @@ -92,7 +93,7 @@ treeherder.controller('PluginCtrl', [ successTab = 'perfDetails'; } - if (thResultStatus(job) === 'success') { + if (getStatus(job) === 'success') { $scope.tabService.selectedTab = successTab; } else { $scope.tabService.selectedTab = failTab; @@ -191,7 +192,7 @@ treeherder.controller('PluginCtrl', [ if ($scope.job_log_urls.length) { $scope.reftestUrl = reftestUrlRoot + $scope.job_log_urls[0].url + "&only_show_unexpected=1"; } - $scope.resultStatusShading = "result-status-shading-" + thResultStatus($scope.job); + $scope.resultStatusShading = "result-status-shading-" + getStatus($scope.job); var performanceData = _.flatten(Object.values(results[3])); if (performanceData) { diff --git a/ui/plugins/similar_jobs/controller.js b/ui/plugins/similar_jobs/controller.js index b306e4161..d00a62491 100755 --- a/ui/plugins/similar_jobs/controller.js +++ b/ui/plugins/similar_jobs/controller.js @@ -1,12 +1,14 @@ +import { getBtnClass, getStatus } from "../../helpers/jobHelper"; + treeherder.controller('SimilarJobsPluginCtrl', [ - '$scope', 'ThLog', 'ThJobModel', 'ThTextLogStepModel', 'thResultStatusInfo', + '$scope', 'ThLog', 'ThJobModel', 'ThTextLogStepModel', 'numberFilter', 'dateFilter', 'thClassificationTypes', - 'thResultStatus', 'ThResultSetModel', 'thNotify', + 'ThResultSetModel', 'thNotify', 'thTabs', function SimilarJobsPluginCtrl( - $scope, ThLog, ThJobModel, ThTextLogStepModel, thResultStatusInfo, + $scope, ThLog, ThJobModel, ThTextLogStepModel, numberFilter, dateFilter, thClassificationTypes, - thResultStatus, ThResultSetModel, thNotify, + ThResultSetModel, thNotify, thTabs) { var $log = new ThLog(this.constructor.name); @@ -85,20 +87,14 @@ treeherder.controller('SimilarJobsPluginCtrl', [ $scope.similar_jobs = []; - $scope.result_status_info = thResultStatusInfo; - $scope.similar_jobs_filters = { machine_id: false, build_platform_id: true, option_collection_hash: true }; - $scope.button_class = function (job) { - var resultState = job.result; - if (job.state !== "completed") { - resultState = job.state; - } - return thResultStatusInfo(resultState).btnClass; - }; + $scope.button_class = job => ( + getBtnClass(getStatus(job)) + ); // this is triggered by the show more link $scope.show_next = function () { @@ -112,7 +108,7 @@ treeherder.controller('SimilarJobsPluginCtrl', [ ThJobModel.get($scope.repoName, job.id) .then(function (job) { $scope.similar_job_selected = job; - $scope.similar_job_selected.result_status = thResultStatus($scope.similar_job_selected); + $scope.similar_job_selected.result_status = getStatus($scope.similar_job_selected); var duration = ( $scope.similar_job_selected.end_timestamp - $scope.similar_job_selected.start_timestamp )/60;