diff --git a/tdrs-backend/tdpservice/conftest.py b/tdrs-backend/tdpservice/conftest.py
index 427f5a22a..3b1245fbf 100644
--- a/tdrs-backend/tdpservice/conftest.py
+++ b/tdrs-backend/tdpservice/conftest.py
@@ -316,6 +316,23 @@ def data_file_data(base_data_file_data, data_file):
}
+@pytest.fixture
+def csv_data_file(data_analyst, fake_file):
+ """Return a CSV data file for testing FRA uploads."""
+ return {
+ 'file': create_temporary_file(fake_file, 'report.csv'),
+ "original_filename": 'report.csv',
+ "slug": str(uuid.uuid4()),
+ "extension": "csv",
+ "section": "Work Outcomes of TANF Exiters",
+ "user": str(data_analyst.id),
+ "quarter": "Q1",
+ "year": 2020,
+ "stt": int(data_analyst.stt.id),
+ "ssp": False,
+ }
+
+
@pytest.fixture
def regional_data_file_data(base_regional_data_file_data, data_file):
"""Return data file creation data for a reigon."""
diff --git a/tdrs-backend/tdpservice/data_files/migrations/0018_alter_datafile_section.py b/tdrs-backend/tdpservice/data_files/migrations/0018_alter_datafile_section.py
new file mode 100644
index 000000000..553f2519e
--- /dev/null
+++ b/tdrs-backend/tdpservice/data_files/migrations/0018_alter_datafile_section.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.15 on 2025-02-11 22:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('data_files', '0017_alter_datafile_section'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='datafile',
+ name='section',
+ field=models.CharField(choices=[('Tribal Closed Case Data', 'Tribal Closed Case Data'), ('Tribal Active Case Data', 'Tribal Active Case Data'), ('Tribal Aggregate Data', 'Tribal Aggregate Data'), ('Tribal Stratum Data', 'Tribal Stratum Data'), ('SSP Aggregate Data', 'Ssp Aggregate Data'), ('SSP Closed Case Data', 'Ssp Closed Case Data'), ('SSP Active Case Data', 'Ssp Active Case Data'), ('SSP Stratum Data', 'Ssp Stratum Data'), ('Active Case Data', 'Active Case Data'), ('Closed Case Data', 'Closed Case Data'), ('Aggregate Data', 'Aggregate Data'), ('Stratum Data', 'Stratum Data'), ('Work Outcomes of TANF Exiters', 'Fra Work Outcome Tanf Exiters'), ('Secondary School Attainment', 'Fra Secondry School Attainment'), ('Supplemental Work Outcomes', 'Fra Supplement Work Outcomes')], max_length=32),
+ ),
+ ]
diff --git a/tdrs-backend/tdpservice/data_files/models.py b/tdrs-backend/tdpservice/data_files/models.py
index 9a93d827f..2e27a7730 100644
--- a/tdrs-backend/tdpservice/data_files/models.py
+++ b/tdrs-backend/tdpservice/data_files/models.py
@@ -119,7 +119,7 @@ class Section(models.TextChoices):
AGGREGATE_DATA = "Aggregate Data"
STRATUM_DATA = "Stratum Data"
- FRA_WORK_OUTCOME_TANF_EXITERS = "Work Outcomes for TANF Exiters"
+ FRA_WORK_OUTCOME_TANF_EXITERS = "Work Outcomes of TANF Exiters"
FRA_SECONDRY_SCHOOL_ATTAINMENT = "Secondary School Attainment"
FRA_SUPPLEMENT_WORK_OUTCOMES = "Supplemental Work Outcomes"
diff --git a/tdrs-backend/tdpservice/data_files/serializers.py b/tdrs-backend/tdpservice/data_files/serializers.py
index fb2eca791..198888c80 100644
--- a/tdrs-backend/tdpservice/data_files/serializers.py
+++ b/tdrs-backend/tdpservice/data_files/serializers.py
@@ -108,15 +108,22 @@ def update(self, instance, validated_data):
"""Throw an error if a user tries to update a data_file."""
raise ImmutabilityError(instance, validated_data)
- def validate_file(self, file):
+ def validate(self, data):
"""Perform all validation steps on a given file."""
user = self.context.get('user')
- validate_file_extension(file.name)
- validate_file_infection(file, file.name, user)
- return file
+ file = data['file'] if 'file' in data else None
+ section = data['section'] if 'section' in data else None
+
+ if file and section:
+ validate_file_extension(file.name, is_fra=DataFile.Section.is_fra(section))
+ validate_file_infection(file, file.name, user)
+
+ return data
def validate_section(self, section):
"""Validate the section field."""
if DataFile.Section.is_fra(section):
- raise serializers.ValidationError("Section cannot be FRA")
+ user = self.context.get('user')
+ if not user.has_fra_access:
+ raise serializers.ValidationError("Section cannot be FRA")
return section
diff --git a/tdrs-backend/tdpservice/data_files/test/test_api.py b/tdrs-backend/tdpservice/data_files/test/test_api.py
index 44af83bc9..e40aa2fb4 100644
--- a/tdrs-backend/tdpservice/data_files/test/test_api.py
+++ b/tdrs-backend/tdpservice/data_files/test/test_api.py
@@ -228,12 +228,20 @@ def test_create_data_file_file_entry(self, api_client, data_file_data, user):
self.assert_data_file_created(response)
self.assert_data_file_exists(data_file_data, 1, user)
- def test_create_data_file_fra(self, api_client, data_file_data, user):
+ def test_create_data_file_fra_no_feat_flag(self, api_client, data_file_data, user):
"""Test ability to create data file metadata registry."""
response = self.post_data_file_fra(api_client, data_file_data)
assert response.data == {'section': [ErrorDetail(string='Section cannot be FRA', code='invalid')]}
self.assert_data_file_error(response)
+ def test_create_data_file_fra_with_feat_flag(self, api_client, csv_data_file, user):
+ """Test ability to create data file metadata registry."""
+ user.feature_flags = {"fra_reports": True}
+ user.save()
+ response = self.post_data_file_fra(api_client, csv_data_file)
+ self.assert_data_file_created(response)
+ self.assert_data_file_exists(csv_data_file, 1, user)
+
def test_data_file_file_version_increment(
self,
api_client,
diff --git a/tdrs-backend/tdpservice/data_files/test/test_models.py b/tdrs-backend/tdpservice/data_files/test/test_models.py
index ded69438c..993295053 100644
--- a/tdrs-backend/tdpservice/data_files/test/test_models.py
+++ b/tdrs-backend/tdpservice/data_files/test/test_models.py
@@ -91,7 +91,7 @@ def test_data_files_filename_is_expected(user):
('SSP Closed Case Data', 'SSP'),
('Active Case Data', 'TAN'),
('Aggregate Data', 'TAN'),
- ('Work Outcomes for TANF Exiters', 'FRA'),
+ ('Work Outcomes of TANF Exiters', 'FRA'),
('Secondary School Attainment', 'FRA'),
('Supplemental Work Outcomes', 'FRA')
])
diff --git a/tdrs-backend/tdpservice/data_files/test/test_serializers.py b/tdrs-backend/tdpservice/data_files/test/test_serializers.py
index f497edfdf..789cf322b 100644
--- a/tdrs-backend/tdpservice/data_files/test/test_serializers.py
+++ b/tdrs-backend/tdpservice/data_files/test/test_serializers.py
@@ -53,7 +53,7 @@ def test_immutability_of_data_file(data_file_instance):
with pytest.raises(ImmutabilityError):
serializer = DataFileSerializer(
data_file_instance, data={
- "original_filename": "BadGuy.js"
+ "original_filename": "BadGuy.js",
},
partial=True
)
diff --git a/tdrs-backend/tdpservice/data_files/validators.py b/tdrs-backend/tdpservice/data_files/validators.py
index a4d0f0bc8..a67f95cb2 100644
--- a/tdrs-backend/tdpservice/data_files/validators.py
+++ b/tdrs-backend/tdpservice/data_files/validators.py
@@ -12,12 +12,17 @@
# The default set of Extensions allowed for an uploaded file
# Supports regex patterns as defined by standard library
# https://docs.python.org/3/library/re.html#regular-expression-syntax
-ALLOWED_FILE_EXTENSIONS = [
+ALLOWED_TANF_FILE_EXTENSIONS = [
r'^(ms\d{2})$', # Files ending in .MS## where # is a digit 0-9
r'^(ts\d{2,3})$', # Files ending in .TS## or .TS### where # is a digit 0-9
'txt', # plain text files
]
+ALLOWED_FRA_FILE_EXTENSIONS = [
+ 'csv',
+ 'xlsx'
+]
+
def _get_unsupported_msg(_type, value, supported_options):
"""Construct a message to convey an unsupported operation."""
@@ -27,13 +32,15 @@ def _get_unsupported_msg(_type, value, supported_options):
)
-def validate_file_extension(file_name: str):
+def validate_file_extension(file_name: str, is_fra: bool = False):
"""Validate the file extension of a file is in our supported list."""
file_extension = (
file_name.split('.')[-1].lower() if '.' in file_name else None
)
- allowed_ext_patterns = '|'.join(ALLOWED_FILE_EXTENSIONS)
+ allowed_list = ALLOWED_FRA_FILE_EXTENSIONS if is_fra else ALLOWED_TANF_FILE_EXTENSIONS
+
+ allowed_ext_patterns = '|'.join(allowed_list)
if (
file_extension is not None
and not re.match(allowed_ext_patterns, file_extension)
@@ -41,7 +48,7 @@ def validate_file_extension(file_name: str):
msg = _get_unsupported_msg(
'file extension',
file_extension,
- ALLOWED_FILE_EXTENSIONS
+ allowed_list
)
raise ValidationError(msg)
diff --git a/tdrs-backend/tdpservice/users/migrations/0044_alter_user_feature_flags.py b/tdrs-backend/tdpservice/users/migrations/0044_alter_user_feature_flags.py
new file mode 100644
index 000000000..91b865cf4
--- /dev/null
+++ b/tdrs-backend/tdpservice/users/migrations/0044_alter_user_feature_flags.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.15 on 2025-02-11 13:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0043_create_regions_m2m_del_region'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='feature_flags',
+ field=models.JSONField(blank=True, default=dict, help_text='Feature flags for this user. This is a JSON field that can be used to store key-value pairs. E.g: {"fra_reports": true}'),
+ ),
+ ]
diff --git a/tdrs-backend/tdpservice/users/models.py b/tdrs-backend/tdpservice/users/models.py
index 00edfb633..ceae28845 100644
--- a/tdrs-backend/tdpservice/users/models.py
+++ b/tdrs-backend/tdpservice/users/models.py
@@ -129,14 +129,14 @@ class Meta:
feature_flags = models.JSONField(
default=dict,
help_text='Feature flags for this user. This is a JSON field that can be used to store key-value pairs. ' +
- 'E.g: {"fra_access": true}',
+ 'E.g: {"fra_reports": true}',
blank=True,
)
@property
def has_fra_access(self):
"""Return whether or not the user has FRA access."""
- return self.feature_flags.get('fra_access', False)
+ return self.feature_flags.get('fra_reports', False)
def __str__(self):
"""Return the username as the string representation of the object."""
diff --git a/tdrs-backend/tdpservice/users/serializers.py b/tdrs-backend/tdpservice/users/serializers.py
index d89e9a32f..c717771c3 100644
--- a/tdrs-backend/tdpservice/users/serializers.py
+++ b/tdrs-backend/tdpservice/users/serializers.py
@@ -115,7 +115,8 @@ class Meta:
'date_joined',
'access_request',
'access_requested_date',
- 'account_approval_status'
+ 'account_approval_status',
+ 'feature_flags',
]
read_only_fields = (
'id',
@@ -131,6 +132,7 @@ class Meta:
'access_request',
'access_requested_date',
'account_approval_status',
+ 'feature_flags',
)
"""Enforce first and last name to be in API call and not empty"""
diff --git a/tdrs-backend/tdpservice/users/test/test_models.py b/tdrs-backend/tdpservice/users/test/test_models.py
index b9051ad09..830649b7b 100644
--- a/tdrs-backend/tdpservice/users/test/test_models.py
+++ b/tdrs-backend/tdpservice/users/test/test_models.py
@@ -80,7 +80,7 @@ def test_user_with_fra_access(client, admin_user, stt):
"""Test that a user with FRA access can only have an STT."""
admin_user.stt = stt
admin_user.is_superuser = True
- admin_user.feature_flags = {"fra_access": False}
+ admin_user.feature_flags = {"fra_reports": False}
admin_user.clean()
admin_user.save()
@@ -95,7 +95,7 @@ def test_user_with_fra_access(client, admin_user, stt):
response = client.get(f"/admin/data_files/datafile/{datafile.id}/change/")
assert response.status_code == 302
- admin_user.feature_flags = {"fra_access": True}
+ admin_user.feature_flags = {"fra_reports": True}
admin_user.save()
response = client.get(f"/admin/data_files/datafile/{datafile.id}/change/")
diff --git a/tdrs-frontend/src/actions/fraReports.js b/tdrs-frontend/src/actions/fraReports.js
new file mode 100644
index 000000000..b58d8ea94
--- /dev/null
+++ b/tdrs-frontend/src/actions/fraReports.js
@@ -0,0 +1,112 @@
+import { v4 as uuidv4 } from 'uuid'
+import axios from 'axios'
+import axiosInstance from '../axios-instance'
+import { objectToUrlParams } from '../utils/stringUtils'
+
+const BACKEND_URL = process.env.REACT_APP_BACKEND_URL
+
+export const SET_IS_LOADING_SUBMISSION_HISTORY =
+ 'SET_IS_LOADING_SUBMISSION_HISTORY'
+export const SET_FRA_SUBMISSION_HISTORY = 'SET_FRA_SUBMISSION_HISTORY'
+export const SET_IS_UPLOADING_FRA_REPORT = 'SET_IS_UPLOADING_FRA_REPORT'
+
+export const getFraSubmissionHistory =
+ (
+ { stt, reportType, fiscalQuarter, fiscalYear },
+ onSuccess = () => null,
+ onError = () => null
+ ) =>
+ async (dispatch) => {
+ dispatch({
+ type: SET_IS_LOADING_SUBMISSION_HISTORY,
+ payload: { isLoadingSubmissionHistory: true },
+ })
+
+ try {
+ const requestParams = {
+ stt: stt.id,
+ file_type: reportType,
+ year: fiscalYear,
+ quarter: fiscalQuarter,
+ }
+
+ const response = await axios.get(
+ `${BACKEND_URL}/data_files/?${objectToUrlParams(requestParams)}`,
+ {
+ responseType: 'json',
+ }
+ )
+
+ dispatch({
+ type: SET_FRA_SUBMISSION_HISTORY,
+ payload: { submissionHistory: response?.data },
+ })
+
+ onSuccess()
+ } catch (error) {
+ onError(error)
+ } finally {
+ dispatch({
+ type: SET_IS_LOADING_SUBMISSION_HISTORY,
+ payload: { isLoadingSubmissionHistory: false },
+ })
+ }
+ }
+
+export const uploadFraReport =
+ (
+ { stt, reportType, fiscalQuarter, fiscalYear, file, user },
+ onSuccess = () => null,
+ onError = () => null
+ ) =>
+ async (dispatch) => {
+ dispatch({
+ type: SET_IS_UPLOADING_FRA_REPORT,
+ payload: { isUploadingFraReport: true },
+ })
+
+ const formData = new FormData()
+ const fraReportData = {
+ file: file,
+ original_filename: file.name,
+ slug: uuidv4(),
+ user: user.id,
+ section: reportType,
+ year: fiscalYear,
+ stt: stt.id,
+ quarter: fiscalQuarter,
+ ssp: false,
+ }
+ for (const [key, value] of Object.entries(fraReportData)) {
+ formData.append(key, value)
+ }
+
+ try {
+ const response = await axiosInstance.post(
+ `${process.env.REACT_APP_BACKEND_URL}/data_files/`,
+ formData,
+ {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ withCredentials: true,
+ }
+ )
+
+ // dispatch(
+ // getFraSubmissionHistory({
+ // stt,
+ // reportType,
+ // fiscalQuarter,
+ // fiscalYear,
+ // })
+ // )
+ // or, dispatch the state update if response from upload can contain updated submission history
+ onSuccess()
+ } catch (error) {
+ onError(error)
+ } finally {
+ dispatch({
+ type: SET_IS_UPLOADING_FRA_REPORT,
+ payload: { isUploadingFraReport: false },
+ })
+ }
+ }
diff --git a/tdrs-frontend/src/actions/fraReports.test.js b/tdrs-frontend/src/actions/fraReports.test.js
new file mode 100644
index 000000000..96aadaf4b
--- /dev/null
+++ b/tdrs-frontend/src/actions/fraReports.test.js
@@ -0,0 +1,361 @@
+import axios from 'axios'
+import { thunk } from 'redux-thunk'
+import configureStore from 'redux-mock-store'
+import { v4 as uuidv4 } from 'uuid'
+
+import {
+ getFraSubmissionHistory,
+ upload,
+ SET_IS_LOADING_SUBMISSION_HISTORY,
+ SET_FRA_SUBMISSION_HISTORY,
+ SET_IS_UPLOADING_FRA_REPORT,
+ uploadFraReport,
+} from './fraReports'
+
+describe('actions/fraReports', () => {
+ jest.mock('axios')
+ const mockAxios = axios
+ const mockStore = configureStore([thunk])
+
+ describe('getFraSubmissionHistory', () => {
+ it('should handle success without callbacks', async () => {
+ const store = mockStore()
+
+ mockAxios.get.mockResolvedValue({
+ data: { yay: 'we did it' },
+ })
+
+ await store.dispatch(
+ getFraSubmissionHistory({
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ })
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(3)
+
+ expect(actions[0].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[0].payload).toStrictEqual({
+ isLoadingSubmissionHistory: true,
+ })
+
+ expect(actions[1].type).toBe(SET_FRA_SUBMISSION_HISTORY)
+ expect(actions[1].payload).toStrictEqual({
+ submissionHistory: { yay: 'we did it' },
+ })
+
+ expect(actions[2].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[2].payload).toStrictEqual({
+ isLoadingSubmissionHistory: false,
+ })
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle fail without callbacks', async () => {
+ const store = mockStore()
+
+ mockAxios.get.mockRejectedValue({
+ message: 'Error',
+ response: {
+ status: 400,
+ data: { detail: 'Mock fail response' },
+ },
+ })
+
+ await store.dispatch(
+ getFraSubmissionHistory({
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ })
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(2)
+
+ expect(actions[0].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[0].payload).toStrictEqual({
+ isLoadingSubmissionHistory: true,
+ })
+
+ expect(actions[1].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[1].payload).toStrictEqual({
+ isLoadingSubmissionHistory: false,
+ })
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onSuccess', async () => {
+ const store = mockStore()
+
+ mockAxios.get.mockResolvedValue({
+ data: { yay: 'we did it' },
+ })
+
+ const onSuccess = jest.fn()
+ const onError = jest.fn()
+
+ await store.dispatch(
+ getFraSubmissionHistory(
+ {
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ },
+ onSuccess,
+ onError
+ )
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(3)
+
+ expect(actions[0].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[0].payload).toStrictEqual({
+ isLoadingSubmissionHistory: true,
+ })
+
+ expect(actions[1].type).toBe(SET_FRA_SUBMISSION_HISTORY)
+ expect(actions[1].payload).toStrictEqual({
+ submissionHistory: { yay: 'we did it' },
+ })
+
+ expect(actions[2].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[2].payload).toStrictEqual({
+ isLoadingSubmissionHistory: false,
+ })
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+
+ expect(onSuccess).toHaveBeenCalledTimes(1)
+ expect(onError).toHaveBeenCalledTimes(0)
+ })
+
+ it('should call onError', async () => {
+ const store = mockStore()
+
+ mockAxios.get.mockRejectedValue({
+ message: 'Error',
+ response: {
+ status: 400,
+ data: { detail: 'Mock fail response' },
+ },
+ })
+
+ const onSuccess = jest.fn()
+ const onError = jest.fn()
+
+ await store.dispatch(
+ getFraSubmissionHistory(
+ {
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ },
+ onSuccess,
+ onError
+ )
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(2)
+
+ expect(actions[0].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[0].payload).toStrictEqual({
+ isLoadingSubmissionHistory: true,
+ })
+
+ expect(actions[1].type).toBe(SET_IS_LOADING_SUBMISSION_HISTORY)
+ expect(actions[1].payload).toStrictEqual({
+ isLoadingSubmissionHistory: false,
+ })
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+
+ expect(onSuccess).toHaveBeenCalledTimes(0)
+ expect(onError).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('uploadFraReport', () => {
+ it('should handle success without callbacks', async () => {
+ const store = mockStore()
+
+ mockAxios.post.mockResolvedValue({
+ data: { yay: 'success' },
+ })
+
+ await store.dispatch(
+ uploadFraReport({
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ file: 'bytes',
+ user: 'me',
+ })
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(2)
+
+ expect(actions[0].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[0].payload).toStrictEqual({
+ isUploadingFraReport: true,
+ })
+
+ expect(actions[1].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[1].payload).toStrictEqual({
+ isUploadingFraReport: false,
+ })
+
+ expect(axios.post).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle fail without callbacks', async () => {
+ const store = mockStore()
+
+ mockAxios.post.mockRejectedValue({
+ message: 'Error',
+ response: {
+ status: 400,
+ data: { detail: 'Mock fail response' },
+ },
+ })
+
+ await store.dispatch(
+ uploadFraReport({
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ file: 'bytes',
+ user: 'me',
+ })
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(2)
+
+ expect(actions[0].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[0].payload).toStrictEqual({
+ isUploadingFraReport: true,
+ })
+
+ expect(actions[1].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[1].payload).toStrictEqual({
+ isUploadingFraReport: false,
+ })
+
+ expect(axios.post).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onSuccess', async () => {
+ const store = mockStore()
+
+ mockAxios.post.mockResolvedValue({
+ data: { yay: 'success' },
+ })
+
+ const onSuccess = jest.fn()
+ const onError = jest.fn()
+
+ await store.dispatch(
+ uploadFraReport(
+ {
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ file: 'bytes',
+ user: 'me',
+ },
+ onSuccess,
+ onError
+ )
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(2)
+
+ expect(actions[0].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[0].payload).toStrictEqual({
+ isUploadingFraReport: true,
+ })
+
+ expect(actions[1].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[1].payload).toStrictEqual({
+ isUploadingFraReport: false,
+ })
+
+ expect(axios.post).toHaveBeenCalledTimes(1)
+
+ expect(onSuccess).toHaveBeenCalledTimes(1)
+ expect(onError).toHaveBeenCalledTimes(0)
+ })
+
+ it('should call onError', async () => {
+ const store = mockStore()
+
+ mockAxios.post.mockRejectedValue({
+ message: 'Error',
+ response: {
+ status: 400,
+ data: { detail: 'Mock fail response' },
+ },
+ })
+
+ const onSuccess = jest.fn()
+ const onError = jest.fn()
+
+ await store.dispatch(
+ uploadFraReport(
+ {
+ stt: 'stt',
+ reportType: 'something',
+ fiscalQuarter: '1',
+ fiscalYear: '2',
+ file: 'bytes',
+ user: 'me',
+ },
+ onSuccess,
+ onError
+ )
+ )
+
+ const actions = store.getActions()
+
+ expect(actions.length).toEqual(2)
+
+ expect(actions[0].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[0].payload).toStrictEqual({
+ isUploadingFraReport: true,
+ })
+
+ expect(actions[1].type).toBe(SET_IS_UPLOADING_FRA_REPORT)
+ expect(actions[1].payload).toStrictEqual({
+ isUploadingFraReport: false,
+ })
+
+ expect(axios.post).toHaveBeenCalledTimes(1)
+
+ expect(onSuccess).toHaveBeenCalledTimes(0)
+ expect(onError).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/tdrs-frontend/src/actions/reports.js b/tdrs-frontend/src/actions/reports.js
index 8ecb8839e..a5ae967a6 100644
--- a/tdrs-frontend/src/actions/reports.js
+++ b/tdrs-frontend/src/actions/reports.js
@@ -258,7 +258,7 @@ export const submit =
message: ''.concat(
error.message,
': ',
- error.response?.data?.file[0]
+ error.response?.data?.non_field_errors[0]
),
})
)
diff --git a/tdrs-frontend/src/components/ComboBox/index.js b/tdrs-frontend/src/components/ComboBox/index.js
deleted file mode 100644
index 0dec99394..000000000
--- a/tdrs-frontend/src/components/ComboBox/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import ComboBox from './ComboBox'
-
-export default ComboBox
diff --git a/tdrs-frontend/src/components/FileUpload/FileUpload.jsx b/tdrs-frontend/src/components/FileUpload/FileUpload.jsx
index 9357a7c2a..ea44cea6c 100644
--- a/tdrs-frontend/src/components/FileUpload/FileUpload.jsx
+++ b/tdrs-frontend/src/components/FileUpload/FileUpload.jsx
@@ -2,7 +2,6 @@ import React, { useRef, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useDispatch, useSelector } from 'react-redux'
import fileTypeChecker from 'file-type-checker'
-import languageEncoding from 'detect-file-encoding-and-language'
import {
clearError,
@@ -14,7 +13,11 @@ import {
} from '../../actions/reports'
import Button from '../Button'
import createFileInputErrorState from '../../utils/createFileInputErrorState'
-import { handlePreview, getTargetClassName } from './utils'
+import {
+ handlePreview,
+ getTargetClassName,
+ tryGetUTF8EncodedFile,
+} from './utils'
const INVALID_FILE_ERROR =
'We can’t process that file format. Please provide a plain text file.'
@@ -35,41 +38,6 @@ const INVALID_EXT_ERROR = (
>
)
-// The package author suggests using a minimum of 500 words to determine the encoding. However, datafiles don't have
-// "words" so we're using bytes instead to determine the encoding. See: https://www.npmjs.com/package/detect-file-encoding-and-language
-const MIN_BYTES = 500
-
-/* istanbul ignore next */
-const tryGetUTF8EncodedFile = async function (fileBytes, file) {
- // Create a small view of the file to determine the encoding.
- const btyesView = new Uint8Array(fileBytes.slice(0, MIN_BYTES))
- const blobView = new Blob([btyesView], { type: 'text/plain' })
- try {
- const fileInfo = await languageEncoding(blobView)
- const bom = btyesView.slice(0, 3)
- const hasBom = bom[0] === 0xef && bom[1] === 0xbb && bom[2] === 0xbf
- if ((fileInfo && fileInfo.encoding !== 'UTF-8') || hasBom) {
- const utf8Encoder = new TextEncoder()
- const decoder = new TextDecoder(fileInfo.encoding)
- const decodedString = decoder.decode(
- hasBom ? fileBytes.slice(3) : fileBytes
- )
- const utf8Bytes = utf8Encoder.encode(decodedString)
- return new File([utf8Bytes], file.name, file.options)
- }
- return file
- } catch (error) {
- // This is a last ditch fallback to ensure consistent functionality and also allows the unit tests to work in the
- // same way they did before this change. When the unit tests (i.e. Node environment) call `languageEncoding` it
- // expects a Buffer/string/URL object. When the browser calls `languageEncoding`, it expects a Blob/File object.
- // There is not a convenient way or universal object to handle both cases. Thus, when the tests run the call to
- // `languageEncoding`, it raises an exception and we return the file as is which is then dispatched as it would
- // have been before this change.
- console.error('Caught error while handling file encoding. Error:', error)
- return file
- }
-}
-
const load = (file, section, input, dropTarget, dispatch) => {
const filereader = new FileReader()
const types = ['png', 'gif', 'jpeg']
diff --git a/tdrs-frontend/src/components/FileUpload/utils.jsx b/tdrs-frontend/src/components/FileUpload/utils.jsx
index 96a74fe0d..536c369ce 100644
--- a/tdrs-frontend/src/components/FileUpload/utils.jsx
+++ b/tdrs-frontend/src/components/FileUpload/utils.jsx
@@ -1,6 +1,7 @@
//This file contains modified versions of code from:
//https://github.com/uswds/uswds/blob/develop/src/js/components/file-input.js
import escapeHtml from '../../utils/escapeHtml'
+import languageEncoding from 'detect-file-encoding-and-language'
export const PREFIX = 'usa'
@@ -95,3 +96,38 @@ export const handlePreview = (fileName, targetClassName) => {
}
return true
}
+
+// The package author suggests using a minimum of 500 words to determine the encoding. However, datafiles don't have
+// "words" so we're using bytes instead to determine the encoding. See: https://www.npmjs.com/package/detect-file-encoding-and-language
+const MIN_BYTES = 500
+
+/* istanbul ignore next */
+export const tryGetUTF8EncodedFile = async function (fileBytes, file) {
+ // Create a small view of the file to determine the encoding.
+ const btyesView = new Uint8Array(fileBytes.slice(0, MIN_BYTES))
+ const blobView = new Blob([btyesView], { type: 'text/plain' })
+ try {
+ const fileInfo = await languageEncoding(blobView)
+ const bom = btyesView.slice(0, 3)
+ const hasBom = bom[0] === 0xef && bom[1] === 0xbb && bom[2] === 0xbf
+ if ((fileInfo && fileInfo.encoding !== 'UTF-8') || hasBom) {
+ const utf8Encoder = new TextEncoder()
+ const decoder = new TextDecoder(fileInfo.encoding)
+ const decodedString = decoder.decode(
+ hasBom ? fileBytes.slice(3) : fileBytes
+ )
+ const utf8Bytes = utf8Encoder.encode(decodedString)
+ return new File([utf8Bytes], file.name, file.options)
+ }
+ return file
+ } catch (error) {
+ // This is a last ditch fallback to ensure consistent functionality and also allows the unit tests to work in the
+ // same way they did before this change. When the unit tests (i.e. Node environment) call `languageEncoding` it
+ // expects a Buffer/string/URL object. When the browser calls `languageEncoding`, it expects a Blob/File object.
+ // There is not a convenient way or universal object to handle both cases. Thus, when the tests run the call to
+ // `languageEncoding`, it raises an exception and we return the file as is which is then dispatched as it would
+ // have been before this change.
+ console.error('Caught error while handling file encoding. Error:', error)
+ return file
+ }
+}
diff --git a/tdrs-frontend/src/components/ComboBox/ComboBox.jsx b/tdrs-frontend/src/components/Form/ComboBox.jsx
similarity index 100%
rename from tdrs-frontend/src/components/ComboBox/ComboBox.jsx
rename to tdrs-frontend/src/components/Form/ComboBox.jsx
diff --git a/tdrs-frontend/src/components/ComboBox/ComboBox.test.js b/tdrs-frontend/src/components/Form/ComboBox.test.js
similarity index 100%
rename from tdrs-frontend/src/components/ComboBox/ComboBox.test.js
rename to tdrs-frontend/src/components/Form/ComboBox.test.js
diff --git a/tdrs-frontend/src/components/Form/DropdownSelect.jsx b/tdrs-frontend/src/components/Form/DropdownSelect.jsx
new file mode 100644
index 000000000..fc9396fa8
--- /dev/null
+++ b/tdrs-frontend/src/components/Form/DropdownSelect.jsx
@@ -0,0 +1,47 @@
+import { React } from 'react'
+import classNames from 'classnames'
+
+const DropdownSelect = ({
+ label,
+ fieldName,
+ setValue,
+ options,
+ errorText,
+ valid,
+ value,
+ classes,
+}) => (
+
+
+
+)
+
+export default DropdownSelect
diff --git a/tdrs-frontend/src/components/Form/RadioSelect.jsx b/tdrs-frontend/src/components/Form/RadioSelect.jsx
new file mode 100644
index 000000000..0795b19ed
--- /dev/null
+++ b/tdrs-frontend/src/components/Form/RadioSelect.jsx
@@ -0,0 +1,38 @@
+import { React } from 'react'
+import classNames from 'classnames'
+
+const RadioSelect = ({
+ label,
+ fieldName,
+ setValue,
+ options,
+ valid,
+ value,
+ classes,
+}) => (
+
+
+
+)
+
+export default RadioSelect
diff --git a/tdrs-frontend/src/components/Form/index.js b/tdrs-frontend/src/components/Form/index.js
new file mode 100644
index 000000000..011b33a7b
--- /dev/null
+++ b/tdrs-frontend/src/components/Form/index.js
@@ -0,0 +1,5 @@
+import ComboBox from './ComboBox'
+import RadioSelect from './RadioSelect'
+import DropdownSelect from './DropdownSelect'
+
+export { ComboBox, RadioSelect, DropdownSelect }
diff --git a/tdrs-frontend/src/components/Header/Header.jsx b/tdrs-frontend/src/components/Header/Header.jsx
index b77de65f7..e07246ddd 100644
--- a/tdrs-frontend/src/components/Header/Header.jsx
+++ b/tdrs-frontend/src/components/Header/Header.jsx
@@ -123,10 +123,21 @@ function Header() {
>
+
+
+
{(userAccessRequestPending || userAccessRequestApproved) && (
{
)
const welcomeLink = screen.getByText('Home')
expect(welcomeLink).toBeInTheDocument()
- const dataFilesLink = screen.getByText('Data Files')
+ const dataFilesLink = screen.getByText('TANF Data Files')
expect(dataFilesLink).toBeInTheDocument()
const profileLink = screen.getByText('Profile')
expect(profileLink).toBeInTheDocument()
@@ -103,7 +103,7 @@ describe('Header', () => {
)
- const dataFilesTab = screen.getByText('Data Files')
+ const dataFilesTab = screen.getByText('TANF Data Files')
expect(dataFilesTab.parentNode).toHaveClass('usa-current')
})
@@ -163,7 +163,7 @@ describe('Header', () => {
)
expect(queryByText('Welcome')).not.toBeInTheDocument()
- expect(queryByText('Data Files')).not.toBeInTheDocument()
+ expect(queryByText('TANF Data Files')).not.toBeInTheDocument()
expect(queryByText('Profile')).not.toBeInTheDocument()
expect(queryByText('Admin')).not.toBeInTheDocument()
})
@@ -190,7 +190,7 @@ describe('Header', () => {
)
- expect(queryByText('Data Files')).not.toBeInTheDocument()
+ expect(queryByText('TANF Data Files')).not.toBeInTheDocument()
expect(queryByText('Profile')).toBeInTheDocument()
expect(queryByText('Admin')).toBeInTheDocument()
})
@@ -222,7 +222,7 @@ describe('Header', () => {
)
- expect(queryByText('Data Files')).not.toBeInTheDocument()
+ expect(queryByText('TANF Data Files')).not.toBeInTheDocument()
expect(queryByText('Profile')).toBeInTheDocument()
expect(queryByText('Admin')).not.toBeInTheDocument()
})
@@ -236,7 +236,7 @@ describe('Header', () => {
)
- expect(queryByText('Data Files')).toBeInTheDocument()
+ expect(queryByText('TANF Data Files')).toBeInTheDocument()
expect(queryByText('Profile')).toBeInTheDocument()
expect(queryByText('Admin')).toBeInTheDocument()
})
diff --git a/tdrs-frontend/src/components/PermissionGuard/PermissionGuard.jsx b/tdrs-frontend/src/components/PermissionGuard/PermissionGuard.jsx
index e046f5400..409234a3a 100644
--- a/tdrs-frontend/src/components/PermissionGuard/PermissionGuard.jsx
+++ b/tdrs-frontend/src/components/PermissionGuard/PermissionGuard.jsx
@@ -2,11 +2,13 @@ import { useSelector } from 'react-redux'
import {
accountStatusIsApproved,
selectUserPermissions,
+ selectFeatureFlags,
} from '../../selectors/auth'
const isAllowed = (
- { permissions, isApproved },
+ { permissions, isApproved, featureFlags },
requiredPermissions,
+ requiredFeatureFlags,
requiresApproval
) => {
if (requiresApproval && !isApproved) {
@@ -17,12 +19,22 @@ const isAllowed = (
return true
}
+ if (!requiredFeatureFlags) {
+ return true
+ }
+
for (var i = 0; i < requiredPermissions.length; i++) {
if (!permissions.includes(requiredPermissions[i])) {
return false
}
}
+ for (var f = 0; f < requiredFeatureFlags.length; f++) {
+ if (featureFlags[requiredFeatureFlags[f]] !== true) {
+ return false
+ }
+ }
+
return true
}
@@ -30,14 +42,17 @@ const PermissionGuard = ({
children,
requiresApproval = false,
requiredPermissions = [],
+ requiredFeatureFlags = [],
notAllowedComponent = null,
}) => {
const permissions = useSelector(selectUserPermissions)
const isApproved = useSelector(accountStatusIsApproved)
+ const featureFlags = useSelector(selectFeatureFlags)
return isAllowed(
- { permissions, isApproved },
+ { permissions, isApproved, featureFlags },
requiredPermissions,
+ requiredFeatureFlags,
requiresApproval
)
? children
diff --git a/tdrs-frontend/src/components/PrivateRoute/PrivateRoute.js b/tdrs-frontend/src/components/PrivateRoute/PrivateRoute.js
index 33df4eb64..2ce9e2989 100644
--- a/tdrs-frontend/src/components/PrivateRoute/PrivateRoute.js
+++ b/tdrs-frontend/src/components/PrivateRoute/PrivateRoute.js
@@ -17,6 +17,7 @@ function PrivateRoute({
children,
title,
requiredPermissions,
+ requiredFeatureFlags,
requiresApproval,
}) {
const authenticated = useSelector((state) => state.auth.authenticated)
@@ -45,6 +46,7 @@ function PrivateRoute({
}
>
diff --git a/tdrs-frontend/src/components/Reports/FRAReports.jsx b/tdrs-frontend/src/components/Reports/FRAReports.jsx
new file mode 100644
index 000000000..94cc1a1e3
--- /dev/null
+++ b/tdrs-frontend/src/components/Reports/FRAReports.jsx
@@ -0,0 +1,792 @@
+import React, { useState, useRef, useEffect } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import classNames from 'classnames'
+import { fileInput } from '@uswds/uswds/src/js/components'
+import fileTypeChecker from 'file-type-checker'
+
+import Button from '../Button'
+import STTComboBox from '../STTComboBox'
+import { quarters, constructYears } from './utils'
+import { accountCanSelectStt } from '../../selectors/auth'
+import { handlePreview, tryGetUTF8EncodedFile } from '../FileUpload/utils'
+import createFileInputErrorState from '../../utils/createFileInputErrorState'
+import Modal from '../Modal'
+
+import {
+ getFraSubmissionHistory,
+ uploadFraReport,
+} from '../../actions/fraReports'
+import { fetchSttList } from '../../actions/sttList'
+import { DropdownSelect, RadioSelect } from '../Form'
+
+const INVALID_FILE_ERROR =
+ 'We can’t process that file format. Please provide a plain text file.'
+
+const INVALID_EXT_ERROR =
+ 'Invalid extension. Accepted file types are: .csv or .xlsx.'
+
+const SelectSTT = ({ valid, value, setValue }) => (
+
+
+
+)
+
+const SelectReportType = ({ valid, value, setValue, options }) => (
+
+)
+
+const SelectFiscalYear = ({ valid, value, setValue }) => (
+ ({
+ label: year,
+ value: year,
+ })),
+ ]}
+ />
+)
+
+const SelectQuarter = ({ valid, value, setValue }) => (
+ ({
+ label: quarterDescription,
+ value: quarter,
+ })),
+ ]}
+ />
+)
+
+const FiscalQuarterExplainer = () => (
+
+ Identifying the right Fiscal Year and Quarter
+
+
+ Fiscal Quarter |
+ Calendar Period |
+
+
+
+
+ Quarter 1 |
+ Oct 1 - Dec 31 |
+
+
+ Quarter 2 |
+ Jan 1 - Mar 31 |
+
+
+ Quarter 3 |
+ Apr 1 - Jun 30 |
+
+
+ Quarter 4 |
+ Jul 1 - Sep 30 |
+
+
+
+)
+
+const SearchForm = ({
+ handleSearch,
+ reportTypeOptions,
+ form,
+ setFormState,
+ needsSttSelection,
+ userProfileStt,
+}) => {
+ const missingStt = !needsSttSelection && !userProfileStt
+ const errorsRef = null
+
+ const setFormValue = (field, value) => {
+ const newFormState = { ...form }
+
+ if (!!value) {
+ newFormState[field].value = value
+ newFormState[field].valid = true
+ } else {
+ newFormState[field].valid = false
+ }
+ newFormState[field].touched = true
+
+ let errors = 0
+ Object.keys(newFormState).forEach((key) => {
+ if (
+ key !== 'errors' &&
+ newFormState[key].touched &&
+ !newFormState[key].valid
+ ) {
+ errors += 1
+ }
+ })
+
+ setFormState({ ...newFormState, errors })
+ }
+
+ return (
+ <>
+ {missingStt && (
+
+ An STT is not set for this user.
+
+ )}
+ {Boolean(form.errors) && (
+
+ There {form.errors === 1 ? 'is' : 'are'} {form.errors} error(s) in
+ this form
+
+ )}
+
+ >
+ )
+}
+
+const UploadForm = ({
+ handleCancel,
+ handleUpload,
+ handleDownload,
+ setLocalAlertState,
+ file,
+ setSelectedFile,
+ section,
+ error,
+ setError,
+}) => {
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ // `init` for the uswds fileInput must be called on the
+ // initial render for it to load properly
+ fileInput.init()
+ }, [])
+
+ // unused for this PR, affects the test cov threshold
+ // uncommented and properly tested in followup PR #3407
+ // useEffect(() => {
+ // const trySettingPreview = () => {
+ // const targetClassName = 'usa-file-input__preview input #fra-file-upload'
+ // const previewState = handlePreview(file?.name, targetClassName)
+ // if (!previewState) {
+ // setTimeout(trySettingPreview, 100)
+ // }
+ // }
+ // if (file?.id) {
+ // trySettingPreview()
+ // }
+ // }, [file])
+
+ const onFileChanged = async (e) => {
+ setError(null)
+ setLocalAlertState({
+ active: false,
+ type: null,
+ message: null,
+ })
+
+ const fileInputValue = e.target.files[0]
+ const input = inputRef.current
+ const dropTarget = inputRef.current.parentNode
+
+ const blob = fileInputValue.slice(0, 4)
+
+ const filereader = new FileReader()
+ const imgFileTypes = ['png', 'gif', 'jpeg']
+ const csvExtension = /(\.csv)$/i
+ const xlsxExtension = /(\.xlsx)$/i
+
+ const loadFile = () =>
+ new Promise((resolve, reject) => {
+ filereader.onerror = () => {
+ filereader.abort()
+ reject(new Error('Problem loading input file'))
+ }
+
+ filereader.onload = () => resolve({ result: filereader.result })
+
+ filereader.readAsArrayBuffer(blob)
+ })
+
+ const isCsv = csvExtension.exec(fileInputValue.name)
+ const isXlsx = xlsxExtension.exec(fileInputValue.name)
+
+ if (!isCsv && !isXlsx) {
+ setError(INVALID_EXT_ERROR)
+ return
+ }
+
+ const { result } = await loadFile()
+
+ const isImg = fileTypeChecker.validateFileType(result, imgFileTypes)
+ if (isImg) {
+ createFileInputErrorState(input, dropTarget)
+ setError(INVALID_FILE_ERROR)
+ return
+ }
+
+ let encodedFile = null
+
+ if (isXlsx) {
+ encodedFile = fileInputValue
+ } else {
+ encodedFile = await tryGetUTF8EncodedFile(result, fileInputValue)
+ }
+
+ setSelectedFile(encodedFile)
+ }
+
+ const onSubmit = (e) => {
+ e.preventDefault()
+
+ if (!!error) {
+ return
+ }
+
+ if (!file || (file && file.id)) {
+ setLocalAlertState({
+ active: true,
+ type: 'error',
+ message: 'No changes have been made to data files',
+ })
+ return
+ }
+
+ handleUpload({ file })
+ }
+
+ const formattedSectionName = section.toLowerCase().replace(' ', '-')
+
+ const ariaDescription = file
+ ? `Selected File ${file?.name}. To change the selected file, click this button.`
+ : `Drag file here or choose from folder.`
+
+ return (
+ <>
+
+ >
+ )
+}
+
+const SubmissionHistory = () => <>>
+
+const ReportTypeSubtext = ({ reportType, reportTypeLabel }) => {
+ let description = ''
+ let aboutLink = ''
+ let templateLink = ''
+
+ switch (reportType) {
+ case 'workOutcomesOfTanfExiters':
+ description =
+ 'The Work Outcomes of TANF Exiters report contains the Social Security Numbers (SSNs) of all work-eligible individuals who exit TANF in a given quarter and the dates in YYYYMM format that each individual exited TANF.'
+ aboutLink =
+ 'https://acf.gov/sites/default/files/documents/ofa/1A.Instructions-work-outcomes-of-TANF-exiters-report.pdf'
+ templateLink =
+ 'https://acf.gov/sites/default/files/documents/ofa/1B.TANF-work-outcomes-of-TANF-exiters-report-example-file-submission.zip'
+ break
+
+ // case 'secondarySchoolAttainment':
+ // description = ''
+ // aboutLink = ''
+ // templateLink = ''
+ // break
+
+ // case 'supplementalWorkOutcomes':
+ // description = ''
+ // aboutLink = ''
+ // templateLink = ''
+ // break
+
+ default:
+ break
+ }
+
+ return (
+
+ )
+}
+
+const FRAReports = () => {
+ const [isUploadReportToggled, setUploadReportToggled] = useState(false)
+ const [errorModalVisible, setErrorModalVisible] = useState(false)
+ const [searchFormValues, setSearchFormValues] = useState(null)
+ const [uploadError, setUploadError] = useState(null)
+
+ const user = useSelector((state) => state.auth.user)
+ const sttList = useSelector((state) => state?.stts?.sttList)
+ const needsSttSelection = useSelector(accountCanSelectStt)
+ const userProfileStt = user?.stt?.name
+
+ const [temporaryFormState, setTemporaryFormState] = useState({
+ errors: 0,
+ stt: {
+ value: needsSttSelection ? null : userProfileStt,
+ valid: false,
+ touched: false,
+ },
+ reportType: {
+ value: 'workOutcomesOfTanfExiters',
+ valid: false,
+ touched: false,
+ },
+ fiscalYear: {
+ value: '',
+ valid: false,
+ touched: false,
+ },
+ fiscalQuarter: {
+ value: '',
+ valid: false,
+ touched: false,
+ },
+ })
+
+ const [selectedFile, setSelectedFile] = useState(null)
+
+ // const fraSubmissionHistory = useSelector((state) => state.fraReports)
+
+ const dispatch = useDispatch()
+
+ const alertRef = useRef(null)
+ const [localAlert, setLocalAlertState] = useState({
+ active: false,
+ type: null,
+ message: null,
+ })
+
+ const reportTypeOptions = [
+ {
+ value: 'workOutcomesOfTanfExiters',
+ label: 'Work Outcomes of TANF Exiters',
+ },
+ {
+ value: 'secondarySchoolAttainment',
+ label: 'Secondary School Attainment',
+ disabled: true,
+ },
+ {
+ value: 'supplementalWorkOutcomes',
+ label: 'Supplemental Work Outcomes',
+ disabled: true,
+ },
+ ]
+
+ const resetPreviousValues = () => {
+ setTemporaryFormState({
+ errors: 0,
+ stt: {
+ ...temporaryFormState.stt,
+ value: searchFormValues.stt.name,
+ },
+ reportType: {
+ ...temporaryFormState.reportType,
+ value: searchFormValues.reportType,
+ },
+ fiscalYear: {
+ ...temporaryFormState.fiscalYear,
+ value: searchFormValues.fiscalYear,
+ },
+ fiscalQuarter: {
+ ...temporaryFormState.fiscalQuarter,
+ value: searchFormValues.fiscalQuarter,
+ },
+ })
+ }
+
+ const validateSearchForm = (selectedValues) => {
+ const validatedForm = { ...temporaryFormState }
+ let isValid = true
+ let errors = 0
+
+ Object.keys(selectedValues).forEach((key) => {
+ if (!!selectedValues[key]) {
+ validatedForm[key].valid = true
+ } else {
+ validatedForm[key].valid = false
+ isValid = false
+ errors += 1
+ }
+ validatedForm[key].touched = true
+ })
+
+ setTemporaryFormState({ ...validatedForm, errors })
+
+ return isValid
+ }
+
+ const handleSearch = (e, bypassSelectedFile = false) => {
+ e.preventDefault()
+
+ if (!bypassSelectedFile && selectedFile && !selectedFile.id) {
+ setErrorModalVisible(true)
+ return
+ }
+
+ const form = temporaryFormState
+
+ const formValues = {
+ stt: sttList?.find((stt) => stt?.name === form.stt.value),
+ }
+
+ Object.keys(form).forEach((key) => {
+ if (key !== 'errors' && key !== 'stt') {
+ formValues[key] = form[key].value
+ }
+ })
+
+ let isValid = validateSearchForm(formValues)
+
+ if (!isValid) {
+ return
+ }
+
+ setUploadReportToggled(false)
+ setSearchFormValues(null)
+ setUploadError(null)
+ setLocalAlertState({
+ active: false,
+ type: null,
+ message: null,
+ })
+
+ const onSearchSuccess = () => {
+ setUploadReportToggled(true)
+ setSearchFormValues(formValues)
+ }
+ const onSearchError = (e) => console.error(e)
+
+ dispatch(
+ getFraSubmissionHistory(formValues, onSearchSuccess, onSearchError)
+ )
+ }
+
+ const handleUpload = ({ file: selectedFile }) => {
+ const onFileUploadSuccess = () => {
+ setSelectedFile(null) // once we have the latest file in submission history, conditional setting of state in constructor should be sufficient
+ setLocalAlertState({
+ active: true,
+ type: 'success',
+ message: `Successfully submitted section: ${getReportTypeLabel()} on ${new Date().toDateString()}`,
+ })
+ }
+
+ const onFileUploadError = (error) => {
+ const error_response = error.response?.data
+ const msg = error_response?.non_field_errors
+ ? error_response.non_field_errors[0]
+ : error_response?.detail
+ ? error_response.detail
+ : error_response?.file
+ ? error_response.file
+ : null
+
+ setLocalAlertState({
+ active: true,
+ type: 'error',
+ message: ''.concat(error.message, ': ', msg),
+ })
+ }
+
+ dispatch(
+ uploadFraReport(
+ {
+ ...searchFormValues,
+ reportType: getReportTypeLabel(),
+ file: selectedFile,
+ user,
+ },
+ onFileUploadSuccess,
+ onFileUploadError
+ )
+ )
+ }
+
+ const getReportTypeLabel = () => {
+ if (isUploadReportToggled) {
+ const { reportType } = searchFormValues
+ return reportTypeOptions.find((o) => o.value === reportType).label
+ }
+
+ return null
+ }
+
+ const makeHeaderLabel = () => {
+ if (isUploadReportToggled) {
+ const { stt, fiscalQuarter, fiscalYear } = searchFormValues
+ const reportTypeLabel = getReportTypeLabel()
+ const quarterLabel = quarters[fiscalQuarter]
+
+ return `${stt.name} - ${reportTypeLabel} - Fiscal Year ${fiscalYear} - ${quarterLabel}`
+ }
+
+ return null
+ }
+
+ const headerRef = useRef(null)
+ useEffect(() => {
+ if (headerRef && headerRef.current) {
+ headerRef.current.focus()
+ }
+ }, [])
+
+ useEffect(() => {
+ if (sttList.length === 0) {
+ dispatch(fetchSttList())
+ }
+ }, [dispatch, sttList])
+
+ useEffect(() => {
+ if (localAlert.active && alertRef && alertRef.current) {
+ alertRef.current.scrollIntoView({ behavior: 'smooth' })
+ }
+ }, [localAlert, alertRef])
+
+ return (
+ <>
+
+
+
+ {isUploadReportToggled && (
+ <>
+
+ {makeHeaderLabel()}
+
+
+
+
+ {localAlert.active && (
+
+ )}
+ {
+ setSelectedFile(null)
+ setUploadError(null)
+ setUploadReportToggled(false)
+ }}
+ setLocalAlertState={setLocalAlertState}
+ file={selectedFile}
+ setSelectedFile={setSelectedFile}
+ section={getReportTypeLabel()}
+ error={uploadError}
+ setError={setUploadError}
+ />
+
+
+ >
+ )}
+
+ {
+ setErrorModalVisible(false)
+ resetPreviousValues()
+ },
+ },
+ {
+ key: '2',
+ text: 'Discard and Search',
+ onClick: (e) => {
+ setErrorModalVisible(false)
+ setSelectedFile(null)
+ setUploadError(null)
+ handleSearch(e, true)
+ },
+ },
+ ]}
+ />
+ >
+ )
+}
+
+export default FRAReports
diff --git a/tdrs-frontend/src/components/Reports/FRAReports.test.js b/tdrs-frontend/src/components/Reports/FRAReports.test.js
new file mode 100644
index 000000000..672093c57
--- /dev/null
+++ b/tdrs-frontend/src/components/Reports/FRAReports.test.js
@@ -0,0 +1,707 @@
+import React from 'react'
+import { fireEvent, waitFor, render } from '@testing-library/react'
+import axios from 'axios'
+import { Provider } from 'react-redux'
+import { FRAReports } from '.'
+import configureStore from '../../configureStore'
+
+const initialState = {
+ auth: {
+ authenticated: false,
+ },
+ stts: {
+ sttList: [
+ {
+ id: 1,
+ type: 'state',
+ code: 'AL',
+ name: 'Alabama',
+ ssp: true,
+ },
+ {
+ id: 2,
+ type: 'state',
+ code: 'AK',
+ name: 'Alaska',
+ ssp: false,
+ },
+ ],
+ loading: false,
+ },
+}
+
+const mockStore = (initial = {}) => configureStore(initial)
+
+const makeTestFile = (name, contents = ['test'], type = 'text/plain') =>
+ new File(contents, name, { type })
+
+describe('FRA Reports Page', () => {
+ it('Renders', () => {
+ const store = mockStore()
+ const { getByText, queryByText } = render(
+
+
+
+ )
+
+ // search form elements exist
+ expect(getByText('File Type')).toBeInTheDocument()
+ expect(getByText('Fiscal Year (October - September)')).toBeInTheDocument()
+ expect(getByText('Quarter')).toBeInTheDocument()
+ expect(
+ getByText('Identifying the right Fiscal Year and Quarter')
+ ).toBeInTheDocument()
+ expect(getByText('Work Outcomes of TANF Exiters')).toBeInTheDocument()
+
+ // error and upload for elements do not
+ expect(queryByText('Submit Report')).not.toBeInTheDocument()
+ })
+
+ describe('Search form', () => {
+ it('Shows STT combobox if admin role', () => {
+ const state = {
+ ...initialState,
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: null,
+ roles: [{ id: 1, name: 'OFA System Admin', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+
+ const { getByText, queryByText } = render(
+
+
+
+ )
+
+ expect(getByText('State, Tribe, or Territory*')).toBeInTheDocument()
+ })
+
+ it('Does not show STT combobox if not admin', () => {
+ const state = {
+ ...initialState,
+ 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',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+
+ const { queryByText } = render(
+
+
+
+ )
+
+ expect(
+ queryByText('Associated State, Tribe, or Territory*')
+ ).not.toBeInTheDocument()
+ })
+
+ it('Shows missing STT error if STT not set', () => {
+ const state = {
+ ...initialState,
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: null,
+ roles: [{ id: 1, name: 'Data Analyst', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+
+ const { getByText, queryByText } = render(
+
+
+
+ )
+
+ expect(
+ queryByText('Associated State, Tribe, or Territory*')
+ ).not.toBeInTheDocument()
+ expect(getByText('An STT is not set for this user.')).toBeInTheDocument()
+ })
+
+ it('Shows errors if required values are not set', () => {
+ const state = {
+ ...initialState,
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: null,
+ roles: [{ id: 1, name: 'OFA System Admin', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+
+ const { getByText, queryByText } = render(
+
+
+
+ )
+
+ // don't fill out any form values
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ // upload form not displayed
+ expect(queryByText('Submit Report')).not.toBeInTheDocument()
+
+ // fields all have errors
+ expect(
+ getByText('A state, tribe, or territory is required')
+ ).toBeInTheDocument()
+ expect(getByText('A fiscal year is required')).toBeInTheDocument()
+ expect(getByText('A quarter is required')).toBeInTheDocument()
+ expect(getByText('There are 3 error(s) in this form')).toBeInTheDocument()
+ })
+
+ it('Updates form validation if values are changed', async () => {
+ const state = {
+ ...initialState,
+ auth: {
+ authenticated: true,
+ user: {
+ email: 'hi@bye.com',
+ stt: null,
+ roles: [{ id: 1, name: 'OFA System Admin', permission: [] }],
+ account_approval_status: 'Approved',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+
+ const { getByText, queryByText, getByLabelText } = render(
+
+
+
+ )
+
+ // don't fill out any form values
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ // upload form not displayed
+ expect(queryByText('Submit Report')).not.toBeInTheDocument()
+
+ // fields all have errors
+ expect(
+ getByText('A state, tribe, or territory is required')
+ ).toBeInTheDocument()
+ expect(getByText('A fiscal year is required')).toBeInTheDocument()
+ expect(getByText('A quarter is required')).toBeInTheDocument()
+ expect(getByText('There are 3 error(s) in this form')).toBeInTheDocument()
+
+ const yearsDropdown = getByLabelText(
+ 'Fiscal Year (October - September)',
+ { exact: false }
+ )
+ fireEvent.change(yearsDropdown, { target: { value: '2021' } })
+
+ await waitFor(() => {
+ expect(queryByText('A fiscal year is required')).not.toBeInTheDocument()
+ expect(
+ getByText('There are 2 error(s) in this form')
+ ).toBeInTheDocument()
+ })
+
+ const quarterDropdown = getByLabelText('Quarter', { exact: false })
+ fireEvent.change(quarterDropdown, { target: { value: 'Q1' } })
+
+ await waitFor(() => {
+ expect(queryByText('A quarter is required')).not.toBeInTheDocument()
+ expect(
+ getByText('There is 1 error(s) in this form')
+ ).toBeInTheDocument()
+ })
+ })
+
+ it('Shows upload form once search has been clicked', async () => {
+ const state = {
+ ...initialState,
+ 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',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+
+ const { getByText, queryByText, getByLabelText } = render(
+
+
+
+ )
+
+ // fill out the form values before clicking search
+ const yearsDropdown = getByLabelText('Fiscal Year (October - September)')
+ fireEvent.change(yearsDropdown, { target: { value: '2021' } })
+
+ const quarterDropdown = getByLabelText('Quarter')
+ fireEvent.change(quarterDropdown, { target: { value: 'Q1' } })
+
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ // upload form displayed
+ await waitFor(() => {
+ expect(
+ getByText(
+ 'Alaska - Work Outcomes of TANF Exiters - Fiscal Year 2021 - Quarter 1 (October - December)'
+ )
+ ).toBeInTheDocument()
+ expect(getByText('Submit Report')).toBeInTheDocument()
+ })
+
+ // fields don't have errors
+ expect(
+ queryByText('A state, tribe, or territory is required')
+ ).not.toBeInTheDocument()
+ expect(queryByText('A fiscal year is required')).not.toBeInTheDocument()
+ expect(queryByText('A quarter is required')).not.toBeInTheDocument()
+ expect(
+ queryByText('There are 3 error(s) in this form')
+ ).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Upload form', () => {
+ const setup = async () => {
+ window.HTMLElement.prototype.scrollIntoView = () => {}
+ const state = {
+ ...initialState,
+ 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',
+ },
+ },
+ }
+
+ const store = mockStore(state)
+ const origDispatch = store.dispatch
+ store.dispatch = jest.fn(origDispatch)
+
+ const component = render(
+
+
+
+ )
+
+ const { getByLabelText, getByText } = component
+
+ // fill out the form values before clicking search
+ const yearsDropdown = getByLabelText('Fiscal Year (October - September)')
+ fireEvent.change(yearsDropdown, { target: { value: '2021' } })
+
+ const quarterDropdown = getByLabelText('Quarter')
+ fireEvent.change(quarterDropdown, { target: { value: 'Q1' } })
+
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ await waitFor(() => {
+ expect(
+ getByText(
+ 'Alaska - Work Outcomes of TANF Exiters - Fiscal Year 2021 - Quarter 1 (October - December)'
+ )
+ ).toBeInTheDocument()
+ expect(
+ getByText(
+ 'The Work Outcomes of TANF Exiters report contains the Social Security Numbers (SSNs) of all work-eligible individuals who exit TANF in a given quarter and the dates in YYYYMM format that each individual exited TANF.'
+ )
+ ).toBeInTheDocument()
+ expect(getByText('Submit Report')).toBeInTheDocument()
+ })
+
+ return { ...component, ...store }
+ }
+
+ it('Allows csv files to be selected and submitted', async () => {
+ const { getByText, dispatch, getByRole, container } = await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+ await waitFor(() =>
+ expect(
+ getByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ )
+
+ const submitButton = getByText('Submit Report')
+ fireEvent.click(submitButton)
+
+ await waitFor(() =>
+ expect(
+ getByText(
+ `Successfully submitted section: Work Outcomes of TANF Exiters on ${new Date().toDateString()}`
+ )
+ ).toBeInTheDocument()
+ )
+ await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2))
+ })
+
+ it('Allows xlsx files to be selected and submitted', async () => {
+ const { getByText, dispatch, getByRole, container } = await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: {
+ files: [
+ makeTestFile('report.xlsx', ['asdfad'], 'application/vnd.ms-excel'),
+ ],
+ },
+ })
+ await waitFor(() =>
+ expect(
+ getByText(
+ 'Selected File report.xlsx. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ )
+
+ const submitButton = getByText('Submit Report')
+ fireEvent.click(submitButton)
+
+ await waitFor(() =>
+ expect(
+ getByText(
+ `Successfully submitted section: Work Outcomes of TANF Exiters on ${new Date().toDateString()}`
+ )
+ ).toBeInTheDocument()
+ )
+ await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2))
+ })
+
+ it('Shows an error if file submission failed', async () => {
+ jest.mock('axios')
+ const mockAxios = axios
+ const { getByText, dispatch, getByRole, container } = await setup()
+
+ mockAxios.post.mockRejectedValue({
+ message: 'Error',
+ response: {
+ status: 400,
+ data: { detail: 'Mock fail response' },
+ },
+ })
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+ await waitFor(() =>
+ expect(
+ getByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ )
+
+ const submitButton = getByText('Submit Report')
+ fireEvent.click(submitButton)
+
+ await waitFor(() =>
+ expect(getByText('Error: Mock fail response')).toBeInTheDocument()
+ )
+ await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2))
+ })
+
+ it('Shows an error if a no file is selected for submission', async () => {
+ const { getByText, dispatch, getByRole, container } = await setup()
+
+ const submitButton = getByText('Submit Report', { selector: 'button' })
+ fireEvent.click(submitButton)
+
+ await waitFor(() => {
+ expect(
+ getByText('No changes have been made to data files')
+ ).toBeInTheDocument()
+ })
+ })
+
+ it('Shows an error if a non-allowed file type is selected', async () => {
+ const { getByText, dispatch, getByRole, container } = await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.png', ['png'], 'img/png')] },
+ })
+ await waitFor(() => {
+ expect(
+ getByText(
+ 'Invalid extension. Accepted file types are: .csv or .xlsx.'
+ )
+ ).toBeInTheDocument()
+ })
+
+ const submitButton = getByText('Submit Report', { selector: 'button' })
+ fireEvent.click(submitButton)
+
+ await waitFor(() => getByRole('alert'))
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ })
+
+ it('Shows a message if search is clicked with an non-uploaded file', async () => {
+ const { getByText, container, getByLabelText, queryByText } =
+ await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+
+ await waitFor(() => {
+ expect(
+ getByText('You have selected the file: report.csv')
+ ).toBeInTheDocument()
+ })
+
+ const yearsDropdown = getByLabelText('Fiscal Year (October - September)')
+ fireEvent.change(yearsDropdown, { target: { value: '2024' } })
+
+ const quarterDropdown = getByLabelText('Quarter')
+ fireEvent.change(quarterDropdown, { target: { value: 'Q2' } })
+
+ await waitFor(() => {
+ expect(
+ getByText('Quarter 2 (January - March)', { selector: 'option' })
+ .selected
+ ).toBe(true)
+ })
+
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ await waitFor(() =>
+ expect(queryByText('Files Not Submitted')).toBeInTheDocument()
+ )
+ })
+
+ it('Cancels the upload if Cancel is clicked', async () => {
+ const { getByText, container, queryByText } = await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+
+ await waitFor(() => {
+ expect(
+ getByText('You have selected the file: report.csv')
+ ).toBeInTheDocument()
+ })
+
+ fireEvent.click(getByText(/Cancel/, { selector: 'button' }))
+
+ await waitFor(() => {
+ expect(
+ queryByText('You have selected the file: report.csv')
+ ).not.toBeInTheDocument()
+ expect(queryByText(/Submit Report/)).not.toBeInTheDocument()
+ })
+ })
+
+ it('Does not show a message if search is clicked after uploading a file', async () => {
+ const { getByText, container, getByLabelText, queryByText, dispatch } =
+ await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+ await waitFor(() =>
+ expect(
+ getByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ )
+
+ fireEvent.click(getByText(/Submit Report/, { selector: 'button' }))
+ await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2))
+
+ const yearsDropdown = getByLabelText('Fiscal Year (October - September)')
+ fireEvent.change(yearsDropdown, { target: { value: '2024' } })
+
+ const quarterDropdown = getByLabelText('Quarter')
+ fireEvent.change(quarterDropdown, { target: { value: 'Q2' } })
+
+ await waitFor(() => {
+ expect(
+ getByText('Quarter 2 (January - March)', { selector: 'option' })
+ .selected
+ ).toBe(true)
+ })
+
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ await waitFor(() =>
+ expect(queryByText('Files Not Submitted')).not.toBeInTheDocument()
+ )
+ })
+
+ it('Allows the user to cancel the error modal and retain previous search selections', async () => {
+ const { getByText, queryByText, getByLabelText, container, dispatch } =
+ await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+ await waitFor(() =>
+ expect(
+ getByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ )
+
+ // make a change to the search selections and click search
+ const yearsDropdown = getByLabelText('Fiscal Year (October - September)')
+ fireEvent.change(yearsDropdown, { target: { value: '2024' } })
+
+ const quarterDropdown = getByLabelText('Quarter')
+ fireEvent.change(quarterDropdown, { target: { value: 'Q2' } })
+ await waitFor(() => {
+ expect(getByText('2024', { selector: 'option' }).selected).toBe(true)
+ expect(
+ getByText('Quarter 2 (January - March)', { selector: 'option' })
+ .selected
+ ).toBe(true)
+ })
+
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ await waitFor(() =>
+ expect(queryByText('Files Not Submitted')).toBeInTheDocument()
+ )
+
+ // click cancel
+ fireEvent.click(getByText(/Cancel/, { selector: '#modal button' }))
+
+ // assert file still exists, search params are the same as initial, dispatch not called
+ await waitFor(() => {
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ expect(queryByText('Files Not Submitted')).not.toBeInTheDocument()
+ expect(
+ getByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ expect(getByText('2021', { selector: 'option' }).selected).toBe(true)
+ expect(
+ getByText('Quarter 1 (October - December)', { selector: 'option' })
+ .selected
+ ).toBe(true)
+ })
+ })
+
+ it('Allows the user to discard the error modal and continue with a new search', async () => {
+ const { getByText, queryByText, getByLabelText, container, dispatch } =
+ await setup()
+
+ const uploadForm = container.querySelector('#fra-file-upload')
+ fireEvent.change(uploadForm, {
+ target: { files: [makeTestFile('report.csv')] },
+ })
+ await waitFor(() =>
+ expect(
+ getByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).toBeInTheDocument()
+ )
+
+ // make a change to the search selections and click search
+ const yearsDropdown = getByLabelText('Fiscal Year (October - September)')
+ fireEvent.change(yearsDropdown, { target: { value: '2024' } })
+
+ const quarterDropdown = getByLabelText('Quarter')
+ fireEvent.change(quarterDropdown, { target: { value: 'Q2' } })
+ await waitFor(() => {
+ expect(getByText('2024', { selector: 'option' }).selected).toBe(true)
+ expect(
+ getByText('Quarter 2 (January - March)', { selector: 'option' })
+ .selected
+ ).toBe(true)
+ })
+
+ fireEvent.click(getByText(/Search/, { selector: 'button' }))
+
+ await waitFor(() =>
+ expect(queryByText('Files Not Submitted')).toBeInTheDocument()
+ )
+
+ // click discard
+ const button = getByText(/Discard and Search/, {
+ selector: '#modal button',
+ })
+ fireEvent.click(button)
+
+ // assert file discarded, search params updated
+ await waitFor(() => {
+ // expect(dispatch).toHaveBeenCalledTimes(2)
+ expect(queryByText('Files Not Submitted')).not.toBeInTheDocument()
+ expect(
+ queryByText(
+ 'Selected File report.csv. To change the selected file, click this button.'
+ )
+ ).not.toBeInTheDocument()
+ expect(getByText('2024', { selector: 'option' }).selected).toBe(true)
+ expect(
+ getByText('Quarter 2 (January - March)', { selector: 'option' })
+ .selected
+ ).toBe(true)
+ expect(
+ getByText(
+ 'Alaska - Work Outcomes of TANF Exiters - Fiscal Year 2024 - Quarter 2 (January - March)'
+ )
+ ).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/tdrs-frontend/src/components/Reports/Reports.jsx b/tdrs-frontend/src/components/Reports/Reports.jsx
index 17f8aec14..a2113e9a9 100644
--- a/tdrs-frontend/src/components/Reports/Reports.jsx
+++ b/tdrs-frontend/src/components/Reports/Reports.jsx
@@ -19,6 +19,7 @@ import SegmentedControl from '../SegmentedControl'
import SubmissionHistory from '../SubmissionHistory'
import ReprocessedModal from '../SubmissionHistory/ReprocessedModal'
import { selectPrimaryUserRole } from '../../selectors/auth'
+import { quarters, constructYearOptions } from './utils'
/**
* Reports is the home page for users to file a report.
@@ -59,13 +60,6 @@ function Reports() {
const [reprocessedModalVisible, setReprocessedModalVisible] = useState(false)
const [reprocessedDate, setReprocessedDate] = useState('')
- const quarters = {
- Q1: 'Quarter 1 (October - December)',
- Q2: 'Quarter 2 (January - March)',
- Q3: 'Quarter 3 (April - June)',
- Q4: 'Quarter 4 (July - September)',
- }
-
const currentStt =
isOFAAdmin || isDIGITTeam || isSystemAdmin ? selectedStt : userProfileStt
@@ -172,24 +166,6 @@ function Reports() {
setTouched((currentForm) => ({ ...currentForm, stt: true }))
}
- const constructYearOptions = () => {
- const years = []
- const today = new Date(Date.now())
-
- const fiscalYear =
- today.getMonth() > 8 ? today.getFullYear() + 1 : today.getFullYear()
-
- for (let i = fiscalYear; i >= 2021; i--) {
- const option = (
-
- )
- years.push(option)
- }
- return years
- }
-
useEffect(() => {
if (sttList.length === 0) {
dispatch(fetchSttList())
diff --git a/tdrs-frontend/src/components/Reports/index.js b/tdrs-frontend/src/components/Reports/index.js
index cb80a4814..f6541cca1 100644
--- a/tdrs-frontend/src/components/Reports/index.js
+++ b/tdrs-frontend/src/components/Reports/index.js
@@ -1,3 +1,6 @@
import Reports from './Reports'
+import FRAReports from './FRAReports'
export default Reports
+
+export { FRAReports }
diff --git a/tdrs-frontend/src/components/Reports/utils.js b/tdrs-frontend/src/components/Reports/utils.js
new file mode 100644
index 000000000..9b5e80005
--- /dev/null
+++ b/tdrs-frontend/src/components/Reports/utils.js
@@ -0,0 +1,38 @@
+export const quarters = {
+ Q1: 'Quarter 1 (October - December)',
+ Q2: 'Quarter 2 (January - March)',
+ Q3: 'Quarter 3 (April - June)',
+ Q4: 'Quarter 4 (July - September)',
+}
+
+export const constructYears = () => {
+ const years = []
+ const today = new Date(Date.now())
+
+ const fiscalYear =
+ today.getMonth() > 8 ? today.getFullYear() + 1 : today.getFullYear()
+
+ for (let i = fiscalYear; i >= 2021; i--) {
+ years.push(i)
+ }
+
+ return years
+}
+
+export const constructYearOptions = () => {
+ const years = []
+ const today = new Date(Date.now())
+
+ const fiscalYear =
+ today.getMonth() > 8 ? today.getFullYear() + 1 : today.getFullYear()
+
+ for (let i = fiscalYear; i >= 2021; i--) {
+ const option = (
+
+ )
+ years.push(option)
+ }
+ return years
+}
diff --git a/tdrs-frontend/src/components/Routes/Routes.js b/tdrs-frontend/src/components/Routes/Routes.js
index 530777cf2..cbad2fe13 100644
--- a/tdrs-frontend/src/components/Routes/Routes.js
+++ b/tdrs-frontend/src/components/Routes/Routes.js
@@ -5,7 +5,7 @@ import SplashPage from '../SplashPage'
import Profile from '../Profile'
import PrivateRoute from '../PrivateRoute'
import LoginCallback from '../LoginCallback'
-import Reports from '../Reports'
+import Reports, { FRAReports } from '../Reports'
import { useSelector } from 'react-redux'
import { accountIsInReview } from '../../selectors/auth'
@@ -42,7 +42,7 @@ const AppRoutes = () => {
path="/data-files"
element={
@@ -50,6 +50,20 @@ const AppRoutes = () => {
}
/>
+
+
+
+ }
+ />
{
+ const { type, payload = {} } = action
+
+ switch (type) {
+ case SET_IS_LOADING_SUBMISSION_HISTORY: {
+ const { isLoadingSubmissionHistory } = payload
+ return {
+ ...state,
+ isLoadingSubmissionHistory,
+ }
+ }
+ case SET_FRA_SUBMISSION_HISTORY: {
+ const { submissionHistory } = payload
+ return {
+ ...state,
+ submissionHistory,
+ }
+ }
+ case SET_IS_UPLOADING_FRA_REPORT: {
+ const { isUploadingFraReport } = payload
+ return {
+ ...state,
+ isUploadingFraReport,
+ }
+ }
+ default:
+ return state
+ }
+}
+
+export default fraReports
diff --git a/tdrs-frontend/src/reducers/index.js b/tdrs-frontend/src/reducers/index.js
index 2757e71b6..e94b726bb 100644
--- a/tdrs-frontend/src/reducers/index.js
+++ b/tdrs-frontend/src/reducers/index.js
@@ -5,6 +5,7 @@ import auth from './auth'
import stts from './sttList'
import requestAccess from './requestAccess'
import reports from './reports'
+import fraReports from './fraReports'
/**
* Combines all store reducers
@@ -18,6 +19,7 @@ const createRootReducer = (history) =>
stts,
requestAccess,
reports,
+ fraReports,
})
export default createRootReducer
diff --git a/tdrs-frontend/src/selectors/auth.js b/tdrs-frontend/src/selectors/auth.js
index 35d915c43..64169fd45 100644
--- a/tdrs-frontend/src/selectors/auth.js
+++ b/tdrs-frontend/src/selectors/auth.js
@@ -2,6 +2,9 @@ const valueIsEmpty = (val) => val === null || val === undefined || val === ''
export const selectUser = (state) => state.auth.user || null
+export const selectFeatureFlags = (state) =>
+ selectUser(state)?.feature_flags || {}
+
// could memoize these with `createSelector` from `reselect`
export const selectUserAccountApprovalStatus = (state) =>
selectUser(state)?.['account_approval_status']
@@ -76,3 +79,9 @@ export const accountCanViewKibana = (state) =>
export const accountCanViewPlg = (state) =>
accountStatusIsApproved(state) &&
['OFA System Admin', 'Developer'].includes(selectPrimaryUserRole(state)?.name)
+
+export const accountCanSelectStt = (state) =>
+ accountStatusIsApproved(state) &&
+ ['OFA System Admin', 'OFA Admin', 'DIGIT Team'].includes(
+ selectPrimaryUserRole(state)?.name
+ )
diff --git a/tdrs-frontend/src/utils/stringUtils.js b/tdrs-frontend/src/utils/stringUtils.js
index e7c2947ce..40ab03e71 100644
--- a/tdrs-frontend/src/utils/stringUtils.js
+++ b/tdrs-frontend/src/utils/stringUtils.js
@@ -4,3 +4,11 @@ export const toTitleCase = (str) =>
/\w\S*/g,
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
)
+
+export const objectToUrlParams = (obj) => {
+ const arr = []
+ Object.keys(obj).forEach((key) => {
+ arr.push(`${key}=${obj[key]}`)
+ })
+ return `${arr.join('&')}`
+}