Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
92 changes: 20 additions & 72 deletions apps/web/src/pulls/PullsSection.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script lang="ts">
import Section from '../shell/Section.svelte';
import { searchedLabel } from '../shell/snapshot';
import { type Pulls, pullsNote, reviewLine } from './pulls';
import PolledSection from '../shell/PolledSection.svelte';
import { type Pulls, reviewLine } from './pulls';

// One of the PRs page's lists, in a section under the head; the texts say when it's empty and when it couldn't load.
// With showReviewers, each row also says where its PR stands with its reviewers (decision 174).
Expand All @@ -15,74 +14,23 @@ let {
}: { pulls: Pulls; title: string; accent: string; empty: string; failed: string; showReviewers?: boolean } = $props();
</script>

{#if pulls.kind === 'loaded'}
{@const { pulls: list, searched, failure } = pulls.snapshot}
<Section {title} {accent} note={list && pullsNote(list.length)}>
{#if failure}
<p class="muted">
The last search failed: {failure}.
{#if list && searched}
These PRs are from <time datetime={searched}>{searchedLabel(searched)}</time>.
<PolledSection polled={pulls} list={(snapshot) => snapshot.pulls} {title} {accent} noun="PR" {empty} {failed}>
{#snippet row(pull)}
<!-- A PR without a link to Bitbucket gets a row that isn't a link. -->
<svelte:element this={pull.url ? 'a' : 'div'} class="list-row" href={pull.url}>
<span class="list-label">#{pull.id}</span>
<span class="list-text">
<span class="list-title">{pull.title}</span>
<span class="list-detail">{pull.repo}</span>
{#if showReviewers}
<span class="list-detail">{reviewLine(pull.reviewers)}</span>
{/if}
</p>
{/if}
{#if list}
<div class="card">
{#if list.length > 0}
<ul class="list">
{#each list as pull}
<li>
<!-- A PR without a link to Bitbucket gets a row that isn't a link. -->
<svelte:element this={pull.url ? 'a' : 'div'} class="list-row" href={pull.url}>
<span class="list-label">#{pull.id}</span>
<span class="list-text">
<span class="list-title">{pull.title}</span>
<span class="list-detail">{pull.repo}</span>
{#if showReviewers}
<span class="list-detail">{reviewLine(pull.reviewers)}</span>
{/if}
</span>
<span class="list-end">
{#if pull.draft}
<span class="chip">Draft</span>
{/if}
</span>
</svelte:element>
</li>
{/each}
</ul>
{:else}
<p class="empty">{empty}</p>
</span>
<span class="list-end">
{#if pull.draft}
<span class="chip">Draft</span>
{/if}
</div>
{/if}
</Section>
{:else if pulls.kind === 'failed'}
<Section {title} {accent}>
<p class="muted">{failed}</p>
</Section>
{/if}

<style>
.muted {
margin: 0;
color: var(--ink-muted);
overflow-wrap: anywhere;
}

.card {
border: 1px solid var(--rule);
background: var(--surface);
box-shadow: var(--offset) var(--offset) 0 var(--shadow);
}

.muted + .card {
margin-top: 20px;
}

.empty {
margin: 0;
padding: 20px;
color: var(--ink-muted);
}
</style>
</span>
</svelte:element>
{/snippet}
</PolledSection>
11 changes: 1 addition & 10 deletions apps/web/src/pulls/pulls.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from 'vitest';

import { loadPulls, loadReviews, pullsNote, type Reviewer, reviewLine, type Snapshot } from './pulls';
import { loadPulls, loadReviews, type Reviewer, reviewLine, type Snapshot } from './pulls';

afterEach(() => {
vi.unstubAllGlobals();
Expand All @@ -21,15 +21,6 @@ test.each([
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(url);
});

test.each([
{ count: 0, want: undefined },
{ count: 1, want: '1 PR' },
{ count: 2, want: '2 PRs' },
{ count: 120, want: '120 PRs' },
])('counts $count pull requests as $want', ({ count, want }) => {
expect(pullsNote(count)).toBe(want);
});

test.each<{ reviewers?: Reviewer[]; want: string }>([
{ reviewers: undefined, want: 'No reviewers' },
{ reviewers: [], want: 'No reviewers' },
Expand Down
8 changes: 0 additions & 8 deletions apps/web/src/pulls/pulls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ export function loadReviews(): Promise<Pulls> {
return loadSnapshot('/api/pulls/reviews');
}

/** The PRs' count: "1 PR" or "3 PRs"; none without PRs, which the list itself says. */
export function pullsNote(count: number): string | undefined {
if (count === 0) {
return undefined;
}
return count === 1 ? '1 PR' : `${count} PRs`;
}

const names = new Intl.ListFormat('en-GB', { type: 'conjunction' });

// The review line's groups, the most urgent first: what I must answer, then whom to remind, then who is done.
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/shell/PolledSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script lang="ts" generics="S extends import('./snapshot').Polls, T">
import type { Snippet } from 'svelte';

import { countLabel } from './count';
import Section from './Section.svelte';
import { type Polled, searchedLabel } from './snapshot';

// A poller's list in a section under a page's head, as the PRs page's lists and the Todos page's Jira tickets are: a row
// for each item, in the list's order, and texts that say when it's empty, why the last search failed and when it
// couldn't load; nothing while it loads or when its source isn't set up (decision 205). Biome doesn't read the generics
// attribute (biomejs/biome#11215), so it names Polls with import() rather than an import that Biome takes for unused.
let {
polled,
list,
title,
accent,
noun,
empty,
failed,
row,
}: {
polled: Polled<S>;
/** The snapshot's list, such as its PRs; none before a search has succeeded. */
list: (snapshot: S) => readonly T[] | undefined;
title: string;
accent: string;
/** What the list holds, such as PR: the head counts "3 PRs", and the failure line says "These PRs are from". */
noun: string;
empty: string;
failed: string;
row: Snippet<[T]>;
} = $props();
</script>

{#if polled.kind === 'loaded'}
{@const { searched, failure } = polled.snapshot}
{@const items = list(polled.snapshot)}
<Section {title} {accent} note={items && countLabel(items.length, noun)}>
{#if failure}
<p class="muted">
The last search failed: {failure}.
{#if items && searched}
These {noun}s are from <time datetime={searched}>{searchedLabel(searched)}</time>.
{/if}
</p>
{/if}
{#if items}
<div class="card">
{#if items.length > 0}
<ul class="list">
{#each items as item}
<li>{@render row(item)}</li>
{/each}
</ul>
{:else}
<p class="empty">{empty}</p>
{/if}
</div>
{/if}
</Section>
{:else if polled.kind === 'failed'}
<Section {title} {accent}>
<p class="muted">{failed}</p>
</Section>
{/if}

<style>
.muted {
margin: 0;
color: var(--ink-muted);
overflow-wrap: anywhere;
}

.card {
border: 1px solid var(--rule);
background: var(--surface);
box-shadow: var(--offset) var(--offset) 0 var(--shadow);
}

.muted + .card {
margin-top: 20px;
}

.empty {
margin: 0;
padding: 20px;
color: var(--ink-muted);
}
</style>
59 changes: 59 additions & 0 deletions apps/web/src/shell/PolledSection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createRawSnippet } from 'svelte';
import { render, screen, within } from '@testing-library/svelte';
import { expect, test } from 'vitest';

import PolledSection from './PolledSection.svelte';
import type { Polled, Polls } from './snapshot';

// A list that keeps its items under a key of its own, as GitHub's lists do.
interface Snapshot extends Polls {
items?: string[];
}

const searched = '2026-10-26T00:41:00Z';
const row = createRawSnippet<[string]>((item) => ({ render: () => `<span>${item()}</span>` }));

function show(polled: Polled<Snapshot>) {
return render(PolledSection<Snapshot, string>, {
props: {
polled,
list: (snapshot) => snapshot.items,
title: 'Assigned',
accent: 'issues',
noun: 'issue',
empty: 'No open issues.',
failed: "Couldn't load the issues.",
row,
},
});
}

test('draws each item of the list with its row, under a head that counts them with the noun', () => {
show({ kind: 'loaded', snapshot: { items: ['First', 'Second'], searched } });

expect(screen.getByRole('heading', { level: 2, name: 'Assigned issues' })).toBeInTheDocument();
expect(screen.getByText('2 issues')).toBeInTheDocument();
expect(
within(screen.getByRole('list'))
.getAllByRole('listitem')
.map((item) => item.textContent)
).toEqual(['First', 'Second']);
});

test('says when the items are from with the noun', () => {
show({ kind: 'loaded', snapshot: { items: ['First'], searched, failure: 'search: 502' } });

expect(screen.getByText(/^The last search failed/)).toHaveTextContent(
'The last search failed: search: 502. These issues are from 26 October at 09:41.'
);
});

test.each<Snapshot>([
{ searched, failure: 'search: 502' },
{ items: ['First'], failure: 'search: 502' },
])("doesn't say when the items are from without both a list and its time: %o", (snapshot) => {
show({ kind: 'loaded', snapshot });

expect(screen.getByText(/^The last search failed/)).toHaveTextContent(/^The last search failed: search: 502\.$/);
expect(document.querySelector('time')).not.toBeInTheDocument();
});
15 changes: 15 additions & 0 deletions apps/web/src/shell/count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, test } from 'vitest';

import { countLabel } from './count';

test.each([
{ count: 0, noun: 'PR', want: undefined },
{ count: 1, noun: 'PR', want: '1 PR' },
{ count: 2, noun: 'PR', want: '2 PRs' },
{ count: 120, noun: 'PR', want: '120 PRs' },
{ count: 0, noun: 'ticket', want: undefined },
{ count: 1, noun: 'ticket', want: '1 ticket' },
{ count: 3, noun: 'ticket', want: '3 tickets' },
])('counts $count of $noun as $want', ({ count, noun, want }) => {
expect(countLabel(count, noun)).toBe(want);
});
10 changes: 10 additions & 0 deletions apps/web/src/shell/count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* A list's count, as the note beside its section's head: "1 PR" or "3 PRs" for the noun PR; none for an empty list,
* which says so itself.
*/
export function countLabel(count: number, noun: string): string | undefined {
if (count === 0) {
return undefined;
}
return count === 1 ? `1 ${noun}` : `${count} ${noun}s`;
}
5 changes: 3 additions & 2 deletions apps/web/src/todos/JiraInbox.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<script lang="ts">
import { countLabel } from '../shell/count';
import Section from '../shell/Section.svelte';
import type { Inbox } from './inbox.svelte';
import { inboxLabel, ticketsNote } from './labels';
import { inboxLabel } from './labels';
import TicketStatus from './TicketStatus.svelte';

let { inbox }: { inbox: Inbox } = $props();
</script>

<!-- No section without a ticket: there's nothing to decide. -->
{#if inbox.tickets.length > 0}
<Section title="Jira" accent="inbox" note={ticketsNote(inbox.tickets.length)}>
<Section title="Jira" accent="inbox" note={countLabel(inbox.tickets.length, 'ticket')}>
<ul class="list card">
{#each inbox.tickets as ticket (ticket.key)}
<li class="list-row">
Expand Down
Loading
Loading