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/',
mains: {
index: {
entry: 'job-view/index.jsx',
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',
entry: 'index',
template: 'ui/index.html',
},
},
@ -72,11 +30,12 @@ module.exports = {
}),
require('@neutrinojs/react')({
devServer: {
historyApiFallback: false,
historyApiFallback: true,
hot: true,
open: !process.env.IN_DOCKER,
proxy: {
// Proxy any paths not recognised by webpack to the specified backend.
'*': {
'/api': {
changeOrigin: true,
headers: {
// Prevent Django CSRF errors, whilst still making it clear

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

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

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

@ -3,6 +3,8 @@ import copy
import pytest
from pages.treeherder import Treeherder
skip = pytest.mark.skip
@pytest.fixture
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)
@skip
def test_expand_job_group(base_url, selenium, test_jobs):
page = Treeherder(selenium, base_url).open()
page.wait.until(lambda _: len(page.all_job_groups) == 1)

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

@ -4,7 +4,7 @@ import { getRevisionUrl } from '../../../ui/helpers/url';
describe('getRevisionUrl helper', () => {
test('escapes some html symbols', () => {
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 fetchMock from 'fetch-mock';
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 pushListFixture from '../mock/push_list';
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 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', () => {
const repoName = 'autoland';
@ -161,41 +178,27 @@ describe('App', () => {
});
afterEach(() => {
window.location.hash = `#/jobs?repo=${repoName}`;
history.push('/');
});
afterAll(() => {
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 () => {
const { getByText, getByAltText } = render(<App />);
const { getByText, getByAltText } = render(testApp());
const appMenu = await waitFor(() => getByAltText('Treeherder'));
expect(appMenu).toBeInTheDocument();
fireEvent.click(appMenu);
const phMenu = await waitFor(() => getByText('Perfherder'));
expect(phMenu.getAttribute('href')).toBe('/perf.html');
expect(phMenu.getAttribute('href')).toBe('/perfherder');
const ifvMenu = await waitFor(() =>
getByText('Intermittent Failures View'),
);
expect(ifvMenu.getAttribute('href')).toBe('/intermittent-failures.html');
expect(ifvMenu.getAttribute('href')).toBe('/intermittent-failures');
});
const testChangingSelectedJob = async (
@ -205,7 +208,7 @@ describe('App', () => {
secondJobSymbol,
secondJobTaskId,
) => {
const { getByText, findByText, findByTestId } = render(<App />);
const { getByText, findByText, findByTestId } = render(testApp());
const firstJob = await findByText(firstJobSymbol);
fireEvent.mouseDown(firstJob);
@ -271,4 +274,90 @@ describe('App', () => {
),
).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 fetchMock from 'fetch-mock';
import {
render,
fireEvent,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { ConnectedRouter } from 'connected-react-router';
import { Provider, ReactReduxContext } from 'react-redux';
import { createBrowserHistory } from 'history';
import App from '../../../ui/job-view/App';
import taskDefinition from '../mock/task_definition.json';
import pushListFixture from '../mock/push_list';
import reposFixture from '../mock/repositories';
import { getApiUrl, bzBaseUrl } from '../../../ui/helpers/url';
import {
getProjectUrl,
replaceLocation,
setUrlParam,
} from '../../../ui/helpers/location';
import { getProjectUrl } from '../../../ui/helpers/location';
import jobListFixtureOne from '../mock/job_list/job_1';
import jobMap from '../mock/job_map';
import { configureStore } from '../../../ui/job-view/redux/configureStore';
const repoName = 'autoland';
const treeStatusResponse = {
@ -35,10 +30,21 @@ const emptyPushResponse = {
const emptyBzResponse = {
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', () => {
beforeAll(() => {
window.location.hash = `#/jobs?repo=${repoName}`;
fetchMock.reset();
fetchMock.get('/revision.txt', []);
fetchMock.get(getApiUrl('/repository/'), reposFixture);
@ -70,10 +76,7 @@ describe('Filtering', () => {
taskDefinition,
);
});
afterEach(() => {
window.location.hash = `#/jobs?repo=${repoName}`;
});
afterEach(() => history.push('/'));
afterAll(() => {
fetchMock.reset();
@ -109,56 +112,67 @@ describe('Filtering', () => {
});
test('should have 1 push', async () => {
const { getAllByText, getAllByTestId, getByTestId } = render(<App />);
// wait till the ``reviewbot`` authored push is shown before filtering.
await waitFor(() => getAllByText('reviewbot'));
setUrlParam('author', 'reviewbot');
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(() =>
const { getAllByText, getAllByTestId, getByText, getByTitle } = render(
testApp(),
);
const unfilteredPushes = await waitFor(() =>
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', () => {
test('should have 10 failures', async () => {
const { getAllByText, getByTitle, findAllByText } = render(<App />);
const { getByTitle, findAllByText, queryAllByText } = render(testApp());
await findAllByText('B');
const unclassifiedOnlyButton = getByTitle(
'Loaded failures / toggle filtering for unclassified failures',
);
await waitFor(() => findAllByText('yaml'));
fireEvent.click(unclassifiedOnlyButton);
// Since yaml is not an unclassified failure, making this call will
// ensure that the filtering has completed. Then we can get an accurate
// 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.
// 10 pushes with 2 failures each, but only 1 unclassified.
expect(jobCount()).toBe(20);
// undo the filtering and make sure we see all the jobs again
fireEvent.click(unclassifiedOnlyButton);
await waitFor(() => getAllByText('yaml'));
await waitFor(() => findAllByText('yaml'));
expect(jobCount()).toBe(50);
});
test('KeyboardShortcut u: toggle unclassified jobs', async () => {
const { getAllByText } = render(<App />);
const { queryAllByText, getAllByText } = render(testApp());
const symbolToRemove = 'yaml';
await waitFor(() => getAllByText(symbolToRemove));
fireEvent.keyDown(document.body, { key: 'u', keyCode: 85 });
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove));
await waitFor(() => {
expect(queryAllByText('yaml')).toHaveLength(0);
});
expect(jobCount()).toBe(20);
});
});
@ -196,10 +210,6 @@ describe('Filtering', () => {
);
});
afterEach(() => {
replaceLocation({});
});
const setFilterText = (filterField, text) => {
fireEvent.click(filterField);
fireEvent.change(filterField, { target: { value: text } });
@ -207,7 +217,8 @@ describe('Filtering', () => {
};
test('click signature should have 10 jobs', async () => {
const { getByTitle, findAllByText } = render(<App />);
const { getByTitle, findAllByText } = render(testApp());
const build = await findAllByText('B');
fireEvent.mouseDown(build[0]);
@ -217,17 +228,19 @@ describe('Filtering', () => {
10000,
);
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 () => {
const { getAllByText, findAllByText } = render(<App />);
const { getAllByText, findAllByText, queryAllByText } = render(testApp());
await findAllByText('B');
const filterField = document.querySelector('#quick-filter');
setFilterText(filterField, 'yaml');
await waitForElementToBeRemoved(() => getAllByText('B'));
await waitFor(() => {
expect(queryAllByText('B')).toHaveLength(0);
});
expect(jobCount()).toBe(10);
// 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 () => {
const { findAllByText } = render(<App />);
const { findAllByText } = render(testApp());
await findAllByText('B');
const filterField = document.querySelector('#quick-filter');
@ -248,14 +261,19 @@ describe('Filtering', () => {
});
test('KeyboardShortcut ctrl+shift+f: clear the quick filter input', async () => {
const { findAllByText, getAllByText, getByPlaceholderText } = render(
<App />,
);
const {
findAllByText,
getAllByText,
getByPlaceholderText,
queryAllByText,
} = render(testApp());
await findAllByText('B');
const filterField = getByPlaceholderText('Filter platforms & jobs');
setFilterText(filterField, 'yaml');
await waitForElementToBeRemoved(() => getAllByText('B'));
await waitFor(() => {
expect(queryAllByText('B')).toHaveLength(0);
});
expect(filterField.value).toEqual('yaml');
fireEvent.keyDown(document, {
@ -277,12 +295,15 @@ describe('Filtering', () => {
};
test('uncheck success should leave 30 jobs', async () => {
const { getAllByText, findAllByText } = render(<App />);
const { getAllByText, findAllByText, queryAllByText } = render(testApp());
await findAllByText('B');
clickFilterChicklet('green');
await waitForElementToBeRemoved(() => getAllByText('D'));
await waitFor(() => {
expect(queryAllByText('D')).toHaveLength(0);
});
expect(jobCount()).toBe(40);
// 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 () => {
const { getAllByText, findAllByText } = render(<App />);
const { getAllByText, findAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'B';
await findAllByText(symbolToRemove);
clickFilterChicklet('red');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove));
await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(20);
// 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 () => {
const { getAllByText, findAllByText } = render(<App />);
const { getAllByText, findAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'yaml';
await findAllByText('B');
clickFilterChicklet('dkgray');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove));
await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(40);
// 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 () => {
const { getAllByText } = render(<App />);
const { getAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'yaml';
await waitFor(() => getAllByText(symbolToRemove));
fireEvent.keyDown(document.body, { key: 'i', keyCode: 73 });
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove));
expect(jobCount()).toBe(40);
expect(window.location.hash).toEqual(
'#/jobs?repo=autoland&resultStatus=testfailed%2Cbusted%2Cexception%2Csuccess%2Cretry%2Cusercancel%2Crunnable',
await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(history.location.search).toEqual(
'?repo=autoland&resultStatus=testfailed%2Cbusted%2Cexception%2Csuccess%2Cretry%2Cusercancel%2Crunnable',
);
});
test('KeyboardShortcut i: toggle on in-progress tasks', async () => {
const { getAllByText, findAllByText } = render(<App />);
const { getAllByText, findAllByText, queryAllByText } = render(testApp());
const symbolToRemove = 'yaml';
await waitFor(() => getAllByText(symbolToRemove));
clickFilterChicklet('dkgray');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove));
await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(40);
await findAllByText('B');
@ -355,17 +386,24 @@ describe('Filtering', () => {
await findAllByText('B');
await waitFor(() => getAllByText(symbolToRemove), 5000);
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 () => {
const { getAllByText, findAllByText, findByText } = render(<App />);
const {
getAllByText,
findAllByText,
findByText,
queryAllByText,
} = render(testApp());
const symbolToRemove = 'yaml';
await findAllByText('B');
clickFilterChicklet('dkgray');
await waitForElementToBeRemoved(() => getAllByText(symbolToRemove));
await waitFor(() => {
expect(queryAllByText(symbolToRemove)).toHaveLength(0);
});
expect(jobCount()).toBe(40);
// undo the filtering with the "Filters | Reset" menu item

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

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

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

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

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

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

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

@ -1,7 +1,7 @@
/* eslint-disable jest/prefer-to-have-length */
import React from 'react';
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 FilterModel from '../../../ui/models/filter';
@ -9,13 +9,20 @@ import mappedGroupFixture from '../mock/mappedGroup';
import mappedGroupDupsFixture from '../mock/mappedGroupDups';
import { addAggregateFields } from '../../../ui/helpers/job';
const history = createBrowserHistory();
describe('JobGroup component', () => {
let countGroup;
let dupGroup;
const repoName = 'mozilla-inbound';
const filterModel = new FilterModel();
const filterModel = new FilterModel({
pushRoute: history.push,
router: { location: history.location },
});
const pushGroupState = 'collapsed';
afterEach(() => history.push('/'));
beforeAll(() => {
mappedGroupFixture.jobs.forEach((job) => addAggregateFields(job));
mappedGroupDupsFixture.jobs.forEach((job) => addAggregateFields(job));
@ -26,136 +33,105 @@ describe('JobGroup component', () => {
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
*/
it('collapsed should show a job and count of 2', () => {
const jobGroup = mount(
<JobGroupComponent
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
);
it('collapsed should show a job and count of 2 icon when collapsed', async () => {
const { getByTestId } = render(jobGroup(countGroup));
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', () => {
const jobGroup = mount(
<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 });
test('should show a job and count of 2 icon when re-collapsed', async () => {
const { getByText, getByTestId } = render(jobGroup(countGroup));
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', () => {
const jobGroup = mount(
<JobGroupComponent
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
);
jobGroup.setState({ expanded: true });
test('should show jobs, not counts when expanded', async () => {
const { getByTestId, getAllByTestId } = render(jobGroup(countGroup));
expect(jobGroup.find('.job-group-count').length).toEqual(0);
expect(jobGroup.find('.job-btn').length).toEqual(3);
const jobGroupCount = await waitFor(() => getByTestId('job-group-count'));
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 jobGroup = mount(
<JobGroupComponent
repoName={repoName}
group={countGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={groupCountsExpanded}
/>,
const { queryByTestId, getAllByTestId } = render(
jobGroup(countGroup, groupCountsExpanded),
);
expect(jobGroup.find('.job-btn').length).toEqual(3);
expect(jobGroup.find('.job-group-count').length).toEqual(0);
const expandedJobs = await waitFor(() => getAllByTestId('job-btn'));
expect(expandedJobs).toHaveLength(3);
const jobGroupCount = await waitFor(() => queryByTestId('job-group-count'));
expect(jobGroupCount).toBeNull();
});
test('should hide duplicates by default', () => {
const jobGroup = mount(
<JobGroupComponent
repoName={repoName}
group={dupGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={false}
groupCountsExpanded={false}
/>,
test('should hide duplicates by default', async () => {
const { getAllByTestId } = render(jobGroup(dupGroup));
const jobGroupCount = await waitFor(() =>
getAllByTestId('job-group-count'),
);
expect(jobGroupCount).toHaveLength(1);
expect(jobGroup.find('.job-group-count').length).toEqual(1);
expect(jobGroup.find('.job-btn').length).toEqual(1);
const expandedJobs = await waitFor(() => getAllByTestId('job-btn'));
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 jobGroup = mount(
<JobGroupComponent
repoName={repoName}
group={dupGroup}
filterPlatformCb={() => {}}
filterModel={filterModel}
pushGroupState={pushGroupState}
platform={<span>windows</span>}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={false}
/>,
const groupCountsExpanded = false;
const { getAllByTestId } = render(
jobGroup(dupGroup, groupCountsExpanded, duplicateJobsVisible),
);
expect(jobGroup.find('.job-group-count').length).toEqual(1);
expect(jobGroup.find('.job-btn').length).toEqual(2);
});
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}
/>,
const jobGroupCount = await waitFor(() =>
getAllByTestId('job-group-count'),
);
expect(jobGroupCount).toHaveLength(1);
expect(jobGroup.find('.job-group-count').length).toEqual(1);
expect(jobGroup.find('.job-btn').length).toEqual(2);
const jobs = await waitFor(() => getAllByTestId('job-btn'));
expect(jobs).toHaveLength(2);
});
});

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

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

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

@ -2,12 +2,12 @@ import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk';
import { cleanup } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import { createBrowserHistory } from 'history';
import {
getProjectUrl,
getQueryString,
replaceLocation,
setUrlParam,
updatePushParams,
} from '../../../../ui/helpers/location';
import pushListFixture from '../../mock/push_list';
import pushListFromChangeFixture from '../../mock/pushListFromchange';
@ -26,12 +26,12 @@ import {
reducer,
fetchPushes,
pollPushes,
fetchNextPushes,
updateRange,
} from '../../../../ui/job-view/redux/stores/pushes';
import { getApiUrl } from '../../../../ui/helpers/url';
import JobModel from '../../../../ui/models/job';
const history = createBrowserHistory();
const mockStore = configureMockStore([thunk]);
const emptyBugzillaResponse = {
bugs: [],
@ -48,7 +48,7 @@ describe('Pushes Redux store', () => {
afterEach(() => {
cleanup();
fetchMock.reset();
replaceLocation({});
history.push('/');
});
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`,
emptyBugzillaResponse,
);
const store = mockStore({ pushes: initialState });
const store = mockStore({
pushes: initialState,
router: { location: history.location },
});
await store.dispatch(fetchPushes());
const actions = store.getActions();
@ -94,9 +97,15 @@ describe('Pushes Redux store', () => {
jobListFixtureTwo,
);
fetchMock.get(
`https://bugzilla.mozilla.org/rest/bug?id=1506219`,
emptyBugzillaResponse,
);
const initialPush = pushListFixture.results[0];
const store = mockStore({
pushes: { ...initialState, pushList: [initialPush] },
router: { location: history.location },
});
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(
getProjectUrl(
'/push/?full=true&count=11&push_timestamp__lte=1562867957',
@ -148,24 +157,29 @@ describe('Pushes Redux store', () => {
);
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({
pushes: {
...initialState,
pushList: [push],
oldestPushTimestamp: push.push_timestamp,
},
router: { location: history.location },
});
setUrlParam('revision', push.revision);
await store.dispatch(fetchNextPushes(10));
await store.dispatch(fetchPushes(10, true));
expect(getQueryString()).toEqual(
'tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0&fromchange=90da061f588d1315ee4087225d041d7474d9dfd8',
expect(window.location.search).toEqual(
`?repo=${repoName}&tochange=ba9c692786e95143b8df3f4b3e9b504dfbc589a0&fromchange=90da061f588d1315ee4087225d041d7474d9dfd8`,
);
});
test('should pare down to single revision updateRange', async () => {
const store = mockStore({
pushes: { ...initialState, pushList: pushListFixture.results },
router: { location: history.location },
});
await store.dispatch(
@ -174,7 +188,6 @@ describe('Pushes Redux store', () => {
const actions = store.getActions();
expect(actions).toEqual([
{ countPinnedJobs: 0, type: 'CLEAR_JOB' },
{
type: SET_PUSHES,
pushResults: {
@ -205,6 +218,7 @@ describe('Pushes Redux store', () => {
const store = mockStore({
pushes: initialState,
router: { location: history.location },
});
setUrlParam('fromchange', '9692347caff487cdcd889489b8e89a825fe6bbd1');
@ -267,7 +281,7 @@ describe('Pushes Redux store', () => {
});
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 state = reducer(
@ -275,7 +289,10 @@ describe('Pushes Redux store', () => {
{ 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(reduced.allUnclassifiedFailureCount).toEqual(2);

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

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

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

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

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

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

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

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

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

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

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

@ -126,14 +126,6 @@ MIDDLEWARE = [
if middleware
]
# Templating
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
}
]
# Database
# 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.
WHITENOISE_KEEP_ONLY_HASHED_FILES = True
# Templating
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'DIRS': [WHITENOISE_ROOT],
}
]
# TREEHERDER

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

@ -1,12 +1,14 @@
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 treeherder.webapp.api import urls as api_urls
from django.views.generic.base import TemplateView
urlpatterns = [
url(r'^api/', include(api_urls)),
url(r'^docs/', include_docs_urls(title='REST API Docs')),
re_path(r'', TemplateView.as_view(template_name='index.html')),
]
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;
}
.tooltip > .tooltip-inner {
.custom-tooltip {
background-color: lightgray;
color: black;
font-size: 14px;

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

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

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

@ -2,7 +2,7 @@ import pick from 'lodash/pick';
import isEqual from 'lodash/isEqual';
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
// job field.
@ -99,14 +99,8 @@ export const hasUrlFilterChanges = function hasUrlFilterChanges(
oldURL,
newURL,
) {
const oldFilters = pick(
parseQueryParams(extractSearchString(oldURL)),
allFilterParams,
);
const newFilters = pick(
parseQueryParams(extractSearchString(newURL)),
allFilterParams,
);
const oldFilters = pick(parseQueryParams(oldURL), allFilterParams);
const newFilters = pick(parseQueryParams(newURL), allFilterParams);
return !isEqual(oldFilters, newFilters);
};

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

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

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

@ -1,17 +1,10 @@
import { thDefaultRepo } from './constants';
import {
createQueryParams,
extractSearchString,
getApiUrl,
uiJobsUrlBase,
} from './url';
import { createQueryParams, getApiUrl } from './url';
export const getQueryString = function getQueryString() {
return extractSearchString(window.location.href);
};
export const getAllUrlParams = function getAllUrlParams() {
return new URLSearchParams(getQueryString());
export const getAllUrlParams = function getAllUrlParams(
location = window.location,
) {
return new URLSearchParams(location.search);
};
export const getUrlParam = function getUrlParam(name) {
@ -22,27 +15,12 @@ export const getRepo = function getRepo() {
return getUrlParam('repo') || thDefaultRepo;
};
export const setLocation = function setLocation(params, hashPrefix = '/jobs') {
window.location.hash = `#${hashPrefix}${createQueryParams(params)}`;
// This won't update the react router history object
export const replaceLocation = function replaceLocation(params) {
window.history.pushState(null, null, createQueryParams(params));
};
// change the url hash without firing a ``hashchange`` event.
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',
) {
export const setUrlParam = function setUrlParam(field, value) {
const params = getAllUrlParams();
if (value) {
@ -50,10 +28,25 @@ export const setUrlParam = function setUrlParam(
} else {
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();
params.delete('selectedJob');
@ -62,7 +55,7 @@ export const getRepoUrl = function getRepoUrl(newRepoName) {
params.delete('revision');
params.delete('author');
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
@ -77,3 +70,23 @@ export const getProjectUrl = function getProjectUrl(uri, repoName) {
export const getProjectJobUrl = function getProjectJobUrl(url, jobId) {
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 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
localStorage.setItem('requestState', nonce);
localStorage.setItem('tcRootUrl', _rootUrl);

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

@ -3,9 +3,9 @@
// https://github.com/mozilla/treeherder/blob/master/treeherder/middleware.py
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/';
@ -21,7 +21,7 @@ export const graphsEndpoint = '/failurecount/';
export const deployedRevisionUrl = '/revision.txt';
export const loginCallbackUrl = '/login.html';
export const loginCallbackUrl = '/login';
export const platformsEndpoint = '/machineplatforms/';
@ -31,7 +31,7 @@ export const investigatedTestsEndPoint = '/investigated-tests/';
export const repoEndpoint = '/repository/';
export const tcAuthCallbackUrl = '/taskcluster-auth.html';
export const tcAuthCallbackUrl = '/taskcluster-auth';
export const textLogErrorsEndpoint = '/text_log_errors/';
@ -105,7 +105,7 @@ export const getLogViewerUrl = function getLogViewerUrl(
repoName,
lineNumber,
) {
const rv = `logviewer.html#?job_id=${jobId}&repo=${repoName}`;
const rv = `/logviewer?job_id=${jobId}&repo=${repoName}`;
return lineNumber ? `${rv}&lineNumber=${lineNumber}` : rv;
};
@ -124,7 +124,7 @@ export const getPushHealthUrl = function getPushHealthUrl(params) {
};
export const getCompareChooserUrl = function getCompareChooserUrl(params) {
return `perf.html#/comparechooser${createQueryParams(params)}`;
return `/perfherder/comparechooser${createQueryParams(params)}`;
};
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
export const createApiUrl = function createApiUrl(api, params) {
const apiUrl = getApiUrl(api);
@ -163,7 +157,5 @@ export const updateQueryParams = function updateHistoryWithQueryParams(
history,
location,
) {
history.replace({ 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;
history.push({ pathname: location.pathname, 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 { HashRouter, Route, Switch, Redirect } from 'react-router-dom';
import { Route, Switch, Redirect } from 'react-router-dom';
import { Container } from 'reactstrap';
import { hot } from 'react-hot-loader/root';
@ -8,7 +8,11 @@ import ErrorMessages from '../shared/ErrorMessages';
import MainView from './MainView';
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) {
super(props);
@ -28,8 +32,8 @@ class App extends React.Component {
render() {
const { user, graphData, tableData, errorMessages } = this.state;
const { path } = this.props.match;
return (
<HashRouter>
<main>
{errorMessages.length > 0 && (
<Container className="pt-5 max-width-default">
@ -39,7 +43,7 @@ class App extends React.Component {
<Switch>
<Route
exact
path="/main"
path={`${path}/main`}
render={(props) => (
<MainView
{...props}
@ -55,7 +59,7 @@ class App extends React.Component {
)}
/>
<Route
path="/main?startday=:startday&endday=:endday&tree=:tree"
path={`${path}/main?startday=:startday&endday=:endday&tree=:tree`}
render={(props) => (
<MainView
{...props}
@ -65,17 +69,19 @@ class App extends React.Component {
/>
)}
/>
<Route path="/bugdetails" component={BugDetailsView} />
<Route
path="/bugdetails?startday=:startday&endday=:endday&tree=:tree&bug=bug"
component={BugDetailsView}
path={`${path}/bugdetails`}
render={(props) => <BugDetailsView {...props} />}
/>
<Redirect from="/" to="/main" />
<Route
path={`${path}/bugdetails?startday=:startday&endday=:endday&tree=:tree&bug=bug`}
render={(props) => <BugDetailsView {...props} />}
/>
<Redirect from={`${path}/`} to={`${path}/main`} />
</Switch>
</main>
</HashRouter>
);
}
}
export default hot(App);
export default hot(IntermittentFailuresApp);

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

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

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

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

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

@ -127,7 +127,7 @@ const MainView = (props) => {
<Col xs="12" className="text-left">
<Breadcrumb listClassName="bg-white">
<BreadcrumbItem>
<a title="Treeherder home page" href="/#/">
<a title="Treeherder home page" href="/">
Treeherder
</a>
</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 pick from 'lodash/pick';
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 { hasUrlFilterChanges, matchesDefaults } from '../helpers/filter';
import { getAllUrlParams, getRepo } from '../helpers/location';
import { matchesDefaults } from '../helpers/filter';
import { getAllUrlParams } from '../helpers/location';
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 FilterModel from '../models/filter';
import RepositoryModel from '../models/repository';
@ -24,8 +30,19 @@ import { PUSH_HEALTH_VISIBILITY } from './headerbars/HealthMenu';
import DetailsPanel from './details/DetailsPanel';
import PushList from './pushes/PushList';
import KeyboardShortcuts from './KeyboardShortcuts';
import { store } from './redux/store';
import { CLEAR_EXPIRED_TRANSIENTS } from './redux/stores/notifications';
import { clearExpiredNotifications } 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 REVISION_POLL_INTERVAL = 1000 * 60 * 5;
@ -51,15 +68,13 @@ class App extends React.Component {
constructor(props) {
super(props);
const filterModel = new FilterModel();
// Set the URL to updated parameter styles, if needed. Otherwise it's a no-op.
filterModel.push();
const filterModel = new FilterModel(this.props);
const urlParams = getAllUrlParams();
const hasSelectedJob =
urlParams.has('selectedJob') || urlParams.has('selectedTaskRun');
this.state = {
repoName: getRepo(),
repoName: this.getOrSetRepo(),
revision: urlParams.get('revision'),
user: { isLoggedIn: false, isStaff: false },
filterModel,
@ -82,7 +97,6 @@ class App extends React.Component {
static getDerivedStateFromProps(props, state) {
return {
...App.getSplitterDimensions(state.hasSelectedJob),
repoName: getRepo(),
};
}
@ -103,7 +117,6 @@ class App extends React.Component {
});
window.addEventListener('resize', this.updateDimensions, false);
window.addEventListener('hashchange', this.handleUrlChanges, false);
window.addEventListener('storage', this.handleStorageEvent);
window.addEventListener(thEvents.filtersUpdated, this.handleFiltersUpdated);
@ -145,14 +158,25 @@ class App extends React.Component {
// clear expired notifications
this.notificationInterval = setInterval(() => {
store.dispatch({ type: CLEAR_EXPIRED_TRANSIENTS });
this.props.clearExpiredNotifications();
}, MAX_TRANSIENT_AGE);
}
componentDidUpdate(prevProps) {
if (
prevProps.router.location.search !== this.props.router.location.search
) {
this.handleUrlChanges();
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.updateDimensions, false);
window.removeEventListener('hashchange', this.handleUrlChanges, false);
window.removeEventListener('storage', this.handleUrlChanges, false);
window.removeEventListener('storage', this.handleStorageEvent);
window.removeEventListener(
thEvents.filtersUpdated,
this.handleFiltersUpdated,
);
if (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) => {
if (e.key === PUSH_HEALTH_VISIBILITY) {
this.setState({
@ -200,9 +251,7 @@ class App extends React.Component {
};
getAllShownJobs = (pushId) => {
const {
pushes: { jobMap },
} = store.getState();
const { jobMap } = this.props;
const jobList = Object.values(jobMap);
return pushId
@ -222,32 +271,32 @@ class App extends React.Component {
);
};
handleUrlChanges = (ev) => {
handleUrlChanges = () => {
const { repos } = this.state;
const { newURL, oldURL } = ev;
const urlParams = getAllUrlParams();
const newRepo = urlParams.get('repo');
// We only want to set state if any of these or the filter values have changed
const { router } = this.props;
const {
selectedJob,
selectedTaskRun,
group_state: groupState,
duplicate_jobs: duplicateJobs,
repo: newRepo,
} = parseQueryParams(router.location.search);
const newState = {
hasSelectedJob:
urlParams.has('selectedJob') || urlParams.has('selectedTaskRun'),
groupCountsExpanded: urlParams.get('group_state') === 'expanded',
duplicateJobsVisible: urlParams.get('duplicate_jobs') === 'visible',
hasSelectedJob: selectedJob || selectedTaskRun,
groupCountsExpanded: groupState === 'expanded',
duplicateJobsVisible: duplicateJobs === 'visible',
currentRepo: repos.find((repo) => repo.name === newRepo),
};
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)) {
this.setState(newState);
stateChanges = { ...stateChanges, ...newState };
}
};
handleFiltersUpdated = () => {
this.setState({ filterModel: new FilterModel() });
this.setState(stateChanges);
};
// If ``show`` is a boolean, then set to that value. If it's not, then toggle
@ -318,7 +367,6 @@ class App extends React.Component {
return (
<div id="global-container" className="height-minus-navbars">
<Provider store={store}>
<KeyboardShortcuts
filterModel={filterModel}
showOnScreenShortcuts={this.showOnScreenShortcuts}
@ -337,6 +385,7 @@ class App extends React.Component {
toggleFieldFilterVisible={this.toggleFieldFilterVisible}
pushHealthVisibility={pushHealthVisibility}
setPushHealthVisibility={this.setPushHealthVisibility}
{...this.props}
/>
<SplitPane
split="horizontal"
@ -369,6 +418,7 @@ class App extends React.Component {
groupCountsExpanded={groupCountsExpanded}
pushHealthVisibility={pushHealthVisibility}
getAllShownJobs={this.getAllShownJobs}
{...this.props}
/>
</span>
</div>
@ -395,10 +445,23 @@ class App extends React.Component {
<ShortcutTable />
</Modal>
</KeyboardShortcuts>
</Provider>
</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
// selectedJobFull data as transient only when the job is selected.
const selectedJobFull = jobResult;
const jobRevision = push.revision;
const jobRevision = push ? push.revision : null;
addAggregateFields(selectedJobFull);
@ -251,7 +251,7 @@ class DetailsPanel extends React.Component {
...d,
}))
.map((d) => ({
url: `/perf.html#/graphs?series=${[
url: `/perfherder/graphs?series=${[
currentRepo.name,
d.signature_id,
1,

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

@ -3,10 +3,13 @@ import PropTypes from 'prop-types';
import { Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
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';
export default class ActiveFilters extends React.Component {
class ActiveFilters extends React.Component {
constructor(props) {
super(props);
@ -70,8 +73,32 @@ export default class ActiveFilters extends React.Component {
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() {
const { isFieldFilterVisible, filterModel, filterBarFilters } = this.props;
const { isFieldFilterVisible, filterBarFilters } = this.props;
const {
newFilterField,
newFilterMatchType,
@ -89,7 +116,7 @@ export default class ActiveFilters extends React.Component {
outline
className="pointable bg-transparent border-0 pt-0 pr-1 pb-1"
title="Clear all of these filters"
onClick={filterModel.clearNonStatusFilters}
onClick={() => this.clearAndUpdateRange()}
>
<FontAwesomeIcon
icon={faTimesCircle}
@ -111,13 +138,13 @@ export default class ActiveFilters extends React.Component {
className="pointable bg-transparent border-0 py-0 pr-1"
title={`Clear filter: ${filter.field}`}
onClick={() =>
filterModel.removeFilter(filter.field, filterValue)
this.clearAndUpdateRange({
filterField: filter.field,
filterValue,
})
}
>
<FontAwesomeIcon
icon={faTimesCircle}
title={`Clear filter: ${filter.field}`}
/>
<FontAwesomeIcon icon={faTimesCircle} />
&nbsp;
</Button>
<span title={`Filter by ${filter.field}: ${filterValue}`}>
@ -235,4 +262,15 @@ ActiveFilters.propTypes = {
isFieldFilterVisible: PropTypes.bool.isRequired,
toggleFieldFilterVisible: PropTypes.func.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';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
import { thAllResultStatuses } from '../../helpers/constants';
import { getJobsUrl } from '../../helpers/url';
import { setSelectedJob, clearSelectedJob } from '../redux/stores/selectedJob';
import { pinJobs } from '../redux/stores/pinnedJobs';
@ -42,6 +42,12 @@ function FiltersMenu(props) {
};
const { email } = user;
const updateParams = (param, value) => {
const params = new URLSearchParams(window.location.search);
params.set(param, value);
return `?${params.toString()}`;
};
return (
<UncontrolledDropdown>
<DropdownToggle
@ -110,12 +116,13 @@ function FiltersMenu(props) {
>
Superseded only
</DropdownItem>
<DropdownItem
tag="a"
title={`Show only pushes for ${email}`}
href={getJobsUrl({ author: email })}
<DropdownItem title={`Show only pushes for ${email}`}>
<Link
className="dropdown-link"
to={{ search: updateParams('author', email) }}
>
My pushes only
</Link>
</DropdownItem>
<DropdownItem
tag="a"

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

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

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

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

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

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

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

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

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

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

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

@ -11,7 +11,11 @@ import {
} from '../../helpers/constants';
import decompress from '../../helpers/gzip';
import { getGroupMapKey } from '../../helpers/aggregateId';
import { getAllUrlParams, getUrlParam } from '../../helpers/location';
import {
getAllUrlParams,
getUrlParam,
setUrlParam,
} from '../../helpers/location';
import JobModel from '../../models/job';
import RunnableJobModel from '../../models/runnableJob';
import { getRevisionTitle } from '../../helpers/revision';
@ -122,17 +126,21 @@ class Push extends React.PureComponent {
this.testForFilteredTry();
window.addEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs);
window.addEventListener('hashchange', this.handleUrlChanges);
}
componentDidUpdate(prevProps, prevState) {
this.showUpdateNotifications(prevState);
this.testForFilteredTry();
if (
prevProps.router.location.search !== this.props.router.location.search
) {
this.handleUrlChanges();
}
}
componentWillUnmount() {
window.removeEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs);
window.removeEventListener('hashchange', this.handleUrlChanges);
}
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 = () => {
const { currentRepo } = this.props;
const filterParams = ['revision', 'author'];
@ -633,6 +665,7 @@ class Push extends React.PureComponent {
pushHealthVisibility={pushHealthVisibility}
groupCountsExpanded={groupCountsExpanded}
pushHealthStatusCallback={this.pushHealthStatusCallback}
togglePushCollapsed={this.togglePushCollapsed}
/>
<div className="push-body-divider" />
{!collapsed ? (
@ -713,10 +746,12 @@ Push.propTypes = {
const mapStateToProps = ({
pushes: { allUnclassifiedFailureCount, decisionTaskMap, bugSummaryMap },
router,
}) => ({
allUnclassifiedFailureCount,
decisionTaskMap,
bugSummaryMap,
router,
});
export default connect(mapStateToProps, {

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

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

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

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

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

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

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

@ -1,22 +1,32 @@
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import createDebounce from 'redux-debounce';
import { connectRouter, routerMiddleware } from 'connected-react-router';
import { createBrowserHistory } from 'history';
import * as selectedJobStore from './stores/selectedJob';
import * as notificationStore from './stores/notifications';
import * as pushesStore from './stores/pushes';
import * as pinnedJobsStore from './stores/pinnedJobs';
export default () => {
const debounceConfig = { nextJob: 200 };
const debouncer = createDebounce(debounceConfig);
const reducers = combineReducers({
const debouncer = createDebounce({ nextJob: 200 });
const reducers = (routerHistory) =>
combineReducers({
router: connectRouter(routerHistory),
notifications: notificationStore.reducer,
selectedJob: selectedJobStore.reducer,
pushes: pushesStore.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,
});
export const clearExpiredNotifications = () => ({
type: CLEAR_EXPIRED_TRANSIENTS,
});
// *** Implementation ***
const doNotify = (
{ notifications, storedNotifications },

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

@ -1,14 +1,10 @@
import pick from 'lodash/pick';
import keyBy from 'lodash/keyBy';
import max from 'lodash/max';
import { push as pushRoute } from 'connected-react-router';
import { parseQueryParams, bugzillaBugsApi } from '../../../helpers/url';
import {
getAllUrlParams,
getQueryString,
getUrlParam,
replaceLocation,
} from '../../../helpers/location';
import { getUrlParam, replaceLocation } from '../../../helpers/location';
import PushModel from '../../../models/push';
import { getTaskRunStr, isUnclassifiedFailure } from '../../../helpers/job';
import FilterModel from '../../../models/filter';
@ -96,8 +92,8 @@ const getLastModifiedJobTime = (jobMap) => {
* gives us the difference in unclassified failures and, of those jobs, the
* ones that have been filtered out
*/
const doRecalculateUnclassifiedCounts = (jobMap) => {
const filterModel = new FilterModel();
const doRecalculateUnclassifiedCounts = (jobMap, router) => {
const filterModel = new FilterModel({ pushRoute, router });
const tiers = filterModel.urlParams.tier;
let allUnclassifiedFailureCount = 0;
let filteredUnclassifiedFailureCount = 0;
@ -122,6 +118,7 @@ const addPushes = (
jobMap,
setFromchange,
dispatch,
router,
oldBugSummaryMap,
) => {
if (data.results.length > 0) {
@ -140,7 +137,7 @@ const addPushes = (
const newStuff = {
pushList: newPushList,
oldestPushTimestamp,
...doRecalculateUnclassifiedCounts(jobMap),
...doRecalculateUnclassifiedCounts(jobMap, router),
...getRevisionTips(newPushList),
};
@ -150,11 +147,11 @@ const addPushes = (
const updatedLastRevision = newPushList[newPushList.length - 1].revision;
if (setFromchange && getUrlParam('fromchange') !== updatedLastRevision) {
const params = getAllUrlParams();
const params = new URLSearchParams(window.location.search);
params.set('fromchange', updatedLastRevision);
replaceLocation(params);
// We are silently updating the url params, but we still want to
// update the ActiveFilters bar to this new change.
// We are silently updating the url params so we don't trigger an unnecessary update
// in componentDidUpdate, but we still want to update the ActiveFilters bar to this new change.
window.dispatchEvent(new CustomEvent(thEvents.filtersUpdated));
}
@ -252,13 +249,17 @@ export const fetchPushes = (
return async (dispatch, getState) => {
const {
pushes: { pushList, jobMap, oldestPushTimestamp },
router,
} = getState();
dispatch({ type: LOADING });
if (getUrlParam('selectedJob') || getUrlParam('selectedTaskRun')) {
dispatch(clearSelectedJob(0));
}
// Only pass supported query string params to this endpoint.
const options = {
...pick(parseQueryParams(getQueryString()), PUSH_FETCH_KEYS),
...pick(parseQueryParams(window.location.search), PUSH_FETCH_KEYS),
};
if (oldestPushTimestamp) {
@ -284,6 +285,7 @@ export const fetchPushes = (
jobMap,
setFromchange,
dispatch,
router,
),
});
}
@ -296,10 +298,11 @@ export const pollPushes = () => {
return async (dispatch, getState) => {
const {
pushes: { pushList, jobMap },
router,
} = getState();
// these params will be passed in each time we poll to remain
// within the constraints of the URL params
const locationSearch = parseQueryParams(getQueryString());
const locationSearch = parseQueryParams(window.location.search);
const pushPollingParams = PUSH_POLLING_KEYS.reduce(
(acc, prop) =>
locationSearch[prop] ? { ...acc, [prop]: locationSearch[prop] } : acc,
@ -330,6 +333,8 @@ export const pollPushes = () => {
pushList,
jobMap,
false,
dispatch,
router,
),
});
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 setPushes = (pushList, jobMap) => ({
export const setPushes = (pushList, jobMap, router) => ({
type: SET_PUSHES,
pushResults: {
pushList,
jobMap,
...getRevisionTips(pushList),
...doRecalculateUnclassifiedCounts(jobMap),
...doRecalculateUnclassifiedCounts(jobMap, router),
oldestPushTimestamp: pushList[pushList.length - 1].push_timestamp,
},
});
export const recalculateUnclassifiedCounts = (filterModel) => ({
export const recalculateUnclassifiedCounts = (filterModel) => {
return (dispatch, getState) => {
const { router } = getState();
return dispatch({
type: RECALCULATE_UNCLASSIFIED_COUNTS,
filterModel,
router,
});
};
};
export const updateJobMap = (jobList) => ({
type: UPDATE_JOB_MAP,
@ -393,6 +380,7 @@ export const updateRange = (range) => {
return (dispatch, getState) => {
const {
pushes: { pushList, jobMap },
router,
} = getState();
const { revision } = range;
// 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,
{},
);
if (getUrlParam('selectedJob') || getUrlParam('selectedTaskRun')) {
dispatch(clearSelectedJob(0));
}
// We already have the one revision they're looking for,
// so we can just erase everything else.
dispatch(setPushes(revisionPushList, revisionJobMap));
dispatch(setPushes(revisionPushList, revisionJobMap, router));
} else {
// Clear and refetch everything. We can't be sure if what we
// already have is partially correct and just needs fill-in.
@ -435,8 +425,9 @@ export const initialState = {
};
export const reducer = (state = initialState, action) => {
const { jobList, pushResults, setFromchange } = action;
const { jobList, pushResults, setFromchange, router } = action;
const { pushList, jobMap, decisionTaskMap } = state;
switch (action.type) {
case LOADING:
return { ...state, loadingPushes: true };
@ -449,7 +440,7 @@ export const reducer = (state = initialState, action) => {
case SET_PUSHES:
return { ...state, loadingPushes: false, ...pushResults };
case RECALCULATE_UNCLASSIFIED_COUNTS:
return { ...state, ...doRecalculateUnclassifiedCounts(jobMap) };
return { ...state, ...doRecalculateUnclassifiedCounts(jobMap, router) };
case UPDATE_JOB_MAP:
return {
...state,

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

@ -1,3 +1,5 @@
import { push as pushRoute } from 'connected-react-router';
import {
findGroupElement,
findGroupInstance,
@ -8,7 +10,11 @@ import {
scrollToElement,
} from '../../../helpers/job';
import { thJobNavSelectors } from '../../../helpers/constants';
import { getUrlParam, setUrlParam } from '../../../helpers/location';
import {
getUrlParam,
setUrlParam,
setUrlParams,
} from '../../../helpers/location';
import JobModel from '../../../models/job';
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 UPDATE_JOB_DETAILS = 'UPDATE_JOB_DETAILS';
export const setSelectedJob = (job, updateDetails = true) => ({
export const setSelectedJob = (job, updateDetails = true) => {
return async (dispatch) => {
dispatch({
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) => ({
type: SELECT_JOB_FROM_QUERY_STRING,
@ -29,27 +44,36 @@ export const setSelectedJobFromQueryString = (notify, jobMap) => ({
jobMap,
});
export const clearSelectedJob = (countPinnedJobs) => ({
export const clearSelectedJob = (countPinnedJobs) => {
return async (dispatch) => {
dispatch({
type: CLEAR_JOB,
countPinnedJobs,
});
const params = setUrlParams([
['selectedTaskRun', null],
['selectedJob', null],
]);
dispatch(pushRoute({ search: params }));
};
};
export const updateJobDetails = (job) => ({
export const updateJobDetails = (job) => {
return async (dispatch) => {
dispatch({
type: UPDATE_JOB_DETAILS,
job,
meta: {
debounce: 'nextJob',
},
});
const doUpdateJobDetails = (job) => {
const taskRun = job ? getTaskRunStr(job) : null;
setUrlParam('selectedTaskRun', taskRun);
return { selectedJob: job };
const params = setUrlParams([['selectedTaskRun', taskRun]]);
dispatch(pushRoute({ search: params }));
};
};
export const doSelectJob = (job, updateDetails) => {
export const doSelectJob = (job) => {
const selected = findSelectedInstance();
if (selected) selected.setSelected(false);
@ -71,9 +95,7 @@ export const doSelectJob = (job, updateDetails) => {
scrollToElement(groupEl);
}
}
if (updateDetails) {
return doUpdateJobDetails(job);
}
return { selectedJob: job };
};
@ -81,8 +103,7 @@ export const doClearSelectedJob = (countPinnedJobs) => {
if (!countPinnedJobs) {
const selected = findSelectedInstance();
if (selected) selected.setSelected(false);
setUrlParam('selectedTaskRun', null);
setUrlParam('selectedJob', null);
return { selectedJob: null };
}
return {};
@ -240,7 +261,7 @@ export const changeJob = (
if (jobInstance) {
// Delay updating details for the new job right away,
// 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) => {
const { job, jobMap, countPinnedJobs, updateDetails, notify } = action;
const { job, jobMap, countPinnedJobs, notify } = action;
switch (action.type) {
case SELECT_JOB:
return doSelectJob(job, updateDetails);
return doSelectJob(job);
case SELECT_JOB_FROM_QUERY_STRING:
return doSetSelectedJobFromQueryString(notify, jobMap);
case UPDATE_JOB_DETAILS:
return doUpdateJobDetails(job);
return { selectedJob: job };
case CLEAR_JOB:
return { ...state, ...doClearSelectedJob(countPinnedJobs) };
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 ErrorLines from './ErrorLines';
import '../css/lazylog-custom-styles.css';
import './logviewer.css';
const JOB_DETAILS_COLLAPSED = 'jobDetailsCollapsed';
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';
import { getAllUrlParams } from '../helpers/location';
export const getNonFilterUrlParams = () =>
[...getAllUrlParams().entries()].reduce(
export const getNonFilterUrlParams = (location) =>
[...getAllUrlParams(location).entries()].reduce(
(acc, [urlField, urlValue]) =>
allFilterParams.includes(urlField.replace(deprecatedThFilterPrefix, ''))
? 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.
// This handles the transition from our old url params to this newer, more
// terse version.
// Also remove usage of the 'filter-' prefix.
const groupedValues = [...getAllUrlParams().entries()].reduce(
const groupedValues = [...getAllUrlParams(location).entries()].reduce(
(acc, [urlField, urlValue]) => {
const field = urlField.replace(deprecatedThFilterPrefix, '');
if (!allFilterParams.includes(field)) {
@ -51,8 +51,11 @@ export const getFilterUrlParamsWithDefaults = () => {
};
export default class FilterModel {
constructor() {
this.urlParams = getFilterUrlParamsWithDefaults();
constructor(props) {
// 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.
@ -60,7 +63,7 @@ export default class FilterModel {
// ensure the repo param is always set
const params = {
repo: thDefaultRepo,
...getNonFilterUrlParams(),
...getNonFilterUrlParams(this.location),
...this.urlParams,
};
@ -86,7 +89,7 @@ export default class FilterModel {
} else {
this.urlParams[field] = [value];
}
this.push();
this.push({ search: this.getFilterQueryString() });
};
// Also used for non-filter params
@ -99,29 +102,24 @@ export default class FilterModel {
(filterValue) => filterValue !== value,
);
}
if (!this.urlParams[field].length) {
delete this.urlParams[field];
}
} else {
delete this.urlParams[field];
}
this.push();
this.push({ search: this.getFilterQueryString() });
};
getFilterQueryString = () =>
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 = () => {
this.urlParams.resultStatus = 'superseded';
this.urlParams.classifiedState = [...thFilterDefaults.classifiedState];
this.push();
this.push({ search: this.getFilterQueryString() });
};
toggleFilter = (field, value) => {
@ -148,7 +146,7 @@ export default class FilterModel {
? currentResultStatuses.filter((rs) => !resultStatuses.includes(rs))
: [...new Set([...resultStatuses, ...currentResultStatuses])];
this.push();
this.push({ search: this.getFilterQueryString() });
};
toggleClassifiedFilter = (classifiedState) => {
@ -161,20 +159,20 @@ export default class FilterModel {
} else {
this.urlParams.resultStatus = [...thFailureResults];
this.urlParams.classifiedState = ['unclassified'];
this.push();
this.push({ search: this.getFilterQueryString() });
}
};
replaceFilter = (field, value) => {
this.urlParams[field] = !Array.isArray(value) ? [value] : value;
this.push();
this.push({ search: this.getFilterQueryString() });
};
clearNonStatusFilters = () => {
const { repo, resultStatus, classifiedState } = this.urlParams;
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.classifiedState = [...classifiedState];
this.push();
this.push({ search: this.getFilterQueryString() });
};
/**

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

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

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

@ -1,5 +1,5 @@
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 { Container } from 'reactstrap';
@ -18,6 +18,9 @@ import CompareSubtestsView from './compare/CompareSubtestsView';
import CompareSubtestDistributionView from './compare/CompareSubtestDistributionView';
import Navigation from './Navigation';
import 'react-table/react-table.css';
import '../css/perf.css';
class App extends React.Component {
constructor(props) {
super(props);
@ -66,9 +69,10 @@ class App extends React.Component {
errorMessages,
compareData,
} = this.state;
const { path } = this.props.match;
return (
<HashRouter>
<React.Fragment>
<Navigation
user={user}
setUser={(user) => this.setState({ user })}
@ -86,7 +90,7 @@ class App extends React.Component {
<Switch>
<Route
exact
path="/alerts"
path={`${path}/alerts`}
render={(props) => (
<AlertsView
{...props}
@ -98,7 +102,7 @@ class App extends React.Component {
)}
/>
<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) => (
<AlertsView
{...props}
@ -110,7 +114,7 @@ class App extends React.Component {
)}
/>
<Route
path="/graphs"
path={`${path}/graphs`}
render={(props) => (
<GraphsView
{...props}
@ -121,7 +125,7 @@ class App extends React.Component {
)}
/>
<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) => (
<GraphsView
{...props}
@ -132,7 +136,7 @@ class App extends React.Component {
)}
/>
<Route
path="/comparechooser"
path={`${path}/comparechooser`}
render={(props) => (
<CompareSelectorView
{...props}
@ -143,7 +147,7 @@ class App extends React.Component {
)}
/>
<Route
path="/comparechooser?originalProject=:originalProject&originalRevision=:originalRevision&newProject=:newProject&newRevision=:newRevision"
path={`${path}/comparechooser?originalProject=:originalProject&originalRevision=:originalRevision&newProject=:newProject&newRevision=:newRevision`}
render={(props) => (
<CompareSelectorView
{...props}
@ -154,7 +158,7 @@ class App extends React.Component {
)}
/>
<Route
path="/compare"
path={`${path}/compare`}
render={(props) => (
<CompareView
{...props}
@ -167,7 +171,7 @@ class App extends React.Component {
)}
/>
<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) => (
<CompareView
{...props}
@ -180,7 +184,7 @@ class App extends React.Component {
)}
/>
<Route
path="/infracompare"
path={`${path}/infracompare`}
render={(props) => (
<InfraCompareView
{...props}
@ -193,7 +197,7 @@ class App extends React.Component {
)}
/>
<Route
path="/comparesubtest"
path={`${path}/comparesubtest`}
render={(props) => (
<CompareSubtestsView
{...props}
@ -204,7 +208,7 @@ class App extends React.Component {
)}
/>
<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) => (
<CompareSubtestsView
{...props}
@ -215,7 +219,7 @@ class App extends React.Component {
)}
/>
<Route
path="/comparesubtestdistribution"
path={`${path}/comparesubtestdistribution`}
render={(props) => (
<CompareSubtestDistributionView
{...props}
@ -226,7 +230,7 @@ class App extends React.Component {
)}
/>
<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) => (
<CompareSubtestDistributionView
{...props}
@ -237,7 +241,7 @@ class App extends React.Component {
)}
/>
<Route
path="/tests"
path={`${path}/tests`}
render={(props) => (
<TestsView
{...props}
@ -249,7 +253,7 @@ class App extends React.Component {
)}
/>
<Route
path="/tests?framework=:framework"
path={`${path}/tests?framework=:framework"`}
render={(props) => (
<TestsView
{...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>
</main>
)}
</HashRouter>
</React.Fragment>
);
}
}

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

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

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

@ -2,11 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Container } from 'reactstrap';
import {
parseQueryParams,
createQueryParams,
updateQueryParams,
} from '../helpers/url';
import { parseQueryParams, createQueryParams } from '../helpers/url';
import PushModel from '../models/push';
import ErrorMessages from '../shared/ErrorMessages';
import LoadingSpinner from '../shared/LoadingSpinner';
@ -37,30 +33,29 @@ const withValidation = ({ requiredParams }, verifyRevisions = true) => (
}
async componentDidMount() {
this.validateParams(parseQueryParams(this.props.location.search));
}
shouldComponentUpdate(prevProps) {
const { location } = this.props;
return location.hash === prevProps.location.hash;
this.validateParams(parseQueryParams(this.props.history.location.search));
}
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
this.validateParams(parseQueryParams(location.search));
this.validateParams(parseQueryParams(history.location.search));
}
}
updateParams = (params) => {
const { location, history } = this.props;
const newParams = { ...parseQueryParams(location.search), ...params };
const queryString = createQueryParams(newParams);
const { history, location } = this.props;
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`;
@ -204,6 +199,7 @@ const withValidation = ({ requiredParams }, verifyRevisions = true) => (
Validation.propTypes = {
location: PropTypes.shape({}).isRequired,
history: PropTypes.shape({}).isRequired,
};
return Validation;

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

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

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

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

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

@ -32,10 +32,10 @@ class AlertsView extends React.Component {
constructor(props) {
super(props);
const { frameworks, validated } = this.props;
const extendedOptions = this.extendDropdownOptions(frameworks);
this.extendedOptions = this.extendDropdownOptions(frameworks);
this.state = {
filters: this.getFiltersFromParams(validated, extendedOptions),
frameworkOptions: extendedOptions,
filters: this.getFiltersFromParams(validated),
frameworkOptions: this.extendedOptions,
page: validated.page ? parseInt(validated.page, 10) : 1,
errorMessages: [],
alertSummaries: [],
@ -55,43 +55,30 @@ class AlertsView extends React.Component {
}
componentDidUpdate(prevProps, prevState) {
const { count, frameworkOptions } = this.state;
const { validated } = this.props;
const prevValitated = prevProps.validated;
const { count } = this.state;
if (prevState.count !== 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 prevParams = parseQueryParams(prevProps.location.search);
// 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 Validation component won't reset the id (since the query param doesn't exist
// unless there is a value)
if (this.props.location.search !== prevProps.location.search) {
this.setState({ id: params.id || null }, this.fetchAlertSummaries);
if (params.id) {
validated.updateParams({ hideDwnToInv: 0 });
}
if (params.id !== prevParams.id) {
this.setState(
{ id: params.id || null, filters: this.getFiltersFromParams(params) },
this.fetchAlertSummaries,
);
}
}
getFiltersFromParams = (validated, frameworkOptions) => {
getFiltersFromParams = (
validated,
frameworkOptions = this.extendedOptions,
) => {
return {
status: this.getDefaultStatus(),
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)
this.setState({ loading: !update, errorMessages: [] });
const { user } = this.props;

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

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

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

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

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

@ -70,7 +70,7 @@ export default class StatusDropdown extends React.Component {
framework: getFrameworkName(frameworks, alertSummary.framework),
revision: 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),
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -312,7 +312,7 @@ export const getGraphsLink = function getGraphsLink(
params.timerange = timeRange;
}
return `perf.html#/graphs?${queryString.stringify(params)}`;
return `./graphs?${queryString.stringify(params)}`;
};
export const createNoiseMetric = function createNoiseMetric(
@ -363,7 +363,7 @@ export const createGraphsLinks = (
links.push({
title: 'graph',
href: graphsLink,
to: graphsLink,
});
return links;
@ -385,7 +385,7 @@ export const getGraphsURL = (
alertRepository,
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
// 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
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}`;
}
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 { 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 Health from './Health';
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) {
const params = new URLSearchParams(search);
@ -14,13 +21,11 @@ function hasProps(search) {
const App = () => {
return (
<BrowserRouter>
<div>
<div>
<Switch>
<Route
exact
path="/pushhealth.html"
path="/"
render={(props) =>
hasProps(props.location.search) ? (
<Health {...props} />
@ -33,7 +38,6 @@ const App = () => {
</Switch>
</div>
</div>
</BrowserRouter>
);
};

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

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

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

@ -4,8 +4,6 @@ import { render } from 'react-dom';
// Treeherder Styles
import '../css/failure-summary.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-notifications.css';
import './pushhealth.css';

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

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

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

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

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

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

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

@ -6,12 +6,13 @@ import {
DropdownMenu,
DropdownItem,
} from 'reactstrap';
import { Link } from 'react-router-dom';
const choices = [
{ url: '/', text: 'Treeherder' },
{ url: '/perf.html', text: 'Perfherder' },
{ url: '/intermittent-failures.html', text: 'Intermittent Failures View' },
{ url: '/pushhealth.html', text: 'Push Health Usage' },
{ url: '/jobs', text: 'Treeherder' },
{ url: '/perfherder', text: 'Perfherder' },
{ url: '/intermittent-failures', text: 'Intermittent Failures View' },
{ url: '/push-health', text: 'Push Health Usage' },
];
export default class LogoMenu extends React.PureComponent {
@ -35,8 +36,8 @@ export default class LogoMenu extends React.PureComponent {
</DropdownToggle>
<DropdownMenu>
{menuChoices.map((choice) => (
<DropdownItem key={choice.text} tag="a" href={choice.url}>
{choice.text}
<DropdownItem key={choice.text}>
<Link to={choice.url}>{choice.text}</Link>
</DropdownItem>
))}
</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,
placement,
textClass,
tooltipClass,
innerClassName,
autohide,
} = this.props;
@ -26,7 +26,7 @@ export default class SimpleTooltip extends React.Component {
<UncontrolledTooltip
placement={placement}
target={this.tooltipRef}
innerClassName={tooltipClass}
innerClassName={innerClassName}
autohide={autohide}
>
{tooltipText}
@ -41,13 +41,13 @@ SimpleTooltip.propTypes = {
.isRequired,
textClass: PropTypes.string,
placement: PropTypes.string,
tooltipClass: PropTypes.string,
innerClassName: PropTypes.string,
autohide: PropTypes.bool,
};
SimpleTooltip.defaultProps = {
textClass: '',
placement: 'top',
tooltipClass: '',
innerClassName: '',
autohide: true,
};

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

@ -10,7 +10,9 @@ export const tcClientIdMap = {
'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}`;
@ -26,7 +28,7 @@ export const checkRootUrl = (rootUrl) => {
// and the default login rootUrls are for https://firefox-ci-tc.services.mozilla.com
if (
rootUrl === prodFirefoxRootUrl &&
clientId === 'treeherder-taskcluster-staging'
clientId === 'treeherder-taskcluster-staging-client'
) {
return stagingFirefoxRootUrl;
}

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

@ -7,6 +7,10 @@ import UserGuideHeader from './UserGuideHeader';
import UserGuideBody from './UserGuideBody';
import UserGuideFooter from './UserGuideFooter';
import '../css/treeherder-userguide.css';
import '../css/treeherder-job-buttons.css';
import '../css/treeherder-base.css';
const App = () => (
<div id="userguide">
<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"
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"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
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"
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:
version "1.2.0"
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"
integrity sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==
history@5.0.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:
history@4.10.1, history@^4.9.0:
version "4.10.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==