Skip to content

Commit eeba338

Browse files
authored
Add typeshare support for ChangelogItem type (#762)
1 parent fec7dc3 commit eeba338

File tree

8 files changed

+2215
-1782
lines changed

8 files changed

+2215
-1782
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/assets/changelog.json

Lines changed: 2034 additions & 1732 deletions
Large diffs are not rendered by default.

client/src/lib/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
Generated by typeshare 1.13.3
33
*/
44

5+
export interface ChangelogItem {
6+
number: number;
7+
summary?: string;
8+
url: string;
9+
mergedAt: string;
10+
}
11+
512
export interface Instructor {
613
name: string;
714
nameNgrams?: string;

client/src/model/changelog-item.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { cleanup, render, screen, within } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import type { ReactNode } from 'react';
4+
import { HelmetProvider } from 'react-helmet-async';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
7+
import type { ChangelogItem } from '../lib/types';
8+
9+
const createItems = (count: number, month: number): ChangelogItem[] =>
10+
Array.from({ length: count }, (_, index) => {
11+
const number = month * 100 + index + 1;
12+
return {
13+
number,
14+
summary: `Summary ${number}`,
15+
url: `https://example.com/${number}`,
16+
mergedAt: `2024-${String(month).padStart(2, '0')}-01T00:00:00Z`,
17+
};
18+
});
19+
20+
vi.mock('../components/layout', () => ({
21+
Layout: ({ children }: { children: ReactNode }) => (
22+
<div data-testid='layout'>{children}</div>
23+
),
24+
}));
25+
26+
const mockChangelog = {
27+
'April 2024': createItems(11, 4),
28+
'March 2024': createItems(2, 3),
29+
};
30+
31+
const resetMockChangelog = () => {
32+
mockChangelog['April 2024'] = createItems(11, 4);
33+
mockChangelog['March 2024'] = createItems(2, 3);
34+
};
35+
36+
vi.mock('../assets/changelog.json', () => ({
37+
default: mockChangelog,
38+
}));
39+
40+
const renderChangelog = async () => {
41+
const { Changelog } = await import('./changelog');
42+
43+
return render(
44+
<HelmetProvider>
45+
<Changelog />
46+
</HelmetProvider>
47+
);
48+
};
49+
50+
describe('Changelog page', () => {
51+
beforeEach(() => {
52+
resetMockChangelog();
53+
});
54+
55+
afterEach(() => {
56+
cleanup();
57+
});
58+
59+
it('renders months in descending chronological order', async () => {
60+
await renderChangelog();
61+
62+
const headings = screen.getAllByRole('heading', { level: 2 });
63+
64+
expect(headings.map((heading) => heading.textContent)).toEqual([
65+
'April 2024',
66+
'March 2024',
67+
]);
68+
});
69+
70+
it('limits entries to five by default and expands when requested', async () => {
71+
const user = userEvent.setup();
72+
await renderChangelog();
73+
74+
const aprilSection = screen.getByText('April 2024').closest('div');
75+
expect(aprilSection).toBeTruthy();
76+
77+
const withinApril = within(aprilSection as HTMLElement);
78+
expect(withinApril.getAllByRole('link')).toHaveLength(5);
79+
80+
const toggleButton = withinApril.getByRole('button', { name: 'Show all' });
81+
82+
await user.click(toggleButton);
83+
expect(withinApril.getAllByRole('link')).toHaveLength(11);
84+
expect(toggleButton).toHaveTextContent('Show less');
85+
86+
await user.click(toggleButton);
87+
expect(withinApril.getAllByRole('link')).toHaveLength(5);
88+
expect(toggleButton).toHaveTextContent('Show all');
89+
});
90+
91+
it('skips entries without a summary', async () => {
92+
mockChangelog['March 2024'][1] = {
93+
...mockChangelog['March 2024'][1],
94+
summary: undefined,
95+
};
96+
97+
await renderChangelog();
98+
99+
const marchSection = screen.getByText('March 2024').closest('div');
100+
expect(marchSection).toBeTruthy();
101+
102+
const withinMarch = within(marchSection as HTMLElement);
103+
expect(withinMarch.getAllByRole('link')).toHaveLength(1);
104+
expect(withinMarch.queryByText(/#302/)).not.toBeInTheDocument();
105+
});
106+
});

client/src/pages/changelog.tsx

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Helmet } from 'react-helmet-async';
33

44
import changelogItems from '../assets/changelog.json';
55
import { Layout } from '../components/layout';
6-
import { ChangelogItem } from '../model/changelog-item';
6+
import type { ChangelogItem } from '../lib/types';
77

88
const typedChangelogItems: Record<string, ChangelogItem[]> = changelogItems;
99

@@ -56,15 +56,29 @@ export const Changelog = () => {
5656
</p>
5757
</div>
5858
<div className='w-full max-w-4xl px-4 sm:px-6 lg:px-8'>
59-
{sortedChangelogItems.map(([month, items]) => (
60-
<div key={month} className='mb-8'>
61-
<h2 className='text-2xl font-semibold text-gray-900 dark:text-gray-200'>
62-
{month}
63-
</h2>
64-
{items
65-
.slice(0, expandedMonths.includes(month) ? items.length : 5)
66-
.map((item, index) => (
67-
<div key={index} className='mt-4'>
59+
{sortedChangelogItems.map(([month, items]) => {
60+
const sanitizedItems = items.filter(
61+
(
62+
item
63+
): item is ChangelogItem & {
64+
summary: string;
65+
} =>
66+
typeof item.summary === 'string' &&
67+
item.summary.trim().length > 0
68+
);
69+
70+
const itemsToRender = sanitizedItems.slice(
71+
0,
72+
expandedMonths.includes(month) ? sanitizedItems.length : 5
73+
);
74+
75+
return (
76+
<div key={month} className='mb-8'>
77+
<h2 className='text-2xl font-semibold text-gray-900 dark:text-gray-200'>
78+
{month}
79+
</h2>
80+
{itemsToRender.map((item) => (
81+
<div key={item.number} className='mt-4'>
6882
<p className='text-lg text-gray-800 dark:text-gray-300'>
6983
- {item.summary.replace('/^- /', '')} (
7084
<a
@@ -79,16 +93,17 @@ export const Changelog = () => {
7993
</p>
8094
</div>
8195
))}
82-
{items.length > 10 && (
83-
<button
84-
onClick={() => toggleShowAll(month)}
85-
className='mt-4 underline dark:text-gray-400'
86-
>
87-
{expandedMonths.includes(month) ? 'Show less' : 'Show all'}
88-
</button>
89-
)}
90-
</div>
91-
))}
96+
{sanitizedItems.length > 10 && (
97+
<button
98+
onClick={() => toggleShowAll(month)}
99+
className='mt-4 underline dark:text-gray-400'
100+
>
101+
{expandedMonths.includes(month) ? 'Show less' : 'Show all'}
102+
</button>
103+
)}
104+
</div>
105+
);
106+
})}
92107
</div>
93108
</div>
94109
</Layout>

tools/changelog-generator/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ serde_json = "1.0.143"
1515
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }
1616
tracing = { workspace = true }
1717
tracing-subscriber = { workspace = true }
18+
typeshare = { workspace = true }

tools/changelog-generator/src/main.rs

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use {
1717
},
1818
tracing::info,
1919
tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt},
20+
typeshare::typeshare,
2021
};
2122

2223
const BASE_URL: &str = "https://github.com";
@@ -100,35 +101,40 @@ impl PullRequest<'_> {
100101
}
101102

102103
#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)]
103-
struct Item {
104-
number: u64,
105-
summary: Option<String>,
106-
url: String,
107-
merged_at: DateTime<Utc>,
104+
#[serde(rename_all = "camelCase")]
105+
#[typeshare]
106+
pub struct ChangelogItem {
107+
pub number: u32,
108+
pub summary: Option<String>,
109+
pub url: String,
110+
#[serde(alias = "merged_at")]
111+
#[typeshare(serialized_as = "String")]
112+
pub merged_at: DateTime<Utc>,
108113
}
109114

110-
impl Item {
115+
impl ChangelogItem {
111116
async fn try_from(
112117
pull_request: PullRequest<'_>,
113118
merged_at: DateTime<Utc>,
114-
) -> Result<Item> {
115-
Ok(Item {
116-
number: pull_request.number,
119+
number: u32,
120+
) -> Result<ChangelogItem> {
121+
Ok(ChangelogItem {
122+
number,
117123
summary: pull_request.summary().await?,
118124
url: pull_request.url()?,
119125
merged_at,
120126
})
121127
}
122128
}
123129

124-
type Entry = HashMap<String, Vec<Item>>;
130+
type Entry = HashMap<String, Vec<ChangelogItem>>;
125131

126132
#[derive(Parser)]
127133
struct Arguments {
128134
#[clap(long, default_value = "../../client/src/assets/changelog.json")]
129135
output: PathBuf,
130136
#[clap(long, value_delimiter = ' ', num_args = 0..)]
131-
regenerate: Vec<u64>,
137+
regenerate: Vec<u32>,
132138
#[clap(long, default_value = "false")]
133139
regenerate_all: bool,
134140
#[clap(long, default_value = "mcgill.courses")]
@@ -166,9 +172,9 @@ impl Arguments {
166172
.flat_map(|(_, value)| {
167173
value.into_iter().map(|item| (item.number, item))
168174
})
169-
.collect::<HashMap<u64, Item>>()
175+
.collect::<HashMap<u32, ChangelogItem>>()
170176
})
171-
.unwrap_or_else(|_| HashMap::new());
177+
.unwrap_or_else(|_| HashMap::<u32, ChangelogItem>::new());
172178

173179
let pull_requests = model
174180
.iter()
@@ -186,24 +192,24 @@ impl Arguments {
186192

187193
for pull_request in pull_requests {
188194
if let Some(merged_at) = pull_request.merged_at {
195+
let number = u32::try_from(pull_request.number).map_err(|_| {
196+
anyhow!("pull request number {} exceeds u32", pull_request.number)
197+
})?;
198+
189199
let month = merged_at.format("%B %Y").to_string();
190200

191-
if let Some(item) = existing_items.get(&pull_request.number) {
192-
if self.regenerate_all
193-
|| self.regenerate.contains(&pull_request.number)
194-
{
195-
grouped
196-
.entry(month)
197-
.or_default()
198-
.push(Item::try_from(pull_request, merged_at).await?);
201+
if let Some(item) = existing_items.get(&number) {
202+
if self.regenerate_all || self.regenerate.contains(&number) {
203+
grouped.entry(month).or_default().push(
204+
ChangelogItem::try_from(pull_request, merged_at, number).await?,
205+
);
199206
} else {
200207
grouped.entry(month).or_default().push(item.clone());
201208
}
202209
} else {
203-
grouped
204-
.entry(month)
205-
.or_default()
206-
.push(Item::try_from(pull_request, merged_at).await?);
210+
grouped.entry(month).or_default().push(
211+
ChangelogItem::try_from(pull_request, merged_at, number).await?,
212+
);
207213
}
208214
}
209215
}

0 commit comments

Comments
 (0)