@@ -836,23 +923,49 @@ const FRAReportsContent = () => {
{!isRegionalStaff && (
<>
+ {/* Screen-reader announcer */}
+
+
+ {localAlert.active ? localAlert.message : ''}
+
+
+
+ {processingAlert.active ? processingAlert.message : ''}
+
+
+
+ {/* Visible alerts (not in accessibility tree, prevents duplicate screen reads */}
{localAlert.active && (
-
)}
+
+ {processingAlert.active && (
+
+
+
{processingAlert.message}
+
+
+ )}
+
null),
+ getFileTypeError: jest.fn(() => null),
+ getYearError: jest.fn(() => false),
+ getQuarterError: jest.fn(() => false),
+}
+
+jest.mock('./ReportsContext', () => ({
+ ReportsProvider: ({ children }) => children,
+ useReportsContext: () => mockContext,
+}))
+
+jest.mock('../../hooks/useFormSubmission', () => ({
+ useFormSubmission: () => ({
+ isSubmitting: false,
+ executeSubmission: jest.fn(),
+ onSubmitStart: jest.fn(),
+ onSubmitComplete: jest.fn(),
+ }),
+}))
+
+jest.mock('@uswds/uswds/src/js/components', () => ({
+ fileInput: { init: jest.fn() },
+}))
+
+describe('FRAReports processing alert', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockContext.isPolling = {}
+ })
+
+ it('renders processing alert and scrolls into view when active', async () => {
+ const scrollIntoViewMock = jest.fn()
+ mockContext.processingAlert = {
+ active: true,
+ type: 'success',
+ message: 'Processing complete.',
+ }
+ mockContext.processingAlertRef = {
+ current: { scrollIntoView: scrollIntoViewMock },
+ }
+ // Set context values so allFieldsFilled is true and alerts render
+ mockContext.fileTypeInputValue = 'workOutcomesOfTanfExiters'
+ mockContext.yearInputValue = '2021'
+ mockContext.quarterInputValue = 'Q1'
+
+ const state = {
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: { id: 2, type: 'state', code: 'AK', name: 'Alaska' },
+ roles: [{ id: 1, name: 'Data Analyst', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ stts: {
+ sttList: [{ id: 2, type: 'state', code: 'AK', name: 'Alaska' }],
+ loading: false,
+ },
+ fraReports: { submissionHistory: [] },
+ }
+
+ const store = configureStore(state)
+ const { getAllByText } = render(
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(
+ getAllByText('Processing complete.').length
+ ).toBeGreaterThanOrEqual(1)
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({
+ behavior: 'smooth',
+ })
+ })
+
+ // Reset for other tests
+ mockContext.processingAlert = {
+ active: false,
+ type: null,
+ message: null,
+ }
+ mockContext.processingAlertRef = { current: null }
+ mockContext.fileTypeInputValue = ''
+ mockContext.yearInputValue = ''
+ mockContext.quarterInputValue = ''
+ })
+})
+
+describe('FRAReports polling restart', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockContext.isPolling = {}
+ })
+
+ it('calls success callback when restart polling completes', async () => {
+ mockContext.startPolling.mockImplementation(
+ (id, request, test, onSuccess) => {
+ onSuccess({ data: { id: 10, summary: { status: 'Approved' } } })
+ }
+ )
+
+ const submissionHistory = [{ id: 10, summary: { status: 'Pending' } }]
+
+ const state = {
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: { id: 2, type: 'state', code: 'AK', name: 'Alaska' },
+ roles: [{ id: 1, name: 'Data Analyst', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ stts: {
+ sttList: [{ id: 2, type: 'state', code: 'AK', name: 'Alaska' }],
+ loading: false,
+ },
+ fraReports: {
+ submissionHistory,
+ },
+ }
+
+ const store = configureStore(state)
+ render(
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).toHaveBeenCalledTimes(1)
+ expect(mockContext.setProcessingAlertState).toHaveBeenCalledWith({
+ active: true,
+ type: 'success',
+ message: 'Processing complete.',
+ })
+ })
+ })
+
+ it('calls error callback when restart polling fails', async () => {
+ mockContext.startPolling.mockImplementation(
+ (id, request, test, onSuccess, onError) => {
+ onError({ message: 'Network error' })
+ }
+ )
+
+ const submissionHistory = [{ id: 10, summary: { status: 'Pending' } }]
+
+ const state = {
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: { id: 2, type: 'state', code: 'AK', name: 'Alaska' },
+ roles: [{ id: 1, name: 'Data Analyst', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ stts: {
+ sttList: [{ id: 2, type: 'state', code: 'AK', name: 'Alaska' }],
+ loading: false,
+ },
+ fraReports: {
+ submissionHistory,
+ },
+ }
+
+ const store = configureStore(state)
+ render(
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockContext.setLocalAlertState).toHaveBeenCalledWith({
+ active: true,
+ type: 'error',
+ message: 'Network error',
+ })
+ })
+ })
+
+ it('calls timeout callback when restart polling times out', async () => {
+ mockContext.startPolling.mockImplementation(
+ (id, request, test, onSuccess, onError, onTimeout) => {
+ onTimeout(onError)
+ }
+ )
+
+ const submissionHistory = [{ id: 10, summary: { status: 'Pending' } }]
+
+ const state = {
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: { id: 2, type: 'state', code: 'AK', name: 'Alaska' },
+ roles: [{ id: 1, name: 'Data Analyst', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ stts: {
+ sttList: [{ id: 2, type: 'state', code: 'AK', name: 'Alaska' }],
+ loading: false,
+ },
+ fraReports: {
+ submissionHistory,
+ },
+ }
+
+ const store = configureStore(state)
+ render(
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockContext.setLocalAlertState).toHaveBeenCalledWith({
+ active: true,
+ type: 'error',
+ message:
+ 'Exceeded max number of tries to update submission status. Refresh this page to restart polling.',
+ })
+ })
+ })
+
+ it('does not restart polling when only isPolling changes', async () => {
+ const submissionHistory = [{ id: 10, summary: { status: 'Pending' } }]
+
+ const state = {
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: { id: 2, type: 'state', code: 'AK', name: 'Alaska' },
+ roles: [{ id: 1, name: 'Data Analyst', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ stts: {
+ sttList: [{ id: 2, type: 'state', code: 'AK', name: 'Alaska' }],
+ loading: false,
+ },
+ fraReports: {
+ submissionHistory,
+ },
+ }
+
+ const store = configureStore(state)
+ const { rerender } = render(
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).toHaveBeenCalledTimes(1)
+ })
+
+ mockContext.isPolling = { 10: true }
+ rerender(
+
+
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/tdrs-frontend/src/components/Reports/FRAReports.test.js b/tdrs-frontend/src/components/Reports/FRAReports.test.js
index 437606cc9..2e8ae3e10 100644
--- a/tdrs-frontend/src/components/Reports/FRAReports.test.js
+++ b/tdrs-frontend/src/components/Reports/FRAReports.test.js
@@ -1,11 +1,13 @@
import React from 'react'
import { fireEvent, waitFor, render, within } from '@testing-library/react'
-import axios from 'axios'
+import { get, post } from '../../fetch-instance'
import { Provider } from 'react-redux'
import { MemoryRouter } from 'react-router-dom'
import { FRAReports } from '.'
import configureStore from '../../configureStore'
+jest.mock('../../fetch-instance')
+
const initialState = {
auth: {
authenticated: false,
@@ -39,6 +41,19 @@ const makeTestFile = (name, contents = ['test'], type = 'text/plain') =>
describe('FRA Reports Page', () => {
beforeEach(() => {
jest.useFakeTimers()
+ get.mockResolvedValue({ data: [], ok: true, status: 200, error: null })
+ post.mockResolvedValue({
+ data: {
+ id: 1,
+ original_filename: 'test.txt',
+ extension: '.txt',
+ section: 'Active Case Data',
+ quarter: 'Q1',
+ },
+ ok: true,
+ status: 200,
+ error: null,
+ })
})
afterEach(() => {
jest.runOnlyPendingTimers()
@@ -159,15 +174,12 @@ describe('FRA Reports Page', () => {
})
it('Shows upload form once search has been clicked', async () => {
- jest.mock('axios')
- const mockAxios = axios
-
let searchUrl = null
- mockAxios.get.mockImplementation((url) => {
+ get.mockImplementation((url) => {
if (url.includes('/data_files/')) {
searchUrl = url
}
- return Promise.resolve({ data: [] })
+ return Promise.resolve({ data: [], ok: true, status: 200, error: null })
})
const state = {
@@ -233,9 +245,6 @@ describe('FRA Reports Page', () => {
describe('Upload form', () => {
const setup = async () => {
- jest.mock('axios')
- const mockAxios = axios
-
window.HTMLElement.prototype.scrollIntoView = () => {}
const state = {
...initialState,
@@ -259,8 +268,11 @@ describe('FRA Reports Page', () => {
const origDispatch = store.dispatch
store.dispatch = jest.fn(origDispatch)
- mockAxios.post.mockResolvedValue({
+ post.mockResolvedValue({
data: { id: 1 },
+ ok: true,
+ status: 200,
+ error: null,
})
const component = render(
@@ -294,11 +306,12 @@ describe('FRA Reports Page', () => {
expect(getByText('Submit Report')).toBeInTheDocument()
})
- return { ...component, ...store, mockAxios }
+ return { ...component, ...store }
}
it('Allows csv files to be selected and submitted', async () => {
- const { getByText, queryByText, dispatch, container } = await setup()
+ const { getByText, getAllByText, queryByText, dispatch, container } =
+ await setup()
const uploadForm = container.querySelector('#fra-file-upload')
fireEvent.change(uploadForm, {
@@ -317,10 +330,10 @@ describe('FRA Reports Page', () => {
await waitFor(() =>
expect(
- getByText(
+ getAllByText(
`Successfully submitted section: Work Outcomes of TANF Exiters on ${new Date().toDateString()}`
- )
- ).toBeInTheDocument()
+ ).length
+ ).toBeGreaterThanOrEqual(1)
)
expect(
queryByText(
@@ -331,7 +344,8 @@ describe('FRA Reports Page', () => {
})
it('Allows xlsx files to be selected and submitted', async () => {
- const { getByText, queryByText, dispatch, container } = await setup()
+ const { getByText, getAllByText, queryByText, dispatch, container } =
+ await setup()
const uploadForm = container.querySelector('#fra-file-upload')
fireEvent.change(uploadForm, {
@@ -354,10 +368,10 @@ describe('FRA Reports Page', () => {
await waitFor(() =>
expect(
- getByText(
+ getAllByText(
`Successfully submitted section: Work Outcomes of TANF Exiters on ${new Date().toDateString()}`
- )
- ).toBeInTheDocument()
+ ).length
+ ).toBeGreaterThanOrEqual(1)
)
expect(
queryByText(
@@ -371,14 +385,14 @@ describe('FRA Reports Page', () => {
// jest.spyOn(global, 'setTimeout')
const {
getByText,
+ getAllByText,
queryAllByTestId,
queryAllByText,
dispatch,
- mockAxios,
container,
} = await setup()
- mockAxios.post.mockResolvedValue({
+ post.mockResolvedValue({
data: {
id: 1,
original_filename: 'testFile.txt',
@@ -394,41 +408,69 @@ describe('FRA Reports Page', () => {
summary: null,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
- let times = 0
- mockAxios.get.mockImplementation((url) => {
- if (url.includes('/data_files/1/')) {
- // status
- times += 1
+ const statusChecks = { 1: 0, 2: 0 }
+ get.mockImplementation((url) => {
+ const match = url.match(/\/data_files\/(\d+)\//)
+
+ if (match && match[1]) {
+ const id = Number(match[1])
+ statusChecks[id] = (statusChecks[id] || 0) + 1
+ const status = statusChecks[id] > 3 ? 'Approved' : 'Pending'
+
return Promise.resolve({
data: {
- id: 1,
- summary: { status: times > 1 ? 'Approved' : 'Pending' },
+ id,
+ summary: { status },
},
- })
- } else {
- // submission history
- return Promise.resolve({
- data: [
- {
- id: 1,
- original_filename: 'testFile.txt',
- extension: 'txt',
- quarter: 'Q1',
- section: 'Work Outcomes of TANF Exiters',
- slug: '1234-5-6-7890',
- year: '2021',
- s3_version_id: '3210',
- created_at: '2025-02-07T23:38:58+0000',
- submitted_by: 'Test Testerson',
- has_error: false,
- summary: { status: 'Pending' },
- latest_reparse_file_meta: '',
- },
- ],
+ ok: true,
+ status: 200,
+ error: null,
})
}
+
+ // submission history
+ return Promise.resolve({
+ data: [
+ {
+ id: 1,
+ original_filename: 'testFile.txt',
+ extension: 'txt',
+ quarter: 'Q1',
+ section: 'Work Outcomes of TANF Exiters',
+ slug: '1234-5-6-7890',
+ year: '2021',
+ s3_version_id: '3210',
+ created_at: '2025-02-07T23:38:58+0000',
+ submitted_by: 'Test Testerson',
+ has_error: false,
+ summary: { status: 'Pending' },
+ latest_reparse_file_meta: '',
+ },
+ {
+ id: 2,
+ original_filename: 'testFile2.txt',
+ extension: 'txt',
+ quarter: 'Q1',
+ section: 'Work Outcomes of TANF Exiters',
+ slug: '1234-5-6-7891',
+ year: '2021',
+ s3_version_id: '3211',
+ created_at: '2025-02-07T23:38:58+0000',
+ submitted_by: 'Test Testerson',
+ has_error: false,
+ summary: { status: 'Pending' },
+ latest_reparse_file_meta: '',
+ },
+ ],
+ ok: true,
+ status: 200,
+ error: null,
+ })
})
const uploadForm = container.querySelector('#fra-file-upload')
@@ -452,41 +494,51 @@ describe('FRA Reports Page', () => {
await waitFor(() =>
expect(
- getByText(
+ getAllByText(
`Successfully submitted section: Work Outcomes of TANF Exiters on ${new Date().toDateString()}`
- )
- ).toBeInTheDocument()
+ ).length
+ ).toBeGreaterThanOrEqual(1)
)
- await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(6))
+ await waitFor(() => expect(dispatch).toHaveBeenCalled())
- expect(queryAllByTestId('spinner')).toHaveLength(3)
- expect(queryAllByText('Pending')).toHaveLength(2)
-
- jest.runOnlyPendingTimers()
+ const historySection = container.querySelector(
+ '.submission-history-section'
+ )
+ const firstTableBody = historySection.querySelector('tbody')
+ const rows = within(firstTableBody).queryAllByRole('row').slice(0, 2)
+ const rowSpinners = rows
+ .map((row) => within(row).queryAllByTestId('spinner')[0])
+ .filter(Boolean)
+ const rowPending = rows
+ .map((row) => within(row).queryAllByText('Pending')[0])
+ .filter(Boolean)
+ expect(rowSpinners).toHaveLength(2)
+ expect(rowPending).toHaveLength(2)
+
+ // Advance timers repeatedly to allow all polling cycles to complete
+ // (statusChecks > 3 per row to transition from Pending to Approved)
+ for (let i = 0; i < 10; i++) {
+ jest.runOnlyPendingTimers()
+ await waitFor(() => {})
+ }
- expect(mockAxios.get).toHaveBeenCalledTimes(4)
- expect(times).toBe(2)
+ expect(get).toHaveBeenCalled()
await waitFor(() => {
- expect(getByText('Approved')).toBeInTheDocument()
+ expect(getAllByText('Approved').length).toBeGreaterThanOrEqual(1)
+ expect(queryAllByTestId('spinner')).toHaveLength(0)
+ expect(queryAllByText('Pending')).toHaveLength(0)
})
-
- expect(queryAllByTestId('spinner')).toHaveLength(0)
- expect(queryAllByText('Pending')).toHaveLength(0)
- expect(getByText('Approved')).toBeInTheDocument()
})
it('Shows an error if file submission failed', async () => {
- jest.mock('axios')
- const mockAxios = axios
- const { getByText, dispatch, container } = await setup()
-
- mockAxios.post.mockRejectedValue({
- message: 'Error',
- response: {
- status: 400,
- data: { detail: 'Mock fail response' },
- },
+ const { getByText, getAllByText, dispatch, container } = await setup()
+
+ post.mockResolvedValue({
+ data: { detail: 'Mock fail response' },
+ ok: false,
+ status: 400,
+ error: new Error('HTTP 400'),
})
const uploadForm = container.querySelector('#fra-file-upload')
@@ -505,16 +557,24 @@ describe('FRA Reports Page', () => {
fireEvent.click(submitButton)
await waitFor(() =>
- expect(getByText('Error: Mock fail response')).toBeInTheDocument()
+ expect(
+ getAllByText('HTTP 400: Mock fail response').length
+ ).toBeGreaterThanOrEqual(1)
)
await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(4))
})
it('Shows an error if a no file is selected for submission', async () => {
- const { getByText } = await setup()
+ const { getByText, getAllByText } = await setup()
const submitButton = getByText('Submit Report', { selector: 'button' })
- expect(submitButton).not.toBeEnabled()
+ fireEvent.click(submitButton)
+
+ await waitFor(() =>
+ expect(
+ getAllByText('No changes have been made to data files').length
+ ).toBeGreaterThan(0)
+ )
})
it('Shows an error if a non-allowed file type is selected', async () => {
@@ -613,7 +673,7 @@ describe('FRA Reports Page', () => {
it('Does not show a message if input is changed after uploading a file', async () => {
const {
getByText,
- getByRole,
+ getAllByText,
container,
getByLabelText,
queryByText,
@@ -635,7 +695,13 @@ describe('FRA Reports Page', () => {
fireEvent.click(getByText(/Submit Report/, { selector: 'button' }))
await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(4))
- await waitFor(() => getByRole('alert'))
+ await waitFor(() =>
+ expect(
+ getAllByText(
+ `Successfully submitted section: Work Outcomes of TANF Exiters on ${new Date().toDateString()}`
+ ).length
+ ).toBeGreaterThanOrEqual(1)
+ )
const yearsDropdown = getByLabelText('Fiscal Year (October - September)*')
fireEvent.change(yearsDropdown, { target: { value: '2024' } })
@@ -765,9 +831,6 @@ describe('FRA Reports Page', () => {
describe('Submission History', () => {
const setup = async (submissionHistoryApiResponse = []) => {
- jest.mock('axios')
- const mockAxios = axios
-
window.HTMLElement.prototype.scrollIntoView = () => {}
const state = {
...initialState,
@@ -806,8 +869,11 @@ describe('FRA Reports Page', () => {
const { getByLabelText, getByText } = component
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: submissionHistoryApiResponse,
+ ok: true,
+ status: 200,
+ error: null,
})
// fill out the form values before clicking search
diff --git a/tdrs-frontend/src/components/Reports/Reports.test.js b/tdrs-frontend/src/components/Reports/Reports.test.js
index a31ca712d..ac5bfb5f8 100644
--- a/tdrs-frontend/src/components/Reports/Reports.test.js
+++ b/tdrs-frontend/src/components/Reports/Reports.test.js
@@ -4,12 +4,14 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Provider } from 'react-redux'
import { MemoryRouter } from 'react-router-dom'
import { thunk } from 'redux-thunk'
-import axios from 'axios'
+import { get, post } from '../../fetch-instance'
import configureStore from 'redux-mock-store'
import appConfigureStore from '../../configureStore'
import Reports from './Reports'
import { SET_FILE, upload } from '../../actions/reports'
+jest.mock('../../fetch-instance')
+
describe('Reports', () => {
let originalScrollIntoView
@@ -19,6 +21,21 @@ describe('Reports', () => {
// Mock it for all tests
window.HTMLElement.prototype.scrollIntoView = jest.fn()
jest.useFakeTimers()
+
+ // Set default mock return values for fetch-instance functions
+ get.mockResolvedValue({ data: [], ok: true, status: 200, error: null })
+ post.mockResolvedValue({
+ data: {
+ id: 1,
+ original_filename: 'test.txt',
+ extension: '.txt',
+ section: 'Active Case Data',
+ quarter: 'Q3',
+ },
+ ok: true,
+ status: 200,
+ error: null,
+ })
})
afterEach(() => {
@@ -368,7 +385,7 @@ describe('Reports', () => {
const origDispatch = store.dispatch
store.dispatch = jest.fn(origDispatch)
- const { getByText, getByLabelText, getByRole } = render(
+ const { getByText, getAllByRole, getByLabelText } = render(
@@ -407,11 +424,17 @@ describe('Reports', () => {
await waitFor(() => expect(getByText('section2.txt')).toBeInTheDocument())
await waitFor(() => expect(getByText('section3.txt')).toBeInTheDocument())
await waitFor(() => expect(getByText('section4.txt')).toBeInTheDocument())
- await waitFor(() => expect(getByText('Submit Data Files')).toBeEnabled())
- expect(store.dispatch).toHaveBeenCalledTimes(14)
+ await waitFor(() => expect(store.dispatch).toHaveBeenCalledTimes(14))
fireEvent.click(getByText('Submit Data Files'))
- await waitFor(() => getByRole('alert'))
+ await waitFor(() => {
+ const statusElements = getAllByRole('status')
+ expect(
+ statusElements.some((el) =>
+ el.textContent.includes('Successfully submitted')
+ )
+ ).toBe(true)
+ })
expect(store.dispatch).toHaveBeenCalledTimes(18)
})
@@ -1036,10 +1059,8 @@ describe('Reports', () => {
it('should show spinners while the upload is parsing', async () => {
jest.useFakeTimers()
- jest.mock('axios')
- const mockAxios = axios
- mockAxios.post.mockResolvedValue({
+ post.mockResolvedValue({
data: {
id: 1,
original_filename: 'testFile.txt',
@@ -1055,10 +1076,13 @@ describe('Reports', () => {
summary: null,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
let times = 0
- mockAxios.get.mockImplementation((url) => {
+ get.mockImplementation((url) => {
if (url.includes('/data_files/1/')) {
// status
times += 1
@@ -1080,6 +1104,9 @@ describe('Reports', () => {
has_error: false,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
} else {
// submission history
@@ -1101,6 +1128,9 @@ describe('Reports', () => {
latest_reparse_file_meta: '',
},
],
+ ok: true,
+ status: 200,
+ error: null,
})
}
})
@@ -1130,6 +1160,7 @@ describe('Reports', () => {
const {
getByText,
+ getAllByText,
queryByText,
getByLabelText,
queryAllByTestId,
@@ -1184,16 +1215,16 @@ describe('Reports', () => {
await waitFor(() =>
expect(
- getByText(
+ getAllByText(
`Successfully submitted section(s): 1 on ${new Date().toDateString()}`
- )
- ).toBeInTheDocument()
+ ).length
+ ).toBeGreaterThanOrEqual(1)
)
await waitFor(() => expect(store.dispatch).toHaveBeenCalledTimes(9))
// act(() => jest.advanceTimersByTime(2000))
- expect(mockAxios.get).toHaveBeenCalledTimes(2)
+ expect(get).toHaveBeenCalledTimes(2)
expect(times).toBe(1)
fireEvent.click(getByText('Submission History'))
@@ -1222,11 +1253,9 @@ describe('Reports', () => {
it('should show spinners while multiple uploads are parsing', async () => {
jest.useFakeTimers()
- jest.mock('axios')
- const mockAxios = axios
let postTimes = 0
- mockAxios.post.mockImplementation((url) => {
+ post.mockImplementation((url) => {
postTimes += 1
if (postTimes === 1) {
@@ -1246,6 +1275,9 @@ describe('Reports', () => {
summary: null,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
}
@@ -1265,12 +1297,15 @@ describe('Reports', () => {
summary: null,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
})
let times1 = 0
let times2 = 0
- mockAxios.get.mockImplementation((url) => {
+ get.mockImplementation((url) => {
if (url.includes('/data_files/1/')) {
// status
times1 += 1
@@ -1292,6 +1327,9 @@ describe('Reports', () => {
has_error: false,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
} else if (url.includes('/data_files/2/')) {
// status
@@ -1314,6 +1352,9 @@ describe('Reports', () => {
has_error: false,
latest_reparse_file_meta: '',
},
+ ok: true,
+ status: 200,
+ error: null,
})
} else {
// submission history
@@ -1350,6 +1391,9 @@ describe('Reports', () => {
latest_reparse_file_meta: '',
},
],
+ ok: true,
+ status: 200,
+ error: null,
})
}
})
@@ -1379,6 +1423,7 @@ describe('Reports', () => {
const {
getByText,
+ getAllByText,
queryByText,
getByLabelText,
queryAllByText,
@@ -1439,16 +1484,16 @@ describe('Reports', () => {
await waitFor(() =>
expect(
- getByText(
+ getAllByText(
`Successfully submitted section(s): 1, and 3 on ${new Date().toDateString()}`
- )
- ).toBeInTheDocument()
+ ).length
+ ).toBeGreaterThanOrEqual(1)
)
await waitFor(() => expect(store.dispatch).toHaveBeenCalledTimes(12))
// act(() => jest.advanceTimersByTime(2000))
- expect(mockAxios.get).toHaveBeenCalledTimes(3)
+ expect(get).toHaveBeenCalledTimes(3)
expect(times1).toBe(1)
expect(times2).toBe(1)
diff --git a/tdrs-frontend/src/components/Reports/ReportsContext.jsx b/tdrs-frontend/src/components/Reports/ReportsContext.jsx
index 6ab9842b8..c2b88728d 100644
--- a/tdrs-frontend/src/components/Reports/ReportsContext.jsx
+++ b/tdrs-frontend/src/components/Reports/ReportsContext.jsx
@@ -222,8 +222,19 @@ export const ReportsProvider = ({ isFra = false, children }) => {
const [reprocessedModalVisible, setReprocessedModalVisible] = useState(false)
const [reprocessedDate, setReprocessedDate] = useState('')
- // Alert state
- const [localAlert, setLocalAlertState] = useState({
+ // Alert state — the `timestamp` field ensures React always sees a new object
+ // even when the same message is set consecutively, so the useEffect that
+ // focuses the alert re-fires and the screen reader announces it again.
+ const [localAlert, setLocalAlert] = useState({
+ active: false,
+ type: null,
+ message: null,
+ })
+ const setLocalAlertState = (alert) =>
+ setLocalAlert({ ...alert, timestamp: Date.now() })
+
+ // Processing alert state (separate from localAlert for accessibility)
+ const [processingAlert, setProcessingAlertState] = useState({
active: false,
type: null,
message: null,
@@ -232,6 +243,7 @@ export const ReportsProvider = ({ isFra = false, children }) => {
// Refs
const headerRef = useRef(null)
const alertRef = useRef(null)
+ const processingAlertRef = useRef(null)
// Redux selectors
const files = useSelector((state) => state.reports.submittedFiles)
@@ -337,6 +349,7 @@ export const ReportsProvider = ({ isFra = false, children }) => {
} else {
setFileTypeInputValue(value)
setLocalAlertState({ active: false, type: null, message: null })
+ setProcessingAlertState({ active: false, type: null, message: null })
dispatch(clearFileList({ fileType: value }))
dispatch(reinitializeSubmittedFiles(value))
setFraSelectedFile(null)
@@ -361,6 +374,7 @@ export const ReportsProvider = ({ isFra = false, children }) => {
} else {
setYearInputValue(value)
setLocalAlertState({ active: false, type: null, message: null })
+ setProcessingAlertState({ active: false, type: null, message: null })
dispatch(clearFileList({ fileType: fileTypeInputValue }))
setFraSelectedFile(null)
}
@@ -377,6 +391,7 @@ export const ReportsProvider = ({ isFra = false, children }) => {
} else {
setQuarterInputValue(value)
setLocalAlertState({ active: false, type: null, message: null })
+ setProcessingAlertState({ active: false, type: null, message: null })
dispatch(clearFileList({ fileType: fileTypeInputValue }))
setFraSelectedFile(null)
}
@@ -398,6 +413,11 @@ export const ReportsProvider = ({ isFra = false, children }) => {
type: null,
message: null,
})
+ setProcessingAlertState({
+ active: false,
+ type: null,
+ message: null,
+ })
// Check if current file type is valid for the new STT
// If SSP is selected but new STT doesn't support SSP, reset to TANF
@@ -529,6 +549,9 @@ export const ReportsProvider = ({ isFra = false, children }) => {
setReprocessedDate,
localAlert,
setLocalAlertState,
+ processingAlert,
+ setProcessingAlertState,
+ processingAlertRef,
selectedSubmissionTab,
setSelectedSubmissionTab,
headerRef,
diff --git a/tdrs-frontend/src/components/Reports/constants.js b/tdrs-frontend/src/components/Reports/constants.js
new file mode 100644
index 000000000..46c935474
--- /dev/null
+++ b/tdrs-frontend/src/components/Reports/constants.js
@@ -0,0 +1,2 @@
+export const POLLING_TIMEOUT_MESSAGE =
+ 'Exceeded max number of tries to update submission status. Refresh this page to restart polling.'
diff --git a/tdrs-frontend/src/components/Reports/tdr/TanfSspReports.jsx b/tdrs-frontend/src/components/Reports/tdr/TanfSspReports.jsx
index 9bea0bb30..37f531e6e 100644
--- a/tdrs-frontend/src/components/Reports/tdr/TanfSspReports.jsx
+++ b/tdrs-frontend/src/components/Reports/tdr/TanfSspReports.jsx
@@ -7,6 +7,7 @@ import SegmentedControl from '../../SegmentedControl'
import FiscalYearSelect from '../components/FiscalYearSelect'
import FiscalQuarterSelect from '../components/FisclaQuarterSelect'
import FeedbackReportAlert from '../../FeedbackReports/FeedbackReportAlert'
+import { POLLING_TIMEOUT_MESSAGE } from '../constants'
import { useReportsContext } from '../ReportsContext'
const TanfSspReports = ({ stt, isRegionalStaff, isDataAnalyst }) => {
@@ -19,6 +20,7 @@ const TanfSspReports = ({ stt, isRegionalStaff, isDataAnalyst }) => {
setReprocessedModalVisible,
setReprocessedDate,
headerRef,
+ localAlert,
} = useReportsContext()
return (
@@ -46,6 +48,19 @@ const TanfSspReports = ({ stt, isRegionalStaff, isDataAnalyst }) => {
{isDataAnalyst && }
+ {localAlert.active &&
+ localAlert.message === POLLING_TIMEOUT_MESSAGE &&
+ (isRegionalStaff || selectedSubmissionTab === 2) && (
+
+ )}
+
{isRegionalStaff ? (
Submission History
diff --git a/tdrs-frontend/src/components/RequestAccessForm/RequestAccessForm.test.js b/tdrs-frontend/src/components/RequestAccessForm/RequestAccessForm.test.js
index bbbfe1929..5dc016243 100644
--- a/tdrs-frontend/src/components/RequestAccessForm/RequestAccessForm.test.js
+++ b/tdrs-frontend/src/components/RequestAccessForm/RequestAccessForm.test.js
@@ -9,7 +9,9 @@ import {
SET_REQUEST_USER_UPDATE_ERROR,
} from '../../actions/updateUserRequest'
import { SET_AUTH } from '../../actions/auth'
-import axios from 'axios'
+import { patch } from '../../fetch-instance'
+
+jest.mock('../../fetch-instance')
jest.mock('../STTComboBox', () => (props) => {
return (
@@ -124,6 +126,12 @@ describe('RequestAccessForm', () => {
})
it('dispatches requestAccess when form is valid', async () => {
+ patch.mockResolvedValue({
+ data: { first_name: 'Jane', last_name: 'Doe' },
+ ok: true,
+ status: 200,
+ error: null,
+ })
const { store } = setup()
// Spy on dispatch
@@ -282,6 +290,13 @@ describe('RequestAccessForm', () => {
})
it('dispatches updateUserRequest in editMode when data changes', async () => {
+ patch.mockResolvedValue({
+ data: { first_name: 'John', last_name: 'Smith' },
+ ok: true,
+ status: 200,
+ error: null,
+ })
+
const initialValues = {
firstName: 'John',
lastName: 'Doe',
@@ -361,7 +376,12 @@ describe('RequestAccessForm', () => {
has_fra_access: false,
pending_requests: 1,
}
- axios.patch.mockResolvedValue({ data: apiUserResponse })
+ patch.mockResolvedValue({
+ data: apiUserResponse,
+ ok: true,
+ status: 200,
+ error: null,
+ })
const { store } = setup(props, storeOverrides)
// Spy on store.dispatch to monitor calls
diff --git a/tdrs-frontend/src/components/SubmissionHistory/helpers.jsx b/tdrs-frontend/src/components/SubmissionHistory/helpers.jsx
index 899f343c5..044ac3fca 100644
--- a/tdrs-frontend/src/components/SubmissionHistory/helpers.jsx
+++ b/tdrs-frontend/src/components/SubmissionHistory/helpers.jsx
@@ -1,5 +1,5 @@
import React from 'react'
-import axios from 'axios'
+import { get } from '../../fetch-instance'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCheckCircle,
@@ -14,14 +14,12 @@ export const formatDate = (dateStr) => new Date(dateStr).toLocaleString()
export const downloadFile = (dispatch, file) => dispatch(download(file))
export const downloadErrorReport = async (file, reportName) => {
try {
- const promise = axios.get(
+ const { data, ok, error } = await get(
`${process.env.REACT_APP_BACKEND_URL}/data_files/${file.id}/download_error_report/`,
- {
- responseType: 'blob',
- }
+ { responseType: 'blob' }
)
- const dataPromise = await promise.then((response) => response.data)
- getParseErrors(dataPromise, reportName)
+ if (!ok) throw error
+ getParseErrors(data, reportName)
} catch (error) {
console.log(error)
}
diff --git a/tdrs-frontend/src/components/SubmissionHistory/helpers.test.js b/tdrs-frontend/src/components/SubmissionHistory/helpers.test.js
index 8ad7a25cc..b21d1bd59 100644
--- a/tdrs-frontend/src/components/SubmissionHistory/helpers.test.js
+++ b/tdrs-frontend/src/components/SubmissionHistory/helpers.test.js
@@ -1,4 +1,16 @@
-import { formatProgramType } from './helpers'
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { get } from '../../fetch-instance'
+import { getParseErrors } from '../../actions/createXLSReport'
+import {
+ formatProgramType,
+ downloadErrorReport,
+ getErrorReportStatus,
+ SubmissionSummaryStatusIcon,
+} from './helpers'
+
+jest.mock('../../fetch-instance')
+jest.mock('../../actions/createXLSReport')
describe('formatProgramType', () => {
it('returns a label for SSP', () => {
@@ -17,3 +29,119 @@ describe('formatProgramType', () => {
expect(formatProgramType('UNKNOWN')).toEqual('')
})
})
+
+describe('downloadErrorReport', () => {
+ beforeEach(() => {
+ get.mockClear()
+ getParseErrors.mockClear()
+ })
+
+ it('downloads and parses the error report on success', async () => {
+ const blob = new Blob(['error-data'])
+ get.mockResolvedValue({ data: blob, ok: true, error: null })
+
+ await downloadErrorReport({ id: 5 }, 'My Error Report')
+
+ expect(get).toHaveBeenCalledWith(
+ expect.stringContaining('/data_files/5/download_error_report/'),
+ { responseType: 'blob' }
+ )
+ expect(getParseErrors).toHaveBeenCalledWith(blob, 'My Error Report')
+ })
+
+ it('logs error when API returns non-ok response', async () => {
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
+ get.mockResolvedValue({
+ data: null,
+ ok: false,
+ error: new Error('Server error'),
+ })
+
+ await downloadErrorReport({ id: 5 }, 'Report')
+
+ expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error))
+ expect(getParseErrors).not.toHaveBeenCalled()
+ consoleSpy.mockRestore()
+ })
+})
+
+describe('getErrorReportStatus', () => {
+ it('returns download button when file has errors', () => {
+ const file = {
+ summary: { status: 'Accepted with Errors' },
+ program_type: 'TAN',
+ year: '2025',
+ quarter: 'Q1',
+ section: 'Active Case Data',
+ hasError: true,
+ id: 10,
+ }
+
+ const result = getErrorReportStatus(file)
+ render(result)
+
+ expect(
+ screen.getByText('2025-Q1-TANF Active Case Data Error Report.xlsx')
+ ).toBeInTheDocument()
+ })
+
+ it('returns No Errors when file has no errors', () => {
+ const file = {
+ summary: { status: 'Accepted' },
+ program_type: 'TAN',
+ year: '2025',
+ quarter: 'Q1',
+ section: 'Active Case Data',
+ hasError: false,
+ }
+
+ expect(getErrorReportStatus(file)).toBe('No Errors')
+ })
+
+ it('returns Pending when summary status is Pending', () => {
+ const file = { summary: { status: 'Pending' } }
+ expect(getErrorReportStatus(file)).toBe('Pending')
+ })
+
+ it('calls downloadErrorReport when button is clicked', () => {
+ get.mockResolvedValue({ data: new Blob(), ok: true, error: null })
+
+ const file = {
+ summary: { status: 'Rejected' },
+ program_type: 'SSP',
+ year: '2025',
+ quarter: 'Q2',
+ section: 'Closed Case Data',
+ hasError: true,
+ id: 42,
+ }
+
+ const result = getErrorReportStatus(file)
+ render(result)
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(get).toHaveBeenCalled()
+ })
+})
+
+describe('SubmissionSummaryStatusIcon', () => {
+ it.each([
+ ['Pending'],
+ ['Accepted'],
+ ['Partially Accepted with Errors'],
+ ['Accepted with Errors'],
+ ['Rejected'],
+ ])('renders icon for status "%s"', (status) => {
+ const { container } = render(
+
+ )
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('renders without icon for unknown status', () => {
+ const { container } = render(
+
+ )
+ expect(container.querySelector('svg')).toBeNull()
+ })
+})
diff --git a/tdrs-frontend/src/fetch-instance.js b/tdrs-frontend/src/fetch-instance.js
new file mode 100644
index 000000000..e0795881b
--- /dev/null
+++ b/tdrs-frontend/src/fetch-instance.js
@@ -0,0 +1,127 @@
+import { faro } from '@grafana/faro-react'
+
+function getCSRFToken() {
+ const match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]*)/)
+ return match ? decodeURIComponent(match[1]) : null
+}
+
+function buildHeaders(customHeaders = {}, includeCSRF = true) {
+ const headers = { 'x-service-name': 'tdp-frontend', ...customHeaders }
+
+ if (includeCSRF) {
+ const csrfToken = getCSRFToken()
+ if (csrfToken) {
+ headers['X-CSRFToken'] = csrfToken
+ }
+ }
+
+ if (faro?.api) {
+ try {
+ const traceContext = faro.api.getTraceContext()
+ if (traceContext) Object.assign(headers, traceContext)
+ } catch (e) {
+ console.error('Failed to add trace context', e)
+ }
+ }
+
+ return headers
+}
+
+async function handleResponse(response, responseType) {
+ if (responseType === 'blob') {
+ return {
+ data: await response.blob(),
+ error: response.ok ? null : new Error(`HTTP ${response.status}`),
+ status: response.status,
+ ok: response.ok,
+ }
+ }
+
+ const contentType = response.headers.get('content-type') || ''
+ let data = null
+
+ if (contentType.includes('application/json')) {
+ data = await response.json().catch(() => null)
+ } else {
+ data = await response.text()
+ }
+
+ return {
+ data,
+ error: response.ok ? null : new Error(`HTTP ${response.status}`),
+ status: response.status,
+ ok: response.ok,
+ }
+}
+
+export async function get(url, options = {}) {
+ const { headers: customHeaders, responseType, params, ...rest } = options
+
+ let finalUrl = url
+ if (params) {
+ const searchParams = new URLSearchParams(params)
+ finalUrl = `${url}?${searchParams.toString()}`
+ }
+
+ try {
+ const response = await fetch(finalUrl, {
+ method: 'GET',
+ credentials: 'include',
+ headers: buildHeaders(customHeaders, false),
+ ...rest,
+ })
+ return handleResponse(response, responseType)
+ } catch (error) {
+ return { data: null, error, status: 0, ok: false }
+ }
+}
+
+export async function post(url, body, options = {}) {
+ const { headers: customHeaders, ...rest } = options
+ const isFormData = body instanceof FormData
+ const headers = buildHeaders(
+ isFormData
+ ? customHeaders
+ : { 'Content-Type': 'application/json', ...customHeaders },
+ true
+ )
+
+ if (isFormData) {
+ delete headers['Content-Type']
+ }
+
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ credentials: 'include',
+ headers,
+ body: isFormData ? body : JSON.stringify(body),
+ ...rest,
+ })
+ return handleResponse(response)
+ } catch (error) {
+ return { data: null, error, status: 0, ok: false }
+ }
+}
+
+export async function patch(url, body, options = {}) {
+ const { headers: customHeaders, ...rest } = options
+
+ try {
+ const response = await fetch(url, {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: buildHeaders(
+ { 'Content-Type': 'application/json', ...customHeaders },
+ true
+ ),
+ body: JSON.stringify(body),
+ ...rest,
+ })
+ return handleResponse(response)
+ } catch (error) {
+ return { data: null, error, status: 0, ok: false }
+ }
+}
+
+export default { get, post, patch }
diff --git a/tdrs-frontend/src/fetch-instance.test.js b/tdrs-frontend/src/fetch-instance.test.js
new file mode 100644
index 000000000..a78e6197e
--- /dev/null
+++ b/tdrs-frontend/src/fetch-instance.test.js
@@ -0,0 +1,267 @@
+import { get, post, patch } from './fetch-instance'
+import { faro } from '@grafana/faro-react'
+
+jest.mock('@grafana/faro-react')
+
+const mockResponse = (body, options = {}) => ({
+ ok: options.ok !== undefined ? options.ok : true,
+ status: options.status || 200,
+ headers: {
+ get: (key) => options.contentType || 'application/json',
+ },
+ json: () => Promise.resolve(body),
+ text: () =>
+ Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
+ blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
+})
+
+describe('fetch-instance', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ global.fetch = jest.fn()
+ Object.defineProperty(document, 'cookie', {
+ writable: true,
+ value: '',
+ })
+ })
+
+ describe('get', () => {
+ it('makes a GET request and returns JSON data', async () => {
+ const data = { message: 'hello' }
+ global.fetch.mockResolvedValue(mockResponse(data))
+
+ const result = await get('/api/test')
+
+ expect(fetch).toHaveBeenCalledWith('/api/test', {
+ method: 'GET',
+ credentials: 'include',
+ headers: expect.objectContaining({
+ 'x-service-name': 'tdp-frontend',
+ }),
+ })
+ expect(result).toEqual({ data, error: null, status: 200, ok: true })
+ })
+
+ it('returns error info for non-ok responses', async () => {
+ global.fetch.mockResolvedValue(
+ mockResponse({ detail: 'Not found' }, { ok: false, status: 404 })
+ )
+
+ const result = await get('/api/missing')
+
+ expect(result.ok).toBe(false)
+ expect(result.status).toBe(404)
+ expect(result.error).toBeInstanceOf(Error)
+ expect(result.error.message).toBe('HTTP 404')
+ })
+
+ it('handles network errors', async () => {
+ const networkError = new Error('Network failure')
+ global.fetch.mockRejectedValue(networkError)
+
+ const result = await get('/api/down')
+
+ expect(result).toEqual({
+ data: null,
+ error: networkError,
+ status: 0,
+ ok: false,
+ })
+ })
+
+ it('handles blob response type', async () => {
+ const blob = new Blob(['file-data'])
+ global.fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/octet-stream' },
+ blob: () => Promise.resolve(blob),
+ })
+
+ const result = await get('/api/download', { responseType: 'blob' })
+
+ expect(result.data).toBe(blob)
+ expect(result.ok).toBe(true)
+ expect(result.error).toBeNull()
+ })
+
+ it('handles non-JSON text responses', async () => {
+ global.fetch.mockResolvedValue(
+ mockResponse('plain text', { contentType: 'text/plain' })
+ )
+
+ const result = await get('/api/text')
+
+ expect(result.data).toBe('plain text')
+ expect(result.ok).toBe(true)
+ })
+ })
+
+ describe('post', () => {
+ it('makes a POST request with JSON body', async () => {
+ const responseData = { id: 1 }
+ global.fetch.mockResolvedValue(mockResponse(responseData))
+
+ const result = await post('/api/create', { name: 'test' })
+
+ expect(fetch).toHaveBeenCalledWith('/api/create', {
+ method: 'POST',
+ credentials: 'include',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ 'x-service-name': 'tdp-frontend',
+ }),
+ body: JSON.stringify({ name: 'test' }),
+ })
+ expect(result).toEqual({
+ data: responseData,
+ error: null,
+ status: 200,
+ ok: true,
+ })
+ })
+
+ it('makes a POST request with FormData', async () => {
+ global.fetch.mockResolvedValue(mockResponse({ id: 2 }))
+ const formData = new FormData()
+ formData.append('file', 'test-file')
+
+ await post('/api/upload', formData)
+
+ const callArgs = fetch.mock.calls[0][1]
+ expect(callArgs.body).toBe(formData)
+ expect(callArgs.headers['Content-Type']).toBeUndefined()
+ })
+
+ it('includes CSRF token from cookie in POST headers', async () => {
+ document.cookie = 'csrftoken=my-csrf-token'
+ global.fetch.mockResolvedValue(mockResponse({}))
+
+ await post('/api/create', {})
+
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/create',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'X-CSRFToken': 'my-csrf-token',
+ }),
+ })
+ )
+ })
+
+ it('omits CSRF header when no cookie is set', async () => {
+ document.cookie = ''
+ global.fetch.mockResolvedValue(mockResponse({}))
+
+ await post('/api/create', {})
+
+ const callArgs = fetch.mock.calls[0][1]
+ expect(callArgs.headers['X-CSRFToken']).toBeUndefined()
+ })
+
+ it('handles network errors', async () => {
+ const networkError = new Error('Connection refused')
+ global.fetch.mockRejectedValue(networkError)
+
+ const result = await post('/api/create', {})
+
+ expect(result).toEqual({
+ data: null,
+ error: networkError,
+ status: 0,
+ ok: false,
+ })
+ })
+ })
+
+ describe('patch', () => {
+ it('makes a PATCH request with JSON body', async () => {
+ const responseData = { updated: true }
+ global.fetch.mockResolvedValue(mockResponse(responseData))
+
+ const result = await patch('/api/update/1', { name: 'updated' })
+
+ expect(fetch).toHaveBeenCalledWith('/api/update/1', {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ 'x-service-name': 'tdp-frontend',
+ }),
+ body: JSON.stringify({ name: 'updated' }),
+ })
+ expect(result).toEqual({
+ data: responseData,
+ error: null,
+ status: 200,
+ ok: true,
+ })
+ })
+
+ it('includes CSRF token from cookie in PATCH headers', async () => {
+ document.cookie = 'csrftoken=patch-csrf-token'
+ global.fetch.mockResolvedValue(mockResponse({}))
+
+ await patch('/api/update/1', {})
+
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/update/1',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'X-CSRFToken': 'patch-csrf-token',
+ }),
+ })
+ )
+ })
+
+ it('handles network errors', async () => {
+ const networkError = new Error('Timeout')
+ global.fetch.mockRejectedValue(networkError)
+
+ const result = await patch('/api/update/1', {})
+
+ expect(result).toEqual({
+ data: null,
+ error: networkError,
+ status: 0,
+ ok: false,
+ })
+ })
+ })
+
+ describe('faro trace context', () => {
+ it('includes trace context headers when faro is available', async () => {
+ faro.api.getTraceContext.mockReturnValue({ traceparent: 'test-trace-id' })
+ global.fetch.mockResolvedValue(mockResponse({}))
+
+ await get('/api/test')
+
+ expect(faro.api.getTraceContext).toHaveBeenCalled()
+ expect(fetch).toHaveBeenCalledWith(
+ '/api/test',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ traceparent: 'test-trace-id',
+ }),
+ })
+ )
+ })
+
+ it('handles faro getTraceContext throwing an error', async () => {
+ faro.api.getTraceContext.mockImplementation(() => {
+ throw new Error('faro error')
+ })
+ global.fetch.mockResolvedValue(mockResponse({}))
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
+
+ await get('/api/test')
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to add trace context',
+ expect.any(Error)
+ )
+ expect(fetch).toHaveBeenCalled()
+ consoleSpy.mockRestore()
+ })
+ })
+})
diff --git a/tdrs-frontend/src/hooks/useFileUploadForm.js b/tdrs-frontend/src/hooks/useFileUploadForm.js
index 94746f3e1..f7e47f354 100644
--- a/tdrs-frontend/src/hooks/useFileUploadForm.js
+++ b/tdrs-frontend/src/hooks/useFileUploadForm.js
@@ -9,6 +9,7 @@ import {
} from '../actions/reports'
import { useEventLogger } from '../utils/eventLogger'
import { useFormSubmission } from './useFormSubmission'
+import { POLLING_TIMEOUT_MESSAGE } from '../components/Reports/constants'
import { useReportsContext } from '../components/Reports/ReportsContext'
/**
@@ -38,6 +39,9 @@ export const useFileUploadForm = ({
fileTypeInputValue,
localAlert,
setLocalAlertState,
+ processingAlert,
+ setProcessingAlertState,
+ processingAlertRef,
uploadedFiles,
setErrorModalVisible,
setModalTriggerSource,
@@ -71,10 +75,10 @@ export const useFileUploadForm = ({
datafile: response?.data,
},
})
- setLocalAlertState({
+ setProcessingAlertState({
active: true,
type: 'success',
- message: 'Parsing complete.',
+ message: 'Processing complete.',
})
},
(error) => {
@@ -86,8 +90,7 @@ export const useFileUploadForm = ({
},
(onError) => {
onError({
- message:
- 'Exceeded max number of tries to update submission status.',
+ message: POLLING_TIMEOUT_MESSAGE,
type: 'warning',
})
}
@@ -115,6 +118,15 @@ export const useFileUploadForm = ({
const onSubmit = async (event) => {
event.preventDefault()
+ if (uploadedFiles.length === 0) {
+ setLocalAlertState({
+ active: true,
+ type: 'error',
+ message: 'No changes have been made to data files',
+ })
+ return
+ }
+
try {
// Transform files if needed (e.g., for Program Audit)
const filesToSubmit = transformFiles
@@ -163,22 +175,36 @@ export const useFileUploadForm = ({
fileInput.init()
}, [])
- // Scroll to alert when it becomes active
+ // Scroll to and focus alert when it becomes active
useEffect(() => {
if (localAlert.active && alertRef && alertRef.current) {
alertRef.current.scrollIntoView({ behavior: 'smooth' })
+ alertRef.current.focus({ preventScroll: true })
}
}, [localAlert, alertRef])
+ // Scroll to processing alert when it becomes active (uses aria-live="polite" for sequential reading)
+ useEffect(() => {
+ if (
+ processingAlert.active &&
+ processingAlertRef &&
+ processingAlertRef.current
+ ) {
+ processingAlertRef.current.scrollIntoView({ behavior: 'smooth' })
+ }
+ }, [processingAlert, processingAlertRef])
+
return {
// Form state
yearInputValue,
quarterInputValue,
fileTypeInputValue,
localAlert,
+ processingAlert,
uploadedFiles,
isSubmitting,
alertRef,
+ processingAlertRef,
formattedSections,
// Form handlers
@@ -187,5 +213,6 @@ export const useFileUploadForm = ({
// Context setters (for FileUpload components)
setLocalAlertState,
+ setProcessingAlertState,
}
}
diff --git a/tdrs-frontend/src/hooks/usePollingTimer.js b/tdrs-frontend/src/hooks/usePollingTimer.js
index 7749f4b9b..0836b377f 100644
--- a/tdrs-frontend/src/hooks/usePollingTimer.js
+++ b/tdrs-frontend/src/hooks/usePollingTimer.js
@@ -105,8 +105,13 @@ export const usePollingTimer = () => {
try {
response = await request()
- } catch (axiosError) {
- const statusCode = axiosError?.response?.status
+ } catch (networkError) {
+ retry(requestId, tryNumber)
+ return
+ }
+
+ if (!response.ok) {
+ const statusCode = response.status
const shouldStopPolling =
statusCode === 400 || statusCode === 401 || statusCode === 403
@@ -114,7 +119,9 @@ export const usePollingTimer = () => {
retry(requestId, tryNumber)
return
} else {
- finish(requestId, () => onError(axiosError))
+ finish(requestId, () =>
+ onError(response.error || new Error(`HTTP ${statusCode}`))
+ )
return
}
}
diff --git a/tdrs-frontend/src/hooks/usePollingTimer.test.js b/tdrs-frontend/src/hooks/usePollingTimer.test.js
index 4636c996a..527847214 100644
--- a/tdrs-frontend/src/hooks/usePollingTimer.test.js
+++ b/tdrs-frontend/src/hooks/usePollingTimer.test.js
@@ -1,14 +1,15 @@
import { render, fireEvent, waitFor, act } from '@testing-library/react'
-import axios from 'axios'
+import { get } from '../fetch-instance'
import { usePollingTimer } from './usePollingTimer'
+jest.mock('../fetch-instance')
+
describe('usePollingTimer', () => {
const setupMockFuncs = () => {
jest.useFakeTimers()
return {
- mockAxios: axios,
requestFunc: jest.fn(() => {
- return axios.get('/fake_status_endpoint/')
+ return get('/fake_status_endpoint/')
}),
testFunc: jest.fn((response) => {
return response?.data?.summary?.status !== 'Pending'
@@ -108,9 +109,11 @@ describe('usePollingTimer', () => {
it('should start polling when startPolling called', async () => {
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: { id: 1, hasErrors: false, summary: { status: 'Pending' } },
+ ok: true,
+ status: 200,
+ error: null,
})
const { queryByText, getByTitle } = setupSingleTimerComponent(
@@ -134,9 +137,11 @@ describe('usePollingTimer', () => {
it('should call retry until the test completes then call onSuccess', async () => {
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: { id: 1, hasErrors: false, summary: { status: 'Pending' } },
+ ok: true,
+ status: 200,
+ error: null,
})
const { queryByText, getByTitle } = setupSingleTimerComponent(
@@ -176,8 +181,11 @@ describe('usePollingTimer', () => {
expect(mocks.onTimeoutFunc).toHaveBeenCalledTimes(0)
// now update the mock and run a third time
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: { id: 1, hasErrors: false, summary: { status: 'Approved' } },
+ ok: true,
+ status: 200,
+ error: null,
})
act(() => jest.advanceTimersByTime(1000))
@@ -195,15 +203,13 @@ describe('usePollingTimer', () => {
it.each([404, 500])(
'should call retry when the backend is down',
- async (status) => {
+ async (statusCode) => {
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
- mockAxios.get.mockRejectedValue({
- message: 'Error',
- response: {
- status,
- data: { detail: 'Mock fail response' },
- },
+ get.mockResolvedValue({
+ data: { detail: 'Mock fail response' },
+ ok: false,
+ status: statusCode,
+ error: new Error(`HTTP ${statusCode}`),
})
const { queryByText, getByTitle } = setupSingleTimerComponent(
@@ -246,16 +252,14 @@ describe('usePollingTimer', () => {
it.each([400, 401, 403])(
'should stop polling and call onError if the request fails with a %s error',
- async (status) => {
+ async (statusCode) => {
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
-
- mockAxios.get.mockRejectedValue({
- message: 'Error',
- response: {
- status,
- data: { detail: 'Mock fail response' },
- },
+
+ get.mockResolvedValue({
+ data: { detail: 'Mock fail response' },
+ ok: false,
+ status: statusCode,
+ error: new Error(`HTTP ${statusCode}`),
})
const { queryByText, getByTitle } = setupSingleTimerComponent(
@@ -285,9 +289,11 @@ describe('usePollingTimer', () => {
it('should stop polling and call onTimeout when max tries reached', async () => {
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: { id: 1, hasErrors: false, summary: { status: 'Pending' } },
+ ok: true,
+ status: 200,
+ error: null,
})
const { queryByText, getByTitle } = setupSingleTimerComponent(
@@ -333,9 +339,11 @@ describe('usePollingTimer', () => {
it('should allow multiple parallel timers with different requestIds', async () => {
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: { id: 1, hasErrors: false, summary: { status: 'Pending' } },
+ ok: true,
+ status: 200,
+ error: null,
})
const { queryByText, getByTitle } = setupMultiTimerComponent(
@@ -407,9 +415,11 @@ describe('usePollingTimer', () => {
jest.spyOn(global, 'setTimeout')
jest.spyOn(global, 'clearTimeout')
const mocks = setupMockFuncs()
- const { mockAxios } = mocks
- mockAxios.get.mockResolvedValue({
+ get.mockResolvedValue({
data: { id: 1, hasErrors: false, summary: { status: 'Pending' } },
+ ok: true,
+ status: 200,
+ error: null,
})
const { queryByText, getByTitle, unmount } = setupMultiTimerComponent(
diff --git a/tdrs-frontend/src/hooks/useSubmissionHistory.js b/tdrs-frontend/src/hooks/useSubmissionHistory.js
index b79f5bb2b..075e1f162 100644
--- a/tdrs-frontend/src/hooks/useSubmissionHistory.js
+++ b/tdrs-frontend/src/hooks/useSubmissionHistory.js
@@ -1,6 +1,10 @@
import { useEffect, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
-import { getAvailableFileList } from '../actions/reports'
+import {
+ getAvailableFileList,
+ getTanfSubmissionStatus,
+ SET_TANF_SUBMISSION_STATUS,
+} from '../actions/reports'
import { useReportsContext } from '../components/Reports/ReportsContext'
/**
@@ -14,7 +18,17 @@ export const useSubmissionHistory = (filterValues) => {
const dispatch = useDispatch()
const { files, loading } = useSelector((state) => state.reports)
const prevFilterValuesRef = useRef()
- const { isPolling } = useReportsContext()
+ const { isPolling, startPolling } = useReportsContext()
+ const isPollingRef = useRef(isPolling)
+ const startPollingRef = useRef(startPolling)
+
+ useEffect(() => {
+ isPollingRef.current = isPolling
+ }, [isPolling])
+
+ useEffect(() => {
+ startPollingRef.current = startPolling
+ }, [startPolling])
useEffect(() => {
// Serialize filterValues for comparison
@@ -30,6 +44,39 @@ export const useSubmissionHistory = (filterValues) => {
}
}, [dispatch, filterValues, isPolling])
+ // Restart polling for any pending files when history is loaded (e.g., after navigation)
+ useEffect(() => {
+ files
+ ?.filter((file) => file?.summary?.status === 'Pending')
+ ?.forEach((file) => {
+ if (isPollingRef.current?.[file.id]) return
+
+ startPollingRef.current(
+ `${file.id}`,
+ () => getTanfSubmissionStatus(file.id),
+ (response) => {
+ const status = response?.data?.summary?.status
+ return status && status !== 'Pending'
+ },
+ (response) => {
+ dispatch({
+ type: SET_TANF_SUBMISSION_STATUS,
+ payload: {
+ datafile_id: file.id,
+ datafile: response?.data,
+ },
+ })
+ },
+ () => {
+ // Silent failure to avoid noisy alerts on navigation-driven polling
+ },
+ () => {
+ // Timed out; leave status as-is and let user refresh manually
+ }
+ )
+ })
+ }, [dispatch, files])
+
return {
files,
loading,
diff --git a/tdrs-frontend/src/hooks/useSubmissionHistory.test.js b/tdrs-frontend/src/hooks/useSubmissionHistory.test.js
new file mode 100644
index 000000000..a43fdefb3
--- /dev/null
+++ b/tdrs-frontend/src/hooks/useSubmissionHistory.test.js
@@ -0,0 +1,127 @@
+import React from 'react'
+import { render, waitFor } from '@testing-library/react'
+import configureMockStore from 'redux-mock-store'
+import { Provider } from 'react-redux'
+import { thunk } from 'redux-thunk'
+
+import { useSubmissionHistory } from './useSubmissionHistory'
+import { get } from '../fetch-instance'
+
+jest.mock('../fetch-instance')
+
+const mockContext = {
+ isPolling: {},
+ startPolling: jest.fn(),
+}
+
+jest.mock('../components/Reports/ReportsContext', () => ({
+ useReportsContext: () => mockContext,
+}))
+
+const mockStore = configureMockStore([thunk])
+
+const renderWithStore = (store, filterValues) => {
+ const TestComponent = () => {
+ useSubmissionHistory(filterValues)
+ return null
+ }
+
+ const renderResult = render(
+
+
+
+ )
+
+ return { ...renderResult, TestComponent }
+}
+
+describe('useSubmissionHistory', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockContext.isPolling = {}
+ get.mockResolvedValue({ data: [], ok: true, status: 200, error: null })
+ })
+
+ it('restarts polling for pending files when history loads', async () => {
+ const pendingFile = { id: 1, summary: { status: 'Pending' } }
+ const completedFile = { id: 2, summary: { status: 'Approved' } }
+ const store = mockStore({
+ reports: {
+ files: [pendingFile, completedFile],
+ loading: false,
+ },
+ })
+
+ renderWithStore(store, {
+ quarter: 'Q1',
+ stt: { id: 1 },
+ year: '2021',
+ file_type: 'test',
+ })
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).toHaveBeenCalledWith(
+ `${pendingFile.id}`,
+ expect.any(Function),
+ expect.any(Function),
+ expect.any(Function),
+ expect.any(Function),
+ expect.any(Function)
+ )
+ })
+ })
+
+ it('does not restart polling when already polling a file', async () => {
+ const pendingFile = { id: 3, summary: { status: 'Pending' } }
+ mockContext.isPolling = { 3: true }
+ const store = mockStore({
+ reports: {
+ files: [pendingFile],
+ loading: false,
+ },
+ })
+
+ renderWithStore(store, {
+ quarter: 'Q1',
+ stt: { id: 1 },
+ year: '2021',
+ file_type: 'test',
+ })
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).not.toHaveBeenCalled()
+ })
+ })
+
+ it('does not restart polling when isPolling changes without new files', async () => {
+ const pendingFile = { id: 4, summary: { status: 'Pending' } }
+ const store = mockStore({
+ reports: {
+ files: [pendingFile],
+ loading: false,
+ },
+ })
+
+ const renderResult = renderWithStore(store, {
+ quarter: 'Q1',
+ stt: { id: 1 },
+ year: '2021',
+ file_type: 'test',
+ })
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).toHaveBeenCalledTimes(1)
+ })
+
+ mockContext.isPolling = { 4: true }
+ renderResult.rerender(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(mockContext.startPolling).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/tdrs-frontend/src/index.js b/tdrs-frontend/src/index.js
index f0fe71d03..cdfa7cd50 100644
--- a/tdrs-frontend/src/index.js
+++ b/tdrs-frontend/src/index.js
@@ -1,6 +1,5 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
-import axios from 'axios'
import { ReduxRouter as Router } from '@lagunovsky/redux-react-router'
import { Provider } from 'react-redux'
@@ -37,9 +36,6 @@ if (
// needs to be called before auth_check
startMirage()
}
-axios.defaults.xsrfCookieName = 'csrftoken'
-axios.defaults.xsrfHeaderName = 'X-CSRFToken'
-axios.defaults.withCredentials = true
// Initialize FaroSDK
if (process.env.REACT_APP_ENABLE_RUM === 'true') {
diff --git a/tdrs-frontend/src/reducers/featureFlags.js b/tdrs-frontend/src/reducers/featureFlags.js
new file mode 100644
index 000000000..607a5c647
--- /dev/null
+++ b/tdrs-frontend/src/reducers/featureFlags.js
@@ -0,0 +1,51 @@
+import {
+ FETCH_FEATURE_FLAGS,
+ SET_FEATURE_FLAGS,
+ SET_FEATURE_FLAGS_ERROR,
+ CLEAR_FEATURE_FLAGS,
+} from '../actions/featureFlags'
+
+const initialState = {
+ loading: false,
+ error: null,
+ flags: null,
+ lastFetched: null,
+}
+
+const featureFlags = (state = initialState, action) => {
+ const { type, payload = {} } = action
+
+ switch (type) {
+ case FETCH_FEATURE_FLAGS:
+ return {
+ ...state,
+ loading: true,
+ error: null,
+ lastFetched: null,
+ }
+ case SET_FEATURE_FLAGS: {
+ const { flags, lastFetched } = payload
+ return {
+ ...state,
+ loading: false,
+ flags,
+ lastFetched,
+ }
+ }
+ case SET_FEATURE_FLAGS_ERROR: {
+ const { error, lastFetched } = payload
+ return {
+ ...state,
+ loading: false,
+ error,
+ lastFetched,
+ }
+ }
+ case CLEAR_FEATURE_FLAGS:
+ return initialState
+ default:
+ return state
+ }
+}
+
+export default featureFlags
diff --git a/tdrs-frontend/src/reducers/featureFlags.test.js b/tdrs-frontend/src/reducers/featureFlags.test.js
new file mode 100644
index 000000000..71913a78a
--- /dev/null
+++ b/tdrs-frontend/src/reducers/featureFlags.test.js
@@ -0,0 +1,96 @@
+import reducer from './featureFlags'
+import {
+ FETCH_FEATURE_FLAGS,
+ SET_FEATURE_FLAGS,
+ SET_FEATURE_FLAGS_ERROR,
+ CLEAR_FEATURE_FLAGS,
+} from '../actions/featureFlags'
+
+describe('reducers/featureFlags', () => {
+ it('should return the initial state', () => {
+ expect(reducer(undefined, {})).toEqual({
+ loading: false,
+ error: null,
+ flags: null,
+ lastFetched: null,
+ })
+ })
+
+ it('should handle FETCH_FEATURE_FLAGS', () => {
+ expect(
+ reducer(undefined, {
+ type: FETCH_FEATURE_FLAGS,
+ })
+ ).toEqual({
+ loading: true,
+ error: null,
+ flags: null,
+ lastFetched: null,
+ })
+ })
+
+ it('should handle SET_FEATURE_FLAGS', () => {
+ const mockFlag = { name: 'test-feature', enabled: true, config: {} }
+ expect(
+ reducer(
+ {
+ loading: true,
+ error: null,
+ flags: null,
+ lastFetched: null,
+ },
+ {
+ type: SET_FEATURE_FLAGS,
+ payload: {
+ flags: [mockFlag],
+ lastFetched: '2020-01-01 5:17am',
+ },
+ }
+ )
+ ).toEqual({
+ loading: false,
+ error: null,
+ flags: [mockFlag],
+ lastFetched: '2020-01-01 5:17am',
+ })
+ })
+
+ it('should handle CLEAR_FEATURE_FLAGS', () => {
+ const mockFlag = { name: 'test-feature', enabled: true, config: {} }
+ expect(
+ reducer(
+ {
+ loading: false,
+ error: 'test msg',
+ flags: [mockFlag],
+ lastFetched: '2020-01-01 5:17am',
+ },
+ {
+ type: CLEAR_FEATURE_FLAGS,
+ }
+ )
+ ).toEqual({
+ loading: false,
+ error: null,
+ flags: null,
+ lastFetched: null,
+ })
+ })
+
+ it('should handle SET_FEATURE_FLAGS_ERROR', () => {
+ expect(
+ reducer(undefined, {
+ type: SET_FEATURE_FLAGS_ERROR,
+ payload: {
+ error: 'something went wrong',
+ lastFetched: '2020-01-01 5:17am',
+ },
+ })
+ ).toEqual({
+ loading: false,
+ error: 'something went wrong',
+ flags: null,
+ lastFetched: '2020-01-01 5:17am',
+ })
+ })
+})
diff --git a/tdrs-frontend/src/reducers/index.js b/tdrs-frontend/src/reducers/index.js
index e0ca90142..952e95e21 100644
--- a/tdrs-frontend/src/reducers/index.js
+++ b/tdrs-frontend/src/reducers/index.js
@@ -7,6 +7,7 @@ import requestAccess from './requestAccess'
import reports from './reports'
import fraReports from './fraReports'
import feedbackWidget from './feedbackWidget'
+import featureFlags from './featureFlags'
/**
* Combines all store reducers
@@ -22,6 +23,7 @@ const createRootReducer = (history) =>
reports,
fraReports,
feedbackWidget,
+ featureFlags,
})
export default createRootReducer
diff --git a/tdrs-frontend/src/utils/eventLogger.js b/tdrs-frontend/src/utils/eventLogger.js
index 6afc02ce6..36a14b7c4 100644
--- a/tdrs-frontend/src/utils/eventLogger.js
+++ b/tdrs-frontend/src/utils/eventLogger.js
@@ -1,10 +1,8 @@
import { useSelector } from 'react-redux'
-import axiosInstance from '../axios-instance'
+import { post } from '../fetch-instance'
function sendDataToServer(data) {
- axiosInstance.post(`${process.env.REACT_APP_BACKEND_URL}/logs/`, data, {
- withCredentials: true,
- })
+ post(`${process.env.REACT_APP_BACKEND_URL}/logs/`, data)
}
function logEvents(initialContext) {