Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5eaca21
Add PR jail for people with too many open PRs
jarstelfox Oct 9, 2026
8a608d0
Keep PR jail's list live while the cell is down
jarstelfox Oct 9, 2026
3da4549
Group PR jail's list so the way out is on top
jarstelfox Oct 9, 2026
8f71dd2
Make PR jail a plain modal you can turn off
jarstelfox Oct 9, 2026
b860416
Split PR jail's switch into Automatic, Manual and Off
jarstelfox Oct 9, 2026
250442c
Snooze PR jail by holding a key, and count down to free
jarstelfox Oct 9, 2026
35c48d8
Open PR jail's lock, and give its list room to read
jarstelfox Oct 9, 2026
38d1651
Rework PR jail into one clear button and the board's own look
jarstelfox Oct 9, 2026
3c9fe06
Fix PR jail's exits, preview and memory
jarstelfox Oct 10, 2026
af6f636
Merge branch 'projectdasher' into pr-jail
jarstelfox Oct 10, 2026
b855563
Close two ways around PR jail's hold
jarstelfox Oct 10, 2026
c056103
Merge branch 'projectdasher' into pr-jail
jarstelfox Oct 10, 2026
20134d0
Don't pop PR jail in two windows at once
jarstelfox Oct 10, 2026
ca13efb
Merge branch 'projectdasher' into pr-jail
jarstelfox Oct 10, 2026
b9e10dd
Keep PR jail's 4-hour wait, and board keys behind modals
jarstelfox Oct 10, 2026
cc4b723
Merge branch 'projectdasher' into pr-jail
jarstelfox Oct 10, 2026
455497a
Hold PR jail while a dialog is up or a drag is under way
jarstelfox Oct 10, 2026
715c617
Merge branch 'projectdasher' into pr-jail
jarstelfox Oct 10, 2026
8e89b17
Keep PR jail's drag check from sticking or missing a drag
jarstelfox Oct 10, 2026
f5fe2e6
Let a cancelled touch clear PR jail's press flag again
jarstelfox Oct 10, 2026
d2d32e6
Merge branch 'projectdasher' into pr-jail
jarstelfox Oct 10, 2026
a17d1ab
End PR jail's drag check on a drop, not only dragend
jarstelfox Oct 10, 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
2 changes: 2 additions & 0 deletions frontend-v2/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { Stats } from './views/Stats';
import type { ProjectsNav } from './views/Projects';
import { Search } from './views/Search';
import { Settings } from './components/Settings';
import { JailMode } from './components/JailMode';

export type { Lens };

Expand Down Expand Up @@ -1172,6 +1173,7 @@ export function App() {
>
v1 board
</a>
<JailMode pulls={pulls} me={me} extraBots={extraBots} initialized={initialized} />
<NotificationPanel
records={liveHistory}
onClear={clearHistory}
Expand Down
305 changes: 305 additions & 0 deletions frontend-v2/src/components/JailMode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { derive, type DerivedPull } from '../../../shared/model/status';
import type { PullData } from '../../../shared/types';
import { setSettings } from '../settings';
import { JailMode } from './JailMode';

// React's act() warns unless the environment says it's a test
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

let n = 0;
/** One of my open PRs, run through the real derive; `ready` waives CR and
* QA so it can merge now, otherwise it waits on CR. */
const pull = (ageDays = 1, { ready = false } = {}): DerivedPull => {
n += 1;
const at = new Date(Date.now() - ageDays * 86400_000 - 60_000).toISOString();
const data: PullData = {
repo: 'iFixit/ifixit',
number: n,
state: 'open',
title: `PR ${n}`,
body: '',
draft: false,
created_at: at,
updated_at: at,
closed_at: null,
merged_at: null,
mergeable: true,
difficulty: null,
additions: 10,
deletions: 5,
changed_files: 2,
milestone: { title: null, due_on: null },
head: { ref: 'branch', sha: 'abc', repo: { owner: { login: 'iFixit' } } },
base: { ref: 'master' },
user: { login: 'me' },
status: {
cr_req: ready ? 0 : 1,
qa_req: ready ? 0 : 1,
allCR: [],
allQA: [],
dev_block: [],
deploy_block: [],
commit_statuses: [],
},
labels: [],
participants: [],
};
return derive(data, undefined);
};
const many = (count: number) => Array.from({ length: count }, () => pull());

let root: Root | null = null;
const host = document.createElement('div');
document.body.append(host);
afterEach(() => {
act(() => root?.unmount());
root = null;
localStorage.clear();
setSettings({ jailMode: 'auto' });
vi.useRealTimers();
});

function render(pulls: DerivedPull[]) {
const r = (root ??= createRoot(host));
act(() => r.render(<JailMode pulls={pulls} me="me" extraBots={new Set()} initialized />));
}

const why = () => document.getElementById('jail-why')?.textContent;
const listed = () => document.querySelectorAll('[role="dialog"] li').length;
const dialog = () => document.querySelector('[role="dialog"]');
const countdown = () => document.querySelector('#jail-title span')?.textContent;
const headers = () => [...document.querySelectorAll('[role="dialog"] h3')].map(h => h.textContent);

describe('JailMode while the cell is down', () => {
it('follows new PRs and merges live', () => {
const first = many(8);
render(first);
expect(why()).toMatch(/^8 open PRs/);
expect(listed()).toBe(8);

// a new PR arrives over the socket
render([...first, pull()]);
expect(why()).toMatch(/^9 open PRs/);
expect(listed()).toBe(9);

expect(countdown()).toBe('2 to go');

// merges bring you under the limits: it says so, then lets you go
vi.useFakeTimers();
render(first.slice(0, 3));
expect(why()).toBe('You’re out. Nice work.');
expect(countdown()).toBe('Free');
expect(listed()).toBe(0);
act(() => vi.advanceTimersByTime(2500));
expect(dialog()).toBeNull();
});

it('lifts ready PRs to the top and counts them in the why line', () => {
const ready = pull(2, { ready: true });
render([...many(7), ready]);
expect(headers()).toEqual(['Ready to merge', 'Waiting on others']);
expect(document.querySelector('[role="dialog"] li a')?.textContent).toContain(
ready.data.title
);
expect(why()).toBe('8 open PRs. Parole at 7. Merge your ready one and you’re out.');
});

it('only shows the badge when PR jail is manual', () => {
setSettings({ jailMode: 'manual' });
render(many(9));
expect(document.querySelector('[role="dialog"]')).toBeNull();
const badge = document.querySelector<HTMLButtonElement>('[aria-label^="PR jail"]');
expect(badge).not.toBeNull();
act(() => badge?.click());
expect(why()).toMatch(/^9 open PRs/);
});

it('stays away, badge and all, when PR jail is off', () => {
setSettings({ jailMode: 'off' });
render(many(9));
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[aria-label^="PR jail"]')).toBeNull();
});

it('lets you out only when the button is held down, by key or pointer', () => {
vi.useFakeTimers();
render(many(8));
const snooze = [
...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button'),
].find(b => b.textContent?.includes('Hold to get back to work'));
// it has focus from the start, so the keyboard can hold it right away
expect(document.activeElement).toBe(snooze);
const key = (type: string, k = 'Enter') =>
act(() => snooze?.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true })));
// a tap of Enter does nothing but say to hold it; neither does Esc
key('keydown');
act(() => vi.advanceTimersByTime(200));
key('keyup');
expect(snooze?.textContent).toBe('Hold it down');
act(() => vi.advanceTimersByTime(3000));
act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })));
expect(dialog()).not.toBeNull();
// holding Space the full two seconds turns the key, then it opens
key('keydown', ' ');
act(() => vi.advanceTimersByTime(2000));
act(() => vi.advanceTimersByTime(700));
expect(dialog()).toBeNull();
});

it('slides a merged PR out of the list as the countdown ticks', () => {
vi.useFakeTimers();
const nine = many(9);
render(nine);
expect(countdown()).toBe('2 to go');
render(nine.slice(1));
expect(countdown()).toBe('1 to go');
// still there for the slide, then gone
expect(document.querySelectorAll('.jail-row-leave')).toHaveLength(1);
expect(listed()).toBe(9);
act(() => vi.advanceTimersByTime(450));
expect(listed()).toBe(8);
});

it('copies a nudge listing the PRs waiting on others', async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.assign(navigator, { clipboard: { writeText } });
const first = pull(3);
render([first, ...many(7)]);
const copy = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find(
b => b.textContent === 'Copy a nudge'
);
await act(async () => copy?.click());
const text = (writeText.mock.calls[0] as unknown as [string])[0];
expect(text.split('\n')).toHaveLength(9);
expect(text).toContain(
`• ${first.data.title} https://github.com/iFixit/ifixit/pull/${first.data.number}`
);
expect(copy?.textContent).toBe('Copied');
});

it('lets a screen reader or voice control out with a lone click', () => {
render(many(8));
const hold = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find(
b => b.textContent?.includes('Hold to get back to work')
);
// a mouse click carries detail 1 and does nothing
act(() => hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })));
expect(dialog()).not.toBeNull();
// assistive tech sends a click with no pointer or key behind it
act(() => hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 })));
expect(dialog()).toBeNull();
});

it('keeps Esc from reaching anything behind it', () => {
render(many(8));
let reached = false;
const behind = (e: KeyboardEvent) => {
if (!e.defaultPrevented) reached = true;
};
document.addEventListener('keydown', behind);
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
);
});
document.removeEventListener('keydown', behind);
expect(reached).toBe(false);
expect(dialog()).not.toBeNull();
});

it('zeroes the last showing once you are under the limits, but keeps its time', () => {
vi.useFakeTimers();
const nine = many(9);
render(nine);
const shownAt = JSON.parse(localStorage.getItem('pd2.jail') ?? 'null').at;
render(nine.slice(0, 3));
expect(JSON.parse(localStorage.getItem('pd2.jail') ?? 'null')).toEqual({
at: shownAt,
count: 0,
over: [],
});
});

it('going back over within 4 hours waits out the rest of them', () => {
const nine = many(9);
render(nine);
act(() =>
document
.querySelector<HTMLButtonElement>('[data-hold]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))
);
expect(dialog()).toBeNull();
render(nine.slice(0, 3)); // under: the record zeroes
render([...nine, pull()]); // over again, minutes later
expect(dialog()).toBeNull();
});

it('board hotkeys stand down while it is open', async () => {
const { modalOpen } = await import('../hooks');
render(many(8));
expect(modalOpen()).toBe(true);
});

it('opens the preview every time Settings asks', () => {
render(many(2));
expect(dialog()).toBeNull();
act(() => window.dispatchEvent(new Event('pd2:preview-jail')));
expect(dialog()).not.toBeNull();
});

it('a quick Space tap is not a lone click, even where the browser clicks on keyup', () => {
render(many(8));
const hold = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find(
b => b.textContent?.includes('Hold to get back to work')
);
act(() => {
hold?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
hold?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }));
// Firefox's keyup click, with no pointer behind it
hold?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }));
});
expect(dialog()).not.toBeNull();
});

it('keeps the last showing while it doesn’t know who you are', () => {
localStorage.setItem('pd2.jail', JSON.stringify({ at: Date.now(), count: 9, over: [] }));
const r = (root ??= createRoot(host));
act(() => r.render(<JailMode pulls={many(9)} me="" extraBots={new Set()} initialized />));
expect(localStorage.getItem('pd2.jail')).not.toBeNull();
expect(dialog()).toBeNull();
});

it('stays down when another window showed it a moment ago', () => {
// another window wrote the record after this one loaded
const r = (root ??= createRoot(host));
act(() =>
r.render(<JailMode pulls={many(8)} me="me" extraBots={new Set()} initialized={false} />)
);
localStorage.setItem('pd2.jail', JSON.stringify({ at: Date.now(), count: 8, over: [] }));
act(() => r.render(<JailMode pulls={many(8)} me="me" extraBots={new Set()} initialized />));
expect(dialog()).toBeNull();
});

it('waits while another dialog is open', () => {
const other = document.createElement('div');
other.setAttribute('aria-modal', 'true');
document.body.append(other);
render(many(9));
expect(dialog()).toBeNull();
other.remove();
});

it('a drop ends a drag even when the dragged row left the page', () => {
const row = document.createElement('div');
document.body.append(row);
row.dispatchEvent(new Event('dragstart', { bubbles: true }));
row.remove(); // moved to another lane: its dragend never reaches the page
document.body.dispatchEvent(new Event('drop', { bubbles: true }));
render(many(9));
expect(dialog()).not.toBeNull();
});
});
Loading
Loading