@conorheffron/ironoc-frontend 9.1.6 → 9.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  The ironoc UI built with react for personal portfolio website.
4
4
 
5
+ [![Deploy to Amazon ECS](https://github.com/conorheffron/ironoc/actions/workflows/aws.yml/badge.svg)](https://github.com/conorheffron/ironoc/actions/workflows/aws.yml)
6
+
5
7
  [![Node.js Package](https://github.com/conorheffron/ironoc/actions/workflows/npm-publish-packages.yml/badge.svg)](https://github.com/conorheffron/ironoc/actions/workflows/npm-publish-packages.yml)
6
8
 
7
9
  [![Node.js CI](https://github.com/conorheffron/ironoc/actions/workflows/node.js.yml/badge.svg)](https://github.com/conorheffron/ironoc/actions/workflows/node.js.yml)
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "@conorheffron/ironoc-frontend",
3
- "version": "9.1.6",
3
+ "version": "9.1.8",
4
4
  "private": false,
5
5
  "license": "GPL-3.0-or-later",
6
6
  "dependencies": {
7
7
  "@apollo/client": "^3.13.8",
8
8
  "@emotion/is-prop-valid": "^1.4.0",
9
- "@emotion/react": "^11.14.0",
10
- "@emotion/styled": "^11.14.1",
9
+ "@emotion/react": "11.14.0",
10
+ "@emotion/styled": "11.14.1",
11
11
  "@fontsource/montserrat": "^5.2.8",
12
12
  "@fontsource/open-sans": "^5.2.7",
13
13
  "@graphiql/plugin-explorer": "^5.1.1",
14
14
  "@graphiql/react": "^0.37.1",
15
+ "@mui/icons-material": "7.3.0",
16
+ "@mui/material": "7.3.0",
17
+ "@react-aria/ssr": "3.9.10",
15
18
  "@testing-library/user-event": "^14.6.1",
16
19
  "axios": "^1.15.2",
17
20
  "bootstrap": "5.3",
@@ -27,6 +30,7 @@
27
30
  "react-dom": "^19.2.0",
28
31
  "react-router": "^7.12.0",
29
32
  "react-router-dom": "^7.12.0",
33
+ "react-transition-group": "4.4.5",
30
34
  "reactstrap": "^9.2.3",
31
35
  "recharts": "^3.3.0",
32
36
  "web-vitals": "^5.1.0"
@@ -58,6 +62,7 @@
58
62
  },
59
63
  "devDependencies": {
60
64
  "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
65
+ "@testing-library/dom": "^10.4.1",
61
66
  "@testing-library/jest-dom": "^6.9.1",
62
67
  "@testing-library/react": "^16.3.0",
63
68
  "jest": "^30.2.0",
package/src/App.css CHANGED
@@ -24,6 +24,7 @@
24
24
  background-color: #1D428A;
25
25
  border-bottom-right-radius: 18%;
26
26
  border-bottom-left-radius: 18%;
27
+ overflow: hidden;
27
28
  }
28
29
 
29
30
  .App-link {
@@ -50,8 +51,8 @@
50
51
  }
51
52
 
52
53
  .carousel-caption {
53
- top: 0;
54
- bottom: auto;
54
+ top: 0;
55
+ bottom: auto;
55
56
  }
56
57
 
57
58
  .App-intro {
@@ -60,23 +61,23 @@
60
61
  }
61
62
 
62
63
  #my-intro {
63
- padding-left: 15%;
64
- padding-right: 15%;
64
+ padding-left: 15%;
65
+ padding-right: 15%;
65
66
  }
66
67
 
67
68
  .strava-badge {
68
- display: inline-block;
69
- background-color: #FC5200;
70
- color: #fff;
71
- padding: 5px 10px 5px 30px;
72
- font-size: 11px;
73
- font-family: Helvetica, Arial, sans-serif;
74
- white-space: nowrap;
75
- text-decoration: none;
76
- background-repeat: no-repeat;
77
- background-position: 10px center;
78
- border-radius: 3px;
79
- background-image: url('https://badges.strava.com/logo-strava-echelon.png')
69
+ display: inline-block;
70
+ background-color: #FC5200;
71
+ color: #fff;
72
+ padding: 5px 10px 5px 30px;
73
+ font-size: 11px;
74
+ font-family: Helvetica, Arial, sans-serif;
75
+ white-space: nowrap;
76
+ text-decoration: none;
77
+ background-repeat: no-repeat;
78
+ background-position: 10px center;
79
+ border-radius: 3px;
80
+ background-image: url('https://badges.strava.com/logo-strava-echelon.png')
80
81
  }
81
82
 
82
83
  .strava-badge-img {
@@ -107,7 +108,7 @@ footer p a {
107
108
  }
108
109
 
109
110
  .carousel-caption h3, h5 {
110
- background-color:yellow;
111
+ background-color: yellow;
111
112
  width: 50%;
112
113
  height: auto;
113
114
  margin: 0 auto;
@@ -130,3 +131,89 @@ footer p a {
130
131
  overflow: hidden;
131
132
  text-overflow: ellipsis; /* Add ellipsis for text overflow */
132
133
  }
134
+
135
+ .donate-carousel-item {
136
+ min-height: 90vh;
137
+ object-fit: cover;
138
+ border-bottom-right-radius: 18%;
139
+ border-bottom-left-radius: 18%;
140
+ }
141
+
142
+ .donate-carousel-bg {
143
+ min-height: 90vh;
144
+ object-fit: cover;
145
+ border-bottom-right-radius: 18%;
146
+ border-bottom-left-radius: 18%;
147
+ }
148
+
149
+ .donate-carousel-caption {
150
+ top: 50%;
151
+ bottom: auto;
152
+ left: 8%;
153
+ right: 8%;
154
+ transform: translateY(-50%);
155
+ padding: 1.25rem;
156
+ color: #ffffff;
157
+ background: rgba(0, 0, 0, 0.45);
158
+ border-radius: 1rem;
159
+ }
160
+
161
+ .donate-title {
162
+ font-size: clamp(1.5rem, 5vw, 3rem);
163
+ }
164
+
165
+ .donate-carousel-caption p {
166
+ font-size: clamp(0.9rem, 2.8vw, 1.2rem);
167
+ line-height: 1.45;
168
+ }
169
+
170
+ .donate-carousel-caption a {
171
+ color: yellow;
172
+ overflow-wrap: anywhere;
173
+ }
174
+
175
+ .donate-overview {
176
+ max-height: none;
177
+ overflow: visible;
178
+ text-overflow: initial;
179
+ }
180
+
181
+ @media (max-width: 576px) {
182
+ .donate-carousel-item {
183
+ min-height: auto;
184
+ border-bottom-right-radius: 18%;
185
+ border-bottom-left-radius: 18%;
186
+ overflow: hidden;
187
+ }
188
+
189
+ .donate-carousel-bg {
190
+ min-height: 720px;
191
+ border-bottom-right-radius: 18%;
192
+ border-bottom-left-radius: 18%;
193
+ }
194
+
195
+ .donate-carousel-caption {
196
+ top: 1rem;
197
+ left: 4%;
198
+ right: 4%;
199
+ transform: none;
200
+ padding: 0.85rem;
201
+ max-height: calc(100% - 2rem);
202
+ overflow-y: auto;
203
+ }
204
+
205
+ .donate-title {
206
+ font-size: 1.35rem;
207
+ margin-bottom: 0.75rem !important;
208
+ }
209
+
210
+ .donate-carousel-caption p {
211
+ font-size: 0.9rem;
212
+ line-height: 1.35;
213
+ margin-bottom: 0.65rem;
214
+ }
215
+
216
+ .donate-overview {
217
+ font-size: 0.85rem;
218
+ }
219
+ }
package/src/App.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import React, { Component } from 'react';
2
2
  import { BrowserRouter as Router, Routes, Route } from 'react-router';
3
- import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
3
+ import { ApolloClient, InMemoryCache, ApolloProvider, HttpLink } from '@apollo/client';
4
4
  import './App.css';
5
5
  import Home from './components/Home';
6
6
  import CoffeeHome from './components/CoffeeHome';
@@ -31,7 +31,7 @@ class App extends Component {
31
31
 
32
32
  // Create Apollo Client for '/donate' route
33
33
  const donateClient = new ApolloClient({
34
- uri: '/graphql', // Replace with your GraphQL endpoint
34
+ link: new HttpLink({ uri: '/graphql' }),
35
35
  cache: new InMemoryCache(),
36
36
  });
37
37
 
package/src/App.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2
+ import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
3
3
  import '@testing-library/jest-dom';
4
4
  import Home from './components/Home';
5
5
  import App from './App';
@@ -108,12 +108,15 @@ describe('Footer Component', () => {
108
108
  fetch.mockClear();
109
109
  });
110
110
 
111
- test('renders without crashing', () => {
112
- render(<Footer />);
111
+ test('renders without crashing', async () => {
112
+ await act(async () => {
113
+ render(<Footer />);
114
+ });
113
115
  expect(screen.getByText(/© 2025 by Conor Heffron/)).toBeInTheDocument();
114
116
  });
115
117
 
116
118
  test('initial state is set correctly', () => {
119
+ global.fetch = jest.fn(() => new Promise(() => {}));
117
120
  const { container } = render(<Footer />);
118
121
  const footerText = container.querySelector('.ft');
119
122
  expect(footerText).toHaveTextContent('© 2025 by Conor Heffron |');
@@ -12,6 +12,58 @@ function isValidUrl(url) {
12
12
  }
13
13
  }
14
14
 
15
+ const brewTitleTranslations = {
16
+ 'Svart Te': 'Black Tea',
17
+ 'Islatte': 'Iced Latte',
18
+ 'Islatte Mocha': 'Iced Mocha',
19
+ 'Frapino Mocka': 'Frapino Mocha',
20
+ 'Apelsinjuice': 'Orange Juice',
21
+ 'Lemonad': 'Lemonade',
22
+ };
23
+
24
+ const ingredientTranslations = {
25
+ 'Ångad mjölk': 'Steamed milk',
26
+ 'Karamellsirap': 'Caramel syrup',
27
+ 'Hett vatten': 'Hot water',
28
+ 'Choklad': 'Chocolate',
29
+ 'Mjölk': 'Milk',
30
+ 'Te': 'Tea',
31
+ 'Ingefära': 'Ginger',
32
+ 'Kardemumma': 'Cardamom',
33
+ 'Kanel': 'Cinnamon',
34
+ 'Matcha-pulver': 'Matcha powder',
35
+ 'Socker*': 'Sugar*',
36
+ 'Kaffe': 'Coffee',
37
+ 'Is': 'Ice',
38
+ 'Sirap': 'Syrup',
39
+ 'Vispgrädde*': 'Whipped cream*',
40
+ 'Karamellsås': 'Caramel sauce',
41
+ 'Färska Apelsiner': 'Fresh oranges',
42
+ 'Citronsaft': 'Lemon juice',
43
+ 'Kolsyrat vatten': 'Sparkling water',
44
+ 'Honung': 'Honey',
45
+ };
46
+
47
+ function translateValue(value, translations) {
48
+ const trimmedValue = typeof value === 'string' ? value.trim() : value;
49
+ return translations[trimmedValue] || trimmedValue;
50
+ }
51
+
52
+ function translateIngredients(ingredients) {
53
+ if (Array.isArray(ingredients)) {
54
+ return ingredients
55
+ .map((ingredient) => translateValue(ingredient, ingredientTranslations))
56
+ .join(', ');
57
+ }
58
+ if (typeof ingredients === 'string') {
59
+ return ingredients
60
+ .split(',')
61
+ .map((ingredient) => translateValue(ingredient, ingredientTranslations))
62
+ .join(', ');
63
+ }
64
+ return '';
65
+ }
66
+
15
67
  class CoffeeCarousel extends Component {
16
68
  render() {
17
69
  const { items } = this.props;
@@ -21,22 +73,23 @@ class CoffeeCarousel extends Component {
21
73
  );
22
74
  return (
23
75
  <Carousel className="App-header">
24
- {validItems.map((item, index) => (
76
+ {validItems.map((item, index) => {
77
+ const translatedTitle = translateValue(item.title, brewTitleTranslations);
78
+ return (
25
79
  <Carousel.Item key={index}>
26
- <img src={item.image} alt={item.title} />
80
+ <img src={item.image} alt={translatedTitle} />
27
81
  <Carousel.Caption>
28
- <h3>{item.title}</h3>
82
+ <h3>{translatedTitle}</h3>
29
83
  {item.ingredients && item.ingredients.length > 0 && (
30
84
  <h5>
31
85
  <b>Ingredients:</b>{' '}
32
- {Array.isArray(item.ingredients)
33
- ? item.ingredients.join(', ')
34
- : item.ingredients}
86
+ {translateIngredients(item.ingredients)}
35
87
  </h5>
36
88
  )}
37
89
  </Carousel.Caption>
38
90
  </Carousel.Item>
39
- ))}
91
+ );
92
+ })}
40
93
  </Carousel>
41
94
  );
42
95
  }
@@ -79,82 +79,52 @@ class Donate extends Component {
79
79
  );
80
80
  }
81
81
 
82
- return (
83
- <div className="App">
84
- <AppNavbar />
85
- <Container>
86
- <Carousel className="App-header">
87
- {donateItems.map((item, index) => (
88
- <Carousel.Item key={index} interval={500}>
89
- <img className="d-block w-100" src={red} alt={item.alt} />
90
-
91
- <Carousel.Caption>
92
- <h1 className="mb-3">
93
- <span style={{ textDecoration: 'underline' }}>{item.name}</span>
94
- </h1>
95
-
96
- <p>
97
- <b>Contact & Help by Phone: </b>
98
- <span dangerouslySetInnerHTML={{ __html: item.phone }} />
99
- </p>
100
-
101
- <p>
102
- <b>Home page: </b>
103
- <a
104
- href={item.link}
105
- target="_blank"
106
- rel="noreferrer"
107
- onClick={() => trackClickOut('charity', item.link)}
108
- >
109
- {item.link}
110
- </a>
111
- </p>
112
-
113
- <p className="overview-text">
114
- <b>Overview:</b> Founded in {item.founded}, {item.overview}
115
- </p>
116
-
117
- <p>
118
- <a
119
- href={item.donate}
120
- target="_blank"
121
- rel="noreferrer"
122
- onClick={() => trackClickOut('charity', item.donate)}
123
- >
124
- Donate here
125
- </a>
126
- </p>
127
- </Carousel.Caption>
128
- </Carousel.Item>
129
- ))}
130
- </Carousel>
131
- </Container>
132
- </div>
133
- );
134
82
  return (
135
83
  <div className="App">
136
84
  <AppNavbar />
137
85
  <Container>
138
86
  <Carousel className="App-header">
139
87
  {donateItems.map((item, index) => (
140
- <Carousel.Item key={index} interval={500}>
141
- <a href={item.donate} target="_blank" rel="noreferrer">
142
- <img className="d-block w-100" src={red} alt={item.alt} />
143
- <Carousel.Caption>
144
- <h1>
145
- <u>{item.name}</u>
146
- </h1><br />
147
- <h8>
148
- <b>Contact & Help by Phone: </b><span dangerouslySetInnerHTML={{ __html: item.phone }} />
149
- </h8><br />
150
- <h7>
151
- <b>Home page: </b><a href={item.link} target="_blank" rel="noreferrer">{item.link}</a>
152
- </h7><br /><br />
153
- <h11 className="overview-text">
154
- <b>Overview:</b> Founded in {item.founded}, {item.overview}
155
- </h11>
156
- </Carousel.Caption>
157
- </a>
88
+ <Carousel.Item key={index} interval={500} className="donate-carousel-item">
89
+ <img className="d-block w-100 donate-carousel-bg" src={red} alt={item.alt} />
90
+
91
+ <Carousel.Caption className="donate-carousel-caption">
92
+ <h1 className="donate-title mb-3">
93
+ <span style={{ textDecoration: 'underline' }}>{item.name}</span>
94
+ </h1>
95
+
96
+ <p className="donate-phone">
97
+ <b>Contact & Help by Phone: </b>
98
+ <span dangerouslySetInnerHTML={{ __html: item.phone }} />
99
+ </p>
100
+
101
+ <p className="donate-link">
102
+ <b>Home page: </b>
103
+ <a
104
+ href={item.link}
105
+ target="_blank"
106
+ rel="noreferrer"
107
+ onClick={() => trackClickOut('charity', item.link)}
108
+ >
109
+ {item.link}
110
+ </a>
111
+ </p>
112
+
113
+ <p className="overview-text donate-overview">
114
+ <b>Overview:</b> Founded in {item.founded}, {item.overview}
115
+ </p>
116
+
117
+ <p className="donate-action">
118
+ <a
119
+ href={item.donate}
120
+ target="_blank"
121
+ rel="noreferrer"
122
+ onClick={() => trackClickOut('charity', item.donate)}
123
+ >
124
+ Donate here
125
+ </a>
126
+ </p>
127
+ </Carousel.Caption>
158
128
  </Carousel.Item>
159
129
  ))}
160
130
  </Carousel>
@@ -127,6 +127,7 @@ const RepoIssues = () => {
127
127
  enableStickyHeader: true,
128
128
  initialState: {
129
129
  showColumnFilters: true,
130
+ columnFilters: [{ id: 'state', value: ['open'] }],
130
131
  columnVisibility: {
131
132
  state: false,
132
133
  body: false,
@@ -61,4 +61,22 @@ describe('CoffeeCarousel', () => {
61
61
  // Ensure ingredients are NOT rendered
62
62
  expect(screen.queryByText('Ingredients:')).not.toBeInTheDocument();
63
63
  });
64
+
65
+ test('translates localized brew titles and ingredients', () => {
66
+ const localizedItems = [
67
+ {
68
+ image: 'https://image1.jpg',
69
+ title: 'Svart Te',
70
+ ingredients: ' Te , Unknown ingredient , Honung ',
71
+ },
72
+ ];
73
+
74
+ render(<CoffeeCarousel items={localizedItems} />);
75
+
76
+ expect(screen.getByText('Black Tea')).toBeInTheDocument();
77
+ expect(screen.getByAltText('Black Tea')).toBeInTheDocument();
78
+ expect(screen.getByText('Tea, Unknown ingredient, Honey')).toBeInTheDocument();
79
+ expect(screen.queryByText('Svart Te')).not.toBeInTheDocument();
80
+ expect(screen.queryByText('Te , Unknown ingredient , Honung')).not.toBeInTheDocument();
81
+ });
64
82
  });
@@ -1,4 +1,4 @@
1
- import { screen, render, waitFor } from '@testing-library/react';
1
+ import { screen, render, waitFor, act } from '@testing-library/react';
2
2
  import axios from 'axios';
3
3
  import App from '../../App';
4
4
  import CoffeeHome from '../CoffeeHome';
@@ -25,12 +25,16 @@ describe('CoffeeHome', () => {
25
25
  axios.get.mockResolvedValueOnce({ data: coffeeItems });
26
26
  });
27
27
 
28
- test('renders AppNavbar component', () => {
29
- render(<App />);
28
+ test('renders AppNavbar component', async () => {
29
+ await act(async () => {
30
+ render(<App />);
31
+ });
30
32
  expect(screen.getByRole('banner')).toBeInTheDocument();
31
33
  });
32
34
 
33
35
  test('displays loading state initially', () => {
36
+ axios.get.mockReset();
37
+ axios.get.mockImplementation(() => new Promise(() => {}));
34
38
  render(<CoffeeHome />);
35
39
  expect(screen.getByText('Loading...')).toBeInTheDocument();
36
40
  });
@@ -1,4 +1,4 @@
1
- import { render, screen, waitFor } from '@testing-library/react';
1
+ import { render, screen, waitFor, act } from '@testing-library/react';
2
2
  import App from '../../App';
3
3
  import { Router, MemoryRouter } from 'react-router';
4
4
  import ControlledCarousel from '../ControlledCarousel';
@@ -35,12 +35,16 @@ describe('Portfolio Controlled Carousel', () => {
35
35
  jest.restoreAllMocks();
36
36
  });
37
37
 
38
- test('renders AppNavbar component', () => {
39
- render(<App />);
38
+ test('renders AppNavbar component', async () => {
39
+ await act(async () => {
40
+ render(<App />);
41
+ });
40
42
  expect(screen.getByRole('banner')).toBeInTheDocument();
41
43
  });
42
44
 
43
45
  test('renders loading state initially', () => {
46
+ global.fetch.mockReset();
47
+ global.fetch.mockImplementation(() => new Promise(() => {}));
44
48
  render(<ControlledCarousel />);
45
49
  expect(screen.getByText('Loading...')).toBeInTheDocument();
46
50
  });
@@ -29,13 +29,28 @@ describe('RepoDetails Component', () => {
29
29
  delete global.fetch;
30
30
  });
31
31
 
32
- test('renders loading spinner initially', () => {
32
+ test('renders loading spinner initially', async () => {
33
+ let resolveFetch;
34
+ global.fetch = jest.fn(
35
+ () =>
36
+ new Promise((resolve) => {
37
+ resolveFetch = resolve;
38
+ })
39
+ );
40
+
33
41
  render(
34
42
  <MemoryRouter>
35
43
  <RepoDetails />
36
44
  </MemoryRouter>
37
45
  );
46
+
38
47
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
48
+
49
+ resolveFetch({
50
+ json: () => Promise.resolve([]),
51
+ });
52
+
53
+ await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
39
54
  });
40
55
 
41
56
  test('fetches and displays repo details isConor=True', async () => {
@@ -60,10 +75,11 @@ describe('RepoDetails Component', () => {
60
75
  <RepoDetails />
61
76
  </MemoryRouter>
62
77
  );
63
- await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
64
- expect(screen.getByText('conorheffron/ironoc')).toBeInTheDocument();
65
- expect(screen.getByText('Ironoc framework')).toBeInTheDocument();
66
- expect(screen.getByText('nodejs, serverless')).toBeInTheDocument();
78
+ await waitFor(() => {
79
+ expect(screen.getByText('conorheffron/ironoc')).toBeInTheDocument();
80
+ expect(screen.getByText('Ironoc framework')).toBeInTheDocument();
81
+ expect(screen.getByText('nodejs, serverless')).toBeInTheDocument();
82
+ });
67
83
 
68
84
  expect(screen.queryByRole('columnheader', { name: /Issues Count/i })).toBeInTheDocument();
69
85
  expect(screen.getByText('37')).toBeInTheDocument();
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import RepoIssues from '../RepoIssues';
4
- import { useMaterialReactTable } from 'material-react-table';
4
+
5
+ // Track useMaterialReactTable call opts for assertions (must start with 'mock' for Jest hoisting)
6
+ const mockUseMRTOpts = [];
5
7
 
6
8
  // Mock react-router
7
9
  jest.mock('react-router', () => ({
@@ -27,16 +29,31 @@ jest.mock('react-bootstrap', () => ({
27
29
  jest.mock('../../AppNavbar', () => () => <div data-testid="navbar">Navbar</div>);
28
30
  jest.mock('../../LoadingSpinner', () => () => <div data-testid="spinner">Loading...</div>);
29
31
 
30
- // Mock MaterialReactTable and useMaterialReactTable
32
+ // Mock MaterialReactTable and useMaterialReactTable.
33
+ // useMaterialReactTable is a plain function (not jest.fn) so React 19 concurrent mode
34
+ // commits re-renders correctly. Calls are tracked via mockUseMRTOpts for assertions.
35
+ // columnFilters from initialState are applied to table.data so MaterialReactTable
36
+ // only receives the filtered rows, allowing DOM-level assertions on visibility.
31
37
  jest.mock('material-react-table', () => ({
32
38
  MaterialReactTable: ({ table }) => (
33
39
  <div data-testid="mrt-table">
34
- {table && table.data && table.data.map((issue, idx) => (
40
+ {(table?.data ?? []).map((issue, idx) => (
35
41
  <div key={idx} data-testid="mrt-row">{issue.title}</div>
36
42
  ))}
37
43
  </div>
38
44
  ),
39
- useMaterialReactTable: jest.fn((opts) => opts),
45
+ useMaterialReactTable: (opts) => {
46
+ mockUseMRTOpts.push(opts);
47
+ const filters = opts?.initialState?.columnFilters ?? [];
48
+ let data = opts?.data ?? [];
49
+ filters.forEach((filter) => {
50
+ data = data.filter((row) => {
51
+ const val = row[filter.id];
52
+ return Array.isArray(filter.value) ? filter.value.includes(val) : val === filter.value;
53
+ });
54
+ });
55
+ return { ...opts, data };
56
+ },
40
57
  }));
41
58
 
42
59
  // Mock @mui/material theme functions
@@ -58,6 +75,7 @@ describe('RepoIssues', () => {
58
75
  const mockIssuesResponse = [];
59
76
 
60
77
  beforeEach(() => {
78
+ mockUseMRTOpts.length = 0;
61
79
  jest.clearAllMocks();
62
80
  useNavigate.mockReturnValue(mockNavigate);
63
81
  global.fetch = jest.fn(() =>
@@ -98,19 +116,21 @@ describe('RepoIssues', () => {
98
116
  expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
99
117
  );
100
118
  expect(screen.getByTestId('mrt-table')).toBeInTheDocument();
119
+ expect(screen.getByText('Test Issue')).toBeInTheDocument();
101
120
  });
102
121
 
103
- it('hides state and description columns by default', async () => {
122
+ it('defaults to open issues while hiding state and description columns', async () => {
104
123
  useParams.mockReturnValue({ id: 'user', repo: 'repo' });
105
124
  render(<RepoIssues />);
106
125
  await waitFor(() =>
107
126
  expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
108
127
  );
109
128
 
110
- expect(useMaterialReactTable).toHaveBeenCalledWith(
129
+ expect(mockUseMRTOpts).toContainEqual(
111
130
  expect.objectContaining({
112
131
  initialState: expect.objectContaining({
113
132
  showColumnFilters: true,
133
+ columnFilters: [{ id: 'state', value: ['open'] }],
114
134
  columnVisibility: {
115
135
  state: false,
116
136
  body: false,
@@ -120,6 +140,24 @@ describe('RepoIssues', () => {
120
140
  );
121
141
  });
122
142
 
143
+ it('renders open issues and excludes closed issues by default', async () => {
144
+ useParams.mockReturnValue({ id: 'user', repo: 'repo' });
145
+ global.fetch = jest.fn(() =>
146
+ Promise.resolve({
147
+ json: () => Promise.resolve([
148
+ { number: 1, state: 'open', labels: [], title: 'Open Issue', body: '' },
149
+ { number: 2, state: 'closed', labels: [], title: 'Closed Issue', body: '' },
150
+ ]),
151
+ })
152
+ );
153
+ window.fetch = global.fetch;
154
+ render(<RepoIssues />);
155
+ await waitFor(() =>
156
+ expect(screen.getByText('Open Issue')).toBeInTheDocument()
157
+ );
158
+ expect(screen.queryByText('Closed Issue')).not.toBeInTheDocument();
159
+ });
160
+
123
161
  it('navigates on form submit', async () => {
124
162
  useParams.mockReturnValue({ id: 'user', repo: 'repo' });
125
163
  global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }));