@conorheffron/ironoc-frontend 7.3.8 → 7.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conorheffron/ironoc-frontend",
3
- "version": "7.3.8",
3
+ "version": "7.3.9",
4
4
  "private": false,
5
5
  "dependencies": {
6
6
  "@apollo/client": "^3.13.8",
@@ -10,6 +10,7 @@
10
10
  "axios": "^1.8.2",
11
11
  "bootstrap": "5.1",
12
12
  "history": "^5.3.0",
13
+ "material-react-table": "^3.2.1",
13
14
  "react": "^18.3.1",
14
15
  "react-bootstrap": "^2.10.5",
15
16
  "react-bootstrap-carousel": "^4.1.1",
package/src/App.css CHANGED
@@ -98,6 +98,10 @@ p a {
98
98
  color: yellow;
99
99
  }
100
100
 
101
+ footer p a {
102
+ color: blue;
103
+ }
104
+
101
105
  .carousel-caption h3, h5 {
102
106
  background-color:yellow;
103
107
  width: 50%;
package/src/App.test.js CHANGED
@@ -96,7 +96,8 @@ describe('Footer Component', () => {
96
96
  test('fetches and displays the version', async () => {
97
97
  render(<Footer />);
98
98
  await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
99
- await waitFor(() => expect(screen.getByText('© 2025 by Conor Heffron | 2.2.0')).toBeInTheDocument());
99
+ await waitFor(() => expect(screen.getByText('© 2025 by Conor Heffron |')).toBeInTheDocument());
100
+ await waitFor(() => expect(screen.getByText('2.2.0')).toBeInTheDocument());
100
101
  });
101
102
 
102
103
  test('handles fetch error', async () => {
package/src/Footer.js CHANGED
@@ -29,7 +29,9 @@ class Footer extends Component {
29
29
  return (
30
30
  <div className="App">
31
31
  <Container>
32
- <footer><p className="ft">© 2025 by Conor Heffron | {version}</p></footer>
32
+ <footer><p className="ft">
33
+ © 2025 by Conor Heffron | <a href="https://github.com/conorheffron/ironoc"
34
+ target="_blank" rel="noreferrer">{version}</a></p></footer>
33
35
  </Container>
34
36
  </div>
35
37
  );
@@ -2,13 +2,26 @@ import React, { Component } from 'react';
2
2
  import { Carousel } from 'react-bootstrap';
3
3
  import '.././App.css';
4
4
 
5
+ // Helper function to validate URLs
6
+ function isValidUrl(url) {
7
+ try {
8
+ const parsed = new URL(url);
9
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
10
+ } catch (e) {
11
+ return false;
12
+ }
13
+ }
14
+
5
15
  class CoffeeCarousel extends Component {
6
16
  render() {
7
17
  const { items } = this.props;
8
18
 
19
+ // Only include items with a valid http/https URL
20
+ const validItems = items.filter(item => isValidUrl(item.image));
21
+
9
22
  return (
10
23
  <Carousel className="App-header">
11
- {items.map((item, index) => (
24
+ {validItems.map((item, index) => (
12
25
  <Carousel.Item key={index}>
13
26
  <img src={item.image} alt={item.title} />
14
27
  <Carousel.Caption>
@@ -1,124 +1,177 @@
1
- import React, { Component } from 'react';
2
- import { Button, Container, InputGroup, Table } from 'reactstrap';
3
- import '.././App.css';
4
- import Form from 'react-bootstrap/Form';
5
- import AppNavbar from '.././AppNavbar';
6
- import LoadingSpinner from '.././LoadingSpinner';
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { Container, InputGroup, Form, Button } from 'react-bootstrap';
3
+ import AppNavbar from '../AppNavbar';
4
+ import LoadingSpinner from '../LoadingSpinner';
7
5
  import { useParams, useNavigate } from 'react-router';
6
+ import {
7
+ MaterialReactTable,
8
+ useMaterialReactTable,
9
+ type MRT_ColumnDef,
10
+ } from 'material-react-table';
11
+ import { darken, lighten, useTheme } from '@mui/material';
8
12
 
9
- // Helper function to inject `params` and `navigate` into class-based components
10
- function withRouter(Component) {
11
- return (props) => {
12
- const params = useParams();
13
- const navigate = useNavigate();
14
- return <Component {...props} params={params} navigate={navigate} />;
15
- };
16
- }
13
+ const RepoIssues = () => {
14
+ const params = useParams();
15
+ const navigate = useNavigate();
16
+ const { id = '', repo = '' } = params;
17
17
 
18
- class RepoIssues extends Component {
19
- constructor(props) {
20
- super(props);
21
- this.state = {
22
- repoIssueList: [],
23
- isLoading: true,
24
- value: ''
25
- };
18
+ const [repoIssueList, setRepoIssueList] = useState([]);
19
+ const [isLoading, setIsLoading] = useState(true);
20
+ const [value, setValue] = useState('');
26
21
 
27
- this.handleChange = this.handleChange.bind(this);
28
- this.onSubmit = this.onSubmit.bind(this);
29
- }
22
+ useEffect(() => {
23
+ const fetchIssues = async () => {
24
+ if (id && repo) {
25
+ const response = await fetch(`/api/get-repo-issue/${id}/${repo}/`);
26
+ const body = await response.json();
27
+ setRepoIssueList(body);
28
+ }
29
+ setIsLoading(false);
30
+ };
31
+ fetchIssues();
32
+ }, [id, repo]);
30
33
 
31
- handleChange(event) {
32
- this.setState({ value: event.target.value });
33
- }
34
+ const handleChange = event => setValue(event.target.value);
34
35
 
35
- onSubmit(event) {
36
+ const onSubmit = event => {
36
37
  event.preventDefault();
37
- const { value } = this.state;
38
- const { id } = this.props.params;
39
- this.props.navigate(`/issues/${id}/${value}`, {
38
+ navigate(`/issues/${id}/${value}`, {
40
39
  replace: true,
41
40
  state: {
42
41
  id: id,
43
42
  repo: value
44
43
  }
45
44
  });
46
- this.props.navigate(0)
47
- }
45
+ navigate(0);
46
+ };
48
47
 
49
- async componentDidMount() {
50
- const { id, repo } = this.props.params;
51
- if (id && repo) {
52
- const response = await fetch(`/api/get-repo-issue/${id}/${repo}/`);
53
- const body = await response.json();
54
- this.setState({ repoIssueList: body, isLoading: false });
55
- } else {
56
- this.setState({ isLoading: false });
57
- }
58
- }
48
+ const projectLink = `/projects/${id}/`;
59
49
 
60
- render() {
61
- const { repoIssueList = [], isLoading = true, value = '' } = this.state;
62
- const { id = '', repo = '' } = this.props.params;
50
+ const issueTags = ['enhancement', 'bug', 'java', 'javascript', 'release', 'ui', 'frontend', 'infra', 'dependencies', 'python-code']
63
51
 
64
- if (isLoading) {
65
- return (
66
- <div className="App">
67
- <AppNavbar />
68
- <Container>
69
- <br /><br /><br />
70
- <LoadingSpinner />
71
- </Container>
72
- </div>
73
- );
74
- }
52
+ // MaterialReactTable columns
53
+ const columns = useMemo(() => [
54
+ {
55
+ accessorKey: 'number',
56
+ header: 'Issue No.',
57
+ Cell: ({ cell }) => {
58
+ const issueLink = `https://github.com/${id}/${repo}/issues/${cell.getValue()}/`;
59
+ return (
60
+ <a href={issueLink} target="_blank" rel="noreferrer">
61
+ <b><i>{cell.getValue()}</i></b>
62
+ </a>
63
+ );
64
+ },
65
+ size: 40,
66
+ },
67
+ {
68
+ accessorKey: 'state',
69
+ header: 'State',
70
+ filterVariant: 'multi-select',
71
+ size: 40,
72
+ },
73
+ {
74
+ accessorKey: 'labels',
75
+ header: 'Labels',
76
+ filterVariant: 'multi-select',
77
+ filterSelectOptions: issueTags,
78
+ Cell: ({ cell }) => <b>{cell.getValue().join(', ')}</b>,
79
+ size: 100,
80
+ },
81
+ {
82
+ accessorKey: 'title',
83
+ header: 'Title',
84
+ size: 150,
85
+ Cell: ({ cell }) => <b>{cell.getValue()}</b>,
86
+ },
87
+ {
88
+ accessorKey: 'body',
89
+ header: 'Description',
90
+ size: 200,
91
+ },
92
+ ], [id, repo]);
75
93
 
76
- const repoList = repoIssueList.map(issue => {
77
- const issueLink = `https://github.com/${id}/${repo}/issues/${issue.number}/`;
78
- return (
79
- <tr key={issue.number}>
80
- <td className="table-info">
81
- <a href={issueLink} target="_blank" rel="noreferrer"><b><i>{issue.number}</i></b></a>
82
- </td>
83
- <td><b>{issue.title}</b></td>
84
- <td>{issue.body}</td>
85
- </tr>
86
- );
87
- });
94
+ const theme = useTheme();
95
+
96
+ //light or dark green
97
+ const baseBackgroundColor =
98
+ theme.palette.mode === 'dark'
99
+ ? 'rgba(3, 44, 43, 1)'
100
+ : 'rgba(244, 255, 233, 1)';
88
101
 
89
- const projectLink = `/projects/${id}/`;
102
+ const table = useMaterialReactTable({
103
+ columns,
104
+ data: repoIssueList,
105
+ enableFacetedValues: true,
106
+ enableStickyHeader: true,
107
+ initialState: { showColumnFilters: true },
108
+ muiTablePaperProps: {
109
+ elevation: 0,
110
+ sx: {
111
+ borderRadius: '0',
112
+ },
113
+ },
114
+ muiTableBodyProps: {
115
+ sx: (theme) => ({
116
+ '& tr:nth-of-type(odd):not([data-selected="true"]):not([data-pinned="true"]) > td':
117
+ {
118
+ backgroundColor: darken(baseBackgroundColor, 0.1),
119
+ },
120
+ '& tr:nth-of-type(odd):not([data-selected="true"]):not([data-pinned="true"]):hover > td':
121
+ {
122
+ backgroundColor: darken(baseBackgroundColor, 0.2),
123
+ },
124
+ '& tr:nth-of-type(even):not([data-selected="true"]):not([data-pinned="true"]) > td':
125
+ {
126
+ backgroundColor: lighten(baseBackgroundColor, 0.1),
127
+ },
128
+ '& tr:nth-of-type(even):not([data-selected="true"]):not([data-pinned="true"]):hover > td':
129
+ {
130
+ backgroundColor: darken(baseBackgroundColor, 0.2),
131
+ },
132
+ }),
133
+ },
134
+ mrtTheme: (theme) => ({
135
+ baseBackgroundColor: baseBackgroundColor,
136
+ draggingBorderColor: theme.palette.secondary.main,
137
+ }),
138
+ });
90
139
 
140
+ if (isLoading) {
91
141
  return (
92
- <div>
93
- <AppNavbar /><br /><br />
94
- <Container fluid>
95
- <br />
96
- <InputGroup className="mb-3">
97
- <Form.Control
98
- placeholder="Enter Project Name... Example: ironoc-db"
99
- aria-label="Enter Project Name..."
100
- aria-describedby="basic-addon2"
101
- type="text"
102
- value={value}
103
- onChange={this.handleChange}
104
- />
105
- <Button color="primary" variant="outline-secondary" id="button-addon2" onClick={this.onSubmit}>Search Issues</Button>
106
- </InputGroup>
107
- <h3 className="table-headers">Issues for project <b>{repo}</b> and account <a href={projectLink}><b>{id}</b></a></h3>
108
- <Table striped hover bordered>
109
- <thead>
110
- <tr className="table-secondary">
111
- <th width="3%">Issue No.</th>
112
- <th width="27%">Title</th>
113
- <th width="70%">Description</th>
114
- </tr>
115
- </thead>
116
- <tbody>{repoList}</tbody>
117
- </Table>
142
+ <div className="App">
143
+ <AppNavbar />
144
+ <Container>
145
+ <br /><br /><br />
146
+ <LoadingSpinner />
118
147
  </Container>
119
148
  </div>
120
149
  );
121
150
  }
122
- }
123
151
 
124
- export default withRouter(RepoIssues);
152
+ return (
153
+ <div>
154
+ <AppNavbar /><br /><br />
155
+ <Container fluid>
156
+ <br />
157
+ <InputGroup className="mb-3">
158
+ <Form.Control
159
+ placeholder="Enter Project Name... Example: ironoc-db"
160
+ aria-label="Enter Project Name..."
161
+ aria-describedby="basic-addon2"
162
+ type="text"
163
+ value={value}
164
+ onChange={handleChange}
165
+ />
166
+ <Button color="primary" variant="outline-secondary" id="button-addon2" onClick={onSubmit}>Search Issues</Button>
167
+ </InputGroup>
168
+ <h3 className="table-headers">
169
+ Issues for project <b>{repo}</b> and account <a href={projectLink}><b>{id}</b></a>
170
+ </h3>
171
+ <MaterialReactTable table={table} />
172
+ </Container>
173
+ </div>
174
+ );
175
+ };
176
+
177
+ export default RepoIssues;
@@ -40,12 +40,12 @@ describe('CoffeeCarousel', () => {
40
40
  test('does not render ingredients if item.ingredients is null or an empty array', () => {
41
41
  const mockItems = [
42
42
  {
43
- image: 'image1.jpg',
43
+ image: 'http://image1.jpg',
44
44
  title: 'Coffee with no ingredients',
45
45
  ingredients: [],
46
46
  },
47
47
  {
48
- image: 'image2.jpg',
48
+ image: 'https://image2.jpg',
49
49
  title: 'Coffee with null ingredients',
50
50
  ingredients: null,
51
51
  },
@@ -1,121 +1,106 @@
1
1
  import React from 'react';
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
- import { MemoryRouter } from 'react-router';
4
3
  import RepoIssues from '../RepoIssues';
5
4
 
5
+ // Mock react-router
6
6
  jest.mock('react-router', () => ({
7
- ...jest.requireActual('react-router'),
8
- useParams: jest.fn(),
9
- useNavigate: jest.fn(),
7
+ useParams: jest.fn(),
8
+ useNavigate: jest.fn(),
10
9
  }));
11
10
 
12
- describe('RepoIssues Component', () => {
13
- const mockNavigate = jest.fn();
14
- const mockParams = { id: 'testUser', repo: 'testRepo' };
15
-
16
- beforeEach(() => {
17
- require('react-router').useNavigate.mockReturnValue(mockNavigate);
18
- require('react-router').useParams.mockReturnValue(mockParams);
19
-
20
- global.fetch = jest.fn(() =>
21
- Promise.resolve({
22
- json: () =>
23
- Promise.resolve([
24
- {
25
- number: 1,
26
- title: 'Test Issue Title',
27
- body: 'Test issue body description',
28
- },
29
- ]),
30
- })
31
- );
32
- });
33
-
34
- afterEach(() => {
35
- jest.clearAllMocks();
36
- });
37
-
38
- test('renders loading spinner initially', () => {
39
- render(
40
- <MemoryRouter>
41
- <RepoIssues />
42
- </MemoryRouter>
43
- );
44
-
45
- expect(screen.getByText(/loading/i)).toBeInTheDocument();
46
- });
47
-
48
- test('fetches and displays repo issues', async () => {
49
- render(
50
- <MemoryRouter>
51
- <RepoIssues />
52
- </MemoryRouter>
53
- );
54
-
55
- await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
56
-
57
- expect(screen.getByText(/Test Issue Title/i)).toBeInTheDocument();
58
- expect(screen.getByText(/Test issue body description/i)).toBeInTheDocument();
59
- expect(screen.getByText(/1/i)).toBeInTheDocument();
60
- });
61
-
62
- test('handles input change', async () => {
63
- render(
64
- <MemoryRouter>
65
- <RepoIssues />
66
- </MemoryRouter>
67
- );
68
-
69
- await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
70
-
71
- const input = screen.getByPlaceholderText(/Enter Project Name/i);
72
- fireEvent.change(input, { target: { value: 'newRepo' } });
73
-
74
- expect(input.value).toBe('newRepo');
75
- });
76
-
77
- test('navigates on form submission', async () => {
78
- render(
79
- <MemoryRouter>
80
- <RepoIssues />
81
- </MemoryRouter>
82
- );
83
-
84
- await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
85
-
86
- const input = screen.getByPlaceholderText(/Enter Project Name/i);
87
- const button = screen.getByText(/Search Issues/i);
88
-
89
- fireEvent.change(input, { target: { value: 'newRepo' } });
90
- fireEvent.click(button);
91
-
92
- expect(mockNavigate).toHaveBeenCalledWith('/issues/testUser/newRepo', {
93
- replace: true,
94
- state: {
95
- id: 'testUser',
96
- repo: 'newRepo',
97
- },
98
- });
99
- });
100
-
101
- test('displays table headers correctly', async () => {
102
- render(
103
- <MemoryRouter>
104
- <RepoIssues />
105
- </MemoryRouter>
106
- );
11
+ // Mock react-bootstrap
12
+ jest.mock('react-bootstrap', () => ({
13
+ Container: ({ children, ...props }) => <div data-testid="container" {...props}>{children}</div>,
14
+ InputGroup: ({ children, ...props }) => <div data-testid="input-group" {...props}>{children}</div>,
15
+ Form: {
16
+ Control: ({ ...props }) => <input data-testid="form-control" {...props} />,
17
+ },
18
+ Button: ({ children, ...props }) => <button {...props}>{children}</button>,
19
+ }));
107
20
 
108
- // Wait for the loading spinner to disappear
109
- await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
21
+ // Mock AppNavbar and LoadingSpinner
22
+ jest.mock('../../AppNavbar', () => () => <div data-testid="navbar">Navbar</div>);
23
+ jest.mock('../../LoadingSpinner', () => () => <div data-testid="spinner">Loading...</div>);
24
+
25
+ // Mock MaterialReactTable and useMaterialReactTable
26
+ jest.mock('material-react-table', () => ({
27
+ MaterialReactTable: ({ table }) => (
28
+ <div data-testid="mrt-table">
29
+ {table && table.data && table.data.map((issue, idx) => (
30
+ <div key={idx} data-testid="mrt-row">{issue.title}</div>
31
+ ))}
32
+ </div>
33
+ ),
34
+ useMaterialReactTable: jest.fn((opts) => opts),
35
+ }));
110
36
 
111
- // Use getByRole for specific headers
112
- const issueNoHeader = screen.getByRole('columnheader', { name: /Issue No./i });
113
- const titleHeader = screen.getByRole('columnheader', { name: /Title/i });
114
- const descriptionHeader = screen.getByRole('columnheader', { name: /Description/i });
37
+ // Mock @mui/material theme functions
38
+ jest.mock('@mui/material', () => ({
39
+ useTheme: () => ({
40
+ palette: {
41
+ mode: 'light',
42
+ secondary: { main: '#00ff00' }
43
+ }
44
+ }),
45
+ darken: (color, amount) => color + '-darken' + amount,
46
+ lighten: (color, amount) => color + '-lighten' + amount,
47
+ }));
115
48
 
116
- // Assert that headers are in the document
117
- expect(issueNoHeader).toBeInTheDocument();
118
- expect(titleHeader).toBeInTheDocument();
119
- expect(descriptionHeader).toBeInTheDocument();
49
+ import { useParams, useNavigate } from 'react-router';
50
+
51
+ describe('RepoIssues', () => {
52
+ const mockNavigate = jest.fn();
53
+
54
+ beforeEach(() => {
55
+ jest.clearAllMocks();
56
+ useNavigate.mockReturnValue(mockNavigate);
57
+ });
58
+
59
+ it('shows loading spinner and navbar initially', () => {
60
+ useParams.mockReturnValue({ id: 'user', repo: 'repo' });
61
+ render(<RepoIssues />);
62
+ expect(screen.getByTestId('navbar')).toBeInTheDocument();
63
+ expect(screen.getByTestId('spinner')).toBeInTheDocument();
64
+ });
65
+
66
+ it('fetches and displays issues in the table after loading', async () => {
67
+ useParams.mockReturnValue({ id: 'user', repo: 'repo' });
68
+ global.fetch = jest.fn(() =>
69
+ Promise.resolve({
70
+ json: () => Promise.resolve([
71
+ {
72
+ number: 1,
73
+ state: 'open',
74
+ labels: ['bug'],
75
+ title: 'Test Issue',
76
+ body: 'Body here',
77
+ },
78
+ ]),
79
+ })
80
+ );
81
+ render(<RepoIssues />);
82
+ await waitFor(() =>
83
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
84
+ );
85
+ expect(screen.getByTestId('mrt-table')).toBeInTheDocument();
86
+ });
87
+
88
+ it('navigates on form submit', async () => {
89
+ useParams.mockReturnValue({ id: 'user', repo: 'repo' });
90
+ global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }));
91
+ render(<RepoIssues />);
92
+ await waitFor(() => expect(screen.queryByTestId('spinner')).not.toBeInTheDocument());
93
+ const input = screen.getByTestId('form-control');
94
+ const button = screen.getByText(/Search Issues/i);
95
+ fireEvent.change(input, { target: { value: 'newrepo' } });
96
+ fireEvent.click(button);
97
+ expect(mockNavigate).toHaveBeenCalledWith('/issues/user/newrepo', {
98
+ replace: true,
99
+ state: {
100
+ id: 'user',
101
+ repo: 'newrepo',
102
+ },
120
103
  });
104
+ expect(mockNavigate).toHaveBeenCalledWith(0);
105
+ });
121
106
  });