@conorheffron/ironoc-frontend 9.1.5 → 9.1.7

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": "9.1.5",
3
+ "version": "9.1.7",
4
4
  "private": false,
5
5
  "license": "GPL-3.0-or-later",
6
6
  "dependencies": {
package/src/App.test.js CHANGED
@@ -9,6 +9,11 @@ import AppNavBar from './AppNavbar';
9
9
  import About from './components/About';
10
10
  import Footer from './Footer';
11
11
  import { Router, useLocation, MemoryRouter } from 'react-router';
12
+ import { trackClickOut } from './utils/activityTracker';
13
+
14
+ jest.mock('./utils/activityTracker', () => ({
15
+ trackClickOut: jest.fn(),
16
+ }));
12
17
 
13
18
  describe('AppNavBar', () => {
14
19
  test('renders AppNavBar component correctly', () => {
@@ -79,6 +84,13 @@ describe('AppNavBar', () => {
79
84
  fireEvent.click(toggler);
80
85
  await waitFor(() => expect(collapse).not.toHaveClass('show'));
81
86
  });
87
+
88
+ test('tracks click-outs for GitHub project links', () => {
89
+ render(<AppNavBar />);
90
+ const githubProjectLink = screen.getByText('iRonoc-DB');
91
+ fireEvent.click(githubProjectLink);
92
+ expect(trackClickOut).toHaveBeenCalledWith('github', 'https://github.com/conorheffron/ironoc-db');
93
+ });
82
94
  });
83
95
 
84
96
  describe('Footer Component', () => {
package/src/AppNavbar.js CHANGED
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
2
2
  import "bootstrap/dist/css/bootstrap.min.css";
3
3
  import {Navbar, NavbarText, Container, Collapse, NavbarBrand, NavbarToggler, Nav, UncontrolledDropdown, DropdownToggle, DropdownItem, DropdownMenu } from "reactstrap";
4
4
  import logo from './img/robot-logo.png';
5
+ import { trackClickOut } from './utils/activityTracker';
5
6
  import "@fontsource/montserrat/700.css";
6
7
  import "@fontsource/open-sans/400-italic.css";
7
8
 
@@ -34,6 +35,10 @@ class AppNavBar extends Component {
34
35
  this.setState({ isOpen: !this.state.isOpen });
35
36
  };
36
37
 
38
+ handleGitHubClick = (target) => {
39
+ trackClickOut('github', target);
40
+ };
41
+
37
42
  render() {
38
43
  const { color, light, dark, fixed, container, expand, className, isOpen } = this.state;
39
44
 
@@ -76,12 +81,12 @@ class AppNavBar extends Component {
76
81
  <UncontrolledDropdown inNavbar nav>
77
82
  <DropdownToggle caret nav>GitHub Projects</DropdownToggle>
78
83
  <DropdownMenu end>
79
- <DropdownItem target="_blank" href="https://github.com/conorheffron/ironoc">iRonoc</DropdownItem>
84
+ <DropdownItem target="_blank" href="https://github.com/conorheffron/ironoc" onClick={() => this.handleGitHubClick('https://github.com/conorheffron/ironoc')}>iRonoc</DropdownItem>
80
85
  <DropdownItem divider />
81
- <DropdownItem target="_blank" href="https://github.com/conorheffron/ironoc-db">iRonoc-DB</DropdownItem>
82
- <DropdownItem target="_blank" href="https://github.com/conorheffron/booking-sys">Booking System Sample</DropdownItem>
83
- <DropdownItem target="_blank" href="https://github.com/conorheffron/nba-stats">NBA Stats Analysis</DropdownItem>
84
- <DropdownItem target="_blank" href="https://github.com/conorheffron/ironoc-pytest">PyTest GitHub Client Package</DropdownItem>
86
+ <DropdownItem target="_blank" href="https://github.com/conorheffron/ironoc-db" onClick={() => this.handleGitHubClick('https://github.com/conorheffron/ironoc-db')}>iRonoc-DB</DropdownItem>
87
+ <DropdownItem target="_blank" href="https://github.com/conorheffron/booking-sys" onClick={() => this.handleGitHubClick('https://github.com/conorheffron/booking-sys')}>Booking System Sample</DropdownItem>
88
+ <DropdownItem target="_blank" href="https://github.com/conorheffron/nba-stats" onClick={() => this.handleGitHubClick('https://github.com/conorheffron/nba-stats')}>NBA Stats Analysis</DropdownItem>
89
+ <DropdownItem target="_blank" href="https://github.com/conorheffron/ironoc-pytest" onClick={() => this.handleGitHubClick('https://github.com/conorheffron/ironoc-pytest')}>PyTest GitHub Client Package</DropdownItem>
85
90
  </DropdownMenu>
86
91
  </UncontrolledDropdown>
87
92
  <UncontrolledDropdown inNavbar nav>
@@ -8,6 +8,7 @@ import AppNavbar from '../AppNavbar';
8
8
  import red from '../img/red-bg.png';
9
9
  import { Container } from 'reactstrap';
10
10
  import LoadingSpinner from '.././LoadingSpinner';
11
+ import { trackClickOut } from '../utils/activityTracker';
11
12
 
12
13
  // Define the GraphQL query to fetch donate items
13
14
  export const GET_DONATE_ITEMS = gql`
@@ -78,6 +79,58 @@ class Donate extends Component {
78
79
  );
79
80
  }
80
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
+ );
81
134
  return (
82
135
  <div className="App">
83
136
  <AppNavbar />
@@ -6,7 +6,6 @@ import { useParams, useNavigate } from 'react-router';
6
6
  import {
7
7
  MaterialReactTable,
8
8
  useMaterialReactTable,
9
- type MRT_ColumnDef,
10
9
  } from 'material-react-table';
11
10
  import { darken, lighten, useTheme } from '@mui/material';
12
11
 
@@ -126,7 +125,14 @@ const RepoIssues = () => {
126
125
  data: repoIssueList,
127
126
  enableFacetedValues: true,
128
127
  enableStickyHeader: true,
129
- initialState: { showColumnFilters: true },
128
+ initialState: {
129
+ showColumnFilters: true,
130
+ columnFilters: [{ id: 'state', value: ['open'] }],
131
+ columnVisibility: {
132
+ state: false,
133
+ body: false,
134
+ },
135
+ },
130
136
  muiTablePaperProps: {
131
137
  elevation: 0,
132
138
  sx: {
@@ -1,8 +1,13 @@
1
1
  import React from 'react';
2
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
3
  import { MockedProvider } from '@apollo/client/testing';
4
4
  import Donate, { GET_DONATE_ITEMS } from '../Donate';
5
5
  import '@testing-library/jest-dom';
6
+ import { trackClickOut } from '../../utils/activityTracker';
7
+
8
+ jest.mock('../../utils/activityTracker', () => ({
9
+ trackClickOut: jest.fn(),
10
+ }));
6
11
 
7
12
  // Mock data for testing
8
13
  const mockDonateItems = [
@@ -94,4 +99,27 @@ describe('Donate Component', () => {
94
99
  });
95
100
  });
96
101
  });
102
+
103
+ it('tracks charity click-outs for donate and homepage links', async () => {
104
+ render(
105
+ <MockedProvider mocks={mocks}>
106
+ <Donate />
107
+ </MockedProvider>
108
+ );
109
+
110
+ await waitFor(() => {
111
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
112
+ });
113
+
114
+ const donateLink = document.querySelector('a[href="https://example.com/donate1"]');
115
+ const homepageLink = document.querySelector('a[href="https://example.com/home1"]');
116
+ expect(donateLink).toBeInTheDocument();
117
+ expect(homepageLink).toBeInTheDocument();
118
+
119
+ fireEvent.click(donateLink);
120
+ fireEvent.click(homepageLink);
121
+
122
+ expect(trackClickOut).toHaveBeenCalledWith('charity', 'https://example.com/donate1');
123
+ expect(trackClickOut).toHaveBeenCalledWith('charity', 'https://example.com/home1');
124
+ });
97
125
  });
@@ -2,6 +2,9 @@ import React from 'react';
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import RepoIssues from '../RepoIssues';
4
4
 
5
+ // Track useMaterialReactTable call opts for assertions (must start with 'mock' for Jest hoisting)
6
+ const mockUseMRTOpts = [];
7
+
5
8
  // Mock react-router
6
9
  jest.mock('react-router', () => ({
7
10
  useParams: jest.fn(),
@@ -10,7 +13,11 @@ jest.mock('react-router', () => ({
10
13
 
11
14
  // Mock react-bootstrap
12
15
  jest.mock('react-bootstrap', () => ({
13
- Container: ({ children, ...props }) => <div data-testid="container" {...props}>{children}</div>,
16
+ Container: ({ children, fluid, ...props }) => (
17
+ <div data-testid="container" data-fluid={fluid ? 'true' : undefined} {...props}>
18
+ {children}
19
+ </div>
20
+ ),
14
21
  InputGroup: ({ children, ...props }) => <div data-testid="input-group" {...props}>{children}</div>,
15
22
  Form: {
16
23
  Control: ({ ...props }) => <input data-testid="form-control" {...props} />,
@@ -22,16 +29,31 @@ jest.mock('react-bootstrap', () => ({
22
29
  jest.mock('../../AppNavbar', () => () => <div data-testid="navbar">Navbar</div>);
23
30
  jest.mock('../../LoadingSpinner', () => () => <div data-testid="spinner">Loading...</div>);
24
31
 
25
- // 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.
26
37
  jest.mock('material-react-table', () => ({
27
38
  MaterialReactTable: ({ table }) => (
28
39
  <div data-testid="mrt-table">
29
- {table && table.data && table.data.map((issue, idx) => (
40
+ {(table?.data ?? []).map((issue, idx) => (
30
41
  <div key={idx} data-testid="mrt-row">{issue.title}</div>
31
42
  ))}
32
43
  </div>
33
44
  ),
34
- 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
+ },
35
57
  }));
36
58
 
37
59
  // Mock @mui/material theme functions
@@ -50,14 +72,24 @@ import { useParams, useNavigate } from 'react-router';
50
72
 
51
73
  describe('RepoIssues', () => {
52
74
  const mockNavigate = jest.fn();
75
+ const mockIssuesResponse = [];
53
76
 
54
77
  beforeEach(() => {
78
+ mockUseMRTOpts.length = 0;
55
79
  jest.clearAllMocks();
56
80
  useNavigate.mockReturnValue(mockNavigate);
81
+ global.fetch = jest.fn(() =>
82
+ Promise.resolve({
83
+ json: () => Promise.resolve(mockIssuesResponse),
84
+ })
85
+ );
86
+ window.fetch = global.fetch;
57
87
  });
58
88
 
59
89
  it('shows loading spinner and navbar initially', () => {
60
90
  useParams.mockReturnValue({ id: 'user', repo: 'repo' });
91
+ global.fetch = jest.fn(() => new Promise(() => {}));
92
+ window.fetch = global.fetch;
61
93
  render(<RepoIssues />);
62
94
  expect(screen.getByTestId('navbar')).toBeInTheDocument();
63
95
  expect(screen.getByTestId('spinner')).toBeInTheDocument();
@@ -78,16 +110,58 @@ describe('RepoIssues', () => {
78
110
  ]),
79
111
  })
80
112
  );
113
+ window.fetch = global.fetch;
81
114
  render(<RepoIssues />);
82
115
  await waitFor(() =>
83
116
  expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
84
117
  );
85
118
  expect(screen.getByTestId('mrt-table')).toBeInTheDocument();
119
+ expect(screen.getByText('Test Issue')).toBeInTheDocument();
120
+ });
121
+
122
+ it('defaults to open issues while hiding state and description columns', async () => {
123
+ useParams.mockReturnValue({ id: 'user', repo: 'repo' });
124
+ render(<RepoIssues />);
125
+ await waitFor(() =>
126
+ expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()
127
+ );
128
+
129
+ expect(mockUseMRTOpts).toContainEqual(
130
+ expect.objectContaining({
131
+ initialState: expect.objectContaining({
132
+ showColumnFilters: true,
133
+ columnFilters: [{ id: 'state', value: ['open'] }],
134
+ columnVisibility: {
135
+ state: false,
136
+ body: false,
137
+ },
138
+ }),
139
+ })
140
+ );
141
+ });
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();
86
159
  });
87
160
 
88
161
  it('navigates on form submit', async () => {
89
162
  useParams.mockReturnValue({ id: 'user', repo: 'repo' });
90
163
  global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }));
164
+ window.fetch = global.fetch;
91
165
  render(<RepoIssues />);
92
166
  await waitFor(() => expect(screen.queryByTestId('spinner')).not.toBeInTheDocument());
93
167
  const input = screen.getByTestId('form-control');
@@ -0,0 +1,24 @@
1
+ export const trackClickOut = (category, target) => {
2
+ if (!category || !target) {
3
+ return;
4
+ }
5
+
6
+ const payload = JSON.stringify({ category, target });
7
+
8
+ try {
9
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
10
+ const blob = new Blob([payload], { type: 'application/json' });
11
+ navigator.sendBeacon('/api/activity/click-out', blob);
12
+ return;
13
+ }
14
+
15
+ fetch('/api/activity/click-out', {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/json' },
18
+ body: payload,
19
+ keepalive: true
20
+ }).catch(() => null);
21
+ } catch {
22
+ // no-op to avoid blocking navigation
23
+ }
24
+ };