diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx index bae1f5fd03..b45ed0a2ea 100644 --- a/src/components/Header/Header.jsx +++ b/src/components/Header/Header.jsx @@ -160,7 +160,6 @@ export function Header(props) { const [hasProfileLoaded, setHasProfileLoaded] = useState(false); const dismissalKey = `lastDismissed_${userId}`; const [lastDismissed, setLastDismissed] = useState(localStorage.getItem(dismissalKey)); - const [isAckLoading, setIsAckLoading] = useState(false); const unreadNotifications = props.notification?.unreadNotifications; // List of unread notifications const dispatch = useDispatch(); const history = useHistory(); diff --git a/src/components/Reports/HitsAndApplicationRatio/ConvertedApplicationGraph.jsx b/src/components/Reports/HitsAndApplicationRatio/ConvertedApplicationGraph.jsx index c0bff792b3..d971a0ad33 100644 --- a/src/components/Reports/HitsAndApplicationRatio/ConvertedApplicationGraph.jsx +++ b/src/components/Reports/HitsAndApplicationRatio/ConvertedApplicationGraph.jsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; import { BarChart, Bar, @@ -10,38 +11,41 @@ import { Label, } from 'recharts'; +const truncate = (str, max = 22) => + typeof str === 'string' && str.length > max ? str.slice(0, max) + '…' : str; + const CustomTooltip = ({ active, payload, isDark, usePercentage }) => { if (active && payload && payload.length) { - const job = payload[0].payload; + const job = payload[0]?.payload || {}; + return (
-

Role: {job.title}

-

Conversion Rate:{' '} - {usePercentage ? `${job.conversionRate}%` : job.conversionRate} -

-

Hits: {job.hits}

-

Applications: {job.applications}

+

Role: {job.title}

+

Conversion Rate: {usePercentage ? `${job.conversionRate}%` : job.conversionRate}

+

Hits: {job.hits}

+

Applications: {job.applications}

); } return null; }; -function ConvertedApplicationGraph({ data, usePercentage, isDark }) { +function ConvertedApplicationGraph({ data = [], usePercentage, isDark }) { const sortedData = useMemo(() => { const key = usePercentage ? 'conversionRate' : 'applications'; - const toNum = (val) => (val == null ? 0 : Number(val)); + const toNum = (v) => Number(v) || 0; + + const cloned = [...data]; + cloned.sort((a, b) => toNum(b[key]) - toNum(a[key])); - return [...data] - .sort((a, b) => toNum(b[key]) - toNum(a[key])) - .slice(0, 10); + return cloned.slice(0, 10); }, [data, usePercentage]); return ( @@ -53,56 +57,47 @@ function ConvertedApplicationGraph({ data, usePercentage, isDark }) {

Top 10 Job Postings by {usePercentage ? 'Conversion Rate' : 'Applications'}

+ {sortedData.length === 0 ? (

No data available for the selected date range.

) : ( - + + v} + tick={{ fill: isDark ? '#e2e8f0' : '#374151', fontSize: 12 }} + stroke={isDark ? '#e2e8f0' : '#374151'} > - } - /> + + } /> + `${value}${usePercentage ? '%' : ''}`} - fill={isDark ? '#4682B4' : '#374151'} + style={{ fill: isDark ? '#FFFFFF' : '#374151', fontWeight: 600 }} /> @@ -112,4 +107,17 @@ function ConvertedApplicationGraph({ data, usePercentage, isDark }) { ); } +ConvertedApplicationGraph.propTypes = { + data: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string, + hits: PropTypes.number, + applications: PropTypes.number, + conversionRate: PropTypes.number, + }) + ), + usePercentage: PropTypes.bool.isRequired, + isDark: PropTypes.bool.isRequired, +}; + export default ConvertedApplicationGraph; diff --git a/src/components/Reports/HitsAndApplicationRatio/JobAnalyticsPage.jsx b/src/components/Reports/HitsAndApplicationRatio/JobAnalyticsPage.jsx index 8c13544643..c7e06e5693 100644 --- a/src/components/Reports/HitsAndApplicationRatio/JobAnalyticsPage.jsx +++ b/src/components/Reports/HitsAndApplicationRatio/JobAnalyticsPage.jsx @@ -13,39 +13,42 @@ function JobAnalyticsPage() { const [dateRange, setDateRange] = useState('All'); const [loading, setLoading] = useState(false); - // detect dark mode + // detect global dark mode but override layout ourselves const [isDark, setIsDark] = useState( - typeof document !== 'undefined' && document.querySelector('.dark-mode') !== null + typeof document !== 'undefined' && + document.body.classList.contains('dark-mode') ); useEffect(() => { if (typeof document === 'undefined') return; - const targetNode = document.body; const observer = new MutationObserver(() => { - const darkActive = document.querySelector('.dark-mode') !== null; - setIsDark(darkActive); + setIsDark(document.body.classList.contains('dark-mode')); }); - observer.observe(targetNode, { attributes: true, subtree: true, attributeFilter: ['class'] }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); return () => observer.disconnect(); }, []); useEffect(() => { const { startDate, endDate } = getDateRange(dateRange); + const fetchData = async () => { setLoading(true); + try { const [topRes, leastRes] = await Promise.all([ httpService.get(ENDPOINTS.TOP_CONVERTED(10, startDate, endDate)), httpService.get(ENDPOINTS.LEAST_CONVERTED(10, startDate, endDate)), ]); + setConvertedData(topRes.data); setNonConvertedData(leastRes.data); } catch (err) { - // eslint-disable-next-line no-console - console.error('Error fetching job analytics:', err); setConvertedData([]); setNonConvertedData([]); } finally { @@ -57,71 +60,98 @@ function JobAnalyticsPage() { }, [dateRange]); return ( -
-
-
- - Date Range: - - setDateRange(e.target.value)} + className={`rounded px-2 py-1 ${ + isDark + ? 'bg-space-cadet text-light border border-yinmn-blue' + : 'bg-white text-gray-900 border border-gray-300' + }`} > - {option} - - ))} - + {dateOptions.map((option) => ( + + ))} + +
+ + {/* Show % */} +
+ setUsePercentage(!usePercentage)} + /> + +
+ +
-
- setUsePercentage(!usePercentage)} - /> - + {/* GRAPHS */} +
+ {loading ? ( +

Loading analytics...

+ ) : ( + <> + + + + + )}
- - {loading ? ( -

Loading analytics...

- ) : ( - <> - - - - )} - + ); } export default JobAnalyticsPage; + diff --git a/src/components/Reports/HitsAndApplicationRatio/NonConvertedApplicationsGraph.jsx b/src/components/Reports/HitsAndApplicationRatio/NonConvertedApplicationsGraph.jsx index e48b4b05ea..c067617a1c 100644 --- a/src/components/Reports/HitsAndApplicationRatio/NonConvertedApplicationsGraph.jsx +++ b/src/components/Reports/HitsAndApplicationRatio/NonConvertedApplicationsGraph.jsx @@ -1,5 +1,5 @@ -// eslint-disable-next-line no-unused-vars import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; import { BarChart, Bar, @@ -11,40 +11,43 @@ import { Label, } from 'recharts'; -const toNum = (v, d = 0) => { - const n = Number(v); - return Number.isFinite(n) ? n : d; +const toNum = (value) => { + const n = Number(value); + return Number.isFinite(n) ? n : 0; }; const fmtPct = (v) => `${toNum(v)}%`; const fmtInt = (v) => toNum(v).toLocaleString(); +const truncate = (str, max = 22) => + typeof str === 'string' && str.length > max ? str.slice(0, max) + '…' : str; + const CustomTooltip = ({ active, payload, usePercentage, isDark }) => { if (active && payload && payload.length) { - const job = payload[0].payload; + const job = payload[0]?.payload || {}; return (
-

Role: {job.title}

-

Conversion Rate: {fmtPct(job.conversionRate)}

-

Hits: {fmtInt(job.hits)}

-

Applications: {fmtInt(job.applications)}

+

Role: {job.title}

+

Conversion Rate: {fmtPct(job.conversionRate)}

+

Hits: {fmtInt(job.hits)}

+

Applications: {fmtInt(job.applications)}

); } return null; }; -function NonConvertedApplicationsGraph({ data = [], usePercentage = true, isDark }) { +function NonConvertedApplicationsGraph({ data = [], usePercentage, isDark }) { const normalized = useMemo( () => - (data || []).map((d) => ({ + data.map((d) => ({ ...d, hits: toNum(d.hits), applications: toNum(d.applications), @@ -54,23 +57,25 @@ function NonConvertedApplicationsGraph({ data = [], usePercentage = true, isDark ); const metricKey = usePercentage ? 'conversionRate' : 'applications'; + const rows = useMemo(() => { const sorted = [...normalized].sort((a, b) => { const diff = toNum(a[metricKey]) - toNum(b[metricKey]); - return diff !== 0 ? diff : toNum(a.conversionRate) - toNum(b.conversionRate); + return diff !== 0 + ? diff + : toNum(a.conversionRate) - toNum(b.conversionRate); }); return sorted.slice(0, 10); }, [normalized, metricKey]); const maxValue = useMemo(() => { if (rows.length === 0) return 1; - const m = Math.max(...rows.map((r) => toNum(r[metricKey], 0))); - return Math.max(1, Math.ceil(m * 1.05)); + const max = Math.max(...rows.map((r) => toNum(r[metricKey]))); + return Math.max(1, Math.ceil(max * 1.05)); }, [rows, metricKey]); const xDomain = usePercentage ? [0, 100] : [0, maxValue]; - const xTickFormatter = (v) => (usePercentage ? `${v}%` : fmtInt(v)); - const labelFormatter = (v) => (usePercentage ? fmtPct(v) : fmtInt(v)); + const xTickFormatter = usePercentage ? fmtPct : fmtInt; return (
+ v} + tick={{ fill: isDark ? '#e2e8f0' : '#374151', fontSize: 12 }} + stroke={isDark ? '#e2e8f0' : '#374151'} > - } - /> + + } /> + @@ -143,4 +151,17 @@ function NonConvertedApplicationsGraph({ data = [], usePercentage = true, isDark ); } +NonConvertedApplicationsGraph.propTypes = { + data: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string, + hits: PropTypes.number, + applications: PropTypes.number, + conversionRate: PropTypes.number, + }) + ), + usePercentage: PropTypes.bool.isRequired, + isDark: PropTypes.bool.isRequired, +}; + export default NonConvertedApplicationsGraph; diff --git a/src/components/Reports/HitsAndApplicationRatio/__tests__/ConvertedApplicationGraph.test.jsx b/src/components/Reports/HitsAndApplicationRatio/__tests__/ConvertedApplicationGraph.test.jsx new file mode 100644 index 0000000000..8c3525c0af --- /dev/null +++ b/src/components/Reports/HitsAndApplicationRatio/__tests__/ConvertedApplicationGraph.test.jsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import ConvertedApplicationGraph from '../ConvertedApplicationGraph'; + +const mockData = [ + { title: 'A', applications: 50, conversionRate: 10 }, + { title: 'B', applications: 100, conversionRate: 20 }, +]; + +describe('ConvertedApplicationGraph', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByText(/Top 10 Job Postings/)).toBeInTheDocument(); + }); + + it('sorts data correctly by applications when usePercentage=false', () => { + const sorted = [...mockData].sort((a, b) => b.applications - a.applications); + expect(sorted[0].title).toBe('B'); + }); + + it('switches metric when usePercentage=true', () => { + render(); + expect(screen.getByText(/Conversion Rate/)).toBeInTheDocument(); + }); + + it('renders correctly in dark mode', () => { + render(); + expect(screen.getByText(/Top 10 Job Postings/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Reports/HitsAndApplicationRatio/__tests__/JobAnalyticsPage.test.jsx b/src/components/Reports/HitsAndApplicationRatio/__tests__/JobAnalyticsPage.test.jsx new file mode 100644 index 0000000000..8112be612f --- /dev/null +++ b/src/components/Reports/HitsAndApplicationRatio/__tests__/JobAnalyticsPage.test.jsx @@ -0,0 +1,30 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import JobAnalyticsPage from '../JobAnalyticsPage'; +import httpService from '~/services/httpService'; + +const mockTop = [{ title: 'A', applications: 10, conversionRate: 20, hits: 50 }]; +const mockLeast = [{ title: 'B', applications: 5, conversionRate: 5, hits: 20 }]; + +describe('JobAnalyticsPage', () => { + beforeEach(() => { + vi.spyOn(httpService, 'get').mockImplementation((url) => { + if (url.includes('top')) return Promise.resolve({ data: mockTop }); + if (url.includes('least')) return Promise.resolve({ data: mockLeast }); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders and fetches data', async () => { + render(); + + // The graphs should render titles + await waitFor(() => { + expect( + screen.getByText(/Top 10 Job Postings by Conversion Rate/) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Reports/HitsAndApplicationRatio/__tests__/NonConvertedApplicationsGraph.test.jsx b/src/components/Reports/HitsAndApplicationRatio/__tests__/NonConvertedApplicationsGraph.test.jsx new file mode 100644 index 0000000000..80c06e5817 --- /dev/null +++ b/src/components/Reports/HitsAndApplicationRatio/__tests__/NonConvertedApplicationsGraph.test.jsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import NonConvertedApplicationsGraph from '../NonConvertedApplicationsGraph'; + +const mockData = [ + { title: 'X', applications: 10, conversionRate: 5 }, + { title: 'Y', applications: 20, conversionRate: 2 }, +]; + +describe('NonConvertedApplicationsGraph', () => { + it('renders title based on percentage mode', () => { + render(); + expect(screen.getByText(/Lowest Conversion Rate/)).toBeInTheDocument(); + }); + + it('renders title for non-percentage mode', () => { + render(); + expect(screen.getByText(/Lowest Applications/)).toBeInTheDocument(); + }); + + it('sorts items correctly', () => { + render(); + const sorted = [...mockData].sort((a, b) => a.conversionRate - b.conversionRate); + expect(sorted[0].conversionRate).toBe(2); + }); + + it('handles dark mode styling', () => { + render(); + expect(screen.getByText(/Lowest Conversion Rate/)).toBeInTheDocument(); + }); +}); diff --git a/src/setupTests.js b/src/setupTests.js index f915d66bdc..cc3b0e3e34 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -40,20 +40,20 @@ vi.mock('axios', () => { vi.mock('msw', () => ({ rest: { - get: vi.fn(), - post: vi.fn(), - patch: vi.fn(), - put: vi.fn(), - delete: vi.fn(), + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + put: vi.fn(), + delete: vi.fn(), options: vi.fn(), }, })) vi.mock('msw/node', () => ({ setupServer: () => ({ - listen: () => {}, - resetHandlers: () => {}, - close: () => {}, + listen: () => { }, + resetHandlers: () => { }, + close: () => { }, }), })); @@ -130,4 +130,12 @@ afterAll(() => { } else { console.error = originalConsoleError; } -}); \ No newline at end of file +}); + +class ResizeObserver { + observe() { return undefined; } // noop + unobserve() { return undefined; } // noop + disconnect() { return undefined; } // noop +} + +global.ResizeObserver = ResizeObserver; \ No newline at end of file