Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0f43926
Add constants and helpers for the advanced filter panel
camd Aug 16, 2026
4a4d7fa
Add FilterPill and StatusSection for advanced filter panel
camd Aug 16, 2026
09c8a23
Add tier/classification section to advanced filter panel
camd Aug 16, 2026
0e6965c
Add field filter section with jobMap typeahead
camd Aug 16, 2026
f9d1cbb
Make per-row datalist ids unique in FieldFilterSection
camd Aug 16, 2026
2867c68
Add staged push range section (dates, author, revision)
camd Aug 16, 2026
83a59c4
Add saved filter presets section
camd Aug 16, 2026
4ba412d
Add advanced filter panel popover shell and styles
camd Aug 16, 2026
7c592c1
Wire advanced filter panel trigger with count badge into toolbar
camd Aug 16, 2026
13bca81
Add shift+f shortcut for the advanced filter panel
camd Aug 16, 2026
7f32787
Add one-time coach mark for the advanced filter panel
camd Aug 16, 2026
cc8ea05
Slim ActiveFilters to chips-only; filter editing lives in the panel
camd Aug 16, 2026
414fdde
Apply lint autofixes
camd Aug 16, 2026
3861cbf
Fix panel open/close wiring, focus management, and a11y polish
camd Aug 16, 2026
0148109
Refine filter panel colors, rename to Advanced Filters, add nav menu …
camd Aug 16, 2026
d9b813e
Polish filter panel frame: thicker border, 12px font, standard close …
camd Aug 16, 2026
5df3005
Reorder filter panel sections: push range first, result status last
camd Aug 16, 2026
8384677
Use secondary-toolbar slate for the filter panel header
camd Aug 16, 2026
86cc77d
Add author/revision autocomplete and card-style panel sections
camd Aug 16, 2026
b14cdf9
Vertically center filter pill labels
camd Aug 16, 2026
9659e6e
Push range UX: dirty-state Apply, Enter-to-apply, input clear buttons…
camd Aug 16, 2026
6186f8b
Optically center pill labels with asymmetric vertical padding
camd Aug 16, 2026
f51a3ed
Convert the advanced filter panel from a popover to an in-flow banner
camd Aug 18, 2026
fc9ed16
Cap the jobs view at 500 downloaded pushes regardless of filters
camd Aug 18, 2026
2a024e1
Move Active Filters above the filter banner and match header colors
camd Aug 18, 2026
82a4566
Restyle the filter banner: darker blue field, centered title, top border
camd Aug 18, 2026
8f58b4d
Fetch the newest pushes in a range up to the 500-push ceiling at once
camd Aug 18, 2026
40b54ab
Fix runtime errors when hundreds of pushes load at once
camd Aug 18, 2026
e89c0b5
Filter banner polish: drop trigger badge, center Clear all, darker pe…
camd Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tests/ui/helpers/http.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createTaskLimiter } from '../../../ui/helpers/http';

describe('createTaskLimiter', () => {
test('runs at most `limit` tasks concurrently and completes them all', async () => {
const limiter = createTaskLimiter(2);
let active = 0;
let maxActive = 0;

const makeTask = (result) => () => {
active++;
maxActive = Math.max(maxActive, active);
return new Promise((resolve) => {
setTimeout(() => {
active--;
resolve(result);
}, 5);
});
};

const results = await Promise.all(
[1, 2, 3, 4, 5].map((n) => limiter(makeTask(n))),
);

expect(results).toEqual([1, 2, 3, 4, 5]);
expect(maxActive).toBe(2);
});

test('keeps processing the queue after a task rejects', async () => {
const limiter = createTaskLimiter(1);
const failing = limiter(() => Promise.reject(new Error('boom')));
const following = limiter(() => Promise.resolve('ok'));

await expect(failing).rejects.toThrow('boom');
await expect(following).resolves.toBe('ok');
});
});
107 changes: 106 additions & 1 deletion tests/ui/job-view/SecondaryNavBar_test.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

import { createRef, useRef, useState } from 'react';
import fetchMock from 'fetch-mock';
import { render, waitFor, fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

import FilterModel from '../../../ui/models/filter';
import SecondaryNavBar from '../../../ui/job-view/headerbars/SecondaryNavBar';
import AdvancedFilterPanel from '../../../ui/job-view/headerbars/filter-panel/AdvancedFilterPanel';
import {
usePushesStore,
initialState,
Expand All @@ -27,6 +29,9 @@ beforeEach(() => {
},
},
);
// The advanced filter panel's push-range section fetches pushes for its
// author/revision autocomplete when the panel mounts.
fetchMock.get('begin:/api/project/autoland/push/', { results: [] });
});

afterEach(() => {
Expand All @@ -38,6 +43,10 @@ afterEach(() => {
usePushesStore.setState({ ...initialState });
});

afterEach(() => {
localStorage.clear();
});

describe('SecondaryNavBar', () => {
const testSecondaryNavBar = (props) => {
return (
Expand All @@ -50,7 +59,9 @@ describe('SecondaryNavBar', () => {
setCurrentRepoTreeStatus={() => {}}
duplicateJobsVisible={false}
groupCountsExpanded={false}
toggleFieldFilterVisible={() => {}}
isFilterPanelOpen={false}
toggleFilterPanel={() => {}}
filterTriggerRef={createRef()}
{...props}
/>
</MemoryRouter>
Expand Down Expand Up @@ -116,4 +127,98 @@ describe('SecondaryNavBar', () => {
expect(props.updateButtonClick).toHaveBeenCalled();
});
});

test('shows the advanced filter trigger and opens panel on click', async () => {
usePushesStore.setState({ ...initialState });
const toggleFilterPanel = jest.fn();
render(testSecondaryNavBar({ toggleFilterPanel }));

await waitFor(() => {
expect(screen.getByText(repoName)).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Advanced filters'));
expect(toggleFilterPanel).toHaveBeenCalled();
});

test('shows the coach mark on first render and dismisses forever', async () => {
usePushesStore.setState({ ...initialState });
render(testSecondaryNavBar());

await waitFor(() => {
expect(screen.getByText(/New: advanced filters/)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
expect(screen.queryByText(/New: advanced filters/)).not.toBeInTheDocument();
expect(localStorage.getItem('thAdvancedFilterCoachMarkSeen')).toBeTruthy();
});

test('does not show the coach mark once seen', async () => {
localStorage.setItem('thAdvancedFilterCoachMarkSeen', '1');
usePushesStore.setState({ ...initialState });
render(testSecondaryNavBar());

await waitFor(() => {
expect(screen.getByText(repoName)).toBeInTheDocument();
});
expect(screen.queryByText(/New: advanced filters/)).not.toBeInTheDocument();
});

// The trigger in the toolbar and the banner panel are wired together
// through App state; clicking the trigger toggles the banner open and
// closed.
test('clicking the trigger again while the panel is open closes it', async () => {
usePushesStore.setState({ ...initialState });

const StatefulJobView = () => {
const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
const toggleFilterPanel = () => setIsFilterPanelOpen((prev) => !prev);
const filterTriggerRef = useRef(null);
const filterModel = useRef(
new FilterModel(mockNavigate, mockLocation),
).current;

return (
<MemoryRouter initialEntries={[`/jobs?repo=${repoName}`]}>
<SecondaryNavBar
updateButtonClick={() => {}}
serverChanged={false}
filterModel={filterModel}
repos={repos}
setCurrentRepoTreeStatus={() => {}}
duplicateJobsVisible={false}
groupCountsExpanded={false}
isFilterPanelOpen={isFilterPanelOpen}
toggleFilterPanel={toggleFilterPanel}
filterTriggerRef={filterTriggerRef}
/>
<AdvancedFilterPanel
isOpen={isFilterPanelOpen}
onClose={toggleFilterPanel}
target={filterTriggerRef}
filterModel={filterModel}
classificationTypes={[]}
/>
</MemoryRouter>
);
};

render(<StatefulJobView />);

await waitFor(() => {
expect(screen.getByText(repoName)).toBeInTheDocument();
});

const trigger = screen.getByLabelText('Advanced filters');

fireEvent.click(trigger);
await waitFor(() => {
expect(screen.getByText('Result status')).toBeInTheDocument();
});

fireEvent.click(trigger);

await waitFor(() => {
expect(screen.queryByText('Result status')).not.toBeInTheDocument();
});
});
});
21 changes: 21 additions & 0 deletions tests/ui/job-view/headerbars/FiltersMenu.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,27 @@ describe('FiltersMenu', () => {
expect(mockFilterModel.resetNonFieldFilters).toHaveBeenCalled();
});

it('calls openFilterPanel when "Advanced Filters…" is clicked', () => {
const mockOpenFilterPanel = jest.fn();

renderWithRouter(
<FiltersMenu
filterModel={mockFilterModel}
getAllShownJobs={mockGetAllShownJobs}
user={mockUser}
openFilterPanel={mockOpenFilterPanel}
/>,
);

// Open the dropdown
fireEvent.click(screen.getByText('Filters'));

// Click on "Advanced Filters…"
fireEvent.click(screen.getByText('Advanced Filters…'));

expect(mockOpenFilterPanel).toHaveBeenCalled();
});

it('renders My pushes only option', () => {
renderWithRouter(
<FiltersMenu
Expand Down
90 changes: 90 additions & 0 deletions tests/ui/job-view/headerbars/PrimaryNavBar_test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useRef, useState } from 'react';
import fetchMock from 'fetch-mock';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

import PrimaryNavBar from '../../../../ui/job-view/headerbars/PrimaryNavBar';
import FilterModel from '../../../../ui/models/filter';
import {
usePushesStore,
initialState,
} from '../../../../ui/shared/stores/pushesStore';
import repos from '../../mock/repositories';

const repoName = 'autoland';
const mockLocation = { search: `?repo=${repoName}`, pathname: '/jobs' };
const mockNavigate = jest.fn();

beforeEach(() => {
fetchMock.get(
'https://treestatus.prod.lando.prod.cloudops.mozgcp.net/trees/firefox-autoland',
{
result: {
message_of_the_day: '',
reason: '',
status: 'open',
tree: 'firefox-autoland',
},
},
);
fetchMock.get('/api/user/', []);
});

afterEach(() => {
fetchMock.reset();
mockNavigate.mockClear();
usePushesStore.setState({ ...initialState });
localStorage.clear();
});

// Regression test for the PrimaryNavBar's React.memo comparator dropping
// updates to isFilterPanelOpen. Without that prop in the comparator,
// toggling the panel from App never re-renders the memoized navbar chain,
// so the trigger's aria-expanded would never reflect the new state.
describe('PrimaryNavBar', () => {
const StatefulPrimaryNavBar = () => {
const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
const toggleFilterPanel = () => setIsFilterPanelOpen((prev) => !prev);
const filterTriggerRef = useRef(null);

return (
<MemoryRouter initialEntries={[`/jobs?repo=${repoName}`]}>
<PrimaryNavBar
repos={repos}
updateButtonClick={() => {}}
serverChanged={false}
filterModel={new FilterModel(mockNavigate, mockLocation)}
setUser={() => {}}
user={{ isLoggedIn: false }}
setCurrentRepoTreeStatus={() => {}}
getAllShownJobs={() => []}
duplicateJobsVisible={false}
groupCountsExpanded={false}
isFilterPanelOpen={isFilterPanelOpen}
toggleFilterPanel={toggleFilterPanel}
filterTriggerRef={filterTriggerRef}
/>
</MemoryRouter>
);
};

test('re-renders the memoized navbar chain when the filter panel toggles', async () => {
usePushesStore.setState({ ...initialState });
render(<StatefulPrimaryNavBar />);

const trigger = await screen.findByLabelText('Advanced filters');
expect(trigger).toHaveAttribute('aria-expanded', 'false');

fireEvent.click(trigger);

await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});

fireEvent.click(trigger);

await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
});
});
Loading