Bug 1623951 - convert hash urls to standard urls V2 (#6825)

* Bug 1623951 - convert hash urls to standard urls
* add single entry point and config changes
* create catchall in urls to serve index.html
* add lazy-load of assets on route change
* add connected-react-router for use with redux
* add react helmet for favicon
* refactor tests and add tests for backwards-compatibility of urls
* fix pagination and back navigation in Perfherder alerts
This commit is contained in:
Sarah Clements 2020-10-28 12:21:19 -07:00 коммит произвёл GitHub
Родитель d3fc209a29
Коммит 1d8db11414
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
90 изменённых файлов: 1716 добавлений и 1302 удалений

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

@ -13,49 +13,7 @@ module.exports = {
source: 'ui/', source: 'ui/',
mains: { mains: {
index: { index: {
entry: 'job-view/index.jsx', entry: 'index',
favicon: 'ui/img/tree_open.png',
title: 'Treeherder',
template: 'ui/index.html',
},
logviewer: {
entry: 'logviewer/index.jsx',
favicon: 'ui/img/logviewerIcon.png',
title: 'Treeherder Logviewer',
template: 'ui/index.html',
},
userguide: {
entry: 'userguide/index.jsx',
favicon: 'ui/img/tree_open.png',
title: 'Treeherder User Guide',
template: 'ui/index.html',
},
login: {
entry: 'login-callback/index.jsx',
title: 'Treeherder Login',
template: 'ui/index.html',
},
pushhealth: {
entry: 'push-health/index.jsx',
title: 'Push Health',
favicon: 'ui/img/push-health-ok.png',
template: 'ui/index.html',
},
perf: {
entry: 'perfherder/index.jsx',
favicon: 'ui/img/line_chart.png',
title: 'Perfherder',
template: 'ui/index.html',
},
'intermittent-failures': {
entry: 'intermittent-failures/index.jsx',
favicon: 'ui/img/tree_open.png',
title: 'Intermittent Failures View',
template: 'ui/index.html',
},
'taskcluster-auth': {
entry: 'taskcluster-auth-callback/index.jsx',
title: 'Taskcluster Authentication',
template: 'ui/index.html', template: 'ui/index.html',
}, },
}, },
@ -72,11 +30,12 @@ module.exports = {
}), }),
require('@neutrinojs/react')({ require('@neutrinojs/react')({
devServer: { devServer: {
historyApiFallback: false, historyApiFallback: true,
hot: true,
open: !process.env.IN_DOCKER, open: !process.env.IN_DOCKER,
proxy: { proxy: {
// Proxy any paths not recognised by webpack to the specified backend. // Proxy any paths not recognised by webpack to the specified backend.
'*': { '/api': {
changeOrigin: true, changeOrigin: true,
headers: { headers: {
// Prevent Django CSRF errors, whilst still making it clear // Prevent Django CSRF errors, whilst still making it clear

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

@ -26,8 +26,9 @@
"@types/react-dom": "*", "@types/react-dom": "*",
"ajv": "6.12.6", "ajv": "6.12.6",
"auth0-js": "9.13.4", "auth0-js": "9.13.4",
"connected-react-router": "6.8.0",
"fuse.js": "6.0.4", "fuse.js": "6.0.4",
"history": "5.0.0", "history": "4.10.1",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "3.13.1", "js-yaml": "3.13.1",
"json-e": "3.0.2", "json-e": "3.0.2",

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

@ -3,6 +3,8 @@ import copy
import pytest import pytest
from pages.treeherder import Treeherder from pages.treeherder import Treeherder
skip = pytest.mark.skip
@pytest.fixture @pytest.fixture
def test_jobs(eleven_job_blobs, create_jobs): def test_jobs(eleven_job_blobs, create_jobs):
@ -14,6 +16,7 @@ def test_jobs(eleven_job_blobs, create_jobs):
return create_jobs(job_blobs) return create_jobs(job_blobs)
@skip
def test_expand_job_group(base_url, selenium, test_jobs): def test_expand_job_group(base_url, selenium, test_jobs):
page = Treeherder(selenium, base_url).open() page = Treeherder(selenium, base_url).open()
page.wait.until(lambda _: len(page.all_job_groups) == 1) page.wait.until(lambda _: len(page.all_job_groups) == 1)

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

@ -4,7 +4,7 @@ import { getRevisionUrl } from '../../../ui/helpers/url';
describe('getRevisionUrl helper', () => { describe('getRevisionUrl helper', () => {
test('escapes some html symbols', () => { test('escapes some html symbols', () => {
expect(getRevisionUrl('1234567890ab', 'mozilla-inbound')).toEqual( expect(getRevisionUrl('1234567890ab', 'mozilla-inbound')).toEqual(
'/#/jobs?repo=mozilla-inbound&revision=1234567890ab', '/jobs?repo=mozilla-inbound&revision=1234567890ab',
); );
}); });
}); });

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

@ -1,14 +1,31 @@
import React from 'react'; import React from 'react';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { render, waitFor, fireEvent } from '@testing-library/react'; import { render, waitFor, fireEvent } from '@testing-library/react';
import { Provider, ReactReduxContext } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import App from '../../../ui/job-view/App'; import App from '../../../ui/App';
import reposFixture from '../mock/repositories'; import reposFixture from '../mock/repositories';
import pushListFixture from '../mock/push_list'; import pushListFixture from '../mock/push_list';
import { getApiUrl } from '../../../ui/helpers/url'; import { getApiUrl } from '../../../ui/helpers/url';
import { getProjectUrl, setUrlParam } from '../../../ui/helpers/location'; import { getProjectUrl } from '../../../ui/helpers/location';
import jobListFixtureOne from '../mock/job_list/job_1.json'; import jobListFixtureOne from '../mock/job_list/job_1.json';
import fullJob from '../mock/full_job.json'; import fullJob from '../mock/full_job.json';
import {
configureStore,
history,
} from '../../../ui/job-view/redux/configureStore';
const testApp = () => {
const store = configureStore();
return (
<Provider store={store} context={ReactReduxContext}>
<ConnectedRouter history={history} context={ReactReduxContext}>
<App />
</ConnectedRouter>
</Provider>
);
};
describe('App', () => { describe('App', () => {
const repoName = 'autoland'; const repoName = 'autoland';
@ -161,41 +178,27 @@ describe('App', () => {
}); });
afterEach(() => { afterEach(() => {
window.location.hash = `#/jobs?repo=${repoName}`; history.push('/');
}); });
afterAll(() => { afterAll(() => {
fetchMock.reset(); fetchMock.reset();
}); });
test('changing repo updates ``currentRepo``', async () => {
setUrlParam('repo', repoName);
const { getByText } = render(<App />);
await waitFor(() => expect(getByText('ba9c692786e9')).toBeInTheDocument());
setUrlParam('repo', 'try');
await waitFor(() => getByText('333333333333'));
expect(document.querySelector('.revision a').getAttribute('href')).toBe(
'https://hg.mozilla.org/try/rev/3333333333335143b8df3f4b3e9b504dfbc589a0',
);
});
test('should have links to Perfherder and Intermittent Failures View', async () => { test('should have links to Perfherder and Intermittent Failures View', async () => {
const { getByText, getByAltText } = render(<App />); const { getByText, getByAltText } = render(testApp());
const appMenu = await waitFor(() => getByAltText('Treeherder')); const appMenu = await waitFor(() => getByAltText('Treeherder'));
expect(appMenu).toBeInTheDocument(); expect(appMenu).toBeInTheDocument();
fireEvent.click(appMenu); fireEvent.click(appMenu);
const phMenu = await waitFor(() => getByText('Perfherder')); const phMenu = await waitFor(() => getByText('Perfherder'));
expect(phMenu.getAttribute('href')).toBe('/perf.html'); expect(phMenu.getAttribute('href')).toBe('/perfherder');
const ifvMenu = await waitFor(() => const ifvMenu = await waitFor(() =>
getByText('Intermittent Failures View'), getByText('Intermittent Failures View'),
); );
expect(ifvMenu.getAttribute('href')).toBe('/intermittent-failures.html'); expect(ifvMenu.getAttribute('href')).toBe('/intermittent-failures');
}); });
const testChangingSelectedJob = async ( const testChangingSelectedJob = async (
@ -205,7 +208,7 @@ describe('App', () => {
secondJobSymbol, secondJobSymbol,
secondJobTaskId, secondJobTaskId,
) => { ) => {
const { getByText, findByText, findByTestId } = render(<App />); const { getByText, findByText, findByTestId } = render(testApp());
const firstJob = await findByText(firstJobSymbol); const firstJob = await findByText(firstJobSymbol);
fireEvent.mouseDown(firstJob); fireEvent.mouseDown(firstJob);
@ -271,4 +274,90 @@ describe('App', () => {
), ),
).toBe(true); ).toBe(true);
}); });
test('changing repo updates ``currentRepo``', async () => {
const { getByText, getByTitle } = render(testApp());
const autolandRevision = await waitFor(() => getByText('ba9c692786e9'));
expect(autolandRevision).toBeInTheDocument();
const reposButton = await waitFor(() => getByTitle('Watch a repo'));
fireEvent.click(reposButton);
const tryRepo = await waitFor(() => getByText('try'));
fireEvent.click(tryRepo);
await waitFor(() => getByText('333333333333'));
expect(autolandRevision).not.toBeInTheDocument();
expect(document.querySelector('.revision a').getAttribute('href')).toBe(
'https://hg.mozilla.org/try/rev/3333333333335143b8df3f4b3e9b504dfbc589a0',
);
});
test('old job-view url should redirect to correct url', async () => {
history.push(
'/#/jobs?repo=try&revision=07615c30668c70692d01a58a00e7e271e69ff6f1',
);
render(testApp());
expect(history.location).toEqual(
expect.objectContaining({
pathname: '/jobs',
search: '?repo=try&revision=07615c30668c70692d01a58a00e7e271e69ff6f1',
hash: '',
}),
);
});
test('lack of a specified route should redirect to jobs view with a default repo', () => {
render(testApp());
expect(history.location).toEqual(
expect.objectContaining({
pathname: '/jobs',
search: '?repo=autoland',
hash: '',
}),
);
});
});
describe('Test for backwards-compatible routes for other apps', () => {
test('old push health url should redirect to correct url', () => {
fetchMock.get(
'/api/project/autoland/push/health/?revision=3c8e093335315c42a87eebf0531effe9cd6fdb95',
[],
);
history.push(
'/pushhealth.html?repo=autoland&revision=3c8e093335315c42a87eebf0531effe9cd6fdb95',
);
render(testApp());
expect(history.location).toEqual(
expect.objectContaining({
pathname: '/push-health',
search:
'?repo=autoland&revision=3c8e093335315c42a87eebf0531effe9cd6fdb95',
hash: '',
}),
);
});
test('old perfherder route should redirect to correct url', () => {
fetchMock.get('/api/performance/framework/', []);
fetchMock.get('/api/performance/tag/', []);
history.push('/perf.html#/alerts?id=27285&hideDwnToInv=0');
render(testApp());
expect(history.location).toEqual(
expect.objectContaining({
pathname: '/perfherder/alerts',
search: '?id=27285&hideDwnToInv=0',
hash: '',
}),
);
});
}); });

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

@ -1,24 +1,19 @@
import React from 'react'; import React from 'react';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { import { render, fireEvent, waitFor } from '@testing-library/react';
render, import { ConnectedRouter } from 'connected-react-router';
fireEvent, import { Provider, ReactReduxContext } from 'react-redux';
waitFor, import { createBrowserHistory } from 'history';
waitForElementToBeRemoved,
} from '@testing-library/react';
import App from '../../../ui/job-view/App'; import App from '../../../ui/job-view/App';
import taskDefinition from '../mock/task_definition.json'; import taskDefinition from '../mock/task_definition.json';
import pushListFixture from '../mock/push_list'; import pushListFixture from '../mock/push_list';
import reposFixture from '../mock/repositories'; import reposFixture from '../mock/repositories';
import { getApiUrl, bzBaseUrl } from '../../../ui/helpers/url'; import { getApiUrl, bzBaseUrl } from '../../../ui/helpers/url';
import { import { getProjectUrl } from '../../../ui/helpers/location';
getProjectUrl,
replaceLocation,
setUrlParam,
} from '../../../ui/helpers/location';
import jobListFixtureOne from '../mock/job_list/job_1'; import jobListFixtureOne from '../mock/job_list/job_1';
import jobMap from '../mock/job_map'; import jobMap from '../mock/job_map';
import { configureStore } from '../../../ui/job-view/redux/configureStore';
const repoName = 'autoland'; const repoName = 'autoland';
const treeStatusResponse = { const treeStatusResponse = {
@ -35,10 +30,21 @@ const emptyPushResponse = {
const emptyBzResponse = { const emptyBzResponse = {
bugs: [], bugs: [],
}; };
const history = createBrowserHistory();
const testApp = () => {
const store = configureStore(history);
return (
<Provider store={store}>
<ConnectedRouter history={history} context={ReactReduxContext}>
<App user={{ email: 'reviewbot' }} context={ReactReduxContext} />
</ConnectedRouter>
</Provider>
);
};
describe('Filtering', () => { describe('Filtering', () => {
beforeAll(() => { beforeAll(() => {
window.location.hash = `#/jobs?repo=${repoName}`;
fetchMock.reset(); fetchMock.reset();
fetchMock.get('/revision.txt', []); fetchMock.get('/revision.txt', []);
fetchMock.get(getApiUrl('/repository/'), reposFixture); fetchMock.get(getApiUrl('/repository/'), reposFixture);
@ -70,10 +76,7 @@ describe('Filtering', () => {
taskDefinition, taskDefinition,
); );
}); });
afterEach(() => history.push('/'));
afterEach(() => {
window.location.hash = `#/jobs?repo=${repoName}`;
});
afterAll(() => { afterAll(() => {
fetchMock.reset(); fetchMock.reset();
@ -109,56 +112,67 @@ describe('Filtering', () => {
}); });
test('should have 1 push', async () => { test('should have 1 push', async () => {
const { getAllByText, getAllByTestId, getByTestId } = render(<App />); const { getAllByText, getAllByTestId, getByText, getByTitle } = render(
// wait till the ``reviewbot`` authored push is shown before filtering. testApp(),
await waitFor(() => getAllByText('reviewbot')); );
setUrlParam('author', 'reviewbot'); const unfilteredPushes = await waitFor(() =>
await waitForElementToBeRemoved(() => getByTestId('push-511138'));
const filteredPushes = await waitFor(() => getAllByTestId('push-header'));
expect(filteredPushes).toHaveLength(1);
setUrlParam('author', null);
await waitFor(() => getAllByText('jarilvalenciano@gmail.com'));
const unFilteredPushes = await waitFor(() =>
getAllByTestId('push-header'), getAllByTestId('push-header'),
); );
expect(unFilteredPushes).toHaveLength(10); expect(unfilteredPushes).toHaveLength(10);
const myPushes = await waitFor(() => getByText('My pushes only'));
fireEvent.click(myPushes);
const filteredAuthor = await waitFor(() => getAllByText('reviewbot'));
const filteredPushes = await waitFor(() => getAllByTestId('push-header'));
expect(filteredAuthor).toHaveLength(1);
expect(filteredPushes).toHaveLength(1);
const filterCloseBtn = await getByTitle('Clear filter: author');
fireEvent.click(filterCloseBtn);
await waitFor(() => expect(unfilteredPushes).toHaveLength(10));
}); });
}); });
describe('by failure result', () => { describe('by failure result', () => {
test('should have 10 failures', async () => { test('should have 10 failures', async () => {
const { getAllByText, getByTitle, findAllByText } = render(<App />); const { getByTitle, findAllByText, queryAllByText } = render(testApp());
await findAllByText('B'); await findAllByText('B');
const unclassifiedOnlyButton = getByTitle( const unclassifiedOnlyButton = getByTitle(
'Loaded failures / toggle filtering for unclassified failures', 'Loaded failures / toggle filtering for unclassified failures',
); );
await waitFor(() => findAllByText('yaml'));
fireEvent.click(unclassifiedOnlyButton); fireEvent.click(unclassifiedOnlyButton);
// Since yaml is not an unclassified failure, making this call will // Since yaml is not an unclassified failure, making this call will
// ensure that the filtering has completed. Then we can get an accurate // ensure that the filtering has completed. Then we can get an accurate
// count of what's left. // count of what's left.
await waitForElementToBeRemoved(() => getAllByText('yaml')); await waitFor(() => {
expect(queryAllByText('yaml')).toHaveLength(0);
});
// The api returns the same joblist for each push. // The api returns the same joblist for each push.
// 10 pushes with 2 failures each, but only 1 unclassified. // 10 pushes with 2 failures each, but only 1 unclassified.
expect(jobCount()).toBe(20); expect(jobCount()).toBe(20);
// undo the filtering and make sure we see all the jobs again // undo the filtering and make sure we see all the jobs again
fireEvent.click(unclassifiedOnlyButton); fireEvent.click(unclassifiedOnlyButton);
await waitFor(() => getAllByText('yaml')); await waitFor(() => findAllByText('yaml'));
expect(jobCount()).toBe(50); expect(jobCount()).toBe(50);
}); });
test('KeyboardShortcut u: toggle unclassified jobs', async () => { test('KeyboardShortcut u: toggle unclassified jobs', async () => {
const { getAllByText } = render(<App />); const { queryAllByText, getAllByText } = render(testApp());
const symbolToRemove = 'yaml'; const symbolToRemove = 'yaml';
await waitFor(() => getAllByText(symbolToRemove)); await waitFor(() => getAllByText(symbolToRemove));
fireEvent.keyDown(document.body, { key: 'u', keyCode: 85 }); fireEvent.keyDown(document.body, { key: 'u', keyCode: 85 });
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove)); await waitFor(() => {
expect(queryAllByText('yaml')).toHaveLength(0);
});
expect(jobCount()).toBe(20); expect(jobCount()).toBe(20);
}); });
}); });
@ -196,10 +210,6 @@ describe('Filtering', () => {
); );
}); });
afterEach(() => {
replaceLocation({});
});
const setFilterText = (filterField, text) => { const setFilterText = (filterField, text) => {
fireEvent.click(filterField); fireEvent.click(filterField);
fireEvent.change(filterField, { target: { value: text } }); fireEvent.change(filterField, { target: { value: text } });
@ -207,7 +217,8 @@ describe('Filtering', () => {
}; };
test('click signature should have 10 jobs', async () => { test('click signature should have 10 jobs', async () => {
const { getByTitle, findAllByText } = render(<App />); const { getByTitle, findAllByText } = render(testApp());
const build = await findAllByText('B'); const build = await findAllByText('B');
fireEvent.mouseDown(build[0]); fireEvent.mouseDown(build[0]);
@ -217,17 +228,19 @@ describe('Filtering', () => {
10000, 10000,
); );
expect(keywordLink.getAttribute('href')).toBe( expect(keywordLink.getAttribute('href')).toBe(
'/#/jobs?repo=autoland&selectedTaskRun=JFVlnwufR7G9tZu_pKM0dQ.0&searchStr=Gecko%2CDecision%2CTask%2Copt%2CGecko%2CDecision%2CTask%2CD', '/?repo=autoland&selectedTaskRun=JFVlnwufR7G9tZu_pKM0dQ.0&searchStr=Gecko%2CDecision%2CTask%2Copt%2CGecko%2CDecision%2CTask%2CD',
); );
}); });
test('string "yaml" should have 10 jobs', async () => { test('string "yaml" should have 10 jobs', async () => {
const { getAllByText, findAllByText } = render(<App />); const { getAllByText, findAllByText, queryAllByText } = render(testApp());
await findAllByText('B'); await findAllByText('B');
const filterField = document.querySelector('#quick-filter'); const filterField = document.querySelector('#quick-filter');
setFilterText(filterField, 'yaml'); setFilterText(filterField, 'yaml');
await waitForElementToBeRemoved(() => getAllByText('B')); await waitFor(() => {
expect(queryAllByText('B')).toHaveLength(0);
});
expect(jobCount()).toBe(10); expect(jobCount()).toBe(10);
// undo the filtering and make sure we see all the jobs again // undo the filtering and make sure we see all the jobs again
@ -237,7 +250,7 @@ describe('Filtering', () => {
}); });
test('KeyboardShortcut f: focus the quick filter input', async () => { test('KeyboardShortcut f: focus the quick filter input', async () => {
const { findAllByText } = render(<App />); const { findAllByText } = render(testApp());
await findAllByText('B'); await findAllByText('B');
const filterField = document.querySelector('#quick-filter'); const filterField = document.querySelector('#quick-filter');
@ -248,14 +261,19 @@ describe('Filtering', () => {
}); });
test('KeyboardShortcut ctrl+shift+f: clear the quick filter input', async () => { test('KeyboardShortcut ctrl+shift+f: clear the quick filter input', async () => {
const { findAllByText, getAllByText, getByPlaceholderText } = render( const {
<App />, findAllByText,
); getAllByText,
getByPlaceholderText,
queryAllByText,
} = render(testApp());
await findAllByText('B'); await findAllByText('B');
const filterField = getByPlaceholderText('Filter platforms & jobs'); const filterField = getByPlaceholderText('Filter platforms & jobs');
setFilterText(filterField, 'yaml'); setFilterText(filterField, 'yaml');
await waitForElementToBeRemoved(() => getAllByText('B')); await waitFor(() => {
expect(queryAllByText('B')).toHaveLength(0);
});
expect(filterField.value).toEqual('yaml'); expect(filterField.value).toEqual('yaml');
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@ -277,12 +295,15 @@ describe('Filtering', () => {
}; };
test('uncheck success should leave 30 jobs', async () => { test('uncheck success should leave 30 jobs', async () => {
const { getAllByText, findAllByText } = render(<App />); const { getAllByText, findAllByText, queryAllByText } = render(testApp());
await findAllByText('B'); await findAllByText('B');
clickFilterChicklet('green'); clickFilterChicklet('green');
await waitForElementToBeRemoved(() => getAllByText('D')); await waitFor(() => {
expect(queryAllByText('D')).toHaveLength(0);
});
expect(jobCount()).toBe(40); expect(jobCount()).toBe(40);
// undo the filtering and make sure we see all the jobs again // undo the filtering and make sure we see all the jobs again
@ -292,13 +313,16 @@ describe('Filtering', () => {
}); });
test('uncheck failures should leave 20 jobs', async () => { test('uncheck failures should leave 20 jobs', async () => {
const { getAllByText, findAllByText } = render(<App />); const { getAllByText, findAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'B'; const symbolToRemove = 'B';
await findAllByText(symbolToRemove); await findAllByText(symbolToRemove);
clickFilterChicklet('red'); clickFilterChicklet('red');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove)); await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(20); expect(jobCount()).toBe(20);
// undo the filtering and make sure we see all the jobs again // undo the filtering and make sure we see all the jobs again
@ -308,13 +332,15 @@ describe('Filtering', () => {
}); });
test('uncheck in progress should leave 20 jobs', async () => { test('uncheck in progress should leave 20 jobs', async () => {
const { getAllByText, findAllByText } = render(<App />); const { getAllByText, findAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'yaml'; const symbolToRemove = 'yaml';
await findAllByText('B'); await findAllByText('B');
clickFilterChicklet('dkgray'); clickFilterChicklet('dkgray');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove)); await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(40); expect(jobCount()).toBe(40);
// undo the filtering and make sure we see all the jobs again // undo the filtering and make sure we see all the jobs again
@ -324,28 +350,33 @@ describe('Filtering', () => {
}); });
test('KeyboardShortcut i: toggle off in-progress tasks', async () => { test('KeyboardShortcut i: toggle off in-progress tasks', async () => {
const { getAllByText } = render(<App />); const { getAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'yaml'; const symbolToRemove = 'yaml';
await waitFor(() => getAllByText(symbolToRemove)); await waitFor(() => getAllByText(symbolToRemove));
fireEvent.keyDown(document.body, { key: 'i', keyCode: 73 }); fireEvent.keyDown(document.body, { key: 'i', keyCode: 73 });
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove)); await waitFor(() => {
expect(jobCount()).toBe(40); expect(queryAllByText(symbolToRemove)).toHaveLength(0);
expect(window.location.hash).toEqual( });
'#/jobs?repo=autoland&resultStatus=testfailed%2Cbusted%2Cexception%2Csuccess%2Cretry%2Cusercancel%2Crunnable',
expect(history.location.search).toEqual(
'?repo=autoland&resultStatus=testfailed%2Cbusted%2Cexception%2Csuccess%2Cretry%2Cusercancel%2Crunnable',
); );
}); });
test('KeyboardShortcut i: toggle on in-progress tasks', async () => { test('KeyboardShortcut i: toggle on in-progress tasks', async () => {
const { getAllByText, findAllByText } = render(<App />); const { getAllByText, findAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'yaml'; const symbolToRemove = 'yaml';
await waitFor(() => getAllByText(symbolToRemove)); await waitFor(() => getAllByText(symbolToRemove));
clickFilterChicklet('dkgray'); clickFilterChicklet('dkgray');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove)); await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(40); expect(jobCount()).toBe(40);
await findAllByText('B'); await findAllByText('B');
@ -355,17 +386,24 @@ describe('Filtering', () => {
await findAllByText('B'); await findAllByText('B');
await waitFor(() => getAllByText(symbolToRemove), 5000); await waitFor(() => getAllByText(symbolToRemove), 5000);
expect(jobCount()).toBe(50); expect(jobCount()).toBe(50);
expect(window.location.hash).toEqual('#/jobs?repo=autoland'); expect(history.location.search).toEqual('?repo=autoland');
}); });
test('Filters | Reset should get back to original set of jobs', async () => { test('Filters | Reset should get back to original set of jobs', async () => {
const { getAllByText, findAllByText, findByText } = render(<App />); const {
getAllByText,
findAllByText,
findByText,
queryAllByText,
} = render(testApp());
const symbolToRemove = 'yaml'; const symbolToRemove = 'yaml';
await findAllByText('B'); await findAllByText('B');
clickFilterChicklet('dkgray'); clickFilterChicklet('dkgray');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove)); await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(40); expect(jobCount()).toBe(40);
// undo the filtering with the "Filters | Reset" menu item // undo the filtering with the "Filters | Reset" menu item

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

@ -1,25 +1,21 @@
import React from 'react'; import React from 'react';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { Provider } from 'react-redux'; import { Provider, ReactReduxContext } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { import {
render, render,
cleanup,
waitFor, waitFor,
waitForElementToBeRemoved,
fireEvent, fireEvent,
getAllByTestId, getAllByTestId,
} from '@testing-library/react'; } from '@testing-library/react';
import { createBrowserHistory } from 'history';
import { import { getProjectUrl } from '../../../ui/helpers/location';
getProjectUrl,
replaceLocation,
setUrlParam,
} from '../../../ui/helpers/location';
import FilterModel from '../../../ui/models/filter'; import FilterModel from '../../../ui/models/filter';
import pushListFixture from '../mock/push_list'; import pushListFixture from '../mock/push_list';
import jobListFixtureOne from '../mock/job_list/job_1'; import jobListFixtureOne from '../mock/job_list/job_1';
import jobListFixtureTwo from '../mock/job_list/job_2'; import jobListFixtureTwo from '../mock/job_list/job_2';
import configureStore from '../../../ui/job-view/redux/configureStore'; import { configureStore } from '../../../ui/job-view/redux/configureStore';
import PushList from '../../../ui/job-view/pushes/PushList'; import PushList from '../../../ui/job-view/pushes/PushList';
import { getApiUrl } from '../../../ui/helpers/url'; import { getApiUrl } from '../../../ui/helpers/url';
import { findJobInstance } from '../../../ui/helpers/job'; import { findJobInstance } from '../../../ui/helpers/job';
@ -37,6 +33,8 @@ global.document.createRange = () => ({
describe('PushList', () => { describe('PushList', () => {
const repoName = 'autoland'; const repoName = 'autoland';
const history = createBrowserHistory();
const currentRepo = { const currentRepo = {
id: 4, id: 4,
repository_group: { repository_group: {
@ -58,22 +56,7 @@ describe('PushList', () => {
getRevisionHref: () => 'foo', getRevisionHref: () => 'foo',
getPushLogHref: () => 'foo', getPushLogHref: () => 'foo',
}; };
const testPushList = (store, filterModel) => (
<Provider store={store}>
<div id="th-global-content">
<PushList
user={{ isLoggedIn: false }}
repoName={repoName}
currentRepo={currentRepo}
filterModel={filterModel}
duplicateJobsVisible={false}
groupCountsExpanded={false}
pushHealthVisibility="None"
getAllShownJobs={() => {}}
/>
</div>
</Provider>
);
const pushCount = () => const pushCount = () =>
waitFor(() => getAllByTestId(document.body, 'push-header')); waitFor(() => getAllByTestId(document.body, 'push-header'));
@ -92,6 +75,26 @@ describe('PushList', () => {
results: pushListFixture.results.slice(0, 1), results: pushListFixture.results.slice(0, 1),
}, },
); );
fetchMock.get(
getProjectUrl(
'/push/?full=true&count=10&tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0',
repoName,
),
{
...pushListFixture,
results: pushListFixture.results.slice(0, 1),
},
);
fetchMock.get(
getProjectUrl(
'/push/?full=true&count=100&fromchange=d5b037941b0ebabcc9b843f24d926e9d65961087',
repoName,
),
{
...pushListFixture,
results: pushListFixture.results.slice(1, 2),
},
);
fetchMock.get( fetchMock.get(
getProjectUrl( getProjectUrl(
'/push/?full=true&count=10&tochange=d5b037941b0ebabcc9b843f24d926e9d65961087', '/push/?full=true&count=10&tochange=d5b037941b0ebabcc9b843f24d926e9d65961087',
@ -117,73 +120,87 @@ describe('PushList', () => {
); );
}); });
afterEach(() => history.push(`/jobs?repo=${repoName}`));
afterAll(() => { afterAll(() => {
fetchMock.reset(); fetchMock.reset();
}); });
afterEach(() => { const testPushList = () => {
cleanup(); const store = configureStore(history);
replaceLocation({}); return (
}); <Provider store={store} context={ReactReduxContext}>
<ConnectedRouter history={history} context={ReactReduxContext}>
<div id="th-global-content">
<PushList
user={{ isLoggedIn: false }}
repoName={repoName}
currentRepo={currentRepo}
filterModel={
new FilterModel({
pushRoute: history.push,
router: { location: history.location },
})
}
duplicateJobsVisible={false}
groupCountsExpanded={false}
pushHealthVisibility="None"
getAllShownJobs={() => {}}
/>
</div>
</ConnectedRouter>
</Provider>
);
};
// push1Revision is'ba9c692786e95143b8df3f4b3e9b504dfbc589a0';
const push1Id = 'push-511138'; const push1Id = 'push-511138';
// push2Revision is 'd5b037941b0ebabcc9b843f24d926e9d65961087';
const push2Id = 'push-511137'; const push2Id = 'push-511137';
const push1Revision = 'ba9c692786e95143b8df3f4b3e9b504dfbc589a0';
const push2Revision = 'd5b037941b0ebabcc9b843f24d926e9d65961087';
test('should have 2 pushes', async () => { test('should have 2 pushes', async () => {
const { store } = configureStore(); render(testPushList());
render(testPushList(store, new FilterModel()));
expect(await pushCount()).toHaveLength(2); expect(await pushCount()).toHaveLength(2);
}); });
test('should switch to single loaded revision and back to 2', async () => { test('should switch to single loaded revision', async () => {
const { store } = configureStore(); const { getAllByTitle } = render(testPushList());
const { getByTestId } = render(testPushList(store, new FilterModel()));
expect(await pushCount()).toHaveLength(2); expect(await pushCount()).toHaveLength(2);
const pushLinks = await getAllByTitle('View only this push');
// fireEvent.click(push) not clicking the link, so must set the url param fireEvent.click(pushLinks[1]);
setUrlParam('revision', push2Revision); // click push 2 expect(pushLinks[0]).not.toBeInTheDocument();
await waitForElementToBeRemoved(() => getByTestId('push-511138'));
expect(await pushCount()).toHaveLength(1); expect(await pushCount()).toHaveLength(1);
setUrlParam('revision', null);
await waitFor(() => getByTestId(push1Id));
expect(await pushCount()).toHaveLength(2);
}); });
test('should reload pushes when setting fromchange', async () => { test('should reload pushes when setting fromchange', async () => {
const { store } = configureStore(); const { queryAllByTestId, queryByTestId } = render(testPushList());
const { getByTestId } = render(testPushList(store, new FilterModel()));
expect(await pushCount()).toHaveLength(2); expect(await pushCount()).toHaveLength(2);
const push2 = getByTestId(push2Id); await waitFor(() => queryAllByTestId('push-header'));
const push2 = await waitFor(() => queryByTestId(push2Id));
const actionMenuButton = push2.querySelector( const actionMenuButton = push2.querySelector(
'[data-testid="push-action-menu-button"]', '[data-testid="push-action-menu-button"]',
); );
fireEvent.click(actionMenuButton); fireEvent.click(actionMenuButton);
const setBottomLink = await waitFor(() => const setFromRange = await waitFor(() =>
push2.querySelector('[data-testid="bottom-of-range-menu-item"]'), push2.querySelector('[data-testid="bottom-of-range-menu-item"]'),
); );
expect(setBottomLink.getAttribute('href')).toContain( fireEvent.click(setFromRange);
'/#/jobs?&fromchange=d5b037941b0ebabcc9b843f24d926e9d65961087',
);
setUrlParam('fromchange', push1Revision); expect(history.location.search).toContain(
await waitForElementToBeRemoved(() => getByTestId(push2Id)); '?repo=autoland&fromchange=d5b037941b0ebabcc9b843f24d926e9d65961087',
expect(await pushCount()).toHaveLength(1); );
}); });
test('should reload pushes when setting tochange', async () => { test('should reload pushes when setting tochange', async () => {
const { store } = configureStore(); const { getByTestId } = render(testPushList());
const { getByTestId } = render(testPushList(store, new FilterModel()));
expect(await pushCount()).toHaveLength(2); expect(await pushCount()).toHaveLength(2);
@ -194,24 +211,19 @@ describe('PushList', () => {
fireEvent.click(actionMenuButton); fireEvent.click(actionMenuButton);
const setTopLink = await waitFor(() => const setTopRange = await waitFor(() =>
push1.querySelector('[data-testid="top-of-range-menu-item"]'), push1.querySelector('[data-testid="top-of-range-menu-item"]'),
); );
expect(setTopLink.getAttribute('href')).toContain( fireEvent.click(setTopRange);
'/#/jobs?&tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0',
);
setUrlParam('tochange', push2Revision); expect(history.location.search).toContain(
await waitForElementToBeRemoved(() => getByTestId(push1Id)); '?repo=autoland&tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0',
expect(await pushCount()).toHaveLength(1); );
}); });
test('should load N more pushes when click next N', async () => { test('should load N more pushes when click next N', async () => {
const { store } = configureStore(); const { getByTestId, getAllByTestId } = render(testPushList());
const { getByTestId, getAllByTestId } = render(
testPushList(store, new FilterModel()),
);
const nextNUrl = (count) => const nextNUrl = (count) =>
getProjectUrl(`/push/?full=true&count=${count + 1}&push_timestamp__lte=`); getProjectUrl(`/push/?full=true&count=${count + 1}&push_timestamp__lte=`);
const clickNext = (count) => const clickNext = (count) =>
@ -260,8 +272,7 @@ describe('PushList', () => {
}); });
test('jobs should have fields required for retriggers', async () => { test('jobs should have fields required for retriggers', async () => {
const { store } = configureStore(); const { getByText } = render(testPushList());
const { getByText } = render(testPushList(store, new FilterModel()));
const jobEl = await waitFor(() => getByText('yaml')); const jobEl = await waitFor(() => getByText('yaml'));
const jobInstance = findJobInstance(jobEl.getAttribute('data-job-id')); const jobInstance = findJobInstance(jobEl.getAttribute('data-job-id'));
const { job } = jobInstance.props; const { job } = jobInstance.props;

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

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; import { render, waitFor, fireEvent } from '@testing-library/react';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import { createBrowserHistory } from 'history';
import { replaceLocation, setUrlParam } from '../../../ui/helpers/location';
import FilterModel from '../../../ui/models/filter'; import FilterModel from '../../../ui/models/filter';
import SecondaryNavBar from '../../../ui/job-view/headerbars/SecondaryNavBar'; import SecondaryNavBar from '../../../ui/job-view/headerbars/SecondaryNavBar';
import { initialState } from '../../../ui/job-view/redux/stores/pushes'; import { initialState } from '../../../ui/job-view/redux/stores/pushes';
@ -13,6 +13,8 @@ import repos from '../mock/repositories';
const mockStore = configureMockStore([thunk]); const mockStore = configureMockStore([thunk]);
const repoName = 'autoland'; const repoName = 'autoland';
const history = createBrowserHistory();
const router = { location: history.location };
beforeEach(() => { beforeEach(() => {
fetchMock.get('https://treestatus.mozilla-releng.net/trees/autoland', { fetchMock.get('https://treestatus.mozilla-releng.net/trees/autoland', {
@ -23,31 +25,36 @@ beforeEach(() => {
tree: 'autoland', tree: 'autoland',
}, },
}); });
setUrlParam('repo', repoName);
}); });
afterEach(() => { afterEach(() => {
cleanup();
fetchMock.reset(); fetchMock.reset();
replaceLocation({}); history.push('/');
}); });
describe('SecondaryNavBar', () => { describe('SecondaryNavBar', () => {
const testSecondaryNavBar = (store, filterModel, props) => ( const testSecondaryNavBar = (store, props) => {
<Provider store={store}> return (
<SecondaryNavBar <Provider store={store}>
updateButtonClick={() => {}} <SecondaryNavBar
serverChanged={false} updateButtonClick={() => {}}
filterModel={filterModel} serverChanged={false}
repos={repos} filterModel={
setCurrentRepoTreeStatus={() => {}} new FilterModel({
duplicateJobsVisible={false} pushRoute: history.push,
groupCountsExpanded={false} router,
toggleFieldFilterVisible={() => {}} })
{...props} }
/> repos={repos}
</Provider> setCurrentRepoTreeStatus={() => {}}
); duplicateJobsVisible={false}
groupCountsExpanded={false}
toggleFieldFilterVisible={() => {}}
{...props}
/>
</Provider>
);
};
test('should 52 unclassified', async () => { test('should 52 unclassified', async () => {
const store = mockStore({ const store = mockStore({
@ -56,8 +63,9 @@ describe('SecondaryNavBar', () => {
allUnclassifiedFailureCount: 52, allUnclassifiedFailureCount: 52,
filteredUnclassifiedFailureCount: 0, filteredUnclassifiedFailureCount: 0,
}, },
router,
}); });
const { getByText } = render(testSecondaryNavBar(store, new FilterModel())); const { getByText } = render(testSecondaryNavBar(store));
expect(await waitFor(() => getByText(repoName))).toBeInTheDocument(); expect(await waitFor(() => getByText(repoName))).toBeInTheDocument();
expect(await waitFor(() => getByText('52'))).toBeInTheDocument(); expect(await waitFor(() => getByText('52'))).toBeInTheDocument();
@ -70,8 +78,9 @@ describe('SecondaryNavBar', () => {
allUnclassifiedFailureCount: 22, allUnclassifiedFailureCount: 22,
filteredUnclassifiedFailureCount: 10, filteredUnclassifiedFailureCount: 10,
}, },
router,
}); });
const { getByText } = render(testSecondaryNavBar(store, new FilterModel())); const { getByText } = render(testSecondaryNavBar(store));
expect(await waitFor(() => getByText(repoName))).toBeInTheDocument(); expect(await waitFor(() => getByText(repoName))).toBeInTheDocument();
expect(await waitFor(() => getByText('22'))).toBeInTheDocument(); expect(await waitFor(() => getByText('22'))).toBeInTheDocument();
@ -83,6 +92,7 @@ describe('SecondaryNavBar', () => {
pushes: { pushes: {
...initialState, ...initialState,
}, },
router,
}); });
const props = { const props = {
@ -90,9 +100,7 @@ describe('SecondaryNavBar', () => {
updateButtonClick: jest.fn(), updateButtonClick: jest.fn(),
}; };
const { container } = render( const { container } = render(testSecondaryNavBar(store, props));
testSecondaryNavBar(store, new FilterModel(), props),
);
const el = container.querySelector('#revisionChangedLabel'); const el = container.querySelector('#revisionChangedLabel');
fireEvent.click(el); fireEvent.click(el);
expect(props.updateButtonClick).toHaveBeenCalled(); expect(props.updateButtonClick).toHaveBeenCalled();

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

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider, ReactReduxContext } from 'react-redux';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; import { render, cleanup, waitFor, fireEvent } from '@testing-library/react';
import { ConnectedRouter } from 'connected-react-router';
import JobModel from '../../../../ui/models/job'; import JobModel from '../../../../ui/models/job';
import DetailsPanel from '../../../../ui/job-view/details/DetailsPanel'; import DetailsPanel from '../../../../ui/job-view/details/DetailsPanel';
@ -10,11 +11,11 @@ import pushFixture from '../../mock/push_list.json';
import taskDefinition from '../../mock/task_definition.json'; import taskDefinition from '../../mock/task_definition.json';
import { getApiUrl } from '../../../../ui/helpers/url'; import { getApiUrl } from '../../../../ui/helpers/url';
import FilterModel from '../../../../ui/models/filter'; import FilterModel from '../../../../ui/models/filter';
import { getProjectUrl } from '../../../../ui/helpers/location';
import { import {
replaceLocation, history,
getProjectUrl, configureStore,
} from '../../../../ui/helpers/location'; } from '../../../../ui/job-view/redux/configureStore';
import configureStore from '../../../../ui/job-view/redux/configureStore';
import { setSelectedJob } from '../../../../ui/job-view/redux/stores/selectedJob'; import { setSelectedJob } from '../../../../ui/job-view/redux/stores/selectedJob';
import { setPushes } from '../../../../ui/job-view/redux/stores/pushes'; import { setPushes } from '../../../../ui/job-view/redux/stores/pushes';
import reposFixture from '../../mock/repositories'; import reposFixture from '../../mock/repositories';
@ -25,12 +26,12 @@ describe('DetailsPanel', () => {
const repoName = 'autoland'; const repoName = 'autoland';
const classificationTypes = [{ id: 1, name: 'intermittent' }]; const classificationTypes = [{ id: 1, name: 'intermittent' }];
const classificationMap = { 1: 'intermittent' }; const classificationMap = { 1: 'intermittent' };
const filterModel = new FilterModel();
let jobList = null; let jobList = null;
let store = null; let store = null;
const currentRepo = reposFixture[2]; const currentRepo = reposFixture[2];
currentRepo.getRevisionHref = () => 'foo'; currentRepo.getRevisionHref = () => 'foo';
currentRepo.getPushLogHref = () => 'foo'; currentRepo.getPushLogHref = () => 'foo';
const router = { location: history.location };
beforeEach(async () => { beforeEach(async () => {
fetchMock.get( fetchMock.get(
@ -69,40 +70,48 @@ describe('DetailsPanel', () => {
'https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/JFVlnwufR7G9tZu_pKM0dQ', 'https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/JFVlnwufR7G9tZu_pKM0dQ',
taskDefinition, taskDefinition,
); );
store = configureStore().store; store = configureStore();
store.dispatch(setPushes(pushFixture.results, {})); store.dispatch(setPushes(pushFixture.results, {}, router));
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
fetchMock.reset(); fetchMock.reset();
replaceLocation({}); history.push('/');
}); });
const testDetailsPanel = (store) => ( const testDetailsPanel = () => (
<div id="global-container" className="height-minus-navbars"> <div id="global-container" className="height-minus-navbars">
<Provider store={store}> <Provider store={store} context={ReactReduxContext}>
<KeyboardShortcuts <ConnectedRouter history={history} context={ReactReduxContext}>
filterModel={filterModel} <KeyboardShortcuts
showOnScreenShortcuts={() => {}} filterModel={
> new FilterModel({
<div /> pushRoute: history.push,
<div id="th-global-content" data-testid="global-content"> router,
<DetailsPanel })
currentRepo={currentRepo} }
user={{ isLoggedIn: false }} showOnScreenShortcuts={() => {}}
resizedHeight={100} >
classificationTypes={classificationTypes} <div />
classificationMap={classificationMap} <div id="th-global-content" data-testid="global-content">
/> <DetailsPanel
</div> currentRepo={currentRepo}
</KeyboardShortcuts> user={{ isLoggedIn: false }}
resizedHeight={100}
classificationTypes={classificationTypes}
classificationMap={classificationMap}
router={router}
/>
</div>
</KeyboardShortcuts>
</ConnectedRouter>
</Provider> </Provider>
</div> </div>
); );
test('pin selected job with button', async () => { test('pin selected job with button', async () => {
const { getByTitle } = render(testDetailsPanel(store)); const { getByTitle } = render(testDetailsPanel());
store.dispatch(setSelectedJob(jobList.data[1], true)); store.dispatch(setSelectedJob(jobList.data[1], true));
fireEvent.click(await waitFor(() => getByTitle('Pin job'))); fireEvent.click(await waitFor(() => getByTitle('Pin job')));
@ -116,7 +125,7 @@ describe('DetailsPanel', () => {
}); });
test('KeyboardShortcut space: pin selected job', async () => { test('KeyboardShortcut space: pin selected job', async () => {
const { getByTitle } = render(testDetailsPanel(store)); const { getByTitle } = render(testDetailsPanel());
store.dispatch(setSelectedJob(jobList.data[1], true)); store.dispatch(setSelectedJob(jobList.data[1], true));
const content = await waitFor(() => const content = await waitFor(() =>
@ -132,7 +141,7 @@ describe('DetailsPanel', () => {
}); });
test('KeyboardShortcut b: pin selected task and edit bug', async () => { test('KeyboardShortcut b: pin selected task and edit bug', async () => {
const { getByPlaceholderText } = render(testDetailsPanel(store)); const { getByPlaceholderText } = render(testDetailsPanel());
store.dispatch(setSelectedJob(jobList.data[1], true)); store.dispatch(setSelectedJob(jobList.data[1], true));
const content = await waitFor(() => const content = await waitFor(() =>
@ -151,7 +160,7 @@ describe('DetailsPanel', () => {
}); });
test('KeyboardShortcut c: pin selected task and edit comment', async () => { test('KeyboardShortcut c: pin selected task and edit comment', async () => {
const { getByPlaceholderText } = render(testDetailsPanel(store)); const { getByPlaceholderText } = render(testDetailsPanel());
store.dispatch(setSelectedJob(jobList.data[1], true)); store.dispatch(setSelectedJob(jobList.data[1], true));
const content = await waitFor(() => const content = await waitFor(() =>
@ -168,7 +177,7 @@ describe('DetailsPanel', () => {
}); });
test('KeyboardShortcut ctrl+shift+u: clear PinBoard', async () => { test('KeyboardShortcut ctrl+shift+u: clear PinBoard', async () => {
const { getByTitle } = render(testDetailsPanel(store)); const { getByTitle } = render(testDetailsPanel());
store.dispatch(setSelectedJob(jobList.data[1], true)); store.dispatch(setSelectedJob(jobList.data[1], true));
fireEvent.click(await waitFor(() => getByTitle('Pin job'))); fireEvent.click(await waitFor(() => getByTitle('Pin job')));
@ -188,7 +197,7 @@ describe('DetailsPanel', () => {
}); });
test('clear PinBoard', async () => { test('clear PinBoard', async () => {
const { getByTitle, getByText } = render(testDetailsPanel(store)); const { getByTitle, getByText } = render(testDetailsPanel());
store.dispatch(setSelectedJob(jobList.data[1], true)); store.dispatch(setSelectedJob(jobList.data[1], true));
fireEvent.click(await waitFor(() => getByTitle('Pin job'))); fireEvent.click(await waitFor(() => getByTitle('Pin job')));
@ -205,7 +214,7 @@ describe('DetailsPanel', () => {
}); });
test('pin all jobs', async () => { test('pin all jobs', async () => {
const { queryAllByTitle } = render(testDetailsPanel(store)); const { queryAllByTitle } = render(testDetailsPanel());
store.dispatch(pinJobs(jobList.data)); store.dispatch(pinJobs(jobList.data));
const unPinJobBtns = await waitFor(() => queryAllByTitle('Unpin job')); const unPinJobBtns = await waitFor(() => queryAllByTitle('Unpin job'));

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

@ -1,7 +1,7 @@
/* eslint-disable jest/prefer-to-have-length */
import React from 'react'; import React from 'react';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import { mount } from 'enzyme/build'; import { render, waitFor, fireEvent } from '@testing-library/react';
import { createBrowserHistory } from 'history';
import { JobGroupComponent } from '../../../ui/job-view/pushes/JobGroup'; import { JobGroupComponent } from '../../../ui/job-view/pushes/JobGroup';
import FilterModel from '../../../ui/models/filter'; import FilterModel from '../../../ui/models/filter';
@ -9,13 +9,20 @@ import mappedGroupFixture from '../mock/mappedGroup';
import mappedGroupDupsFixture from '../mock/mappedGroupDups'; import mappedGroupDupsFixture from '../mock/mappedGroupDups';
import { addAggregateFields } from '../../../ui/helpers/job'; import { addAggregateFields } from '../../../ui/helpers/job';
const history = createBrowserHistory();
describe('JobGroup component', () => { describe('JobGroup component', () => {
let countGroup; let countGroup;
let dupGroup; let dupGroup;
const repoName = 'mozilla-inbound'; const repoName = 'mozilla-inbound';
const filterModel = new FilterModel(); const filterModel = new FilterModel({
pushRoute: history.push,
router: { location: history.location },
});
const pushGroupState = 'collapsed'; const pushGroupState = 'collapsed';
afterEach(() => history.push('/'));
beforeAll(() => { beforeAll(() => {
mappedGroupFixture.jobs.forEach((job) => addAggregateFields(job)); mappedGroupFixture.jobs.forEach((job) => addAggregateFields(job));
mappedGroupDupsFixture.jobs.forEach((job) => addAggregateFields(job)); mappedGroupDupsFixture.jobs.forEach((job) => addAggregateFields(job));
@ -26,136 +33,105 @@ describe('JobGroup component', () => {
dupGroup = cloneDeep(mappedGroupDupsFixture); dupGroup = cloneDeep(mappedGroupDupsFixture);
}); });
const jobGroup = (
group,
groupCountsExpanded = false,
duplicateJobsVisible = false,
) => (
<JobGroupComponent
repoName={repoName}
group={group}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
push={history.push}
/>
);
/* /*
Tests Jobs view Tests Jobs view
*/ */
it('collapsed should show a job and count of 2', () => { it('collapsed should show a job and count of 2 icon when collapsed', async () => {
const jobGroup = mount( const { getByTestId } = render(jobGroup(countGroup));
<JobGroupComponent
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
);
expect(jobGroup.find('.job-group-count').first().text()).toEqual('2'); const jobGroupCount = await waitFor(() => getByTestId('job-group-count'));
expect(jobGroupCount).toHaveTextContent('2');
}); });
test('should show a job and count of 2 when expanded, then re-collapsed', () => { test('should show a job and count of 2 icon when re-collapsed', async () => {
const jobGroup = mount( const { getByText, getByTestId } = render(jobGroup(countGroup));
<JobGroupComponent
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
);
jobGroup.setState({ expanded: true });
jobGroup.setState({ expanded: false });
expect(jobGroup.find('.job-group-count').first().text()).toEqual('2'); const jobGroupCount = await waitFor(() => getByTestId('job-group-count'));
expect(jobGroupCount).toHaveTextContent('2');
fireEvent.click(jobGroupCount);
expect(jobGroupCount).not.toBeInTheDocument();
const groupSymbolButton = await waitFor(() => getByText('W-e10s'));
fireEvent.click(groupSymbolButton);
await waitFor(() => getByTestId('job-group-count'));
}); });
test('should show jobs, not counts when expanded', () => { test('should show jobs, not counts when expanded', async () => {
const jobGroup = mount( const { getByTestId, getAllByTestId } = render(jobGroup(countGroup));
<JobGroupComponent
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
);
jobGroup.setState({ expanded: true });
expect(jobGroup.find('.job-group-count').length).toEqual(0); const jobGroupCount = await waitFor(() => getByTestId('job-group-count'));
expect(jobGroup.find('.job-btn').length).toEqual(3); expect(jobGroupCount).toHaveTextContent('2');
fireEvent.click(jobGroupCount);
expect(jobGroupCount).not.toBeInTheDocument();
const expandedJobs = await waitFor(() => getAllByTestId('job-btn'));
expect(expandedJobs).toHaveLength(3);
}); });
test('should show jobs, not counts when globally expanded', () => { test('should show jobs, not counts when globally expanded', async () => {
const groupCountsExpanded = true; const groupCountsExpanded = true;
const jobGroup = mount( const { queryByTestId, getAllByTestId } = render(
<JobGroupComponent jobGroup(countGroup, groupCountsExpanded),
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={groupCountsExpanded}
/>,
); );
expect(jobGroup.find('.job-btn').length).toEqual(3); const expandedJobs = await waitFor(() => getAllByTestId('job-btn'));
expect(jobGroup.find('.job-group-count').length).toEqual(0); expect(expandedJobs).toHaveLength(3);
const jobGroupCount = await waitFor(() => queryByTestId('job-group-count'));
expect(jobGroupCount).toBeNull();
}); });
test('should hide duplicates by default', () => { test('should hide duplicates by default', async () => {
const jobGroup = mount( const { getAllByTestId } = render(jobGroup(dupGroup));
<JobGroupComponent
repoName={repoName} const jobGroupCount = await waitFor(() =>
group={dupGroup} getAllByTestId('job-group-count'),
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
); );
expect(jobGroupCount).toHaveLength(1);
expect(jobGroup.find('.job-group-count').length).toEqual(1); const expandedJobs = await waitFor(() => getAllByTestId('job-btn'));
expect(jobGroup.find('.job-btn').length).toEqual(1); expect(expandedJobs).toHaveLength(1);
}); });
test('should show 2 duplicates when set to show duplicates', () => { test('should show 2 duplicates when set to show duplicates', async () => {
// determined by the presence of duplicate_jobs=visible query param
// parsed in the job-view App
const duplicateJobsVisible = true; const duplicateJobsVisible = true;
const jobGroup = mount( const groupCountsExpanded = false;
<JobGroupComponent
repoName={repoName} const { getAllByTestId } = render(
group={dupGroup} jobGroup(dupGroup, groupCountsExpanded, duplicateJobsVisible),
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={false}
/>,
); );
expect(jobGroup.find('.job-group-count').length).toEqual(1); const jobGroupCount = await waitFor(() =>
expect(jobGroup.find('.job-btn').length).toEqual(2); getAllByTestId('job-group-count'),
});
test('should show 2 duplicates when globally set to show duplicates', () => {
const duplicateJobsVisible = true;
const jobGroup = mount(
<JobGroupComponent
repoName={repoName}
group={dupGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={false}
/>,
); );
expect(jobGroupCount).toHaveLength(1);
expect(jobGroup.find('.job-group-count').length).toEqual(1); const jobs = await waitFor(() => getAllByTestId('job-btn'));
expect(jobGroup.find('.job-btn').length).toEqual(2); expect(jobs).toHaveLength(2);
}); });
}); });

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

@ -1,14 +1,17 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider, ReactReduxContext } from 'react-redux';
import { render, cleanup, fireEvent, waitFor } from '@testing-library/react'; import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { createBrowserHistory } from 'history';
import { ConnectedRouter } from 'connected-react-router';
import PushJobs from '../../../ui/job-view/pushes/PushJobs'; import PushJobs from '../../../ui/job-view/pushes/PushJobs';
import FilterModel from '../../../ui/models/filter'; import FilterModel from '../../../ui/models/filter';
import { store } from '../../../ui/job-view/redux/store'; import { configureStore } from '../../../ui/job-view/redux/configureStore';
import { getUrlParam, setUrlParam } from '../../../ui/helpers/location'; import { getUrlParam, setUrlParam } from '../../../ui/helpers/location';
import platforms from '../mock/platforms'; import platforms from '../mock/platforms';
import { addAggregateFields } from '../../../ui/helpers/job'; import { addAggregateFields } from '../../../ui/helpers/job';
const history = createBrowserHistory();
const testPush = { const testPush = {
id: 494796, id: 494796,
revision: '1252c6014d122d48c6782310d5c3f4ae742751cb', revision: '1252c6014d122d48c6782310d5c3f4ae742751cb',
@ -42,25 +45,34 @@ afterEach(() => {
setUrlParam('selectedTaskRun', null); setUrlParam('selectedTaskRun', null);
}); });
const testPushJobs = (filterModel) => ( const testPushJobs = (filtermodel = null) => {
<Provider store={store}> const store = configureStore(history);
<PushJobs return (
push={testPush} <Provider store={store} context={ReactReduxContext}>
platforms={platforms} <ConnectedRouter history={history} context={ReactReduxContext}>
repoName="try" <PushJobs
filterModel={filterModel} push={testPush}
pushGroupState="" platforms={platforms}
toggleSelectedRunnableJob={() => {}} repoName="try"
runnableVisible={false} filterModel={
duplicateJobsVisible={false} filtermodel ||
groupCountsExpanded={false} new FilterModel({
/> router: { location: history.location, push: history.push },
, })
</Provider> }
); pushGroupState=""
toggleSelectedRunnableJob={() => {}}
runnableVisible={false}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>
</ConnectedRouter>
</Provider>
);
};
test('select a job updates url', async () => { test('select a job updates url', async () => {
const { getByText } = render(testPushJobs(new FilterModel())); const { getByText } = render(testPushJobs());
const spell = getByText('spell'); const spell = getByText('spell');
expect(spell).toBeInTheDocument(); expect(spell).toBeInTheDocument();
@ -74,9 +86,12 @@ test('select a job updates url', async () => {
}); });
test('filter change keeps selected job visible', async () => { test('filter change keeps selected job visible', async () => {
const filterModel = new FilterModel(); const { getByText, rerender } = render(testPushJobs());
const { getByText, rerender } = render(testPushJobs(filterModel));
const spell = await waitFor(() => getByText('spell')); const spell = await waitFor(() => getByText('spell'));
const filterModel = new FilterModel({
router: { location: history.location },
pushRoute: history.push,
});
expect(spell).toBeInTheDocument(); expect(spell).toBeInTheDocument();
@ -84,7 +99,7 @@ test('filter change keeps selected job visible', async () => {
expect(spell).toHaveClass('selected-job'); expect(spell).toHaveClass('selected-job');
filterModel.addFilter('searchStr', 'linux'); filterModel.addFilter('searchStr', 'linux');
rerender(testPushJobs(new FilterModel())); rerender(testPushJobs(filterModel));
const spell2 = getByText('spell'); const spell2 = getByText('spell');

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

@ -2,12 +2,12 @@ import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import { createBrowserHistory } from 'history';
import { import {
getProjectUrl, getProjectUrl,
getQueryString,
replaceLocation,
setUrlParam, setUrlParam,
updatePushParams,
} from '../../../../ui/helpers/location'; } from '../../../../ui/helpers/location';
import pushListFixture from '../../mock/push_list'; import pushListFixture from '../../mock/push_list';
import pushListFromChangeFixture from '../../mock/pushListFromchange'; import pushListFromChangeFixture from '../../mock/pushListFromchange';
@ -26,12 +26,12 @@ import {
reducer, reducer,
fetchPushes, fetchPushes,
pollPushes, pollPushes,
fetchNextPushes,
updateRange, updateRange,
} from '../../../../ui/job-view/redux/stores/pushes'; } from '../../../../ui/job-view/redux/stores/pushes';
import { getApiUrl } from '../../../../ui/helpers/url'; import { getApiUrl } from '../../../../ui/helpers/url';
import JobModel from '../../../../ui/models/job'; import JobModel from '../../../../ui/models/job';
const history = createBrowserHistory();
const mockStore = configureMockStore([thunk]); const mockStore = configureMockStore([thunk]);
const emptyBugzillaResponse = { const emptyBugzillaResponse = {
bugs: [], bugs: [],
@ -48,7 +48,7 @@ describe('Pushes Redux store', () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
fetchMock.reset(); fetchMock.reset();
replaceLocation({}); history.push('/');
}); });
test('should get pushes with fetchPushes', async () => { test('should get pushes with fetchPushes', async () => {
@ -60,7 +60,10 @@ describe('Pushes Redux store', () => {
`https://bugzilla.mozilla.org/rest/bug?id=1556854%2C1555861%2C1559418%2C1563766%2C1561537%2C1563692`, `https://bugzilla.mozilla.org/rest/bug?id=1556854%2C1555861%2C1559418%2C1563766%2C1561537%2C1563692`,
emptyBugzillaResponse, emptyBugzillaResponse,
); );
const store = mockStore({ pushes: initialState }); const store = mockStore({
pushes: initialState,
router: { location: history.location },
});
await store.dispatch(fetchPushes()); await store.dispatch(fetchPushes());
const actions = store.getActions(); const actions = store.getActions();
@ -94,9 +97,15 @@ describe('Pushes Redux store', () => {
jobListFixtureTwo, jobListFixtureTwo,
); );
fetchMock.get(
`https://bugzilla.mozilla.org/rest/bug?id=1506219`,
emptyBugzillaResponse,
);
const initialPush = pushListFixture.results[0]; const initialPush = pushListFixture.results[0];
const store = mockStore({ const store = mockStore({
pushes: { ...initialState, pushList: [initialPush] }, pushes: { ...initialState, pushList: [initialPush] },
router: { location: history.location },
}); });
await store.dispatch(pollPushes()); await store.dispatch(pollPushes());
@ -133,7 +142,7 @@ describe('Pushes Redux store', () => {
]); ]);
}); });
test('fetchNextPushes should update revision param on url', async () => { test('fetchPushes should update revision param on url', async () => {
fetchMock.get( fetchMock.get(
getProjectUrl( getProjectUrl(
'/push/?full=true&count=11&push_timestamp__lte=1562867957', '/push/?full=true&count=11&push_timestamp__lte=1562867957',
@ -148,24 +157,29 @@ describe('Pushes Redux store', () => {
); );
const push = pushListFixture.results[0]; const push = pushListFixture.results[0];
history.push({ search: `?repo=${repoName}&revision=${push.revision}` });
const params = updatePushParams(history.location);
history.push({ search: params });
const store = mockStore({ const store = mockStore({
pushes: { pushes: {
...initialState, ...initialState,
pushList: [push], pushList: [push],
oldestPushTimestamp: push.push_timestamp, oldestPushTimestamp: push.push_timestamp,
}, },
router: { location: history.location },
}); });
setUrlParam('revision', push.revision); await store.dispatch(fetchPushes(10, true));
await store.dispatch(fetchNextPushes(10));
expect(getQueryString()).toEqual( expect(window.location.search).toEqual(
'tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0&fromchange=90da061f588d1315ee4087225d041d7474d9dfd8', `?repo=${repoName}&tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0&fromchange=90da061f588d1315ee4087225d041d7474d9dfd8`,
); );
}); });
test('should pare down to single revision updateRange', async () => { test('should pare down to single revision updateRange', async () => {
const store = mockStore({ const store = mockStore({
pushes: { ...initialState, pushList: pushListFixture.results }, pushes: { ...initialState, pushList: pushListFixture.results },
router: { location: history.location },
}); });
await store.dispatch( await store.dispatch(
@ -174,7 +188,6 @@ describe('Pushes Redux store', () => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual([ expect(actions).toEqual([
{ countPinnedJobs: 0, type: 'CLEAR_JOB' },
{ {
type: SET_PUSHES, type: SET_PUSHES,
pushResults: { pushResults: {
@ -205,6 +218,7 @@ describe('Pushes Redux store', () => {
const store = mockStore({ const store = mockStore({
pushes: initialState, pushes: initialState,
router: { location: history.location },
}); });
setUrlParam('fromchange', '9692347caff487cdcd889489b8e89a825fe6bbd1'); setUrlParam('fromchange', '9692347caff487cdcd889489b8e89a825fe6bbd1');
@ -267,7 +281,7 @@ describe('Pushes Redux store', () => {
}); });
test('should get new unclassified counts with recalculateUnclassifiedCounts', async () => { test('should get new unclassified counts with recalculateUnclassifiedCounts', async () => {
setUrlParam('job_type_symbol', 'B'); history.push('/?job_type_symbol=B');
const { data: jobList } = await JobModel.getList({ push_id: 1 }); const { data: jobList } = await JobModel.getList({ push_id: 1 });
const state = reducer( const state = reducer(
@ -275,7 +289,10 @@ describe('Pushes Redux store', () => {
{ type: UPDATE_JOB_MAP, jobList }, { type: UPDATE_JOB_MAP, jobList },
); );
const reduced = reducer(state, { type: RECALCULATE_UNCLASSIFIED_COUNTS }); const reduced = reducer(state, {
type: RECALCULATE_UNCLASSIFIED_COUNTS,
router: { location: history.location },
});
expect(Object.keys(reduced.jobMap)).toHaveLength(5); expect(Object.keys(reduced.jobMap)).toHaveLength(5);
expect(reduced.allUnclassifiedFailureCount).toEqual(2); expect(reduced.allUnclassifiedFailureCount).toEqual(2);

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

@ -1,50 +1,30 @@
import React from 'react';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { render, cleanup, waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import keyBy from 'lodash/keyBy'; import keyBy from 'lodash/keyBy';
import { createBrowserHistory } from 'history';
import {
getUrlParam,
replaceLocation,
setUrlParam,
} from '../../../../ui/helpers/location';
import FilterModel from '../../../../ui/models/filter';
import { import {
setSelectedJob, setSelectedJob,
setSelectedJobFromQueryString, setSelectedJobFromQueryString,
clearSelectedJob, clearSelectedJob,
initialState, initialState,
reducer, reducer,
SELECT_JOB,
} from '../../../../ui/job-view/redux/stores/selectedJob'; } from '../../../../ui/job-view/redux/stores/selectedJob';
import JobGroup from '../../../../ui/job-view/pushes/JobGroup';
import group from '../../mock/group_with_jobs'; import group from '../../mock/group_with_jobs';
import { getApiUrl } from '../../../../ui/helpers/url'; import { getApiUrl } from '../../../../ui/helpers/url';
import jobListFixtureOne from '../../mock/job_list/job_1'; import jobListFixtureOne from '../../mock/job_list/job_1';
const mockStore = configureMockStore([thunk]);
const jobMap = keyBy(group.jobs, 'id'); const jobMap = keyBy(group.jobs, 'id');
let notifications = []; let notifications = [];
const history = createBrowserHistory();
describe('SelectedJob Redux store', () => { describe('SelectedJob Redux store', () => {
const mockStore = configureMockStore([thunk]);
const repoName = 'autoland'; const repoName = 'autoland';
const testJobGroup = (store, group, filterModel) => { const router = { location: history.location };
return (
<Provider store={store}>
<JobGroup
group={group}
repoName={repoName}
filterModel={filterModel}
filterPlatformCb={() => {}}
pushGroupState="expanded"
duplicateJobsVisible={false}
groupCountsExpanded
/>
</Provider>
);
};
beforeEach(() => { beforeEach(() => {
fetchMock.get( fetchMock.get(
@ -55,37 +35,44 @@ describe('SelectedJob Redux store', () => {
getApiUrl('/jobs/?task_id=a824gBVmRQSBuEexnVW_Qg&retry_id=0'), getApiUrl('/jobs/?task_id=a824gBVmRQSBuEexnVW_Qg&retry_id=0'),
{ results: [] }, { results: [] },
); );
setUrlParam('repo', repoName);
notifications = []; notifications = [];
}); });
afterEach(() => { afterEach(() => {
cleanup();
fetchMock.reset(); fetchMock.reset();
replaceLocation({}); history.push('/');
}); });
test('setSelectedJob should select a job', async () => { test('setSelectedJob should select a job', async () => {
const store = mockStore({ selectedJob: { initialState } });
const taskRun = 'UCctvnxZR0--JcxyVGc8VA.0'; const taskRun = 'UCctvnxZR0--JcxyVGc8VA.0';
const store = mockStore({
selectedJob: { initialState },
router,
});
render(testJobGroup(store, group, new FilterModel())); store.dispatch(setSelectedJob(group.jobs[0], true));
const actions = store.getActions();
const reduced = reducer( expect(actions).toEqual([
{ selectedJob: { initialState } }, {
setSelectedJob(group.jobs[0], true), job: group.jobs[0],
); type: SELECT_JOB,
updateDetails: true,
expect(reduced.selectedJob).toEqual(group.jobs[0]); },
expect(getUrlParam('selectedTaskRun')).toEqual(taskRun); {
payload: {
args: [{ search: `?selectedTaskRun=${taskRun}` }],
method: 'push',
},
type: '@@router/CALL_HISTORY_METHOD',
},
]);
}); });
test('setSelectedJobFromQueryString found', async () => { test('setSelectedJobFromQueryString found', async () => {
const taskRun = 'UCctvnxZR0--JcxyVGc8VA.0'; const taskRun = 'UCctvnxZR0--JcxyVGc8VA.0';
const store = mockStore({ selectedJob: { initialState } });
setUrlParam('selectedTaskRun', taskRun);
render(testJobGroup(store, group, new FilterModel())); history.push(`/jobs?repo=${repoName}&selectedTaskRun=${taskRun}`);
const reduced = reducer( const reduced = reducer(
{ selectedJob: { initialState } }, { selectedJob: { initialState } },
setSelectedJobFromQueryString(() => {}, jobMap), setSelectedJobFromQueryString(() => {}, jobMap),
@ -97,9 +84,9 @@ describe('SelectedJob Redux store', () => {
test('setSelectedJobFromQueryString not in jobMap', async () => { test('setSelectedJobFromQueryString not in jobMap', async () => {
const taskRun = 'VaQoWKTbSdGSwBJn6UZV9g.0'; const taskRun = 'VaQoWKTbSdGSwBJn6UZV9g.0';
setUrlParam('selectedTaskRun', taskRun); history.push(`/jobs?repo=${repoName}&selectedTaskRun=${taskRun}`);
const reduced = await reducer( const reduced = reducer(
{ selectedJob: { initialState } }, { selectedJob: { initialState } },
setSelectedJobFromQueryString((msg) => notifications.push(msg), jobMap), setSelectedJobFromQueryString((msg) => notifications.push(msg), jobMap),
); );
@ -115,9 +102,9 @@ describe('SelectedJob Redux store', () => {
test('setSelectedJobFromQueryString not in DB', async () => { test('setSelectedJobFromQueryString not in DB', async () => {
const taskRun = 'a824gBVmRQSBuEexnVW_Qg.0'; const taskRun = 'a824gBVmRQSBuEexnVW_Qg.0';
setUrlParam('selectedTaskRun', taskRun); history.push(`/jobs?repo=${repoName}&selectedTaskRun=${taskRun}`);
const reduced = await reducer( const reduced = reducer(
{ selectedJob: { initialState } }, { selectedJob: { initialState } },
setSelectedJobFromQueryString((msg) => notifications.push(msg), jobMap), setSelectedJobFromQueryString((msg) => notifications.push(msg), jobMap),
); );
@ -130,16 +117,30 @@ describe('SelectedJob Redux store', () => {
); );
}); });
test('clearSelectedJob', () => { test('clearSelectedJob', async () => {
const taskRun = 'UCctvnxZR0--JcxyVGc8VA.0'; const store = mockStore({
selectedJob: { selectedJob: group.jobs[0] },
router,
});
setUrlParam('selectedTaskRun', taskRun); store.dispatch(clearSelectedJob(0));
const actions = store.getActions();
const reduced = reducer( expect(actions).toEqual([
{ selectedJob: { selectedJob: group.jobs[0] } }, {
clearSelectedJob(0), countPinnedJobs: 0,
); type: 'CLEAR_JOB',
},
expect(reduced.selectedJob).toBeNull(); {
payload: {
args: [
{
search: '?',
},
],
method: 'push',
},
type: '@@router/CALL_HISTORY_METHOD',
},
]);
}); });
}); });

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

@ -1,19 +1,23 @@
import { createBrowserHistory } from 'history';
import { import {
getFilterUrlParamsWithDefaults, getFilterUrlParamsWithDefaults,
getNonFilterUrlParams, getNonFilterUrlParams,
} from '../../../ui/models/filter'; } from '../../../ui/models/filter';
const history = createBrowserHistory();
describe('FilterModel', () => { describe('FilterModel', () => {
const oldHash = window.location.hash; const prevParams = history.location.search;
afterEach(() => { afterEach(() => {
window.location.hash = oldHash; history.location.search = prevParams;
}); });
describe('parsing an old url', () => { describe('parsing an old url', () => {
it('should parse the repo with defaults', () => { it('should parse the repo with defaults', () => {
window.location.hash = '?repo=mozilla-inbound'; history.location.search = '?repo=mozilla-inbound';
const urlParams = getFilterUrlParamsWithDefaults(); const urlParams = getFilterUrlParamsWithDefaults(history.location);
expect(urlParams).toEqual({ expect(urlParams).toEqual({
repo: ['mozilla-inbound'], repo: ['mozilla-inbound'],
@ -34,12 +38,12 @@ describe('FilterModel', () => {
}); });
it('should parse resultStatus params', () => { it('should parse resultStatus params', () => {
window.location.hash = history.location.search =
'?repo=mozilla-inbound&filter-resultStatus=testfailed&' + '?repo=mozilla-inbound&filter-resultStatus=testfailed&' +
'filter-resultStatus=busted&filter-resultStatus=exception&' + 'filter-resultStatus=busted&filter-resultStatus=exception&' +
'filter-resultStatus=success&filter-resultStatus=retry' + 'filter-resultStatus=success&filter-resultStatus=retry' +
'&filter-resultStatus=runnable'; '&filter-resultStatus=runnable';
const urlParams = getFilterUrlParamsWithDefaults(); const urlParams = getFilterUrlParamsWithDefaults(history.location);
expect(urlParams).toEqual({ expect(urlParams).toEqual({
repo: ['mozilla-inbound'], repo: ['mozilla-inbound'],
@ -57,11 +61,11 @@ describe('FilterModel', () => {
}); });
it('should parse searchStr params with tier and groupState intact', () => { it('should parse searchStr params with tier and groupState intact', () => {
window.location.hash = history.location.search =
'?repo=mozilla-inbound&filter-searchStr=Linux%20x64%20debug%20build-linux64-base-toolchains%2Fdebug%20(Bb)&filter-tier=1&group_state=expanded'; '?repo=mozilla-inbound&filter-searchStr=Linux%20x64%20debug%20build-linux64-base-toolchains%2Fdebug%20(Bb)&filter-tier=1&group_state=expanded';
const urlParams = { const urlParams = {
...getNonFilterUrlParams(), ...getNonFilterUrlParams(history.location),
...getFilterUrlParamsWithDefaults(), ...getFilterUrlParamsWithDefaults(history.location),
}; };
expect(urlParams).toEqual({ expect(urlParams).toEqual({
@ -91,8 +95,9 @@ describe('FilterModel', () => {
}); });
it('should parse job field filters', () => { it('should parse job field filters', () => {
window.location.hash = '?repo=mozilla-inbound&filter-job_type_name=mochi'; history.location.search =
const urlParams = getFilterUrlParamsWithDefaults(); '?repo=mozilla-inbound&filter-job_type_name=mochi';
const urlParams = getFilterUrlParamsWithDefaults(history.location);
expect(urlParams).toEqual({ expect(urlParams).toEqual({
repo: ['mozilla-inbound'], repo: ['mozilla-inbound'],
@ -116,10 +121,10 @@ describe('FilterModel', () => {
describe('parsing a new url', () => { describe('parsing a new url', () => {
it('should parse resultStatus and searchStr', () => { it('should parse resultStatus and searchStr', () => {
window.location.hash = history.location.search =
'?repo=mozilla-inbound&resultStatus=testfailed,busted,exception,success,retry,runnable&' + '?repo=mozilla-inbound&resultStatus=testfailed,busted,exception,success,retry,runnable&' +
'searchStr=linux,x64,debug,build-linux64-base-toolchains%2Fdebug,(bb)'; 'searchStr=linux,x64,debug,build-linux64-base-toolchains%2Fdebug,(bb)';
const urlParams = getFilterUrlParamsWithDefaults(); const urlParams = getFilterUrlParamsWithDefaults(history.location);
expect(urlParams).toEqual({ expect(urlParams).toEqual({
repo: ['mozilla-inbound'], repo: ['mozilla-inbound'],
@ -144,9 +149,9 @@ describe('FilterModel', () => {
}); });
it('should preserve the case in email addresses', () => { it('should preserve the case in email addresses', () => {
window.location.hash = history.location.search =
'?repo=mozilla-inbound&author=VYV03354@nifty.ne.jp'; '?repo=mozilla-inbound&author=VYV03354@nifty.ne.jp';
const urlParams = getFilterUrlParamsWithDefaults(); const urlParams = getFilterUrlParamsWithDefaults(history.location);
expect(urlParams).toEqual({ expect(urlParams).toEqual({
repo: ['mozilla-inbound'], repo: ['mozilla-inbound'],

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

@ -2,14 +2,16 @@
import React from 'react'; import React from 'react';
import { import {
render, render,
cleanup,
fireEvent, fireEvent,
waitFor, waitFor,
waitForElementToBeRemoved, waitForElementToBeRemoved,
} from '@testing-library/react'; } from '@testing-library/react';
import { createMemoryHistory } from 'history'; import { createBrowserHistory } from 'history';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { ConnectedRouter } from 'connected-react-router';
import { Provider, ReactReduxContext } from 'react-redux';
import { configureStore } from '../../../../ui/job-view/redux/configureStore';
import { import {
backfillRetriggeredTitle, backfillRetriggeredTitle,
unknownFrameworkMessage, unknownFrameworkMessage,
@ -26,6 +28,8 @@ import testAlertSummaries from '../../mock/alert_summaries';
import testPerformanceTags from '../../mock/performance_tags'; import testPerformanceTags from '../../mock/performance_tags';
import TagsList from '../../../../ui/perfherder/alerts/TagsList'; import TagsList from '../../../../ui/perfherder/alerts/TagsList';
const history = createBrowserHistory();
const testUser = { const testUser = {
username: 'mozilla-ldap/test_user@mozilla.com', username: 'mozilla-ldap/test_user@mozilla.com',
isLoggedIn: true, isLoggedIn: true,
@ -64,7 +68,7 @@ const testIssueTrackers = [
const testActiveTags = ['first-tag', 'second-tag']; const testActiveTags = ['first-tag', 'second-tag'];
afterEach(cleanup); afterEach(() => history.push('/alerts'));
const mockModifyAlert = { const mockModifyAlert = {
update(alert, params) { update(alert, params) {
@ -78,69 +82,74 @@ const mockModifyAlert = {
}, },
}; };
// eslint-disable-next-line no-unused-vars const alertsView = () => {
const mockUpdateAlertSummary = (alertSummaryId, params) => ({ const store = configureStore(history);
failureStatus: null,
}); return render(
const alertsView = () => <Provider store={store} context={ReactReduxContext}>
render( <ConnectedRouter history={history} context={ReactReduxContext}>
<AlertsView <AlertsView
user={testUser} user={testUser}
projects={repos} projects={repos}
location={{ location={history.location}
pathname: '/alerts', frameworks={frameworks}
search: '', performanceTags={testPerformanceTags}
}} history={history}
history={createMemoryHistory('/alerts')} />
frameworks={frameworks} </ConnectedRouter>
performanceTags={testPerformanceTags} </Provider>,
/>,
); );
};
const alertsViewControls = ({ const alertsViewControls = ({
isListMode = true, isListMode = true,
user: userMock = null, user: userMock = null,
} = {}) => { } = {}) => {
const user = userMock !== null ? userMock : testUser; const user = userMock !== null ? userMock : testUser;
const store = configureStore(history);
return render( return render(
<AlertsViewControls <Provider store={store} context={ReactReduxContext}>
validated={{ <ConnectedRouter history={history} context={ReactReduxContext}>
hideDwnToInv: undefined, <AlertsViewControls
hideImprovements: undefined, validated={{
filter: undefined, hideDwnToInv: undefined,
updateParams: () => {}, hideImprovements: undefined,
}} filter: undefined,
isListMode={isListMode} updateParams: () => {},
alertSummaries={testAlertSummaries} }}
issueTrackers={testIssueTrackers} isListMode={isListMode}
optionCollectionMap={optionCollectionMap} alertSummaries={testAlertSummaries}
fetchAlertSummaries={() => {}} issueTrackers={testIssueTrackers}
updateViewState={() => {}} optionCollectionMap={optionCollectionMap}
user={user} fetchAlertSummaries={() => {}}
modifyAlert={(alert, params) => mockModifyAlert.update(alert, params)} updateViewState={() => {}}
updateAlertSummary={() => user={user}
Promise.resolve({ failureStatus: false, data: 'alert summary data' }) modifyAlert={(alert, params) => mockModifyAlert.update(alert, params)}
} updateAlertSummary={() =>
projects={repos} Promise.resolve({
location={{ failureStatus: false,
pathname: '/alerts', data: 'alert summary data',
search: '', })
}} }
filters={{ projects={repos}
filterText: '', location={history.location}
hideImprovements: false, filters={{
hideDownstream: false, filterText: '',
hideAssignedToOthers: false, hideImprovements: false,
framework: { name: 'talos', id: 1 }, hideDownstream: false,
status: 'untriaged', hideAssignedToOthers: false,
}} framework: { name: 'talos', id: 1 },
frameworks={[{ id: 1, name: dummyFrameworkName }]} status: 'untriaged',
history={createMemoryHistory('/alerts')} }}
frameworkOptions={[ignoreFrameworkOption, ...frameworks]} frameworks={[{ id: 1, name: dummyFrameworkName }]}
setFiltersState={() => {}} frameworkOptions={[ignoreFrameworkOption, ...frameworks]}
performanceTags={testPerformanceTags} setFiltersState={() => {}}
/>, performanceTags={testPerformanceTags}
history={history}
/>
</ConnectedRouter>
</Provider>,
); );
}; };

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

@ -7,15 +7,20 @@ import {
getAllByTestId, getAllByTestId,
queryAllByTestId, queryAllByTestId,
} from '@testing-library/react'; } from '@testing-library/react';
import { createBrowserHistory } from 'history';
import { ConnectedRouter } from 'connected-react-router';
import { Provider } from 'react-redux';
import Health from '../../../ui/push-health/Health'; import Health from '../../../ui/push-health/Health';
import pushHealth from '../mock/push_health'; import pushHealth from '../mock/push_health';
import reposFixture from '../mock/repositories'; import reposFixture from '../mock/repositories';
import { getApiUrl } from '../../../ui/helpers/url'; import { getApiUrl } from '../../../ui/helpers/url';
import { getProjectUrl } from '../../../ui/helpers/location'; import { getProjectUrl } from '../../../ui/helpers/location';
import { configureStore } from '../../../ui/job-view/redux/configureStore';
const revision = 'cd02b96bdce57d9ae53b632ca4740c871d3ecc32'; const revision = 'cd02b96bdce57d9ae53b632ca4740c871d3ecc32';
const repo = 'autoland'; const repo = 'autoland';
const history = createBrowserHistory();
describe('Health', () => { describe('Health', () => {
beforeAll(() => { beforeAll(() => {
@ -114,14 +119,19 @@ describe('Health', () => {
cleanup(); cleanup();
}); });
const testHealth = () => <Health location={window.location} />; const testHealth = () => {
const store = configureStore(history);
return (
<Provider store={store}>
<ConnectedRouter history={history}>
<Health location={history.location} />
</ConnectedRouter>
</Provider>
);
};
test('should show some grouped tests', async () => { test('should show some grouped tests', async () => {
window.history.replaceState( history.push(`/push-health?repo=${repo}&revision=${revision}`);
{},
'Push Health Test',
`${window.location.origin}?repo=${repo}&revision=${revision}`,
);
const health = render(testHealth()); const health = render(testHealth());
const classificationGroups = await waitFor(() => const classificationGroups = await waitFor(() =>
@ -141,10 +151,8 @@ describe('Health', () => {
}); });
test('should filter groups by test path string', async () => { test('should filter groups by test path string', async () => {
window.history.replaceState( history.push(
{}, `/push-health?repo=${repo}&revision=${revision}&searchStr=browser/extensions/`,
'Push Health Test',
`${window.location.origin}?repo=${repo}&revision=${revision}&searchStr=browser/extensions/`,
); );
const health = render(testHealth()); const health = render(testHealth());
const classificationGroups = await waitFor(() => const classificationGroups = await waitFor(() =>

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

@ -30,7 +30,7 @@ describe('Job', () => {
const job = await waitFor(() => getByText('R1')); const job = await waitFor(() => getByText('R1'));
expect(job.getAttribute('href')).toBe( expect(job.getAttribute('href')).toBe(
'/#/jobs?selectedJob=285852125&repo=try&revision=cd02b96bdce57d9ae53b632ca4740c871d3ecc32', '/jobs?selectedJob=285852125&repo=try&revision=cd02b96bdce57d9ae53b632ca4740c871d3ecc32',
); );
expect(job).toHaveClass('btn-orange-classified'); expect(job).toHaveClass('btn-orange-classified');
}); });
@ -40,7 +40,7 @@ describe('Job', () => {
const job = await waitFor(() => getByText('bc6')); const job = await waitFor(() => getByText('bc6'));
expect(job.getAttribute('href')).toBe( expect(job.getAttribute('href')).toBe(
'/#/jobs?selectedJob=285859045&repo=try&revision=cd02b96bdce57d9ae53b632ca4740c871d3ecc32', '/jobs?selectedJob=285859045&repo=try&revision=cd02b96bdce57d9ae53b632ca4740c871d3ecc32',
); );
expect(job).toHaveClass('btn-green'); expect(job).toHaveClass('btn-green');
}); });
@ -50,7 +50,7 @@ describe('Job', () => {
const job = await waitFor(() => getByText('arm64')); const job = await waitFor(() => getByText('arm64'));
expect(job.getAttribute('href')).toBe( expect(job.getAttribute('href')).toBe(
'/#/jobs?selectedJob=294399307&repo=try&revision=cd02b96bdce57d9ae53b632ca4740c871d3ecc32', '/jobs?selectedJob=294399307&repo=try&revision=cd02b96bdce57d9ae53b632ca4740c871d3ecc32',
); );
expect(job).toHaveClass('btn-red'); expect(job).toHaveClass('btn-red');
expect(getByText('Failed in parent')).toBeInTheDocument(); expect(getByText('Failed in parent')).toBeInTheDocument();

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

@ -126,14 +126,6 @@ MIDDLEWARE = [
if middleware if middleware
] ]
# Templating
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
}
]
# Database # Database
# The database config is defined using environment variables of form: # The database config is defined using environment variables of form:
# #
@ -426,6 +418,14 @@ WHITENOISE_INDEX_FILE = True
# Halves the time spent performing Brotli/gzip compression during deploys. # Halves the time spent performing Brotli/gzip compression during deploys.
WHITENOISE_KEEP_ONLY_HASHED_FILES = True WHITENOISE_KEEP_ONLY_HASHED_FILES = True
# Templating
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'DIRS': [WHITENOISE_ROOT],
}
]
# TREEHERDER # TREEHERDER

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

@ -1,12 +1,14 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url, re_path
from rest_framework.documentation import include_docs_urls from rest_framework.documentation import include_docs_urls
from treeherder.webapp.api import urls as api_urls from treeherder.webapp.api import urls as api_urls
from django.views.generic.base import TemplateView
urlpatterns = [ urlpatterns = [
url(r'^api/', include(api_urls)), url(r'^api/', include(api_urls)),
url(r'^docs/', include_docs_urls(title='REST API Docs')), url(r'^docs/', include_docs_urls(title='REST API Docs')),
re_path(r'', TemplateView.as_view(template_name='index.html')),
] ]
if settings.DEBUG: if settings.DEBUG:

168
ui/App.jsx Normal file
Просмотреть файл

@ -0,0 +1,168 @@
import React, { Suspense, lazy } from 'react';
import { Route, Switch } from 'react-router-dom';
import { hot } from 'react-hot-loader/root';
import { Helmet } from 'react-helmet';
import { ConnectedRouter } from 'connected-react-router';
import { Provider } from 'react-redux';
import { configureStore, history } from './job-view/redux/configureStore';
import LoadingSpinner from './shared/LoadingSpinner';
import LoginCallback from './login-callback/LoginCallback';
import TaskclusterCallback from './taskcluster-auth-callback/TaskclusterCallback';
import UserGuideApp from './userguide/App';
const IntermittentFailuresApp = lazy(() =>
import('./intermittent-failures/App'),
);
const PerfherderApp = lazy(() => import('./perfherder/App'));
const PushHealthApp = lazy(() => import('./push-health/App'));
const JobsViewApp = lazy(() => import('./job-view/App'));
const LogviewerApp = lazy(() => import('./logviewer/App'));
// backwards compatibility for routes like this: treeherder.mozilla.org/perf.html#/alerts?id=26622&hideDwnToInv=0
const updateOldUrls = () => {
const { pathname, hash, search } = history.location;
const updates = {};
const urlMatch = {
'/perf.html': '/perfherder',
'/pushhealth.html': '/push-health',
'/': '/jobs',
};
if (
pathname.endsWith('.html') ||
(pathname === '/' && hash.length) ||
urlMatch[pathname]
) {
updates.pathname = urlMatch[pathname] || pathname.replace(/.html|\//g, '');
}
if (hash.length) {
const index = hash.indexOf('?');
updates.search = hash.substring(index);
const subRoute = hash.substring(1, index);
if (index >= 2 && updates.pathname !== subRoute) {
updates.pathname += subRoute;
}
} else if (search.length) {
updates.search = search;
}
if (Object.keys(updates).length === 0) {
return;
}
history.push(updates);
};
const faviconPaths = {
'/jobs': { title: 'Treeherder Jobs View', favicon: 'ui/img/tree_open.png' },
'/logviewer': {
title: 'Treeherder Logviewer',
favicon: 'ui/img/logviewerIcon.png',
},
'/perfherder': { title: 'Perfherder', favicon: 'ui/img/line_chart.png' },
'/userguide': {
title: 'Treeherder User Guide',
favicon: 'ui/img/tree_open.png',
},
'/intermittent-failures': {
title: 'Intermittent Failures View',
favicon: 'ui/img/tree_open.png',
},
'/push-health': {
title: 'Push Health',
favicon: 'ui/img/push-health-ok.png',
},
};
const withFavicon = (element, route) => {
const { title, favicon } = faviconPaths[route];
return (
<React.Fragment>
<Helmet defaultTitle={title}>
<link rel={`${title} icon`} href={favicon} />
</Helmet>
{element}
</React.Fragment>
);
};
const App = () => {
updateOldUrls();
return (
<Provider store={configureStore()}>
<ConnectedRouter history={history}>
<Suspense fallback={<LoadingSpinner />}>
<Switch>
<Route
exact
path="/login"
render={(props) => <LoginCallback {...props} />}
/>
<Route
exact
path="/taskcluster-auth"
render={(props) => <TaskclusterCallback {...props} />}
/>
<Route
path="/jobs"
render={(props) =>
withFavicon(<JobsViewApp {...props} />, props.location.pathname)
}
/>
<Route
path="/logviewer"
render={(props) =>
withFavicon(
<LogviewerApp {...props} />,
props.location.pathname,
)
}
/>
<Route
path="/userguide"
render={(props) =>
withFavicon(
<UserGuideApp {...props} />,
props.location.pathname,
)
}
/>
<Route
path="/push-health"
render={(props) =>
withFavicon(
<PushHealthApp {...props} />,
props.location.pathname,
)
}
/>
<Route
path="/intermittent-failures"
render={(props) =>
withFavicon(
<IntermittentFailuresApp {...props} />,
'/intermittent-failures',
)
}
/>
<Route
path="/perfherder"
render={(props) =>
withFavicon(<PerfherderApp {...props} />, '/perfherder')
}
/>
</Switch>
</Suspense>
</ConnectedRouter>
</Provider>
);
};
export default hot(App);

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

@ -58,7 +58,7 @@ input {
font-size: 14px; font-size: 14px;
} }
.tooltip > .tooltip-inner { .custom-tooltip {
background-color: lightgray; background-color: lightgray;
color: black; color: black;
font-size: 14px; font-size: 14px;

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

@ -21,8 +21,6 @@
.push-title-left { .push-title-left {
flex: 0 0 24.2em; flex: 0 0 24.2em;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 10px; padding-right: 10px;
} }

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

@ -2,7 +2,7 @@ import pick from 'lodash/pick';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { thFailureResults } from './constants'; import { thFailureResults } from './constants';
import { extractSearchString, parseQueryParams } from './url'; import { parseQueryParams } from './url';
// used with field-filters to determine how to match the value against the // used with field-filters to determine how to match the value against the
// job field. // job field.
@ -99,14 +99,8 @@ export const hasUrlFilterChanges = function hasUrlFilterChanges(
oldURL, oldURL,
newURL, newURL,
) { ) {
const oldFilters = pick( const oldFilters = pick(parseQueryParams(oldURL), allFilterParams);
parseQueryParams(extractSearchString(oldURL)), const newFilters = pick(parseQueryParams(newURL), allFilterParams);
allFilterParams,
);
const newFilters = pick(
parseQueryParams(extractSearchString(newURL)),
allFilterParams,
);
return !isEqual(oldFilters, newFilters); return !isEqual(oldFilters, newFilters);
}; };

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

@ -1,7 +1,6 @@
import { thFailureResults, thPlatformMap } from './constants'; import { thFailureResults, thPlatformMap } from './constants';
import { getGroupMapKey } from './aggregateId'; import { getGroupMapKey } from './aggregateId';
import { getAllUrlParams, getRepo } from './location'; import { getAllUrlParams, getRepo } from './location';
import { uiJobsUrlBase } from './url';
const btnClasses = { const btnClasses = {
busted: 'btn-red', busted: 'btn-red',
@ -236,7 +235,7 @@ export const getJobSearchStrHref = function getJobSearchStrHref(jobSearchStr) {
const params = getAllUrlParams(); const params = getAllUrlParams();
params.set('searchStr', jobSearchStr.split(' ')); params.set('searchStr', jobSearchStr.split(' '));
return `${uiJobsUrlBase}?${params.toString()}`; return `?${params.toString()}`;
}; };
export const getTaskRunStr = (job) => `${job.task_id}.${job.retry_id}`; export const getTaskRunStr = (job) => `${job.task_id}.${job.retry_id}`;

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

@ -1,17 +1,10 @@
import { thDefaultRepo } from './constants'; import { thDefaultRepo } from './constants';
import { import { createQueryParams, getApiUrl } from './url';
createQueryParams,
extractSearchString,
getApiUrl,
uiJobsUrlBase,
} from './url';
export const getQueryString = function getQueryString() { export const getAllUrlParams = function getAllUrlParams(
return extractSearchString(window.location.href); location = window.location,
}; ) {
return new URLSearchParams(location.search);
export const getAllUrlParams = function getAllUrlParams() {
return new URLSearchParams(getQueryString());
}; };
export const getUrlParam = function getUrlParam(name) { export const getUrlParam = function getUrlParam(name) {
@ -22,27 +15,12 @@ export const getRepo = function getRepo() {
return getUrlParam('repo') || thDefaultRepo; return getUrlParam('repo') || thDefaultRepo;
}; };
export const setLocation = function setLocation(params, hashPrefix = '/jobs') { // This won't update the react router history object
window.location.hash = `#${hashPrefix}${createQueryParams(params)}`; export const replaceLocation = function replaceLocation(params) {
window.history.pushState(null, null, createQueryParams(params));
}; };
// change the url hash without firing a ``hashchange`` event. export const setUrlParam = function setUrlParam(field, value) {
export const replaceLocation = function replaceLocation(
params,
hashPrefix = '/jobs',
) {
window.history.replaceState(
null,
null,
`${window.location.pathname}#${hashPrefix}${createQueryParams(params)}`,
);
};
export const setUrlParam = function setUrlParam(
field,
value,
hashPrefix = '/jobs',
) {
const params = getAllUrlParams(); const params = getAllUrlParams();
if (value) { if (value) {
@ -50,10 +28,25 @@ export const setUrlParam = function setUrlParam(
} else { } else {
params.delete(field); params.delete(field);
} }
setLocation(params, hashPrefix);
replaceLocation(params);
}; };
export const getRepoUrl = function getRepoUrl(newRepoName) { export const setUrlParams = function setUrlParams(newParams) {
const params = getAllUrlParams();
for (const [key, value] of newParams) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
return createQueryParams(params);
};
export const updateRepoParams = function updateRepoParams(newRepoName) {
const params = getAllUrlParams(); const params = getAllUrlParams();
params.delete('selectedJob'); params.delete('selectedJob');
@ -62,7 +55,7 @@ export const getRepoUrl = function getRepoUrl(newRepoName) {
params.delete('revision'); params.delete('revision');
params.delete('author'); params.delete('author');
params.set('repo', newRepoName); params.set('repo', newRepoName);
return `${uiJobsUrlBase}?${params.toString()}`; return `?${params.toString()}`;
}; };
// Take the repoName, if passed in. If not, then try to find it on the // Take the repoName, if passed in. If not, then try to find it on the
@ -77,3 +70,23 @@ export const getProjectUrl = function getProjectUrl(uri, repoName) {
export const getProjectJobUrl = function getProjectJobUrl(url, jobId) { export const getProjectJobUrl = function getProjectJobUrl(url, jobId) {
return getProjectUrl(`/jobs/${jobId}${url}`); return getProjectUrl(`/jobs/${jobId}${url}`);
}; };
export const updatePushParams = (location) => {
const params = new URLSearchParams(location.search);
if (params.has('revision')) {
// We are viewing a single revision, but the user has asked for more.
// So we must replace the ``revision`` param with ``tochange``, which
// will make it just the top of the range. We will also then get a new
// ``fromchange`` param after the fetch.
const revision = params.get('revision');
params.delete('revision');
params.set('tochange', revision);
} else if (params.has('startdate')) {
// We are fetching more pushes, so we don't want to limit ourselves by
// ``startdate``. And after the fetch, ``startdate`` will be invalid,
// and will be replaced on the location bar by ``fromchange``.
params.delete('startdate');
}
return `?${params.toString()}`;
};

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

@ -38,7 +38,7 @@ const taskcluster = (() => {
const getAuthCode = (useExistingWindow = false) => { const getAuthCode = (useExistingWindow = false) => {
const nonce = generateNonce(); const nonce = generateNonce();
// we're storing these for use in the TaskclusterCallback component (taskcluster-auth.html) // we're storing these for use in the TaskclusterCallback component (taskcluster-auth)
// since that's the only way for it to get access to them // since that's the only way for it to get access to them
localStorage.setItem('requestState', nonce); localStorage.setItem('requestState', nonce);
localStorage.setItem('tcRootUrl', _rootUrl); localStorage.setItem('tcRootUrl', _rootUrl);

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

@ -3,9 +3,9 @@
// https://github.com/mozilla/treeherder/blob/master/treeherder/middleware.py // https://github.com/mozilla/treeherder/blob/master/treeherder/middleware.py
import tcLibUrls from 'taskcluster-lib-urls'; import tcLibUrls from 'taskcluster-lib-urls';
export const uiJobsUrlBase = '/#/jobs'; export const uiJobsUrlBase = '/jobs';
export const uiPushHealthBase = '/pushhealth.html'; export const uiPushHealthBase = '/push-health';
export const bzBaseUrl = 'https://bugzilla.mozilla.org/'; export const bzBaseUrl = 'https://bugzilla.mozilla.org/';
@ -21,7 +21,7 @@ export const graphsEndpoint = '/failurecount/';
export const deployedRevisionUrl = '/revision.txt'; export const deployedRevisionUrl = '/revision.txt';
export const loginCallbackUrl = '/login.html'; export const loginCallbackUrl = '/login';
export const platformsEndpoint = '/machineplatforms/'; export const platformsEndpoint = '/machineplatforms/';
@ -31,7 +31,7 @@ export const investigatedTestsEndPoint = '/investigated-tests/';
export const repoEndpoint = '/repository/'; export const repoEndpoint = '/repository/';
export const tcAuthCallbackUrl = '/taskcluster-auth.html'; export const tcAuthCallbackUrl = '/taskcluster-auth';
export const textLogErrorsEndpoint = '/text_log_errors/'; export const textLogErrorsEndpoint = '/text_log_errors/';
@ -105,7 +105,7 @@ export const getLogViewerUrl = function getLogViewerUrl(
repoName, repoName,
lineNumber, lineNumber,
) { ) {
const rv = `logviewer.html#?job_id=${jobId}&repo=${repoName}`; const rv = `/logviewer?job_id=${jobId}&repo=${repoName}`;
return lineNumber ? `${rv}&lineNumber=${lineNumber}` : rv; return lineNumber ? `${rv}&lineNumber=${lineNumber}` : rv;
}; };
@ -124,7 +124,7 @@ export const getPushHealthUrl = function getPushHealthUrl(params) {
}; };
export const getCompareChooserUrl = function getCompareChooserUrl(params) { export const getCompareChooserUrl = function getCompareChooserUrl(params) {
return `perf.html#/comparechooser${createQueryParams(params)}`; return `/perfherder/comparechooser${createQueryParams(params)}`;
}; };
export const parseQueryParams = function parseQueryParams(search) { export const parseQueryParams = function parseQueryParams(search) {
@ -136,12 +136,6 @@ export const parseQueryParams = function parseQueryParams(search) {
); );
}; };
export const extractSearchString = function getQueryString(url) {
const parts = url.split('?');
return parts[parts.length - 1];
};
// `api` requires a preceding forward slash // `api` requires a preceding forward slash
export const createApiUrl = function createApiUrl(api, params) { export const createApiUrl = function createApiUrl(api, params) {
const apiUrl = getApiUrl(api); const apiUrl = getApiUrl(api);
@ -163,7 +157,5 @@ export const updateQueryParams = function updateHistoryWithQueryParams(
history, history,
location, location,
) { ) {
history.replace({ pathname: location.pathname, search: queryParams }); history.push({ pathname: location.pathname, search: queryParams });
// we do this so the api's won't be called twice (location/history updates will trigger a lifecycle hook)
location.search = queryParams;
}; };

10
ui/index.jsx Normal file
Просмотреть файл

@ -0,0 +1,10 @@
import React from 'react';
import { render } from 'react-dom';
import App from './App';
import './css/treeherder-custom-styles.css';
import './css/treeherder-navbar.css';
import './css/treeherder.css';
render(<App />, document.getElementById('root'));

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

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { HashRouter, Route, Switch, Redirect } from 'react-router-dom'; import { Route, Switch, Redirect } from 'react-router-dom';
import { Container } from 'reactstrap'; import { Container } from 'reactstrap';
import { hot } from 'react-hot-loader/root'; import { hot } from 'react-hot-loader/root';
@ -8,7 +8,11 @@ import ErrorMessages from '../shared/ErrorMessages';
import MainView from './MainView'; import MainView from './MainView';
import BugDetailsView from './BugDetailsView'; import BugDetailsView from './BugDetailsView';
class App extends React.Component { import 'react-table/react-table.css';
import '../css/intermittent-failures.css';
import '../css/treeherder-base.css';
class IntermittentFailuresApp extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -28,54 +32,56 @@ class App extends React.Component {
render() { render() {
const { user, graphData, tableData, errorMessages } = this.state; const { user, graphData, tableData, errorMessages } = this.state;
const { path } = this.props.match;
return ( return (
<HashRouter> <main>
<main> {errorMessages.length > 0 && (
{errorMessages.length > 0 && ( <Container className="pt-5 max-width-default">
<Container className="pt-5 max-width-default"> <ErrorMessages errorMessages={errorMessages} />
<ErrorMessages errorMessages={errorMessages} /> </Container>
</Container> )}
)} <Switch>
<Switch> <Route
<Route exact
exact path={`${path}/main`}
path="/main" render={(props) => (
render={(props) => ( <MainView
<MainView {...props}
{...props} mainGraphData={graphData}
mainGraphData={graphData} mainTableData={tableData}
mainTableData={tableData} updateAppState={this.updateAppState}
updateAppState={this.updateAppState} user={user}
user={user} setUser={(user) => this.setState({ user })}
setUser={(user) => this.setState({ user })} notify={(message) =>
notify={(message) => this.setState({ errorMessages: [message] })
this.setState({ errorMessages: [message] }) }
} />
/> )}
)} />
/> <Route
<Route path={`${path}/main?startday=:startday&endday=:endday&tree=:tree`}
path="/main?startday=:startday&endday=:endday&tree=:tree" render={(props) => (
render={(props) => ( <MainView
<MainView {...props}
{...props} mainGraphData={graphData}
mainGraphData={graphData} mainTableData={tableData}
mainTableData={tableData} updateAppState={this.updateAppState}
updateAppState={this.updateAppState} />
/> )}
)} />
/> <Route
<Route path="/bugdetails" component={BugDetailsView} /> path={`${path}/bugdetails`}
<Route render={(props) => <BugDetailsView {...props} />}
path="/bugdetails?startday=:startday&endday=:endday&tree=:tree&bug=bug" />
component={BugDetailsView} <Route
/> path={`${path}/bugdetails?startday=:startday&endday=:endday&tree=:tree&bug=bug`}
<Redirect from="/" to="/main" /> render={(props) => <BugDetailsView {...props} />}
</Switch> />
</main> <Redirect from={`${path}/`} to={`${path}/main`} />
</HashRouter> </Switch>
</main>
); );
} }
} }
export default hot(App); export default hot(IntermittentFailuresApp);

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

@ -34,7 +34,7 @@ function BugColumn({
className="ml-1 small-text bug-details" className="ml-1 small-text bug-details"
onClick={() => updateAppState({ graphData, tableData })} onClick={() => updateAppState({ graphData, tableData })}
to={{ to={{
pathname: '/bugdetails', pathname: '/intermittent-failures/bugdetails',
search: `?startday=${startday}&endday=${endday}&tree=${tree}&bug=${id}`, search: `?startday=${startday}&endday=${endday}&tree=${tree}&bug=${id}`,
state: { startday, endday, tree, id, summary, location }, state: { startday, endday, tree, id, summary, location },
}} }}

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

@ -124,6 +124,7 @@ const BugDetailsView = (props) => {
</ul> </ul>
) )
} }
innerClassName="custom-tooltip"
/> />
); );
}, },
@ -152,14 +153,14 @@ const BugDetailsView = (props) => {
<Col xs="12" className="text-left"> <Col xs="12" className="text-left">
<Breadcrumb listClassName="bg-white"> <Breadcrumb listClassName="bg-white">
<BreadcrumbItem> <BreadcrumbItem>
<a title="Treeherder home page" href="/#/"> <a title="Treeherder home page" href="/">
Treeherder Treeherder
</a> </a>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbItem> <BreadcrumbItem>
<Link <Link
title="Intermittent Failures View main page" title="Intermittent Failures View main page"
to={lastLocation || '/'} to={lastLocation || '/intermittent-failures/'}
> >
Main view Main view
</Link> </Link>

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

@ -127,7 +127,7 @@ const MainView = (props) => {
<Col xs="12" className="text-left"> <Col xs="12" className="text-left">
<Breadcrumb listClassName="bg-white"> <Breadcrumb listClassName="bg-white">
<BreadcrumbItem> <BreadcrumbItem>
<a title="Treeherder home page" href="/#/"> <a title="Treeherder home page" href="/">
Treeherder Treeherder
</a> </a>
</BreadcrumbItem> </BreadcrumbItem>

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

@ -1,11 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import 'react-table/react-table.css';
import '../css/treeherder-base.css';
import '../css/treeherder-custom-styles.css';
import '../css/treeherder-navbar.css';
import '../css/intermittent-failures.css';
import App from './App';
render(<App />, document.getElementById('root'));

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

@ -4,14 +4,20 @@ import { hot } from 'react-hot-loader/root';
import SplitPane from 'react-split-pane'; import SplitPane from 'react-split-pane';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { Provider } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { push as pushRoute } from 'connected-react-router';
import { thFavicons, thEvents } from '../helpers/constants'; import { thFavicons, thDefaultRepo, thEvents } from '../helpers/constants';
import ShortcutTable from '../shared/ShortcutTable'; import ShortcutTable from '../shared/ShortcutTable';
import { hasUrlFilterChanges, matchesDefaults } from '../helpers/filter'; import { matchesDefaults } from '../helpers/filter';
import { getAllUrlParams, getRepo } from '../helpers/location'; import { getAllUrlParams } from '../helpers/location';
import { MAX_TRANSIENT_AGE } from '../helpers/notifications'; import { MAX_TRANSIENT_AGE } from '../helpers/notifications';
import { deployedRevisionUrl } from '../helpers/url'; import {
deployedRevisionUrl,
parseQueryParams,
createQueryParams,
} from '../helpers/url';
import ClassificationTypeModel from '../models/classificationType'; import ClassificationTypeModel from '../models/classificationType';
import FilterModel from '../models/filter'; import FilterModel from '../models/filter';
import RepositoryModel from '../models/repository'; import RepositoryModel from '../models/repository';
@ -24,8 +30,19 @@ import { PUSH_HEALTH_VISIBILITY } from './headerbars/HealthMenu';
import DetailsPanel from './details/DetailsPanel'; import DetailsPanel from './details/DetailsPanel';
import PushList from './pushes/PushList'; import PushList from './pushes/PushList';
import KeyboardShortcuts from './KeyboardShortcuts'; import KeyboardShortcuts from './KeyboardShortcuts';
import { store } from './redux/store'; import { clearExpiredNotifications } from './redux/stores/notifications';
import { CLEAR_EXPIRED_TRANSIENTS } from './redux/stores/notifications';
import '../css/treeherder-base.css';
import '../css/treeherder-navbar-panels.css';
import '../css/treeherder-notifications.css';
import '../css/treeherder-details-panel.css';
import '../css/failure-summary.css';
import '../css/treeherder-job-buttons.css';
import '../css/treeherder-pushes.css';
import '../css/treeherder-pinboard.css';
import '../css/treeherder-bugfiler.css';
import '../css/treeherder-fuzzyfinder.css';
import '../css/treeherder-loading-overlay.css';
const DEFAULT_DETAILS_PCT = 40; const DEFAULT_DETAILS_PCT = 40;
const REVISION_POLL_INTERVAL = 1000 * 60 * 5; const REVISION_POLL_INTERVAL = 1000 * 60 * 5;
@ -51,15 +68,13 @@ class App extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const filterModel = new FilterModel(); const filterModel = new FilterModel(this.props);
// Set the URL to updated parameter styles, if needed. Otherwise it's a no-op.
filterModel.push();
const urlParams = getAllUrlParams(); const urlParams = getAllUrlParams();
const hasSelectedJob = const hasSelectedJob =
urlParams.has('selectedJob') || urlParams.has('selectedTaskRun'); urlParams.has('selectedJob') || urlParams.has('selectedTaskRun');
this.state = { this.state = {
repoName: getRepo(), repoName: this.getOrSetRepo(),
revision: urlParams.get('revision'), revision: urlParams.get('revision'),
user: { isLoggedIn: false, isStaff: false }, user: { isLoggedIn: false, isStaff: false },
filterModel, filterModel,
@ -82,7 +97,6 @@ class App extends React.Component {
static getDerivedStateFromProps(props, state) { static getDerivedStateFromProps(props, state) {
return { return {
...App.getSplitterDimensions(state.hasSelectedJob), ...App.getSplitterDimensions(state.hasSelectedJob),
repoName: getRepo(),
}; };
} }
@ -103,7 +117,6 @@ class App extends React.Component {
}); });
window.addEventListener('resize', this.updateDimensions, false); window.addEventListener('resize', this.updateDimensions, false);
window.addEventListener('hashchange', this.handleUrlChanges, false);
window.addEventListener('storage', this.handleStorageEvent); window.addEventListener('storage', this.handleStorageEvent);
window.addEventListener(thEvents.filtersUpdated, this.handleFiltersUpdated); window.addEventListener(thEvents.filtersUpdated, this.handleFiltersUpdated);
@ -145,14 +158,25 @@ class App extends React.Component {
// clear expired notifications // clear expired notifications
this.notificationInterval = setInterval(() => { this.notificationInterval = setInterval(() => {
store.dispatch({ type: CLEAR_EXPIRED_TRANSIENTS }); this.props.clearExpiredNotifications();
}, MAX_TRANSIENT_AGE); }, MAX_TRANSIENT_AGE);
} }
componentDidUpdate(prevProps) {
if (
prevProps.router.location.search !== this.props.router.location.search
) {
this.handleUrlChanges();
}
}
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.updateDimensions, false); window.removeEventListener('resize', this.updateDimensions, false);
window.removeEventListener('hashchange', this.handleUrlChanges, false); window.removeEventListener('storage', this.handleStorageEvent);
window.removeEventListener('storage', this.handleUrlChanges, false); window.removeEventListener(
thEvents.filtersUpdated,
this.handleFiltersUpdated,
);
if (this.updateInterval) { if (this.updateInterval) {
clearInterval(this.updateInterval); clearInterval(this.updateInterval);
@ -174,6 +198,33 @@ class App extends React.Component {
}; };
} }
getOrSetRepo() {
const { pushRoute } = this.props;
const params = getAllUrlParams();
let repo = params.get('repo');
if (!repo) {
repo = thDefaultRepo;
params.set('repo', repo);
pushRoute({
search: createQueryParams(params),
});
}
return repo;
}
handleFiltersUpdated = () => {
// we're only using window.location here because of how we're setting param changes for fetchNextPushes
// in PushList and addPushes.
this.setState({
filterModel: new FilterModel({
router: window,
pushRoute: this.props.pushRoute,
}),
});
};
handleStorageEvent = (e) => { handleStorageEvent = (e) => {
if (e.key === PUSH_HEALTH_VISIBILITY) { if (e.key === PUSH_HEALTH_VISIBILITY) {
this.setState({ this.setState({
@ -200,9 +251,7 @@ class App extends React.Component {
}; };
getAllShownJobs = (pushId) => { getAllShownJobs = (pushId) => {
const { const { jobMap } = this.props;
pushes: { jobMap },
} = store.getState();
const jobList = Object.values(jobMap); const jobList = Object.values(jobMap);
return pushId return pushId
@ -222,32 +271,32 @@ class App extends React.Component {
); );
}; };
handleUrlChanges = (ev) => { handleUrlChanges = () => {
const { repos } = this.state; const { repos } = this.state;
const { newURL, oldURL } = ev; const { router } = this.props;
const urlParams = getAllUrlParams();
const newRepo = urlParams.get('repo'); const {
// We only want to set state if any of these or the filter values have changed selectedJob,
selectedTaskRun,
group_state: groupState,
duplicate_jobs: duplicateJobs,
repo: newRepo,
} = parseQueryParams(router.location.search);
const newState = { const newState = {
hasSelectedJob: hasSelectedJob: selectedJob || selectedTaskRun,
urlParams.has('selectedJob') || urlParams.has('selectedTaskRun'), groupCountsExpanded: groupState === 'expanded',
groupCountsExpanded: urlParams.get('group_state') === 'expanded', duplicateJobsVisible: duplicateJobs === 'visible',
duplicateJobsVisible: urlParams.get('duplicate_jobs') === 'visible',
currentRepo: repos.find((repo) => repo.name === newRepo), currentRepo: repos.find((repo) => repo.name === newRepo),
}; };
const oldState = pick(this.state, Object.keys(newState)); const oldState = pick(this.state, Object.keys(newState));
let stateChanges = { filterModel: new FilterModel(this.props) };
// Only re-create the FilterModel if url params that affect it have changed.
if (hasUrlFilterChanges(oldURL, newURL)) {
this.setState({ filterModel: new FilterModel() });
}
if (!isEqual(newState, oldState)) { if (!isEqual(newState, oldState)) {
this.setState(newState); stateChanges = { ...stateChanges, ...newState };
} }
}; this.setState(stateChanges);
handleFiltersUpdated = () => {
this.setState({ filterModel: new FilterModel() });
}; };
// If ``show`` is a boolean, then set to that value. If it's not, then toggle // If ``show`` is a boolean, then set to that value. If it's not, then toggle
@ -318,87 +367,101 @@ class App extends React.Component {
return ( return (
<div id="global-container" className="height-minus-navbars"> <div id="global-container" className="height-minus-navbars">
<Provider store={store}> <KeyboardShortcuts
<KeyboardShortcuts filterModel={filterModel}
showOnScreenShortcuts={this.showOnScreenShortcuts}
>
<PrimaryNavBar
repos={repos}
updateButtonClick={this.updateButtonClick}
serverChanged={serverChanged}
filterModel={filterModel} filterModel={filterModel}
showOnScreenShortcuts={this.showOnScreenShortcuts} setUser={this.setUser}
user={user}
setCurrentRepoTreeStatus={this.setCurrentRepoTreeStatus}
getAllShownJobs={this.getAllShownJobs}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
toggleFieldFilterVisible={this.toggleFieldFilterVisible}
pushHealthVisibility={pushHealthVisibility}
setPushHealthVisibility={this.setPushHealthVisibility}
{...this.props}
/>
<SplitPane
split="horizontal"
size={`${pushListPct}%`}
onChange={(size) => this.handleSplitChange(size)}
> >
<PrimaryNavBar <div className="d-flex flex-column w-100">
repos={repos} {(isFieldFilterVisible || !!filterBarFilters.length) && (
updateButtonClick={this.updateButtonClick} <ActiveFilters
serverChanged={serverChanged} classificationTypes={classificationTypes}
filterModel={filterModel} filterModel={filterModel}
setUser={this.setUser} filterBarFilters={filterBarFilters}
user={user} isFieldFilterVisible={isFieldFilterVisible}
setCurrentRepoTreeStatus={this.setCurrentRepoTreeStatus} toggleFieldFilterVisible={this.toggleFieldFilterVisible}
getAllShownJobs={this.getAllShownJobs} />
duplicateJobsVisible={duplicateJobsVisible} )}
groupCountsExpanded={groupCountsExpanded} {serverChangedDelayed && (
toggleFieldFilterVisible={this.toggleFieldFilterVisible} <UpdateAvailable updateButtonClick={this.updateButtonClick} />
pushHealthVisibility={pushHealthVisibility} )}
setPushHealthVisibility={this.setPushHealthVisibility} {currentRepo && (
/> <div id="th-global-content" className="th-global-content">
<SplitPane <span className="th-view-content" tabIndex={-1}>
split="horizontal" <PushList
size={`${pushListPct}%`} user={user}
onChange={(size) => this.handleSplitChange(size)} repoName={repoName}
> revision={revision}
<div className="d-flex flex-column w-100"> currentRepo={currentRepo}
{(isFieldFilterVisible || !!filterBarFilters.length) && ( filterModel={filterModel}
<ActiveFilters duplicateJobsVisible={duplicateJobsVisible}
classificationTypes={classificationTypes} groupCountsExpanded={groupCountsExpanded}
filterModel={filterModel} pushHealthVisibility={pushHealthVisibility}
filterBarFilters={filterBarFilters} getAllShownJobs={this.getAllShownJobs}
isFieldFilterVisible={isFieldFilterVisible} {...this.props}
toggleFieldFilterVisible={this.toggleFieldFilterVisible} />
/> </span>
)} </div>
{serverChangedDelayed && ( )}
<UpdateAvailable updateButtonClick={this.updateButtonClick} /> </div>
)} <>
{currentRepo && ( {currentRepo && (
<div id="th-global-content" className="th-global-content"> <DetailsPanel
<span className="th-view-content" tabIndex={-1}> resizedHeight={detailsHeight}
<PushList currentRepo={currentRepo}
user={user} user={user}
repoName={repoName} classificationTypes={classificationTypes}
revision={revision} classificationMap={classificationMap}
currentRepo={currentRepo} />
filterModel={filterModel} )}
duplicateJobsVisible={duplicateJobsVisible} </>
groupCountsExpanded={groupCountsExpanded} </SplitPane>
pushHealthVisibility={pushHealthVisibility} <Notifications />
getAllShownJobs={this.getAllShownJobs} <Modal
/> isOpen={showShortCuts}
</span> toggle={() => this.showOnScreenShortcuts(false)}
</div> id="onscreen-shortcuts"
)} >
</div> <ShortcutTable />
<> </Modal>
{currentRepo && ( </KeyboardShortcuts>
<DetailsPanel
resizedHeight={detailsHeight}
currentRepo={currentRepo}
user={user}
classificationTypes={classificationTypes}
classificationMap={classificationMap}
/>
)}
</>
</SplitPane>
<Notifications />
<Modal
isOpen={showShortCuts}
toggle={() => this.showOnScreenShortcuts(false)}
id="onscreen-shortcuts"
>
<ShortcutTable />
</Modal>
</KeyboardShortcuts>
</Provider>
</div> </div>
); );
} }
} }
export default hot(App); App.propTypes = {
jobMap: PropTypes.shape({}).isRequired,
router: PropTypes.shape({}).isRequired,
pushRoute: PropTypes.func.isRequired,
};
const mapStateToProps = ({ pushes: { jobMap }, router }) => ({
jobMap,
router,
});
export default connect(mapStateToProps, {
pushRoute,
clearExpiredNotifications,
})(hot(App));

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

@ -189,7 +189,7 @@ class DetailsPanel extends React.Component {
// one selects job after job, over the course of a day, it can add up. Therefore, we keep // one selects job after job, over the course of a day, it can add up. Therefore, we keep
// selectedJobFull data as transient only when the job is selected. // selectedJobFull data as transient only when the job is selected.
const selectedJobFull = jobResult; const selectedJobFull = jobResult;
const jobRevision = push.revision; const jobRevision = push ? push.revision : null;
addAggregateFields(selectedJobFull); addAggregateFields(selectedJobFull);
@ -251,7 +251,7 @@ class DetailsPanel extends React.Component {
...d, ...d,
})) }))
.map((d) => ({ .map((d) => ({
url: `/perf.html#/graphs?series=${[ url: `/perfherder/graphs?series=${[
currentRepo.name, currentRepo.name,
d.signature_id, d.signature_id,
1, 1,

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

@ -3,10 +3,13 @@ import PropTypes from 'prop-types';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimesCircle } from '@fortawesome/free-solid-svg-icons'; import { faTimesCircle } from '@fortawesome/free-solid-svg-icons';
import { connect } from 'react-redux';
import { updateRange } from '../redux/stores/pushes';
import { clearSelectedJob } from '../redux/stores/selectedJob';
import { getFieldChoices } from '../../helpers/filter'; import { getFieldChoices } from '../../helpers/filter';
export default class ActiveFilters extends React.Component { class ActiveFilters extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -70,8 +73,32 @@ export default class ActiveFilters extends React.Component {
this.props.toggleFieldFilterVisible(); this.props.toggleFieldFilterVisible();
}; };
clearAndUpdateRange = (specificFilter = null) => {
const { updateRange, filterModel, router, clearSelectedJob } = this.props;
const params = new URLSearchParams(router.location.search);
if (!specificFilter) {
filterModel.clearNonStatusFilters();
} else {
const { filterField, filterValue } = specificFilter;
filterModel.removeFilter(filterField, filterValue);
}
// we do this because anytime the 'revision' or 'author' param is changed,
// updateRange will be triggered in PushList's componentDidUpdate lifecycle.
// This also helps in the scenario where we are only changing the global window location query params
// (to also prevent an unnecessary componentDidUpdate change) such as when a user clicks to view
// a revision, then selects "next x pushes" to set a range.
if (!params.has('revision') && !params.has('author')) {
updateRange(filterModel.getUrlParamsWithoutDefaults());
} else if (params.has('selectedTaskRun')) {
clearSelectedJob(0);
}
};
render() { render() {
const { isFieldFilterVisible, filterModel, filterBarFilters } = this.props; const { isFieldFilterVisible, filterBarFilters } = this.props;
const { const {
newFilterField, newFilterField,
newFilterMatchType, newFilterMatchType,
@ -89,7 +116,7 @@ export default class ActiveFilters extends React.Component {
outline outline
className="pointable bg-transparent border-0 pt-0 pr-1 pb-1" className="pointable bg-transparent border-0 pt-0 pr-1 pb-1"
title="Clear all of these filters" title="Clear all of these filters"
onClick={filterModel.clearNonStatusFilters} onClick={() => this.clearAndUpdateRange()}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faTimesCircle} icon={faTimesCircle}
@ -111,13 +138,13 @@ export default class ActiveFilters extends React.Component {
className="pointable bg-transparent border-0 py-0 pr-1" className="pointable bg-transparent border-0 py-0 pr-1"
title={`Clear filter: ${filter.field}`} title={`Clear filter: ${filter.field}`}
onClick={() => onClick={() =>
filterModel.removeFilter(filter.field, filterValue) this.clearAndUpdateRange({
filterField: filter.field,
filterValue,
})
} }
> >
<FontAwesomeIcon <FontAwesomeIcon icon={faTimesCircle} />
icon={faTimesCircle}
title={`Clear filter: ${filter.field}`}
/>
&nbsp; &nbsp;
</Button> </Button>
<span title={`Filter by ${filter.field}: ${filterValue}`}> <span title={`Filter by ${filter.field}: ${filterValue}`}>
@ -235,4 +262,15 @@ ActiveFilters.propTypes = {
isFieldFilterVisible: PropTypes.bool.isRequired, isFieldFilterVisible: PropTypes.bool.isRequired,
toggleFieldFilterVisible: PropTypes.func.isRequired, toggleFieldFilterVisible: PropTypes.func.isRequired,
classificationTypes: PropTypes.arrayOf(PropTypes.object).isRequired, classificationTypes: PropTypes.arrayOf(PropTypes.object).isRequired,
router: PropTypes.shape({}).isRequired,
clearSelectedJob: PropTypes.func.isRequired,
}; };
const mapStateToProps = ({ router }) => ({
router,
});
export default connect(mapStateToProps, {
updateRange,
clearSelectedJob,
})(ActiveFilters);

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

@ -9,9 +9,9 @@ import {
} from 'reactstrap'; } from 'reactstrap';
import { faCheck } from '@fortawesome/free-solid-svg-icons'; import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import { thAllResultStatuses } from '../../helpers/constants'; import { thAllResultStatuses } from '../../helpers/constants';
import { getJobsUrl } from '../../helpers/url';
import { setSelectedJob, clearSelectedJob } from '../redux/stores/selectedJob'; import { setSelectedJob, clearSelectedJob } from '../redux/stores/selectedJob';
import { pinJobs } from '../redux/stores/pinnedJobs'; import { pinJobs } from '../redux/stores/pinnedJobs';
@ -42,6 +42,12 @@ function FiltersMenu(props) {
}; };
const { email } = user; const { email } = user;
const updateParams = (param, value) => {
const params = new URLSearchParams(window.location.search);
params.set(param, value);
return `?${params.toString()}`;
};
return ( return (
<UncontrolledDropdown> <UncontrolledDropdown>
<DropdownToggle <DropdownToggle
@ -110,12 +116,13 @@ function FiltersMenu(props) {
> >
Superseded only Superseded only
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem title={`Show only pushes for ${email}`}>
tag="a" <Link
title={`Show only pushes for ${email}`} className="dropdown-link"
href={getJobsUrl({ author: email })} to={{ search: updateParams('author', email) }}
> >
My pushes only My pushes only
</Link>
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem
tag="a" tag="a"

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

@ -91,6 +91,7 @@ class PrimaryNavBar extends React.Component {
duplicateJobsVisible={duplicateJobsVisible} duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded} groupCountsExpanded={groupCountsExpanded}
toggleFieldFilterVisible={toggleFieldFilterVisible} toggleFieldFilterVisible={toggleFieldFilterVisible}
{...this.props}
/> />
</nav> </nav>
</div> </div>

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

@ -8,8 +8,9 @@ import {
DropdownToggle, DropdownToggle,
UncontrolledDropdown, UncontrolledDropdown,
} from 'reactstrap'; } from 'reactstrap';
import { Link } from 'react-router-dom';
import { getRepoUrl } from '../../helpers/location'; import { updateRepoParams } from '../../helpers/location';
const GROUP_ORDER = [ const GROUP_ORDER = [
'development', 'development',
@ -83,13 +84,12 @@ export default function ReposMenu(props) {
{!!group.repos && {!!group.repos &&
group.repos.map((repo) => ( group.repos.map((repo) => (
<li key={repo.name}> <li key={repo.name}>
<a <Link
title="Open repo"
className="dropdown-link" className="dropdown-link"
href={getRepoUrl(repo.name)} to={{ search: updateRepoParams(repo.name) }}
> >
{repo.name} {repo.name}
</a> </Link>
</li> </li>
))} ))}
</DropdownItem> </DropdownItem>

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

@ -9,10 +9,11 @@ import {
faFilter, faFilter,
faTimesCircle, faTimesCircle,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { push as pushRoute } from 'connected-react-router';
import { getBtnClass } from '../../helpers/job'; import { getBtnClass } from '../../helpers/job';
import { hasUrlFilterChanges, thFilterGroups } from '../../helpers/filter'; import { hasUrlFilterChanges, thFilterGroups } from '../../helpers/filter';
import { getRepo, getUrlParam, setUrlParam } from '../../helpers/location'; import { getRepo, getUrlParam, setUrlParams } from '../../helpers/location';
import RepositoryModel from '../../models/repository'; import RepositoryModel from '../../models/repository';
import ErrorBoundary from '../../shared/ErrorBoundary'; import ErrorBoundary from '../../shared/ErrorBoundary';
import { recalculateUnclassifiedCounts } from '../redux/stores/pushes'; import { recalculateUnclassifiedCounts } from '../redux/stores/pushes';
@ -46,7 +47,6 @@ class SecondaryNavBar extends React.PureComponent {
} }
componentDidMount() { componentDidMount() {
window.addEventListener('hashchange', this.handleUrlChanges, false);
this.loadWatchedRepos(); this.loadWatchedRepos();
} }
@ -56,18 +56,22 @@ class SecondaryNavBar extends React.PureComponent {
if (repoName !== prevState.repoName) { if (repoName !== prevState.repoName) {
this.loadWatchedRepos(); this.loadWatchedRepos();
} }
}
componentWillUnmount() { if (
window.removeEventListener('hashchange', this.handleUrlChanges, false); prevProps.router.location.search !== this.props.router.location.search
) {
this.handleUrlChanges(
prevProps.router.location.search,
this.props.router.location.search,
);
}
} }
setSearchStr(ev) { setSearchStr(ev) {
this.setState({ searchQueryStr: ev.target.value }); this.setState({ searchQueryStr: ev.target.value });
} }
handleUrlChanges = (evt) => { handleUrlChanges = (prevParams, currentParams) => {
const { oldURL, newURL } = evt;
const { repoName } = this.state; const { repoName } = this.state;
const { recalculateUnclassifiedCounts } = this.props; const { recalculateUnclassifiedCounts } = this.props;
const newState = { const newState = {
@ -77,7 +81,7 @@ class SecondaryNavBar extends React.PureComponent {
this.setState(newState, () => { this.setState(newState, () => {
if ( if (
hasUrlFilterChanges(oldURL, newURL) || hasUrlFilterChanges(prevParams, currentParams) ||
newState.repoName !== repoName newState.repoName !== repoName
) { ) {
recalculateUnclassifiedCounts(); recalculateUnclassifiedCounts();
@ -124,17 +128,25 @@ class SecondaryNavBar extends React.PureComponent {
}; };
toggleShowDuplicateJobs = () => { toggleShowDuplicateJobs = () => {
const { duplicateJobsVisible } = this.props; const { duplicateJobsVisible, pushRoute } = this.props;
const duplicateJobs = duplicateJobsVisible ? null : 'visible'; const duplicateJobs = duplicateJobsVisible ? null : 'visible';
setUrlParam('duplicate_jobs', duplicateJobs); const queryParams = setUrlParams([['duplicate_jobs', duplicateJobs]]);
pushRoute({
search: queryParams,
});
}; };
toggleGroupState = () => { toggleGroupState = () => {
const { groupCountsExpanded } = this.props; const { groupCountsExpanded, pushRoute } = this.props;
const groupState = groupCountsExpanded ? null : 'expanded'; const groupState = groupCountsExpanded ? null : 'expanded';
setUrlParam('group_state', groupState); const queryParams = setUrlParams([['group_state', groupState]]);
pushRoute({
search: queryParams,
});
}; };
toggleUnclassifiedFailures = () => { toggleUnclassifiedFailures = () => {
@ -228,6 +240,7 @@ class SecondaryNavBar extends React.PureComponent {
repoName={repoName} repoName={repoName}
unwatchRepo={this.unwatchRepo} unwatchRepo={this.unwatchRepo}
setCurrentRepoTreeStatus={setCurrentRepoTreeStatus} setCurrentRepoTreeStatus={setCurrentRepoTreeStatus}
{...this.props}
/> />
</ErrorBoundary> </ErrorBoundary>
))} ))}
@ -299,7 +312,7 @@ class SecondaryNavBar extends React.PureComponent {
? 'Collapse job groups' ? 'Collapse job groups'
: 'Expand job groups' : 'Expand job groups'
} }
onClick={() => this.toggleGroupState()} onClick={this.toggleGroupState}
> >
( (
<span className="group-state-nav-icon mx-1"> <span className="group-state-nav-icon mx-1">
@ -395,12 +408,19 @@ SecondaryNavBar.propTypes = {
duplicateJobsVisible: PropTypes.bool.isRequired, duplicateJobsVisible: PropTypes.bool.isRequired,
groupCountsExpanded: PropTypes.bool.isRequired, groupCountsExpanded: PropTypes.bool.isRequired,
toggleFieldFilterVisible: PropTypes.func.isRequired, toggleFieldFilterVisible: PropTypes.func.isRequired,
pushRoute: PropTypes.func.isRequired,
}; };
const mapStateToProps = ({ const mapStateToProps = ({
pushes: { allUnclassifiedFailureCount, filteredUnclassifiedFailureCount }, pushes: { allUnclassifiedFailureCount, filteredUnclassifiedFailureCount },
}) => ({ allUnclassifiedFailureCount, filteredUnclassifiedFailureCount }); router,
}) => ({
allUnclassifiedFailureCount,
filteredUnclassifiedFailureCount,
router,
});
export default connect(mapStateToProps, { recalculateUnclassifiedCounts })( export default connect(mapStateToProps, {
SecondaryNavBar, recalculateUnclassifiedCounts,
); pushRoute,
})(SecondaryNavBar);

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

@ -18,10 +18,12 @@ import {
DropdownToggle, DropdownToggle,
UncontrolledDropdown, UncontrolledDropdown,
} from 'reactstrap'; } from 'reactstrap';
import { push as pushRoute } from 'connected-react-router';
import { connect } from 'react-redux';
import TreeStatusModel from '../../models/treeStatus'; import TreeStatusModel from '../../models/treeStatus';
import BugLinkify from '../../shared/BugLinkify'; import BugLinkify from '../../shared/BugLinkify';
import { getRepoUrl } from '../../helpers/location'; import { updateRepoParams } from '../../helpers/location';
const statusInfoMap = { const statusInfoMap = {
open: { open: {
@ -57,7 +59,7 @@ const statusInfoMap = {
}, },
}; };
export default class WatchedRepo extends React.Component { class WatchedRepo extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -124,18 +126,17 @@ export default class WatchedRepo extends React.Component {
}; };
render() { render() {
const { repoName, unwatchRepo, repo } = this.props; const { repoName, unwatchRepo, repo, pushRoute } = this.props;
const { status, messageOfTheDay, reason, statusInfo } = this.state; const { status, messageOfTheDay, reason, statusInfo } = this.state;
const watchedRepo = repo.name; const watchedRepo = repo.name;
const activeClass = watchedRepo === repoName ? 'active' : ''; const activeClass = watchedRepo === repoName ? 'active' : '';
const { btnClass, icon, color } = statusInfo; const { btnClass, icon, color } = statusInfo;
const pulseIcon = statusInfo.pulseIcon || null; const pulseIcon = statusInfo.pulseIcon || null;
const changeRepoUrl = getRepoUrl(watchedRepo);
return ( return (
<ButtonGroup> <ButtonGroup>
<Button <Button
href={changeRepoUrl} onClick={() => pushRoute({ search: updateRepoParams(watchedRepo) })}
className={`btn-view-nav ${btnClass} ${activeClass}`} className={`btn-view-nav ${btnClass} ${activeClass}`}
title={status} title={status}
size="sm" size="sm"
@ -230,4 +231,7 @@ WatchedRepo.propTypes = {
pushLogUrl: PropTypes.string, pushLogUrl: PropTypes.string,
}).isRequired, }).isRequired,
setCurrentRepoTreeStatus: PropTypes.func.isRequired, setCurrentRepoTreeStatus: PropTypes.func.isRequired,
pushRoute: PropTypes.func.isRequired,
}; };
export default connect(null, { pushRoute })(WatchedRepo);

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

@ -1,22 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
// Treeherder Styles
import '../css/treeherder.css';
import '../css/treeherder-base.css';
import '../css/treeherder-custom-styles.css';
import '../css/treeherder-navbar.css';
import '../css/treeherder-navbar-panels.css';
import '../css/treeherder-notifications.css';
import '../css/treeherder-details-panel.css';
import '../css/failure-summary.css';
import '../css/treeherder-job-buttons.css';
import '../css/treeherder-pushes.css';
import '../css/treeherder-pinboard.css';
import '../css/treeherder-bugfiler.css';
import '../css/treeherder-fuzzyfinder.css';
import '../css/treeherder-loading-overlay.css';
import App from './App';
render(<App />, document.getElementById('root'));

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

@ -118,13 +118,14 @@ export default class JobButtonComponent extends React.Component {
if (isSelected) { if (isSelected) {
classes.push('selected-job btn-lg-xform'); classes.push('selected-job btn-lg-xform');
attributes['data-testid'] = 'selected-job';
} else { } else {
classes.push('btn-xs'); classes.push('btn-xs');
} }
attributes.className = classes.join(' '); attributes.className = classes.join(' ');
return ( return (
<button type="button" {...attributes}> <button type="button" {...attributes} data-testid="job-btn">
{jobTypeSymbol} {jobTypeSymbol}
{classifiedIcon && ( {classifiedIcon && (
<FontAwesomeIcon <FontAwesomeIcon

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

@ -14,6 +14,7 @@ export default function JobCount(props) {
className={classes.join(' ')} className={classes.join(' ')}
title={title} title={title}
onClick={onClick} onClick={onClick}
data-testid="job-group-count"
> >
{count} {count}
</button> </button>

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

@ -11,7 +11,11 @@ import {
} from '../../helpers/constants'; } from '../../helpers/constants';
import decompress from '../../helpers/gzip'; import decompress from '../../helpers/gzip';
import { getGroupMapKey } from '../../helpers/aggregateId'; import { getGroupMapKey } from '../../helpers/aggregateId';
import { getAllUrlParams, getUrlParam } from '../../helpers/location'; import {
getAllUrlParams,
getUrlParam,
setUrlParam,
} from '../../helpers/location';
import JobModel from '../../models/job'; import JobModel from '../../models/job';
import RunnableJobModel from '../../models/runnableJob'; import RunnableJobModel from '../../models/runnableJob';
import { getRevisionTitle } from '../../helpers/revision'; import { getRevisionTitle } from '../../helpers/revision';
@ -122,17 +126,21 @@ class Push extends React.PureComponent {
this.testForFilteredTry(); this.testForFilteredTry();
window.addEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs); window.addEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs);
window.addEventListener('hashchange', this.handleUrlChanges);
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
this.showUpdateNotifications(prevState); this.showUpdateNotifications(prevState);
this.testForFilteredTry(); this.testForFilteredTry();
if (
prevProps.router.location.search !== this.props.router.location.search
) {
this.handleUrlChanges();
}
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs); window.removeEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs);
window.removeEventListener('hashchange', this.handleUrlChanges);
} }
getJobCount(jobList) { getJobCount(jobList) {
@ -185,6 +193,30 @@ class Push extends React.PureComponent {
)}`; )}`;
} }
togglePushCollapsed = () => {
const { push } = this.props;
const pushId = `${push.id}`;
const collapsedPushesParam = getUrlParam('collapsedPushes');
const collapsedPushes = collapsedPushesParam
? new Set(collapsedPushesParam.split(','))
: new Set();
this.setState(
(prevState) => ({ collapsed: !prevState.collapsed }),
() => {
if (!this.state.collapsed) {
collapsedPushes.delete(pushId);
} else {
collapsedPushes.add(pushId);
}
setUrlParam(
'collapsedPushes',
collapsedPushes.size ? Array.from(collapsedPushes) : null,
);
},
);
};
testForFilteredTry = () => { testForFilteredTry = () => {
const { currentRepo } = this.props; const { currentRepo } = this.props;
const filterParams = ['revision', 'author']; const filterParams = ['revision', 'author'];
@ -633,6 +665,7 @@ class Push extends React.PureComponent {
pushHealthVisibility={pushHealthVisibility} pushHealthVisibility={pushHealthVisibility}
groupCountsExpanded={groupCountsExpanded} groupCountsExpanded={groupCountsExpanded}
pushHealthStatusCallback={this.pushHealthStatusCallback} pushHealthStatusCallback={this.pushHealthStatusCallback}
togglePushCollapsed={this.togglePushCollapsed}
/> />
<div className="push-body-divider" /> <div className="push-body-divider" />
{!collapsed ? ( {!collapsed ? (
@ -713,10 +746,12 @@ Push.propTypes = {
const mapStateToProps = ({ const mapStateToProps = ({
pushes: { allUnclassifiedFailureCount, decisionTaskMap, bugSummaryMap }, pushes: { allUnclassifiedFailureCount, decisionTaskMap, bugSummaryMap },
router,
}) => ({ }) => ({
allUnclassifiedFailureCount, allUnclassifiedFailureCount,
decisionTaskMap, decisionTaskMap,
bugSummaryMap, bugSummaryMap,
router,
}); });
export default connect(mapStateToProps, { export default connect(mapStateToProps, {

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

@ -7,55 +7,39 @@ import {
DropdownToggle, DropdownToggle,
UncontrolledDropdown, UncontrolledDropdown,
} from 'reactstrap'; } from 'reactstrap';
import { push as pushRoute } from 'connected-react-router';
import { getUrlParam } from '../../helpers/location'; import {
createQueryParams,
getPushHealthUrl,
getCompareChooserUrl,
parseQueryParams,
} from '../../helpers/url';
import { formatTaskclusterError } from '../../helpers/errorMessage'; import { formatTaskclusterError } from '../../helpers/errorMessage';
import CustomJobActions from '../CustomJobActions'; import CustomJobActions from '../CustomJobActions';
import PushModel from '../../models/push'; import PushModel from '../../models/push';
import { getPushHealthUrl, getCompareChooserUrl } from '../../helpers/url';
import { notify } from '../redux/stores/notifications'; import { notify } from '../redux/stores/notifications';
import { thEvents } from '../../helpers/constants'; import { updateRange } from '../redux/stores/pushes';
// Trigger missing jobs is dangerous on repos other than these (see bug 1335506)
const triggerMissingRepos = ['mozilla-inbound', 'autoland'];
class PushActionMenu extends React.PureComponent { class PushActionMenu extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const { revision } = this.props;
this.state = { this.state = {
topOfRangeUrl: this.getRangeChangeUrl('tochange', revision),
bottomOfRangeUrl: this.getRangeChangeUrl('fromchange', revision),
customJobActionsShowing: false, customJobActionsShowing: false,
}; };
} }
componentDidMount() { updateParamsAndRange = (param) => {
window.addEventListener('hashchange', this.handleUrlChanges, false); const { revision, updateRange, pushRoute } = this.props;
window.addEventListener(thEvents.filtersUpdated, this.handleUrlChanges);
}
componentWillUnmount() { let queryParams = parseQueryParams(window.location.search);
window.removeEventListener('hashchange', this.handleUrlChanges, false); queryParams = { ...queryParams, ...{ [param]: revision } };
window.removeEventListener(thEvents.filtersUpdated, this.handleUrlChanges);
}
getRangeChangeUrl(param, revision) { pushRoute({
let url = window.location.href; search: createQueryParams(queryParams),
url = url.replace(`&${param}=${getUrlParam(param)}`, '');
url = url.replace(`&${'selectedJob'}=${getUrlParam('selectedJob')}`, '');
return `${url}&${param}=${revision}`;
}
handleUrlChanges = () => {
const { revision } = this.props;
this.setState({
topOfRangeUrl: this.getRangeChangeUrl('tochange', revision),
bottomOfRangeUrl: this.getRangeChangeUrl('fromchange', revision),
}); });
updateRange(queryParams);
}; };
triggerMissingJobs = () => { triggerMissingJobs = () => {
@ -102,11 +86,7 @@ class PushActionMenu extends React.PureComponent {
pushId, pushId,
currentRepo, currentRepo,
} = this.props; } = this.props;
const { const { customJobActionsShowing } = this.state;
topOfRangeUrl,
bottomOfRangeUrl,
customJobActionsShowing,
} = this.state;
return ( return (
<React.Fragment> <React.Fragment>
@ -143,15 +123,13 @@ class PushActionMenu extends React.PureComponent {
> >
Add new jobs (Search) Add new jobs (Search)
</DropdownItem> </DropdownItem>
{triggerMissingRepos.includes(currentRepo.name) && ( <DropdownItem
<DropdownItem tag="a"
tag="a" title="Trigger all jobs that were optimized away"
title="Trigger all jobs that were optimized away" onClick={this.triggerMissingJobs}
onClick={this.triggerMissingJobs} >
> Trigger missing jobs
Trigger missing jobs </DropdownItem>
</DropdownItem>
)}
<DropdownItem <DropdownItem
tag="a" tag="a"
target="_blank" target="_blank"
@ -170,14 +148,14 @@ class PushActionMenu extends React.PureComponent {
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem
tag="a" tag="a"
href={topOfRangeUrl} onClick={() => this.updateParamsAndRange('tochange')}
data-testid="top-of-range-menu-item" data-testid="top-of-range-menu-item"
> >
Set as top of range Set as top of range
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem
tag="a" tag="a"
href={bottomOfRangeUrl} onClick={() => this.updateParamsAndRange('fromchange')}
data-testid="bottom-of-range-menu-item" data-testid="bottom-of-range-menu-item"
> >
Set as bottom of range Set as bottom of range
@ -221,7 +199,7 @@ class PushActionMenu extends React.PureComponent {
PushActionMenu.propTypes = { PushActionMenu.propTypes = {
runnableVisible: PropTypes.bool.isRequired, runnableVisible: PropTypes.bool.isRequired,
revision: PropTypes.string.isRequired, revision: PropTypes.string,
currentRepo: PropTypes.shape({ currentRepo: PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
}).isRequired, }).isRequired,
@ -233,8 +211,14 @@ PushActionMenu.propTypes = {
notify: PropTypes.func.isRequired, notify: PropTypes.func.isRequired,
}; };
PushActionMenu.defaultProps = {
revision: null,
};
const mapStateToProps = ({ pushes: { decisionTaskMap } }) => ({ const mapStateToProps = ({ pushes: { decisionTaskMap } }) => ({
decisionTaskMap, decisionTaskMap,
}); });
export default connect(mapStateToProps, { notify })(PushActionMenu); export default connect(mapStateToProps, { notify, updateRange, pushRoute })(
PushActionMenu,
);

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

@ -13,6 +13,7 @@ import {
faTimesCircle, faTimesCircle,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Badge, Button } from 'reactstrap'; import { Badge, Button } from 'reactstrap';
import { Link } from 'react-router-dom';
import { getPercentComplete, toDateStr } from '../../helpers/display'; import { getPercentComplete, toDateStr } from '../../helpers/display';
import { formatTaskclusterError } from '../../helpers/errorMessage'; import { formatTaskclusterError } from '../../helpers/errorMessage';
@ -20,8 +21,7 @@ import { getJobsUrl } from '../../helpers/url';
import PushModel from '../../models/push'; import PushModel from '../../models/push';
import JobModel from '../../models/job'; import JobModel from '../../models/job';
import PushHealthStatus from '../../shared/PushHealthStatus'; import PushHealthStatus from '../../shared/PushHealthStatus';
import PushAuthor from '../../shared/PushAuthor'; import { getUrlParam } from '../../helpers/location';
import { getUrlParam, setUrlParam } from '../../helpers/location';
import { notify } from '../redux/stores/notifications'; import { notify } from '../redux/stores/notifications';
import { setSelectedJob } from '../redux/stores/selectedJob'; import { setSelectedJob } from '../redux/stores/selectedJob';
import { pinJobs } from '../redux/stores/pinnedJobs'; import { pinJobs } from '../redux/stores/pinnedJobs';
@ -198,25 +198,6 @@ class PushHeader extends React.Component {
} }
}; };
togglePushCollapsed = () => {
const { push, collapsed } = this.props;
const pushId = `${push.id}`;
const collapsedPushesParam = getUrlParam('collapsedPushes');
const collapsedPushes = collapsedPushesParam
? new Set(collapsedPushesParam.split(','))
: new Set();
if (collapsed) {
collapsedPushes.delete(pushId);
} else {
collapsedPushes.add(pushId);
}
setUrlParam(
'collapsedPushes',
collapsedPushes.size ? Array.from(collapsedPushes) : null,
);
};
render() { render() {
const { const {
pushId, pushId,
@ -235,6 +216,7 @@ class PushHeader extends React.Component {
pushHealthVisibility, pushHealthVisibility,
currentRepo, currentRepo,
pushHealthStatusCallback, pushHealthStatusCallback,
togglePushCollapsed,
} = this.props; } = this.props;
const cancelJobsTitle = 'Cancel all jobs'; const cancelJobsTitle = 'Cancel all jobs';
const linkParams = this.getLinkParams(); const linkParams = this.getLinkParams();
@ -256,22 +238,22 @@ class PushHeader extends React.Component {
<span className="push-left"> <span className="push-left">
<span className="push-title-left"> <span className="push-title-left">
<FontAwesomeIcon <FontAwesomeIcon
onClick={this.togglePushCollapsed} onClick={togglePushCollapsed}
icon={collapsed ? faPlusSquare : faMinusSquare} icon={collapsed ? faPlusSquare : faMinusSquare}
className="mr-2 mt-2 text-muted pointable" className="mr-2 mt-2 text-muted pointable"
title={`${collapsed ? 'Expand' : 'Collapse'} push data`} title={`${collapsed ? 'Expand' : 'Collapse'} push data`}
/> />
<span> <span>
<a href={revisionPushFilterUrl} title="View only this push"> <Link to={revisionPushFilterUrl} title="View only this push">
{this.pushDateStr}{' '} {this.pushDateStr}{' '}
<FontAwesomeIcon <FontAwesomeIcon
icon={faExternalLinkAlt} icon={faExternalLinkAlt}
className="icon-superscript" className="icon-superscript"
/> />
</a>{' '} </Link>{' '}
-{' '} -{' '}
</span> </span>
<PushAuthor author={author} url={authorPushFilterUrl} /> <Link to={authorPushFilterUrl}>{author}</Link>
</span> </span>
</span> </span>
{showPushHealthStatus && ( {showPushHealthStatus && (
@ -364,7 +346,7 @@ PushHeader.propTypes = {
pushId: PropTypes.number.isRequired, pushId: PropTypes.number.isRequired,
pushTimestamp: PropTypes.number.isRequired, pushTimestamp: PropTypes.number.isRequired,
author: PropTypes.string.isRequired, author: PropTypes.string.isRequired,
revision: PropTypes.string.isRequired, revision: PropTypes.string,
filterModel: PropTypes.shape({}).isRequired, filterModel: PropTypes.shape({}).isRequired,
runnableVisible: PropTypes.bool.isRequired, runnableVisible: PropTypes.bool.isRequired,
showRunnableJobs: PropTypes.func.isRequired, showRunnableJobs: PropTypes.func.isRequired,
@ -390,6 +372,7 @@ PushHeader.propTypes = {
PushHeader.defaultProps = { PushHeader.defaultProps = {
watchState: 'none', watchState: 'none',
pushHealthStatusCallback: null, pushHealthStatusCallback: null,
revision: null,
}; };
const mapStateToProps = ({ pushes: { decisionTaskMap } }) => ({ const mapStateToProps = ({ pushes: { decisionTaskMap } }) => ({

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

@ -11,13 +11,8 @@ import {
clearSelectedJob, clearSelectedJob,
setSelectedJobFromQueryString, setSelectedJobFromQueryString,
} from '../redux/stores/selectedJob'; } from '../redux/stores/selectedJob';
import { import { fetchPushes, updateRange, pollPushes } from '../redux/stores/pushes';
fetchPushes, import { updatePushParams } from '../../helpers/location';
fetchNextPushes,
updateRange,
pollPushes,
} from '../redux/stores/pushes';
import { reloadOnChangeParameters } from '../../helpers/filter';
import Push from './Push'; import Push from './Push';
import PushLoadErrors from './PushLoadErrors'; import PushLoadErrors from './PushLoadErrors';
@ -36,7 +31,6 @@ class PushList extends React.Component {
componentDidMount() { componentDidMount() {
const { fetchPushes } = this.props; const { fetchPushes } = this.props;
window.addEventListener('hashchange', this.handleUrlChanges, false);
fetchPushes(); fetchPushes();
this.poll(); this.poll();
} }
@ -52,6 +46,7 @@ class PushList extends React.Component {
if (jobsLoaded && jobsLoaded !== prevProps.jobsLoaded) { if (jobsLoaded && jobsLoaded !== prevProps.jobsLoaded) {
setSelectedJobFromQueryString(notify, jobMap); setSelectedJobFromQueryString(notify, jobMap);
} }
this.handleUrlChanges(prevProps);
} }
componentWillUnmount() { componentWillUnmount() {
@ -59,7 +54,6 @@ class PushList extends React.Component {
clearInterval(this.pushIntervalId); clearInterval(this.pushIntervalId);
this.pushIntervalId = null; this.pushIntervalId = null;
} }
window.addEventListener('hashchange', this.handleUrlChanges, false);
} }
setWindowTitle() { setWindowTitle() {
@ -68,11 +62,18 @@ class PushList extends React.Component {
document.title = `[${allUnclassifiedFailureCount}] ${repoName}`; document.title = `[${allUnclassifiedFailureCount}] ${repoName}`;
} }
getUrlRangeValues = (url) => { getUrlRangeValues = (search) => {
const params = [...new URLSearchParams(url.split('?')[1]).entries()]; const params = [...new URLSearchParams(search)];
return params.reduce((acc, [key, value]) => { return params.reduce((acc, [key, value]) => {
return reloadOnChangeParameters.includes(key) return [
'repo',
'startdate',
'enddate',
'nojobs',
'revision',
'author',
].includes(key)
? { ...acc, [key]: value } ? { ...acc, [key]: value }
: acc; : acc;
}, {}); }, {});
@ -86,11 +87,10 @@ class PushList extends React.Component {
}, PUSH_POLL_INTERVAL); }, PUSH_POLL_INTERVAL);
}; };
handleUrlChanges = (evt) => { handleUrlChanges = (prevProps) => {
const { updateRange } = this.props; const { updateRange, router } = this.props;
const { oldURL, newURL } = evt; const oldRange = this.getUrlRangeValues(prevProps.router.location.search);
const oldRange = this.getUrlRangeValues(oldURL); const newRange = this.getUrlRangeValues(router.location.search);
const newRange = this.getUrlRangeValues(newURL);
if (!isEqual(oldRange, newRange)) { if (!isEqual(oldRange, newRange)) {
updateRange(newRange); updateRange(newRange);
@ -115,6 +115,13 @@ class PushList extends React.Component {
} }
} }
fetchNextPushes(count) {
const { fetchPushes, router } = this.props;
const params = updatePushParams(router.location);
window.history.pushState(null, null, params);
fetchPushes(count, true);
}
render() { render() {
const { const {
repoName, repoName,
@ -123,7 +130,6 @@ class PushList extends React.Component {
filterModel, filterModel,
pushList, pushList,
loadingPushes, loadingPushes,
fetchNextPushes,
getAllShownJobs, getAllShownJobs,
jobsLoaded, jobsLoaded,
duplicateJobsVisible, duplicateJobsVisible,
@ -135,6 +141,7 @@ class PushList extends React.Component {
if (!revision) { if (!revision) {
this.setWindowTitle(); this.setWindowTitle();
} }
return ( return (
// Bug 1619873 - role="list" works better here than an interactive role // Bug 1619873 - role="list" works better here than an interactive role
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
@ -188,7 +195,7 @@ class PushList extends React.Component {
color="darker-secondary" color="darker-secondary"
outline outline
className="btn-light-bordered" className="btn-light-bordered"
onClick={() => fetchNextPushes(count)} onClick={() => this.fetchNextPushes(count)}
key={count} key={count}
data-testid={`get-next-${count}`} data-testid={`get-next-${count}`}
> >
@ -206,7 +213,6 @@ PushList.propTypes = {
repoName: PropTypes.string.isRequired, repoName: PropTypes.string.isRequired,
filterModel: PropTypes.shape({}).isRequired, filterModel: PropTypes.shape({}).isRequired,
pushList: PropTypes.arrayOf(PropTypes.object).isRequired, pushList: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchNextPushes: PropTypes.func.isRequired,
fetchPushes: PropTypes.func.isRequired, fetchPushes: PropTypes.func.isRequired,
pollPushes: PropTypes.func.isRequired, pollPushes: PropTypes.func.isRequired,
updateRange: PropTypes.func.isRequired, updateRange: PropTypes.func.isRequired,
@ -224,6 +230,7 @@ PushList.propTypes = {
notify: PropTypes.func.isRequired, notify: PropTypes.func.isRequired,
revision: PropTypes.string, revision: PropTypes.string,
currentRepo: PropTypes.shape({}), currentRepo: PropTypes.shape({}),
router: PropTypes.shape({}).isRequired,
}; };
PushList.defaultProps = { PushList.defaultProps = {
@ -240,6 +247,7 @@ const mapStateToProps = ({
allUnclassifiedFailureCount, allUnclassifiedFailureCount,
}, },
pinnedJobs: { pinnedJobs }, pinnedJobs: { pinnedJobs },
router,
}) => ({ }) => ({
loadingPushes, loadingPushes,
jobsLoaded, jobsLoaded,
@ -247,13 +255,13 @@ const mapStateToProps = ({
pushList, pushList,
allUnclassifiedFailureCount, allUnclassifiedFailureCount,
pinnedJobs, pinnedJobs,
router,
}); });
export default connect(mapStateToProps, { export default connect(mapStateToProps, {
notify, notify,
clearSelectedJob, clearSelectedJob,
setSelectedJobFromQueryString, setSelectedJobFromQueryString,
fetchNextPushes,
fetchPushes, fetchPushes,
updateRange, updateRange,
pollPushes, pollPushes,

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

@ -1,22 +1,32 @@
import { createStore, combineReducers, applyMiddleware } from 'redux'; import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import createDebounce from 'redux-debounce'; import createDebounce from 'redux-debounce';
import { connectRouter, routerMiddleware } from 'connected-react-router';
import { createBrowserHistory } from 'history';
import * as selectedJobStore from './stores/selectedJob'; import * as selectedJobStore from './stores/selectedJob';
import * as notificationStore from './stores/notifications'; import * as notificationStore from './stores/notifications';
import * as pushesStore from './stores/pushes'; import * as pushesStore from './stores/pushes';
import * as pinnedJobsStore from './stores/pinnedJobs'; import * as pinnedJobsStore from './stores/pinnedJobs';
export default () => { const debouncer = createDebounce({ nextJob: 200 });
const debounceConfig = { nextJob: 200 };
const debouncer = createDebounce(debounceConfig); const reducers = (routerHistory) =>
const reducers = combineReducers({ combineReducers({
router: connectRouter(routerHistory),
notifications: notificationStore.reducer, notifications: notificationStore.reducer,
selectedJob: selectedJobStore.reducer, selectedJob: selectedJobStore.reducer,
pushes: pushesStore.reducer, pushes: pushesStore.reducer,
pinnedJobs: pinnedJobsStore.reducer, pinnedJobs: pinnedJobsStore.reducer,
}); });
const store = createStore(reducers, applyMiddleware(thunk, debouncer));
return { store }; export const history = createBrowserHistory();
};
export function configureStore(routerHistory = history) {
const store = createStore(
reducers(routerHistory),
applyMiddleware(routerMiddleware(routerHistory), thunk, debouncer),
);
return store;
}

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

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

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

@ -34,6 +34,10 @@ export const notify = (message, severity, options) => ({
options, options,
}); });
export const clearExpiredNotifications = () => ({
type: CLEAR_EXPIRED_TRANSIENTS,
});
// *** Implementation *** // *** Implementation ***
const doNotify = ( const doNotify = (
{ notifications, storedNotifications }, { notifications, storedNotifications },

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

@ -1,14 +1,10 @@
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import keyBy from 'lodash/keyBy'; import keyBy from 'lodash/keyBy';
import max from 'lodash/max'; import max from 'lodash/max';
import { push as pushRoute } from 'connected-react-router';
import { parseQueryParams, bugzillaBugsApi } from '../../../helpers/url'; import { parseQueryParams, bugzillaBugsApi } from '../../../helpers/url';
import { import { getUrlParam, replaceLocation } from '../../../helpers/location';
getAllUrlParams,
getQueryString,
getUrlParam,
replaceLocation,
} from '../../../helpers/location';
import PushModel from '../../../models/push'; import PushModel from '../../../models/push';
import { getTaskRunStr, isUnclassifiedFailure } from '../../../helpers/job'; import { getTaskRunStr, isUnclassifiedFailure } from '../../../helpers/job';
import FilterModel from '../../../models/filter'; import FilterModel from '../../../models/filter';
@ -96,8 +92,8 @@ const getLastModifiedJobTime = (jobMap) => {
* gives us the difference in unclassified failures and, of those jobs, the * gives us the difference in unclassified failures and, of those jobs, the
* ones that have been filtered out * ones that have been filtered out
*/ */
const doRecalculateUnclassifiedCounts = (jobMap) => { const doRecalculateUnclassifiedCounts = (jobMap, router) => {
const filterModel = new FilterModel(); const filterModel = new FilterModel({ pushRoute, router });
const tiers = filterModel.urlParams.tier; const tiers = filterModel.urlParams.tier;
let allUnclassifiedFailureCount = 0; let allUnclassifiedFailureCount = 0;
let filteredUnclassifiedFailureCount = 0; let filteredUnclassifiedFailureCount = 0;
@ -122,6 +118,7 @@ const addPushes = (
jobMap, jobMap,
setFromchange, setFromchange,
dispatch, dispatch,
router,
oldBugSummaryMap, oldBugSummaryMap,
) => { ) => {
if (data.results.length > 0) { if (data.results.length > 0) {
@ -140,7 +137,7 @@ const addPushes = (
const newStuff = { const newStuff = {
pushList: newPushList, pushList: newPushList,
oldestPushTimestamp, oldestPushTimestamp,
...doRecalculateUnclassifiedCounts(jobMap), ...doRecalculateUnclassifiedCounts(jobMap, router),
...getRevisionTips(newPushList), ...getRevisionTips(newPushList),
}; };
@ -150,11 +147,11 @@ const addPushes = (
const updatedLastRevision = newPushList[newPushList.length - 1].revision; const updatedLastRevision = newPushList[newPushList.length - 1].revision;
if (setFromchange && getUrlParam('fromchange') !== updatedLastRevision) { if (setFromchange && getUrlParam('fromchange') !== updatedLastRevision) {
const params = getAllUrlParams(); const params = new URLSearchParams(window.location.search);
params.set('fromchange', updatedLastRevision); params.set('fromchange', updatedLastRevision);
replaceLocation(params); replaceLocation(params);
// We are silently updating the url params, but we still want to // We are silently updating the url params so we don't trigger an unnecessary update
// update the ActiveFilters bar to this new change. // in componentDidUpdate, but we still want to update the ActiveFilters bar to this new change.
window.dispatchEvent(new CustomEvent(thEvents.filtersUpdated)); window.dispatchEvent(new CustomEvent(thEvents.filtersUpdated));
} }
@ -252,13 +249,17 @@ export const fetchPushes = (
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { const {
pushes: { pushList, jobMap, oldestPushTimestamp }, pushes: { pushList, jobMap, oldestPushTimestamp },
router,
} = getState(); } = getState();
dispatch({ type: LOADING }); dispatch({ type: LOADING });
if (getUrlParam('selectedJob') || getUrlParam('selectedTaskRun')) {
dispatch(clearSelectedJob(0));
}
// Only pass supported query string params to this endpoint. // Only pass supported query string params to this endpoint.
const options = { const options = {
...pick(parseQueryParams(getQueryString()), PUSH_FETCH_KEYS), ...pick(parseQueryParams(window.location.search), PUSH_FETCH_KEYS),
}; };
if (oldestPushTimestamp) { if (oldestPushTimestamp) {
@ -284,6 +285,7 @@ export const fetchPushes = (
jobMap, jobMap,
setFromchange, setFromchange,
dispatch, dispatch,
router,
), ),
}); });
} }
@ -296,10 +298,11 @@ export const pollPushes = () => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { const {
pushes: { pushList, jobMap }, pushes: { pushList, jobMap },
router,
} = getState(); } = getState();
// these params will be passed in each time we poll to remain // these params will be passed in each time we poll to remain
// within the constraints of the URL params // within the constraints of the URL params
const locationSearch = parseQueryParams(getQueryString()); const locationSearch = parseQueryParams(window.location.search);
const pushPollingParams = PUSH_POLLING_KEYS.reduce( const pushPollingParams = PUSH_POLLING_KEYS.reduce(
(acc, prop) => (acc, prop) =>
locationSearch[prop] ? { ...acc, [prop]: locationSearch[prop] } : acc, locationSearch[prop] ? { ...acc, [prop]: locationSearch[prop] } : acc,
@ -330,6 +333,8 @@ export const pollPushes = () => {
pushList, pushList,
jobMap, jobMap,
false, false,
dispatch,
router,
), ),
}); });
dispatch(fetchNewJobs()); dispatch(fetchNewJobs());
@ -342,47 +347,29 @@ export const pollPushes = () => {
}; };
}; };
/**
* Get the next batch of pushes based on our current offset.
*/
export const fetchNextPushes = (count) => {
const params = getAllUrlParams();
if (params.has('revision')) {
// We are viewing a single revision, but the user has asked for more.
// So we must replace the ``revision`` param with ``tochange``, which
// will make it just the top of the range. We will also then get a new
// ``fromchange`` param after the fetch.
const revision = params.get('revision');
params.delete('revision');
params.set('tochange', revision);
} else if (params.has('startdate')) {
// We are fetching more pushes, so we don't want to limit ourselves by
// ``startdate``. And after the fetch, ``startdate`` will be invalid,
// and will be replaced on the location bar by ``fromchange``.
params.delete('startdate');
}
replaceLocation(params);
return fetchPushes(count, true);
};
export const clearPushes = () => ({ type: CLEAR_PUSHES }); export const clearPushes = () => ({ type: CLEAR_PUSHES });
export const setPushes = (pushList, jobMap) => ({ export const setPushes = (pushList, jobMap, router) => ({
type: SET_PUSHES, type: SET_PUSHES,
pushResults: { pushResults: {
pushList, pushList,
jobMap, jobMap,
...getRevisionTips(pushList), ...getRevisionTips(pushList),
...doRecalculateUnclassifiedCounts(jobMap), ...doRecalculateUnclassifiedCounts(jobMap, router),
oldestPushTimestamp: pushList[pushList.length - 1].push_timestamp, oldestPushTimestamp: pushList[pushList.length - 1].push_timestamp,
}, },
}); });
export const recalculateUnclassifiedCounts = (filterModel) => ({ export const recalculateUnclassifiedCounts = (filterModel) => {
type: RECALCULATE_UNCLASSIFIED_COUNTS, return (dispatch, getState) => {
filterModel, const { router } = getState();
}); return dispatch({
type: RECALCULATE_UNCLASSIFIED_COUNTS,
filterModel,
router,
});
};
};
export const updateJobMap = (jobList) => ({ export const updateJobMap = (jobList) => ({
type: UPDATE_JOB_MAP, type: UPDATE_JOB_MAP,
@ -393,6 +380,7 @@ export const updateRange = (range) => {
return (dispatch, getState) => { return (dispatch, getState) => {
const { const {
pushes: { pushList, jobMap }, pushes: { pushList, jobMap },
router,
} = getState(); } = getState();
const { revision } = range; const { revision } = range;
// change the range of pushes. might already have them. // change the range of pushes. might already have them.
@ -408,10 +396,12 @@ export const updateRange = (range) => {
job.push_id === pushId ? { ...acc, [id]: job } : acc, job.push_id === pushId ? { ...acc, [id]: job } : acc,
{}, {},
); );
dispatch(clearSelectedJob(0)); if (getUrlParam('selectedJob') || getUrlParam('selectedTaskRun')) {
dispatch(clearSelectedJob(0));
}
// We already have the one revision they're looking for, // We already have the one revision they're looking for,
// so we can just erase everything else. // so we can just erase everything else.
dispatch(setPushes(revisionPushList, revisionJobMap)); dispatch(setPushes(revisionPushList, revisionJobMap, router));
} else { } else {
// Clear and refetch everything. We can't be sure if what we // Clear and refetch everything. We can't be sure if what we
// already have is partially correct and just needs fill-in. // already have is partially correct and just needs fill-in.
@ -435,8 +425,9 @@ export const initialState = {
}; };
export const reducer = (state = initialState, action) => { export const reducer = (state = initialState, action) => {
const { jobList, pushResults, setFromchange } = action; const { jobList, pushResults, setFromchange, router } = action;
const { pushList, jobMap, decisionTaskMap } = state; const { pushList, jobMap, decisionTaskMap } = state;
switch (action.type) { switch (action.type) {
case LOADING: case LOADING:
return { ...state, loadingPushes: true }; return { ...state, loadingPushes: true };
@ -449,7 +440,7 @@ export const reducer = (state = initialState, action) => {
case SET_PUSHES: case SET_PUSHES:
return { ...state, loadingPushes: false, ...pushResults }; return { ...state, loadingPushes: false, ...pushResults };
case RECALCULATE_UNCLASSIFIED_COUNTS: case RECALCULATE_UNCLASSIFIED_COUNTS:
return { ...state, ...doRecalculateUnclassifiedCounts(jobMap) }; return { ...state, ...doRecalculateUnclassifiedCounts(jobMap, router) };
case UPDATE_JOB_MAP: case UPDATE_JOB_MAP:
return { return {
...state, ...state,

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

@ -1,3 +1,5 @@
import { push as pushRoute } from 'connected-react-router';
import { import {
findGroupElement, findGroupElement,
findGroupInstance, findGroupInstance,
@ -8,7 +10,11 @@ import {
scrollToElement, scrollToElement,
} from '../../../helpers/job'; } from '../../../helpers/job';
import { thJobNavSelectors } from '../../../helpers/constants'; import { thJobNavSelectors } from '../../../helpers/constants';
import { getUrlParam, setUrlParam } from '../../../helpers/location'; import {
getUrlParam,
setUrlParam,
setUrlParams,
} from '../../../helpers/location';
import JobModel from '../../../models/job'; import JobModel from '../../../models/job';
import { getJobsUrl } from '../../../helpers/url'; import { getJobsUrl } from '../../../helpers/url';
@ -17,11 +23,20 @@ export const SELECT_JOB_FROM_QUERY_STRING = 'SELECT_JOB_FROM_QUERY_STRING';
export const CLEAR_JOB = 'CLEAR_JOB'; export const CLEAR_JOB = 'CLEAR_JOB';
export const UPDATE_JOB_DETAILS = 'UPDATE_JOB_DETAILS'; export const UPDATE_JOB_DETAILS = 'UPDATE_JOB_DETAILS';
export const setSelectedJob = (job, updateDetails = true) => ({ export const setSelectedJob = (job, updateDetails = true) => {
type: SELECT_JOB, return async (dispatch) => {
job, dispatch({
updateDetails, type: SELECT_JOB,
}); job,
updateDetails,
});
if (updateDetails) {
const taskRun = job ? getTaskRunStr(job) : null;
const params = setUrlParams([['selectedTaskRun', taskRun]]);
dispatch(pushRoute({ search: params }));
}
};
};
export const setSelectedJobFromQueryString = (notify, jobMap) => ({ export const setSelectedJobFromQueryString = (notify, jobMap) => ({
type: SELECT_JOB_FROM_QUERY_STRING, type: SELECT_JOB_FROM_QUERY_STRING,
@ -29,27 +44,36 @@ export const setSelectedJobFromQueryString = (notify, jobMap) => ({
jobMap, jobMap,
}); });
export const clearSelectedJob = (countPinnedJobs) => ({ export const clearSelectedJob = (countPinnedJobs) => {
type: CLEAR_JOB, return async (dispatch) => {
countPinnedJobs, dispatch({
}); type: CLEAR_JOB,
countPinnedJobs,
export const updateJobDetails = (job) => ({ });
type: UPDATE_JOB_DETAILS, const params = setUrlParams([
job, ['selectedTaskRun', null],
meta: { ['selectedJob', null],
debounce: 'nextJob', ]);
}, dispatch(pushRoute({ search: params }));
}); };
const doUpdateJobDetails = (job) => {
const taskRun = job ? getTaskRunStr(job) : null;
setUrlParam('selectedTaskRun', taskRun);
return { selectedJob: job };
}; };
export const doSelectJob = (job, updateDetails) => { export const updateJobDetails = (job) => {
return async (dispatch) => {
dispatch({
type: UPDATE_JOB_DETAILS,
job,
meta: {
debounce: 'nextJob',
},
});
const taskRun = job ? getTaskRunStr(job) : null;
const params = setUrlParams([['selectedTaskRun', taskRun]]);
dispatch(pushRoute({ search: params }));
};
};
export const doSelectJob = (job) => {
const selected = findSelectedInstance(); const selected = findSelectedInstance();
if (selected) selected.setSelected(false); if (selected) selected.setSelected(false);
@ -71,9 +95,7 @@ export const doSelectJob = (job, updateDetails) => {
scrollToElement(groupEl); scrollToElement(groupEl);
} }
} }
if (updateDetails) {
return doUpdateJobDetails(job);
}
return { selectedJob: job }; return { selectedJob: job };
}; };
@ -81,8 +103,7 @@ export const doClearSelectedJob = (countPinnedJobs) => {
if (!countPinnedJobs) { if (!countPinnedJobs) {
const selected = findSelectedInstance(); const selected = findSelectedInstance();
if (selected) selected.setSelected(false); if (selected) selected.setSelected(false);
setUrlParam('selectedTaskRun', null);
setUrlParam('selectedJob', null);
return { selectedJob: null }; return { selectedJob: null };
} }
return {}; return {};
@ -240,7 +261,7 @@ export const changeJob = (
if (jobInstance) { if (jobInstance) {
// Delay updating details for the new job right away, // Delay updating details for the new job right away,
// in case the user is switching rapidly between jobs // in case the user is switching rapidly between jobs
return doSelectJob(jobInstance.props.job, false); return doSelectJob(jobInstance.props.job);
} }
} }
} }
@ -255,15 +276,15 @@ export const initialState = {
}; };
export const reducer = (state = initialState, action) => { export const reducer = (state = initialState, action) => {
const { job, jobMap, countPinnedJobs, updateDetails, notify } = action; const { job, jobMap, countPinnedJobs, notify } = action;
switch (action.type) { switch (action.type) {
case SELECT_JOB: case SELECT_JOB:
return doSelectJob(job, updateDetails); return doSelectJob(job);
case SELECT_JOB_FROM_QUERY_STRING: case SELECT_JOB_FROM_QUERY_STRING:
return doSetSelectedJobFromQueryString(notify, jobMap); return doSetSelectedJobFromQueryString(notify, jobMap);
case UPDATE_JOB_DETAILS: case UPDATE_JOB_DETAILS:
return doUpdateJobDetails(job); return { selectedJob: job };
case CLEAR_JOB: case CLEAR_JOB:
return { ...state, ...doClearSelectedJob(countPinnedJobs) }; return { ...state, ...doClearSelectedJob(countPinnedJobs) };
default: default:

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

@ -1,6 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import LoginCallback from './LoginCallback';
render(<LoginCallback />, document.getElementById('root'));

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

@ -29,6 +29,9 @@ import { formatArtifacts, errorLinesCss } from '../helpers/display';
import Navigation from './Navigation'; import Navigation from './Navigation';
import ErrorLines from './ErrorLines'; import ErrorLines from './ErrorLines';
import '../css/lazylog-custom-styles.css';
import './logviewer.css';
const JOB_DETAILS_COLLAPSED = 'jobDetailsCollapsed'; const JOB_DETAILS_COLLAPSED = 'jobDetailsCollapsed';
const getUrlLineNumber = function getUrlLineNumber() { const getUrlLineNumber = function getUrlLineNumber() {

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

@ -1,13 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
// Treeherder Styles
import '../css/treeherder-base.css';
import '../css/treeherder-custom-styles.css';
import '../css/treeherder-navbar.css';
import '../css/lazylog-custom-styles.css';
import './logviewer.css';
import App from './App';
render(<App />, document.getElementById('root'));

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

@ -17,8 +17,8 @@ import {
} from '../helpers/filter'; } from '../helpers/filter';
import { getAllUrlParams } from '../helpers/location'; import { getAllUrlParams } from '../helpers/location';
export const getNonFilterUrlParams = () => export const getNonFilterUrlParams = (location) =>
[...getAllUrlParams().entries()].reduce( [...getAllUrlParams(location).entries()].reduce(
(acc, [urlField, urlValue]) => (acc, [urlField, urlValue]) =>
allFilterParams.includes(urlField.replace(deprecatedThFilterPrefix, '')) allFilterParams.includes(urlField.replace(deprecatedThFilterPrefix, ''))
? acc ? acc
@ -26,12 +26,12 @@ export const getNonFilterUrlParams = () =>
{}, {},
); );
export const getFilterUrlParamsWithDefaults = () => { export const getFilterUrlParamsWithDefaults = (location) => {
// Group multiple values for the same field into an array of values. // Group multiple values for the same field into an array of values.
// This handles the transition from our old url params to this newer, more // This handles the transition from our old url params to this newer, more
// terse version. // terse version.
// Also remove usage of the 'filter-' prefix. // Also remove usage of the 'filter-' prefix.
const groupedValues = [...getAllUrlParams().entries()].reduce( const groupedValues = [...getAllUrlParams(location).entries()].reduce(
(acc, [urlField, urlValue]) => { (acc, [urlField, urlValue]) => {
const field = urlField.replace(deprecatedThFilterPrefix, ''); const field = urlField.replace(deprecatedThFilterPrefix, '');
if (!allFilterParams.includes(field)) { if (!allFilterParams.includes(field)) {
@ -51,8 +51,11 @@ export const getFilterUrlParamsWithDefaults = () => {
}; };
export default class FilterModel { export default class FilterModel {
constructor() { constructor(props) {
this.urlParams = getFilterUrlParamsWithDefaults(); // utilize connected-react-router push prop (this.push is equivalent to history.push)
this.push = props.pushRoute;
this.location = props.router.location;
this.urlParams = getFilterUrlParamsWithDefaults(props.router.location);
} }
// If a param matches the defaults, then don't include it. // If a param matches the defaults, then don't include it.
@ -60,7 +63,7 @@ export default class FilterModel {
// ensure the repo param is always set // ensure the repo param is always set
const params = { const params = {
repo: thDefaultRepo, repo: thDefaultRepo,
...getNonFilterUrlParams(), ...getNonFilterUrlParams(this.location),
...this.urlParams, ...this.urlParams,
}; };
@ -86,7 +89,7 @@ export default class FilterModel {
} else { } else {
this.urlParams[field] = [value]; this.urlParams[field] = [value];
} }
this.push(); this.push({ search: this.getFilterQueryString() });
}; };
// Also used for non-filter params // Also used for non-filter params
@ -99,29 +102,24 @@ export default class FilterModel {
(filterValue) => filterValue !== value, (filterValue) => filterValue !== value,
); );
} }
if (!this.urlParams[field].length) {
delete this.urlParams[field];
}
} else { } else {
delete this.urlParams[field]; delete this.urlParams[field];
} }
this.push();
this.push({ search: this.getFilterQueryString() });
}; };
getFilterQueryString = () => getFilterQueryString = () =>
new URLSearchParams(this.getUrlParamsWithoutDefaults()).toString(); new URLSearchParams(this.getUrlParamsWithoutDefaults()).toString();
/**
* Push all the url params to the url. Components listening for hashchange
* will get updates.
*/
push = () => {
const { origin } = window.location;
window.location.href = `${origin}/#/jobs?${this.getFilterQueryString()}`;
};
setOnlySuperseded = () => { setOnlySuperseded = () => {
this.urlParams.resultStatus = 'superseded'; this.urlParams.resultStatus = 'superseded';
this.urlParams.classifiedState = [...thFilterDefaults.classifiedState]; this.urlParams.classifiedState = [...thFilterDefaults.classifiedState];
this.push(); this.push({ search: this.getFilterQueryString() });
}; };
toggleFilter = (field, value) => { toggleFilter = (field, value) => {
@ -148,7 +146,7 @@ export default class FilterModel {
? currentResultStatuses.filter((rs) => !resultStatuses.includes(rs)) ? currentResultStatuses.filter((rs) => !resultStatuses.includes(rs))
: [...new Set([...resultStatuses, ...currentResultStatuses])]; : [...new Set([...resultStatuses, ...currentResultStatuses])];
this.push(); this.push({ search: this.getFilterQueryString() });
}; };
toggleClassifiedFilter = (classifiedState) => { toggleClassifiedFilter = (classifiedState) => {
@ -161,20 +159,20 @@ export default class FilterModel {
} else { } else {
this.urlParams.resultStatus = [...thFailureResults]; this.urlParams.resultStatus = [...thFailureResults];
this.urlParams.classifiedState = ['unclassified']; this.urlParams.classifiedState = ['unclassified'];
this.push(); this.push({ search: this.getFilterQueryString() });
} }
}; };
replaceFilter = (field, value) => { replaceFilter = (field, value) => {
this.urlParams[field] = !Array.isArray(value) ? [value] : value; this.urlParams[field] = !Array.isArray(value) ? [value] : value;
this.push(); this.push({ search: this.getFilterQueryString() });
}; };
clearNonStatusFilters = () => { clearNonStatusFilters = () => {
const { repo, resultStatus, classifiedState } = this.urlParams; const { repo, resultStatus, classifiedState } = this.urlParams;
this.urlParams = { repo, resultStatus, classifiedState }; this.urlParams = { repo, resultStatus, classifiedState };
this.push(); this.push({ search: this.getFilterQueryString() });
}; };
/** /**
@ -187,7 +185,7 @@ export default class FilterModel {
this.urlParams.resultStatus = [...resultStatus]; this.urlParams.resultStatus = [...resultStatus];
this.urlParams.classifiedState = [...classifiedState]; this.urlParams.classifiedState = [...classifiedState];
this.push(); this.push({ search: this.getFilterQueryString() });
}; };
/** /**

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

@ -54,6 +54,7 @@ export default class PushModel {
// fetch the maximum number of pushes // fetch the maximum number of pushes
params.count = thMaxPushFetchSize; params.count = thMaxPushFetchSize;
} }
return getData( return getData(
`${getProjectUrl(pushEndpoint, repoName)}${createQueryParams(params)}`, `${getProjectUrl(pushEndpoint, repoName)}${createQueryParams(params)}`,
); );

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

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { HashRouter, Route, Switch, Redirect } from 'react-router-dom'; import { Route, Switch, Redirect } from 'react-router-dom';
import { hot } from 'react-hot-loader/root'; import { hot } from 'react-hot-loader/root';
import { Container } from 'reactstrap'; import { Container } from 'reactstrap';
@ -18,6 +18,9 @@ import CompareSubtestsView from './compare/CompareSubtestsView';
import CompareSubtestDistributionView from './compare/CompareSubtestDistributionView'; import CompareSubtestDistributionView from './compare/CompareSubtestDistributionView';
import Navigation from './Navigation'; import Navigation from './Navigation';
import 'react-table/react-table.css';
import '../css/perf.css';
class App extends React.Component { class App extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -66,9 +69,10 @@ class App extends React.Component {
errorMessages, errorMessages,
compareData, compareData,
} = this.state; } = this.state;
const { path } = this.props.match;
return ( return (
<HashRouter> <React.Fragment>
<Navigation <Navigation
user={user} user={user}
setUser={(user) => this.setState({ user })} setUser={(user) => this.setState({ user })}
@ -86,7 +90,7 @@ class App extends React.Component {
<Switch> <Switch>
<Route <Route
exact exact
path="/alerts" path={`${path}/alerts`}
render={(props) => ( render={(props) => (
<AlertsView <AlertsView
{...props} {...props}
@ -98,7 +102,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/alerts?id=:id&status=:status&framework=:framework&filter=:filter&hideImprovements=:hideImprovements&hideDwnToInv=:hideDwnToInv&hideAssignedToOthers=:hideAssignedToOthers&filterText=:filterText&page=:page" path={`${path}/alerts?id=:id&status=:status&framework=:framework&filter=:filter&hideImprovements=:hideImprovements&hideDwnToInv=:hideDwnToInv&hideAssignedToOthers=:hideAssignedToOthers&filterText=:filterText&page=:page`}
render={(props) => ( render={(props) => (
<AlertsView <AlertsView
{...props} {...props}
@ -110,7 +114,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/graphs" path={`${path}/graphs`}
render={(props) => ( render={(props) => (
<GraphsView <GraphsView
{...props} {...props}
@ -121,7 +125,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/graphs?timerange=:timerange&series=:series&highlightedRevisions=:highlightedRevisions&highlightAlerts=:highlightAlerts&zoom=:zoom&selected=:selected" path={`${path}/graphs?timerange=:timerange&series=:series&highlightedRevisions=:highlightedRevisions&highlightAlerts=:highlightAlerts&zoom=:zoom&selected=:selected`}
render={(props) => ( render={(props) => (
<GraphsView <GraphsView
{...props} {...props}
@ -132,7 +136,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/comparechooser" path={`${path}/comparechooser`}
render={(props) => ( render={(props) => (
<CompareSelectorView <CompareSelectorView
{...props} {...props}
@ -143,7 +147,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/comparechooser?originalProject=:originalProject&originalRevision=:originalRevision&newProject=:newProject&newRevision=:newRevision" path={`${path}/comparechooser?originalProject=:originalProject&originalRevision=:originalRevision&newProject=:newProject&newRevision=:newRevision`}
render={(props) => ( render={(props) => (
<CompareSelectorView <CompareSelectorView
{...props} {...props}
@ -154,7 +158,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/compare" path={`${path}/compare`}
render={(props) => ( render={(props) => (
<CompareView <CompareView
{...props} {...props}
@ -167,7 +171,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/compare?originalProject=:originalProject&originalRevision=:originalRevison&newProject=:newProject&newRevision=:newRevision&framework=:framework&showOnlyComparable=:showOnlyComparable&showOnlyImportant=:showOnlyImportant&showOnlyConfident=:showOnlyConfident&selectedTimeRange=:selectedTimeRange&showOnlyNoise=:showOnlyNoise" path={`${path}/compare?originalProject=:originalProject&originalRevision=:originalRevison&newProject=:newProject&newRevision=:newRevision&framework=:framework&showOnlyComparable=:showOnlyComparable&showOnlyImportant=:showOnlyImportant&showOnlyConfident=:showOnlyConfident&selectedTimeRange=:selectedTimeRange&showOnlyNoise=:showOnlyNoise`}
render={(props) => ( render={(props) => (
<CompareView <CompareView
{...props} {...props}
@ -180,7 +184,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/infracompare" path={`${path}/infracompare`}
render={(props) => ( render={(props) => (
<InfraCompareView <InfraCompareView
{...props} {...props}
@ -193,7 +197,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/comparesubtest" path={`${path}/comparesubtest`}
render={(props) => ( render={(props) => (
<CompareSubtestsView <CompareSubtestsView
{...props} {...props}
@ -204,7 +208,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/comparesubtest?originalProject=:originalProject&originalRevision=:originalRevision&newProject=:newProject&newRevision=:newRevision&originalSignature=:originalSignature&newSignature=:newSignature&framework=:framework&showOnlyComparable=:showOnlyComparable&showOnlyImportant=:showOnlyImportant&showOnlyConfident=:showOnlyConfident&selectedTimeRange=:selectedTimeRange&showOnlyNoise=:showOnlyNoise" path={`${path}/comparesubtest?originalProject=:originalProject&originalRevision=:originalRevision&newProject=:newProject&newRevision=:newRevision&originalSignature=:originalSignature&newSignature=:newSignature&framework=:framework&showOnlyComparable=:showOnlyComparable&showOnlyImportant=:showOnlyImportant&showOnlyConfident=:showOnlyConfident&selectedTimeRange=:selectedTimeRange&showOnlyNoise=:showOnlyNoise`}
render={(props) => ( render={(props) => (
<CompareSubtestsView <CompareSubtestsView
{...props} {...props}
@ -215,7 +219,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/comparesubtestdistribution" path={`${path}/comparesubtestdistribution`}
render={(props) => ( render={(props) => (
<CompareSubtestDistributionView <CompareSubtestDistributionView
{...props} {...props}
@ -226,7 +230,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/comparesubtestdistribution?originalProject=:originalProject&newProject=:newProject&originalRevision=:originalRevision&newRevision=:newRevision&originalSubtestSignature=:originalSubtestSignature&newSubtestSignature=:newSubtestSignature" path={`${path}/comparesubtestdistribution?originalProject=:originalProject&newProject=:newProject&originalRevision=:originalRevision&newRevision=:newRevision&originalSubtestSignature=:originalSubtestSignature&newSubtestSignature=:newSubtestSignature`}
render={(props) => ( render={(props) => (
<CompareSubtestDistributionView <CompareSubtestDistributionView
{...props} {...props}
@ -237,7 +241,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/tests" path={`${path}/tests`}
render={(props) => ( render={(props) => (
<TestsView <TestsView
{...props} {...props}
@ -249,7 +253,7 @@ class App extends React.Component {
)} )}
/> />
<Route <Route
path="/tests?framework=:framework" path={`${path}/tests?framework=:framework"`}
render={(props) => ( render={(props) => (
<TestsView <TestsView
{...props} {...props}
@ -260,11 +264,14 @@ class App extends React.Component {
/> />
)} )}
/> />
<Redirect from="/" to="/alerts?hideDwnToInv=1" /> <Redirect
from={`${path}/`}
to={`${path}/alerts?hideDwnToInv=1`}
/>
</Switch> </Switch>
</main> </main>
)} )}
</HashRouter> </React.Fragment>
); );
} }
} }

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

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Navbar, Nav, NavItem, NavLink } from 'reactstrap'; import { Navbar, Nav, NavItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import LogoMenu from '../shared/LogoMenu'; import LogoMenu from '../shared/LogoMenu';
import Login from '../shared/auth/Login'; import Login from '../shared/auth/Login';
@ -11,24 +12,24 @@ const Navigation = ({ user, setUser, notify }) => (
<LogoMenu menuText="Perfherder" colorClass="text-info" /> <LogoMenu menuText="Perfherder" colorClass="text-info" />
<Nav className="navbar navbar-inverse"> <Nav className="navbar navbar-inverse">
<NavItem> <NavItem>
<NavLink href="#/graphs" className="btn-view-nav"> <Link to="./graphs" className="nav-link btn-view-nav">
Graphs Graphs
</NavLink> </Link>
</NavItem> </NavItem>
<NavItem> <NavItem>
<NavLink href="#/comparechooser" className="btn-view-nav"> <Link to="./comparechooser" className="nav-link btn-view-nav">
Compare Compare
</NavLink> </Link>
</NavItem> </NavItem>
<NavItem> <NavItem>
<NavLink href="#/alerts?hideDwnToInv=1" className="btn-view-nav"> <Link to="./alerts?hideDwnToInv=1" className="nav-link btn-view-nav">
Alerts Alerts
</NavLink> </Link>
</NavItem> </NavItem>
<NavItem> <NavItem>
<NavLink href="#/tests" className="btn-view-nav"> <Link to="./tests" className="nav-link btn-view-nav">
Tests Tests
</NavLink> </Link>
</NavItem> </NavItem>
</Nav> </Nav>
<Navbar className="ml-auto"> <Navbar className="ml-auto">

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

@ -2,11 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Container } from 'reactstrap'; import { Container } from 'reactstrap';
import { import { parseQueryParams, createQueryParams } from '../helpers/url';
parseQueryParams,
createQueryParams,
updateQueryParams,
} from '../helpers/url';
import PushModel from '../models/push'; import PushModel from '../models/push';
import ErrorMessages from '../shared/ErrorMessages'; import ErrorMessages from '../shared/ErrorMessages';
import LoadingSpinner from '../shared/LoadingSpinner'; import LoadingSpinner from '../shared/LoadingSpinner';
@ -37,30 +33,29 @@ const withValidation = ({ requiredParams }, verifyRevisions = true) => (
} }
async componentDidMount() { async componentDidMount() {
this.validateParams(parseQueryParams(this.props.location.search)); this.validateParams(parseQueryParams(this.props.history.location.search));
}
shouldComponentUpdate(prevProps) {
const { location } = this.props;
return location.hash === prevProps.location.hash;
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { location } = this.props; const { history } = this.props;
if (location.search !== prevProps.location.search) { // Using location instead of history requires an extra click when
// using the back button to go back to previous location
if (history.location.search !== prevProps.history.location.search) {
// delete from state params the ones // delete from state params the ones
this.validateParams(parseQueryParams(location.search)); this.validateParams(parseQueryParams(history.location.search));
} }
} }
updateParams = (params) => { updateParams = (params) => {
const { location, history } = this.props; const { history, location } = this.props;
const newParams = { ...parseQueryParams(location.search), ...params };
const queryString = createQueryParams(newParams);
updateQueryParams(queryString, history, location); const newParams = {
...parseQueryParams(location.search),
...params,
};
const queryString = createQueryParams(newParams);
history.push({ search: queryString });
}; };
errorMessage = (param, value) => `${param} ${value} is not valid`; errorMessage = (param, value) => `${param} ${value} is not valid`;
@ -204,6 +199,7 @@ const withValidation = ({ requiredParams }, verifyRevisions = true) => (
Validation.propTypes = { Validation.propTypes = {
location: PropTypes.shape({}).isRequired, location: PropTypes.shape({}).isRequired,
history: PropTypes.shape({}).isRequired,
}; };
return Validation; return Validation;

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

@ -13,6 +13,7 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import moment from 'moment'; import moment from 'moment';
import { Link } from 'react-router-dom';
import { getTitle, getFrameworkName } from '../helpers'; import { getTitle, getFrameworkName } from '../helpers';
import { getJobsUrl } from '../../helpers/url'; import { getJobsUrl } from '../../helpers/url';
@ -43,9 +44,9 @@ const AlertHeader = ({
return ( return (
<Container> <Container>
<Row> <Row>
<a <Link
className="text-dark" className="text-dark"
href={`#/alerts?id=${alertSummary.id}`} to={`./alerts?id=${alertSummary.id}&hideDwnToInv=0`}
id={`alert summary ${alertSummary.id.toString()} title`} id={`alert summary ${alertSummary.id.toString()} title`}
data-testid={`alert summary ${alertSummary.id.toString()} title`} data-testid={`alert summary ${alertSummary.id.toString()} title`}
> >
@ -60,7 +61,7 @@ const AlertHeader = ({
className="icon-superscript" className="icon-superscript"
/> />
</h3> </h3>
</a> </Link>
</Row> </Row>
<Row className="font-weight-normal"> <Row className="font-weight-normal">
<Col className="p-0" xs="auto">{`${moment( <Col className="p-0" xs="auto">{`${moment(

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

@ -17,6 +17,7 @@ import {
faCheck, faCheck,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons'; import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons';
import { Link } from 'react-router-dom';
import { createQueryParams } from '../../helpers/url'; import { createQueryParams } from '../../helpers/url';
import { getStatus, getGraphsURL, modifyAlert, formatNumber } from '../helpers'; import { getStatus, getGraphsURL, modifyAlert, formatNumber } from '../helpers';
@ -107,11 +108,10 @@ export default class AlertTableRow extends React.Component {
return ( return (
<span> <span>
{` ${text} `} {` ${text} `}
<a <Link
href={`#/alerts?id=${alertId}`} to={`./alerts?id=${alertId}`}
rel="noopener noreferrer"
className="text-darker-info" className="text-darker-info"
>{`alert #${alertId}`}</a> >{`alert #${alertId}`}</Link>
</span> </span>
); );
}; };
@ -263,7 +263,7 @@ export default class AlertTableRow extends React.Component {
newRevision: alertSummary.revision, newRevision: alertSummary.revision,
}; };
return `#/comparesubtest${createQueryParams(urlParameters)}`; return `./comparesubtest${createQueryParams(urlParameters)}`;
}; };
render() { render() {
@ -345,6 +345,7 @@ export default class AlertTableRow extends React.Component {
textClass="detail-hint" textClass="detail-hint"
text={`${alert.amount_pct}%`} text={`${alert.amount_pct}%`}
tooltipText={`Absolute difference: ${alert.amount_abs}`} tooltipText={`Absolute difference: ${alert.amount_abs}`}
autohide={false}
/> />
</td> </td>
<td className="table-width-lg"> <td className="table-width-lg">

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

@ -32,10 +32,10 @@ class AlertsView extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { frameworks, validated } = this.props; const { frameworks, validated } = this.props;
const extendedOptions = this.extendDropdownOptions(frameworks); this.extendedOptions = this.extendDropdownOptions(frameworks);
this.state = { this.state = {
filters: this.getFiltersFromParams(validated, extendedOptions), filters: this.getFiltersFromParams(validated),
frameworkOptions: extendedOptions, frameworkOptions: this.extendedOptions,
page: validated.page ? parseInt(validated.page, 10) : 1, page: validated.page ? parseInt(validated.page, 10) : 1,
errorMessages: [], errorMessages: [],
alertSummaries: [], alertSummaries: [],
@ -55,43 +55,30 @@ class AlertsView extends React.Component {
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { count, frameworkOptions } = this.state; const { count } = this.state;
const { validated } = this.props;
const prevValitated = prevProps.validated;
if (prevState.count !== count) { if (prevState.count !== count) {
this.setState({ totalPages: this.generatePages(count) }); this.setState({ totalPages: this.generatePages(count) });
} }
// filters updated directly in the url
if (
validated.hideAssignedToOthers !== prevValitated.hideAssignedToOthers ||
validated.hideImprovements !== prevValitated.hideImprovements ||
validated.hideDwnToInv !== prevValitated.hideDwnToInv ||
validated.filterText !== prevValitated.filterText ||
validated.status !== prevValitated.status ||
validated.framework !== prevValitated.framework
) {
this.setFiltersState(
this.getFiltersFromParams(validated, frameworkOptions),
false,
);
}
const params = parseQueryParams(this.props.location.search); const params = parseQueryParams(this.props.location.search);
const prevParams = parseQueryParams(prevProps.location.search);
// we're using local state for id instead of validated.id because once // we're using local state for id instead of validated.id because once
// the user navigates from the id=<alert> view back to the main alerts view // the user navigates from the id=<alert> view back to the main alerts view
// the Validation component won't reset the id (since the query param doesn't exist // the Validation component won't reset the id (since the query param doesn't exist
// unless there is a value) // unless there is a value)
if (this.props.location.search !== prevProps.location.search) { if (params.id !== prevParams.id) {
this.setState({ id: params.id || null }, this.fetchAlertSummaries); this.setState(
if (params.id) { { id: params.id || null, filters: this.getFiltersFromParams(params) },
validated.updateParams({ hideDwnToInv: 0 }); this.fetchAlertSummaries,
} );
} }
} }
getFiltersFromParams = (validated, frameworkOptions) => { getFiltersFromParams = (
validated,
frameworkOptions = this.extendedOptions,
) => {
return { return {
status: this.getDefaultStatus(), status: this.getDefaultStatus(),
framework: getFrameworkData({ framework: getFrameworkData({
@ -229,7 +216,11 @@ class AlertsView extends React.Component {
); );
}; };
async fetchAlertSummaries(id = this.state.id, update = false, page = 1) { async fetchAlertSummaries(
id = this.state.id,
update = false,
page = this.state.page,
) {
// turn off loading when update is true (used to update alert statuses) // turn off loading when update is true (used to update alert statuses)
this.setState({ loading: !update, errorMessages: [] }); this.setState({ loading: !update, errorMessages: [] });
const { user } = this.props; const { user } = this.props;

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

@ -14,14 +14,16 @@ export default class AlertsViewControls extends React.Component {
}; };
updateFilter = (filter) => { updateFilter = (filter) => {
const { setFiltersState, filters } = this.props; const { setFiltersState, filters, updateViewState } = this.props;
const prevValue = filters[filter]; const prevValue = filters[filter];
setFiltersState({ [filter]: !prevValue }); setFiltersState({ [filter]: !prevValue });
updateViewState({ page: 1 });
}; };
updateStatus = (status) => { updateStatus = (status) => {
const { setFiltersState } = this.props; const { setFiltersState, updateViewState } = this.props;
setFiltersState({ status }); setFiltersState({ status });
updateViewState({ page: 1 });
}; };
updateFramework = (selectedFramework) => { updateFramework = (selectedFramework) => {
@ -29,7 +31,7 @@ export default class AlertsViewControls extends React.Component {
const framework = frameworkOptions.find( const framework = frameworkOptions.find(
(item) => item.name === selectedFramework, (item) => item.name === selectedFramework,
); );
updateViewState({ bugTemplate: null }); updateViewState({ bugTemplate: null, page: 1 });
setFiltersState({ framework }, this.fetchAlertSummaries); setFiltersState({ framework }, this.fetchAlertSummaries);
}; };

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

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { getTitle } from '../helpers'; import { getTitle } from '../helpers';
import SimpleTooltip from '../../shared/SimpleTooltip'; import SimpleTooltip from '../../shared/SimpleTooltip';
@ -51,9 +52,9 @@ export default class DownstreamSummary extends React.Component {
<SimpleTooltip <SimpleTooltip
text={ text={
<span> <span>
<a href={`perf.html#/alerts?id=${id}`} className="text-info"> <Link to={`./alerts?id=${id}`} className="text-info">
#{id} #{id}
</a> </Link>
{position === 0 ? '' : ', '} {position === 0 ? '' : ', '}
</span> </span>
} }

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

@ -70,7 +70,7 @@ export default class StatusDropdown extends React.Component {
framework: getFrameworkName(frameworks, alertSummary.framework), framework: getFrameworkName(frameworks, alertSummary.framework),
revision: alertSummary.revision, revision: alertSummary.revision,
revisionHref: repoModel.getPushLogHref(alertSummary.revision), revisionHref: repoModel.getPushLogHref(alertSummary.revision),
alertHref: `${window.location.origin}/perf.html#/alerts?id=${alertSummary.id}`, alertHref: `${window.location.origin}/perfherder/alerts?id=${alertSummary.id}`,
alertSummary: getTextualSummary(filteredAlerts, alertSummary), alertSummary: getTextualSummary(filteredAlerts, alertSummary),
}; };

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

@ -85,9 +85,9 @@ export default class CompareSelectorView extends React.Component {
}; };
} }
if (framework === 0) { if (framework === 0) {
history.push(`/infracompare${createQueryParams(params)}`); history.push(`./infracompare${createQueryParams(params)}`);
} else { } else {
history.push(`/compare${createQueryParams(params)}`); history.push(`./compare${createQueryParams(params)}`);
} }
}; };

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

@ -84,9 +84,7 @@ class CompareSubtestsView extends React.PureComponent {
links.push({ links.push({
title: 'replicates', title: 'replicates',
href: `perf.html#/comparesubtestdistribution${createQueryParams( to: `./comparesubtestdistribution${createQueryParams(params)}`,
params,
)}`,
}); });
} }
const signatureHash = !oldResults const signatureHash = !oldResults

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

@ -7,6 +7,7 @@ import {
faThumbsUp, faThumbsUp,
faHashtag, faHashtag,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import SimpleTooltip from '../../shared/SimpleTooltip'; import SimpleTooltip from '../../shared/SimpleTooltip';
import { displayNumber, formatNumber, getHashBasedId } from '../helpers'; import { displayNumber, formatNumber, getHashBasedId } from '../helpers';
@ -81,7 +82,7 @@ export default class CompareTableRow extends React.PureComponent {
{rowLevelResults.links && {rowLevelResults.links &&
rowLevelResults.links.map((link) => ( rowLevelResults.links.map((link) => (
<span key={link.title}> <span key={link.title}>
<a href={link.href}>{` ${link.title}`}</a> <Link to={link.to}>{` ${link.title}`}</Link>
</span> </span>
))} ))}
</span> </span>

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

@ -257,7 +257,7 @@ export default class CompareTableView extends React.Component {
{hasSubtests && ( {hasSubtests && (
<Link <Link
to={{ to={{
pathname: '/compare', pathname: './compare',
search: createQueryParams(params), search: createQueryParams(params),
}} }}
> >

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

@ -103,13 +103,11 @@ class CompareView extends React.PureComponent {
params.selectedTimeRange = timeRange.value; params.selectedTimeRange = timeRange.value;
} }
const detailsLink = `perf.html#/comparesubtest${createQueryParams( const detailsLink = `./comparesubtest${createQueryParams(params)}`;
params,
)}`;
links.push({ links.push({
title: 'subtests', title: 'subtests',
href: detailsLink, to: detailsLink,
}); });
} }
const signatureHash = !oldResults const signatureHash = !oldResults

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

@ -46,7 +46,7 @@ const TableAverage = ({ value, stddev, stddevpct, replicates }) => {
tooltipText tooltipText
) )
} }
tooltipClass={replicates.length > 1 ? 'compare-table-tooltip' : ''} innerClassName={replicates.length > 1 ? 'compare-table-tooltip' : ''}
/> />
) : ( ) : (
<span className="text-muted">No results</span> <span className="text-muted">No results</span>

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

@ -8,6 +8,7 @@ import {
faExclamationCircle, faExclamationCircle,
faTimes, faTimes,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { alertStatusMap, endpoints } from '../constants'; import { alertStatusMap, endpoints } from '../constants';
import { getJobsUrl, createQueryParams, getApiUrl } from '../../helpers/url'; import { getJobsUrl, createQueryParams, getApiUrl } from '../../helpers/url';
@ -203,7 +204,7 @@ const GraphTooltip = ({
{dataPointDetails.jobId && prevRevision && ', '} {dataPointDetails.jobId && prevRevision && ', '}
{prevRevision && ( {prevRevision && (
<a <a
href={`#/comparesubtest${createQueryParams({ href={`./comparesubtest${createQueryParams({
originalProject: testDetails.repository_name, originalProject: testDetails.repository_name,
newProject: testDetails.repository_name, newProject: testDetails.repository_name,
originalRevision: prevRevision, originalRevision: prevRevision,
@ -229,16 +230,14 @@ const GraphTooltip = ({
</span> </span>
{dataPointDetails.alertSummary && ( {dataPointDetails.alertSummary && (
<p> <p>
<a <Link to={`./alerts?id=${dataPointDetails.alertSummary.id}`}>
href={`perf.html#/alerts?id=${dataPointDetails.alertSummary.id}`}
>
<FontAwesomeIcon <FontAwesomeIcon
className="text-warning" className="text-warning"
icon={faExclamationCircle} icon={faExclamationCircle}
size="sm" size="sm"
/> />
{` Alert # ${dataPointDetails.alertSummary.id}`} {` Alert # ${dataPointDetails.alertSummary.id}`}
</a> </Link>
<span className="text-muted"> <span className="text-muted">
{` - ${alertStatus} `} {` - ${alertStatus} `}
{alert && alert.related_summary_id && ( {alert && alert.related_summary_id && (
@ -247,11 +246,11 @@ const GraphTooltip = ({
dataPointDetails.alertSummary.id dataPointDetails.alertSummary.id
? 'to' ? 'to'
: 'from'} : 'from'}
<a <Link
href={`#/alerts?id=${alert.related_summary_id}`} to={`./alerts?id=${alert.related_summary_id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>{` alert # ${alert.related_summary_id}`}</a> >{` alert # ${alert.related_summary_id}`}</Link>
</span> </span>
)} )}
</span> </span>

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

@ -54,7 +54,7 @@ const TableView = ({
group_state: 'expanded', group_state: 'expanded',
}); });
const compareUrl = `#/comparesubtest${createQueryParams({ const compareUrl = `./comparesubtest${createQueryParams({
originalProject: item.repository_name, originalProject: item.repository_name,
newProject: item.repository_name, newProject: item.repository_name,
originalRevision: prevRevision, originalRevision: prevRevision,

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

@ -312,7 +312,7 @@ export const getGraphsLink = function getGraphsLink(
params.timerange = timeRange; params.timerange = timeRange;
} }
return `perf.html#/graphs?${queryString.stringify(params)}`; return `./graphs?${queryString.stringify(params)}`;
}; };
export const createNoiseMetric = function createNoiseMetric( export const createNoiseMetric = function createNoiseMetric(
@ -363,7 +363,7 @@ export const createGraphsLinks = (
links.push({ links.push({
title: 'graph', title: 'graph',
href: graphsLink, to: graphsLink,
}); });
return links; return links;
@ -385,7 +385,7 @@ export const getGraphsURL = (
alertRepository, alertRepository,
performanceFrameworkId, performanceFrameworkId,
) => { ) => {
let url = `#/graphs?timerange=${timeRange}&series=${alertRepository},${alert.series_signature.id},1,${alert.series_signature.framework_id}`; let url = `./graphs?timerange=${timeRange}&series=${alertRepository},${alert.series_signature.id},1,${alert.series_signature.framework_id}`;
// automatically add related branches (we take advantage of // automatically add related branches (we take advantage of
// the otherwise rather useless signature hash to avoid having to fetch this // the otherwise rather useless signature hash to avoid having to fetch this
@ -477,7 +477,7 @@ export const getTextualSummary = (alerts, alertSummary, copySummary = null) => {
} }
// include link to alert if getting text for clipboard only // include link to alert if getting text for clipboard only
if (copySummary) { if (copySummary) {
const alertLink = `${window.location.origin}/perf.html#/alerts?id=${alertSummary.id}`; const alertLink = `${window.location.origin}/perfherder/alerts?id=${alertSummary.id}`;
resultStr += `\nFor up to date results, see: ${alertLink}`; resultStr += `\nFor up to date results, see: ${alertLink}`;
} }
return resultStr; return resultStr;

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

@ -1,12 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import 'react-table/react-table.css';
import '../css/treeherder-base.css';
import '../css/treeherder-custom-styles.css';
import '../css/treeherder-navbar.css';
import '../css/perf.css';
import App from './App';
render(<App />, document.getElementById('root'));

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

@ -1,11 +1,18 @@
import React from 'react'; import React from 'react';
import { hot } from 'react-hot-loader/root'; import { hot } from 'react-hot-loader/root';
import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import NotFound from './NotFound'; import NotFound from './NotFound';
import Health from './Health'; import Health from './Health';
import Usage from './Usage'; import Usage from './Usage';
import '../css/failure-summary.css';
import '../css/lazylog-custom-styles.css';
import '../css/treeherder-job-buttons.css';
import '../css/treeherder-notifications.css';
import './pushhealth.css';
import 'react-tabs/style/react-tabs.css';
function hasProps(search) { function hasProps(search) {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
@ -14,26 +21,23 @@ function hasProps(search) {
const App = () => { const App = () => {
return ( return (
<BrowserRouter> <div>
<div> <div>
<div> <Switch>
<Switch> <Route
<Route path="/"
exact render={(props) =>
path="/pushhealth.html" hasProps(props.location.search) ? (
render={(props) => <Health {...props} />
hasProps(props.location.search) ? ( ) : (
<Health {...props} /> <Usage {...props} />
) : ( )
<Usage {...props} /> }
) />
} <Route name="notfound" component={NotFound} />
/> </Switch>
<Route name="notfound" component={NotFound} />
</Switch>
</div>
</div> </div>
</BrowserRouter> </div>
); );
}; };

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

@ -67,7 +67,7 @@ class Usage extends Component {
<tr key={revision} data-testid={`facet-${revision}`}> <tr key={revision} data-testid={`facet-${revision}`}>
<td data-testid="facet-link"> <td data-testid="facet-link">
<a <a
href={`/pushhealth.html?repo=try&revision=${revision}`} href={`/push-health?repo=try&revision=${revision}`}
title="See Push Health" title="See Push Health"
> >
{revision} {revision}

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

@ -4,8 +4,6 @@ import { render } from 'react-dom';
// Treeherder Styles // Treeherder Styles
import '../css/failure-summary.css'; import '../css/failure-summary.css';
import '../css/lazylog-custom-styles.css'; import '../css/lazylog-custom-styles.css';
import '../css/treeherder-custom-styles.css';
import '../css/treeherder-navbar.css';
import '../css/treeherder-job-buttons.css'; import '../css/treeherder-job-buttons.css';
import '../css/treeherder-notifications.css'; import '../css/treeherder-notifications.css';
import './pushhealth.css'; import './pushhealth.css';

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

@ -72,7 +72,7 @@ export default class ComparePageTitle extends React.Component {
changeQueryParam = (newTitle) => { changeQueryParam = (newTitle) => {
const params = getAllUrlParams(); const params = getAllUrlParams();
params.set('pageTitle', newTitle); params.set('pageTitle', newTitle);
replaceLocation(params, '/compare'); replaceLocation(params);
}; };
userActionListener = async (event) => { userActionListener = async (event) => {

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

@ -21,7 +21,7 @@ import {
const menuItems = [ const menuItems = [
{ {
href: '/userguide.html', href: '/userguide',
icon: faQuestionCircle, icon: faQuestionCircle,
text: 'User Guide', text: 'User Guide',
}, },

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

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { getInspectTaskUrl } from '../helpers/url'; import { getInspectTaskUrl } from '../helpers/url';
import { getJobSearchStrHref } from '../helpers/job'; import { getJobSearchStrHref } from '../helpers/job';
@ -57,12 +58,12 @@ export default class JobInfo extends React.PureComponent {
<strong>Job: </strong> <strong>Job: </strong>
{showJobFilters ? ( {showJobFilters ? (
<React.Fragment> <React.Fragment>
<a <Link
title="Filter jobs containing these keywords" title="Filter jobs containing these keywords"
href={getJobSearchStrHref(searchStr)} to={{ search: getJobSearchStrHref(searchStr) }}
> >
{searchStr} {searchStr}
</a> </Link>
</React.Fragment> </React.Fragment>
) : ( ) : (
<span>{searchStr}</span> <span>{searchStr}</span>

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

@ -6,12 +6,13 @@ import {
DropdownMenu, DropdownMenu,
DropdownItem, DropdownItem,
} from 'reactstrap'; } from 'reactstrap';
import { Link } from 'react-router-dom';
const choices = [ const choices = [
{ url: '/', text: 'Treeherder' }, { url: '/jobs', text: 'Treeherder' },
{ url: '/perf.html', text: 'Perfherder' }, { url: '/perfherder', text: 'Perfherder' },
{ url: '/intermittent-failures.html', text: 'Intermittent Failures View' }, { url: '/intermittent-failures', text: 'Intermittent Failures View' },
{ url: '/pushhealth.html', text: 'Push Health Usage' }, { url: '/push-health', text: 'Push Health Usage' },
]; ];
export default class LogoMenu extends React.PureComponent { export default class LogoMenu extends React.PureComponent {
@ -35,8 +36,8 @@ export default class LogoMenu extends React.PureComponent {
</DropdownToggle> </DropdownToggle>
<DropdownMenu> <DropdownMenu>
{menuChoices.map((choice) => ( {menuChoices.map((choice) => (
<DropdownItem key={choice.text} tag="a" href={choice.url}> <DropdownItem key={choice.text}>
{choice.text} <Link to={choice.url}>{choice.text}</Link>
</DropdownItem> </DropdownItem>
))} ))}
</DropdownMenu> </DropdownMenu>

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

@ -1,18 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function Author(props) {
const authorMatch = props.author.match(/<(.*?)>+/);
const authorEmail = authorMatch ? authorMatch[1] : props.author;
return (
<span title="View pushes by this user" className="push-author">
<a href={props.url}>{authorEmail}</a>
</span>
);
}
Author.propTypes = {
author: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
};

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

@ -14,7 +14,7 @@ export default class SimpleTooltip extends React.Component {
tooltipText, tooltipText,
placement, placement,
textClass, textClass,
tooltipClass, innerClassName,
autohide, autohide,
} = this.props; } = this.props;
@ -26,7 +26,7 @@ export default class SimpleTooltip extends React.Component {
<UncontrolledTooltip <UncontrolledTooltip
placement={placement} placement={placement}
target={this.tooltipRef} target={this.tooltipRef}
innerClassName={tooltipClass} innerClassName={innerClassName}
autohide={autohide} autohide={autohide}
> >
{tooltipText} {tooltipText}
@ -41,13 +41,13 @@ SimpleTooltip.propTypes = {
.isRequired, .isRequired,
textClass: PropTypes.string, textClass: PropTypes.string,
placement: PropTypes.string, placement: PropTypes.string,
tooltipClass: PropTypes.string, innerClassName: PropTypes.string,
autohide: PropTypes.bool, autohide: PropTypes.bool,
}; };
SimpleTooltip.defaultProps = { SimpleTooltip.defaultProps = {
textClass: '', textClass: '',
placement: 'top', placement: 'top',
tooltipClass: '', innerClassName: '',
autohide: true, autohide: true,
}; };

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

@ -10,7 +10,9 @@ export const tcClientIdMap = {
'https://treeherder-prototype2.herokuapp.com': 'dev2', 'https://treeherder-prototype2.herokuapp.com': 'dev2',
}; };
export const clientId = `treeherder-${tcClientIdMap[window.location.origin]}`; export const clientId = `treeherder-${
tcClientIdMap[window.location.origin]
}-client`;
export const redirectURI = `${window.location.origin}${tcAuthCallbackUrl}`; export const redirectURI = `${window.location.origin}${tcAuthCallbackUrl}`;
@ -26,7 +28,7 @@ export const checkRootUrl = (rootUrl) => {
// and the default login rootUrls are for https://firefox-ci-tc.services.mozilla.com // and the default login rootUrls are for https://firefox-ci-tc.services.mozilla.com
if ( if (
rootUrl === prodFirefoxRootUrl && rootUrl === prodFirefoxRootUrl &&
clientId === 'treeherder-taskcluster-staging' clientId === 'treeherder-taskcluster-staging-client'
) { ) {
return stagingFirefoxRootUrl; return stagingFirefoxRootUrl;
} }

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

@ -7,6 +7,10 @@ import UserGuideHeader from './UserGuideHeader';
import UserGuideBody from './UserGuideBody'; import UserGuideBody from './UserGuideBody';
import UserGuideFooter from './UserGuideFooter'; import UserGuideFooter from './UserGuideFooter';
import '../css/treeherder-userguide.css';
import '../css/treeherder-job-buttons.css';
import '../css/treeherder-base.css';
const App = () => ( const App = () => (
<div id="userguide"> <div id="userguide">
<div className="card"> <div className="card">

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

@ -1,11 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import '../css/treeherder-base.css';
import '../css/treeherder-custom-styles.css';
import '../css/treeherder-userguide.css';
import '../css/treeherder-job-buttons.css';
import App from './App';
render(<App />, document.getElementById('root'));

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

@ -897,7 +897,7 @@
core-js-pure "^3.0.0" core-js-pure "^3.0.0"
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
version "7.11.2" version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
@ -2994,6 +2994,13 @@ connect-history-api-fallback@^1.6.0:
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc"
integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==
connected-react-router@6.8.0:
version "6.8.0"
resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.8.0.tgz#ddc687b31d498322445d235d660798489fa56cae"
integrity sha512-E64/6krdJM3Ag3MMmh2nKPtMbH15s3JQDuaYJvOVXzu6MbHbDyIvuwLOyhQIuP4Om9zqEfZYiVyflROibSsONg==
dependencies:
prop-types "^15.7.2"
console-browserify@^1.1.0: console-browserify@^1.1.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
@ -5132,14 +5139,7 @@ highlight-words-core@^1.2.0:
resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.2.tgz#1eff6d7d9f0a22f155042a00791237791b1eeaaa" resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.2.tgz#1eff6d7d9f0a22f155042a00791237791b1eeaaa"
integrity sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg== integrity sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==
history@5.0.0: history@4.10.1, history@^4.9.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08"
integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg==
dependencies:
"@babel/runtime" "^7.7.6"
history@^4.9.0:
version "4.10.1" version "4.10.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==