From 89192f36ca892a151a131d27ac09ff6d4d15f48a Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 10 Feb 2025 15:56:25 -0500 Subject: [PATCH 1/7] Initial commit --- frontend/src/components/WidgetContainer.js | 4 -- frontend/src/hooks/useAsyncWidgetExport.js | 65 +++++++++++++++++++ frontend/src/hooks/useWidgetMenuItems.js | 4 +- .../components/RegionalCommLogTable.js | 37 ++++++----- src/scopes/communicationLog/id.ts | 17 +++++ src/scopes/communicationLog/index.ts | 5 ++ 6 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 frontend/src/hooks/useAsyncWidgetExport.js create mode 100644 src/scopes/communicationLog/id.ts diff --git a/frontend/src/components/WidgetContainer.js b/frontend/src/components/WidgetContainer.js index 07f8f68e32..5f1b72db8c 100644 --- a/frontend/src/components/WidgetContainer.js +++ b/frontend/src/components/WidgetContainer.js @@ -31,7 +31,6 @@ export default function WidgetContainer( displayTable, setDisplayTable, enableCheckboxes, - exportRows, // slot components SubtitleDrawer, @@ -67,7 +66,6 @@ export default function WidgetContainer( SubtitleDrawer={() => SubtitleDrawer || null} menuItems={menuItems} enableCheckboxes={enableCheckboxes} - exportRows={exportRows} > {titleSlot} @@ -137,7 +135,6 @@ WidgetContainer.propTypes = { widgetContainerTitleClass: PropTypes.string, displayPaginationBoxOutline: PropTypes.bool, enableCheckboxes: PropTypes.bool, - exportRows: PropTypes.func, showFiltersNotApplicable: PropTypes.bool, }; @@ -163,7 +160,6 @@ WidgetContainer.defaultProps = { displayTable: false, setDisplayTable: null, enableCheckboxes: false, - exportRows: null, // Drawer components SubtitleDrawer: null, diff --git a/frontend/src/hooks/useAsyncWidgetExport.js b/frontend/src/hooks/useAsyncWidgetExport.js new file mode 100644 index 0000000000..4c2458bd0f --- /dev/null +++ b/frontend/src/hooks/useAsyncWidgetExport.js @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import { DECIMAL_BASE } from '@ttahub/common'; +import { IS } from '../Constants'; + +/* +export const getCommunicationLogs = async ( + sortBy, direction, offset, limit = 10, filters = [], format = 'json', +) => { +*/ + +export default function useAsyncWidgetExport( + checkboxes, + exportName, + sortConfig, + fetcher, +) { + const exportRows = useCallback(async (exportType) => { + const filters = []; + + if (exportType === 'selected') { + const selectedRowsStrings = Object.keys(checkboxes).filter((key) => checkboxes[key]); + // Loop all selected rows and parseInt to an array of integers. + // If the ID isn't a number, keep it as a string. + const selectedRowsIds = selectedRowsStrings.map((s) => { + const parsedInt = parseInt(s, DECIMAL_BASE); + return s.includes('-') ? s : parsedInt; + }); + // Filter the recipients to export to only include the selected rows. + filters.push({ + topic: 'id', + condition: IS, + query: selectedRowsIds, + }); + } + + let url = null; + + try { + const logs = await fetcher( + sortConfig.sortBy, + sortConfig.direction, + 0, + false, + filters, + 'csv', + ); + url = window.URL.createObjectURL(logs); + const a = document.createElement('a'); + a.setAttribute('hidden', ''); + a.setAttribute('href', url); + a.setAttribute('download', exportName); + document.body.appendChild(a); + a.click(); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + window.URL.revokeObjectURL(url); + } + }, [checkboxes, exportName, fetcher, sortConfig.direction, sortConfig.sortBy]); + + return { + exportRows, + }; +} diff --git a/frontend/src/hooks/useWidgetMenuItems.js b/frontend/src/hooks/useWidgetMenuItems.js index 7968e70f98..80272186a7 100644 --- a/frontend/src/hooks/useWidgetMenuItems.js +++ b/frontend/src/hooks/useWidgetMenuItems.js @@ -26,14 +26,14 @@ export default function useWidgetMenuItems( if (showTabularData) { menu.push({ label: 'Export table', - onClick: () => exportRows(), + onClick: async () => exportRows(), }); } if (showTabularData && atLeastOneRowIsSelected) { menu.push({ label: 'Export selected rows', - onClick: () => exportRows('selected'), + onClick: async () => exportRows('selected'), }); } diff --git a/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js b/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js index 16a506ca32..9eeded7d2b 100644 --- a/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js +++ b/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js @@ -6,13 +6,13 @@ import Modal from '../../../components/Modal'; import WidgetContainer from '../../../components/WidgetContainer'; import useWidgetMenuItems from '../../../hooks/useWidgetMenuItems'; import UserContext from '../../../UserContext'; -import useWidgetExport from '../../../hooks/useWidgetExport'; import useWidgetSorting from '../../../hooks/useWidgetSorting'; import { EMPTY_ARRAY } from '../../../Constants'; import { deleteCommunicationLogById, getCommunicationLogs } from '../../../fetchers/communicationLog'; import AppLoadingContext from '../../../AppLoadingContext'; import { UsersIcon } from '../../../components/icons'; import HorizontalTableWidget from '../../../widgets/HorizontalTableWidget'; +import useAsyncWidgetExport from '../../../hooks/useAsyncWidgetExport'; const COMMUNICATION_LOG_PER_PAGE = 10; @@ -23,7 +23,9 @@ const DEFAULT_SORT_CONFIG = { }; const headers = ['Recipient', 'Date', 'Purpose', 'Goals', 'Creator name', 'Other TTA staff', 'Result']; -const headersForExporting = [...headers, 'Region', 'Recipient next steps', 'Specialist next steps', 'Files']; +// const headersForExporting = [ +// ...headers, 'Region', 'Recipient next steps', 'Specialist next steps', 'Files' +// ]; const DeleteLogModal = ({ modalRef, @@ -87,22 +89,6 @@ export default function RegionalCommLogTable({ filters }) { const { user } = useContext(UserContext); const { setIsAppLoading } = useContext(AppLoadingContext); - const { exportRows } = useWidgetExport( - tabularData, - headersForExporting, - checkboxes, - 'Log ID', - 'Communication_Log_Export', - ); - - const menuItems = useWidgetMenuItems( - showTabularData, - setShowTabularData, - null, // capture function - checkboxes, - exportRows, - ).filter((m) => !m.label.includes('Display')); - const { requestSort, sortConfig, @@ -117,6 +103,21 @@ export default function RegionalCommLogTable({ filters }) { EMPTY_ARRAY, // keyColumns ); + const { exportRows } = useAsyncWidgetExport( + checkboxes, + 'Communication_Log_Export', + sortConfig, + getCommunicationLogs, + ); + + const menuItems = useWidgetMenuItems( + showTabularData, + setShowTabularData, + null, // capture function + checkboxes, + exportRows, + ).filter((m) => !m.label.includes('Display')); + const handleDelete = (log) => { setLogToDelete(log); modalRef.current.toggleModal(true); diff --git a/src/scopes/communicationLog/id.ts b/src/scopes/communicationLog/id.ts new file mode 100644 index 0000000000..1699d45462 --- /dev/null +++ b/src/scopes/communicationLog/id.ts @@ -0,0 +1,17 @@ +import { Op } from 'sequelize'; + +export function withIds(ids: string[]) { + return { + id: { + [Op.in]: ids.map((id) => Number(id)), + }, + }; +} + +export function withoutIds(ids: string[]) { + return { + id: { + [Op.notIn]: ids.map((id) => Number(id)), + }, + }; +} diff --git a/src/scopes/communicationLog/index.ts b/src/scopes/communicationLog/index.ts index 8c2c7a5e46..13cc90e1e7 100644 --- a/src/scopes/communicationLog/index.ts +++ b/src/scopes/communicationLog/index.ts @@ -6,8 +6,13 @@ import { withResult, withoutResult } from './result'; import { afterCommunicationDate, beforeCommunicationDate, withinCommunicationDate } from './communicationDate'; import { withPurpose, withoutPurpose } from './purpose'; import { withoutRegion, withRegion } from './region'; +import { withIds, withoutIds } from './id'; export const topicToQuery = { + id: { + in: (query: string[]) => withIds(query), + nin: (query: string[]) => withoutIds(query), + }, creator: { ctn: (query: string[]) => withCreator(query), nctn: (query: string[]) => withoutCreator(query), From 2353729b4ced2e2b45abd1bfb82e986062636c62 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Mon, 10 Feb 2025 16:50:49 -0500 Subject: [PATCH 2/7] Update to use backend for CSV gen --- frontend/src/hooks/useAsyncWidgetExport.js | 24 +++---------- frontend/src/hooks/useWidgetExport.js | 27 ++------------ .../RecipientRecord/pages/CommunicationLog.js | 36 +++++++++---------- .../components/RegionalCommLogTable.js | 3 -- frontend/src/utils.js | 34 ++++++++++++++++++ src/lib/transform.js | 34 ++++++++++++++++++ src/routes/communicationLog/handlers.ts | 2 ++ 7 files changed, 94 insertions(+), 66 deletions(-) diff --git a/frontend/src/hooks/useAsyncWidgetExport.js b/frontend/src/hooks/useAsyncWidgetExport.js index 4c2458bd0f..2235d725e8 100644 --- a/frontend/src/hooks/useAsyncWidgetExport.js +++ b/frontend/src/hooks/useAsyncWidgetExport.js @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { DECIMAL_BASE } from '@ttahub/common'; import { IS } from '../Constants'; +import { blobToCsvDownload, checkboxesToIds } from '../utils'; /* export const getCommunicationLogs = async ( @@ -18,13 +18,7 @@ export default function useAsyncWidgetExport( const filters = []; if (exportType === 'selected') { - const selectedRowsStrings = Object.keys(checkboxes).filter((key) => checkboxes[key]); - // Loop all selected rows and parseInt to an array of integers. - // If the ID isn't a number, keep it as a string. - const selectedRowsIds = selectedRowsStrings.map((s) => { - const parsedInt = parseInt(s, DECIMAL_BASE); - return s.includes('-') ? s : parsedInt; - }); + const selectedRowsIds = checkboxesToIds(checkboxes); // Filter the recipients to export to only include the selected rows. filters.push({ topic: 'id', @@ -33,10 +27,8 @@ export default function useAsyncWidgetExport( }); } - let url = null; - try { - const logs = await fetcher( + const blob = await fetcher( sortConfig.sortBy, sortConfig.direction, 0, @@ -44,18 +36,10 @@ export default function useAsyncWidgetExport( filters, 'csv', ); - url = window.URL.createObjectURL(logs); - const a = document.createElement('a'); - a.setAttribute('hidden', ''); - a.setAttribute('href', url); - a.setAttribute('download', exportName); - document.body.appendChild(a); - a.click(); + blobToCsvDownload(blob, exportName); } catch (error) { // eslint-disable-next-line no-console console.error(error); - } finally { - window.URL.revokeObjectURL(url); } }, [checkboxes, exportName, fetcher, sortConfig.direction, sortConfig.sortBy]); diff --git a/frontend/src/hooks/useWidgetExport.js b/frontend/src/hooks/useWidgetExport.js index d262dffbd6..ccfb19858c 100644 --- a/frontend/src/hooks/useWidgetExport.js +++ b/frontend/src/hooks/useWidgetExport.js @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { DECIMAL_BASE } from '@ttahub/common'; +import { blobToCsvDownload, checkboxesToIds } from '../utils'; export default function useWidgetExport( data, @@ -10,18 +10,10 @@ export default function useWidgetExport( exportDataName = null, // Specify the data to export. ) { const exportRows = useCallback((exportType) => { - let url = null; try { let dataToExport = data; if (exportType === 'selected') { - // Get all the ids of the rowsToExport that have a value of true. - const selectedRowsStrings = Object.keys(checkboxes).filter((key) => checkboxes[key]); - // Loop all selected rows and parseInt to an array of integers. - // If the ID isn't a number, keep it as a string. - const selectedRowsIds = selectedRowsStrings.map((s) => { - const parsedInt = parseInt(s, DECIMAL_BASE); - return s.includes('-') ? s : parsedInt; - }); + const selectedRowsIds = checkboxesToIds(checkboxes); // Filter the recipients to export to only include the selected rows. dataToExport = data.filter((row) => selectedRowsIds.includes(row.id)); } @@ -50,23 +42,10 @@ export default function useWidgetExport( // Create CSV. const csvString = csvRows.join('\n'); const blob = new Blob([csvString], { type: 'text/csv' }); - - // Check if url exists with the attribute of download. - if (document.getElementsByName('download').length > 0) { - document.getElementsByName('download')[0].remove(); - } - url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.setAttribute('hidden', ''); - a.setAttribute('href', url); - a.setAttribute('download', exportName); - document.body.appendChild(a); - a.click(); + blobToCsvDownload(blob, exportName); } catch (error) { // eslint-disable-next-line no-console console.error(error); - } finally { - window.URL.revokeObjectURL(url); } }, [checkboxes, data, exportHeading, exportName, headers, exportDataName]); diff --git a/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js b/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js index ae338f6759..a03e9e8aed 100644 --- a/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js +++ b/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import useDeepCompareEffect from 'use-deep-compare-effect'; import { useHistory } from 'react-router-dom'; -import { deleteCommunicationLogById, getCommunicationLogsByRecipientId } from '../../../fetchers/communicationLog'; +import { deleteCommunicationLogById, getCommunicationLogs, getCommunicationLogsByRecipientId } from '../../../fetchers/communicationLog'; import AppLoadingContext from '../../../AppLoadingContext'; import WidgetContainer from '../../../components/WidgetContainer'; import HorizontalTableWidget from '../../../widgets/HorizontalTableWidget'; @@ -17,11 +17,11 @@ import { resultFilter, } from '../../../components/filter/communicationLogFilters'; import useWidgetMenuItems from '../../../hooks/useWidgetMenuItems'; -import useWidgetExport from '../../../hooks/useWidgetExport'; import useWidgetSorting from '../../../hooks/useWidgetSorting'; import { EMPTY_ARRAY } from '../../../Constants'; import Modal from '../../../components/Modal'; import { UsersIcon } from '../../../components/icons'; +import useAsyncWidgetExport from '../../../hooks/useAsyncWidgetExport'; const COMMUNICATION_LOG_PER_PAGE = 10; const FILTER_KEY = 'communication-log-filters'; @@ -42,7 +42,6 @@ const COMMUNICATION_LOG_FILTER_CONFIG = [ COMMUNICATION_LOG_FILTER_CONFIG.sort((a, b) => a.display.localeCompare(b.display)); const headers = ['Date', 'Purpose', 'Goals', 'Creator name', 'Other TTA staff', 'Result']; -const headersForExporting = [...headers, 'Recipient next steps', 'Specialist next steps', 'Files']; const DeleteLogModal = ({ modalRef, @@ -118,22 +117,6 @@ export default function CommunicationLog({ regionId, recipientId }) { COMMUNICATION_LOG_FILTER_CONFIG, ); - const { exportRows } = useWidgetExport( - tabularData, - headersForExporting, - checkboxes, - 'Log ID', - 'Communication_Log_Export', - ); - - const menuItems = useWidgetMenuItems( - showTabularData, - setShowTabularData, - null, // capture function - checkboxes, - exportRows, - ).filter((m) => !m.label.includes('Display')); - const { requestSort, sortConfig, @@ -148,6 +131,21 @@ export default function CommunicationLog({ regionId, recipientId }) { EMPTY_ARRAY, // keyColumns ); + const { exportRows } = useAsyncWidgetExport( + checkboxes, + 'Communication_Log_Export', + sortConfig, + getCommunicationLogs, + ); + + const menuItems = useWidgetMenuItems( + showTabularData, + setShowTabularData, + null, // capture function + checkboxes, + exportRows, + ).filter((m) => !m.label.includes('Display')); + const handleDelete = (log) => { setLogToDelete(log); modalRef.current.toggleModal(true); diff --git a/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js b/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js index 9eeded7d2b..3999b22c85 100644 --- a/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js +++ b/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js @@ -23,9 +23,6 @@ const DEFAULT_SORT_CONFIG = { }; const headers = ['Recipient', 'Date', 'Purpose', 'Goals', 'Creator name', 'Other TTA staff', 'Result']; -// const headersForExporting = [ -// ...headers, 'Region', 'Recipient next steps', 'Specialist next steps', 'Files' -// ]; const DeleteLogModal = ({ modalRef, diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 141ee3d571..2476d1a554 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -275,3 +275,37 @@ export const parseFeedIntoDom = (feed) => { return parsedDom; }; + +export const checkboxesToIds = (checkboxes) => { + const selectedRowsStrings = Object.keys(checkboxes).filter((key) => checkboxes[key]); + // Loop all selected rows and parseInt to an array of integers. + // If the ID isn't a number, keep it as a string. + return selectedRowsStrings.map((s) => { + const parsedInt = parseInt(s, DECIMAL_BASE); + return s.includes('-') ? s : parsedInt; + }); +}; + +export const blobToCsvDownload = (blob, fileName) => { + let url; + try { + // Check if url exists with the attribute of download + // and remove it if it does. + if (document.getElementsByName('download').length > 0) { + Array.from(document.getElementsByName('download')).forEach((el) => el.remove()); + } + + url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.setAttribute('hidden', ''); + a.setAttribute('href', url); + a.setAttribute('download', fileName); + document.body.appendChild(a); + a.click(); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + window.URL.revokeObjectURL(url); + } +}; diff --git a/src/lib/transform.js b/src/lib/transform.js index 0ac13246a6..8ef9a2fe03 100644 --- a/src/lib/transform.js +++ b/src/lib/transform.js @@ -66,6 +66,37 @@ function transformRelatedModelProp(field, prop) { return transformer; } +/* + * Generates a function that can transform values of a related model + * @param {string} field The field of the related model + * @param {string} prop The key on the related model to transform + * @param {string} nestedProp The key on the related model to transform + * @returns {function} A function that will perform the transformation + */ +function transformRelatedModelPropNested(field, prop, nestedProp = 'label') { + function transformer(instance) { + const obj = {}; + let records = instance[field]; + if (records) { + if (!Array.isArray(records)) { + records = [records]; + } + const value = records.map((r) => { + if (!r[prop]) { + return ''; + } + return r[prop].map((p) => (p[nestedProp] || '')).sort().join('\n'); + }).sort().join('\n'); + Object.defineProperty(obj, prop, { + value, + enumerable: true, + }); + } + return obj; + } + return transformer; +} + /* * Generates a function that can transform values of a related model * @param {string} field The field of the related model @@ -532,6 +563,7 @@ const arTransformers = [ const logTransformers = [ 'id', + transformRelatedModelProp('data', 'regionId'), transformRelatedModel('recipients', 'name'), transformRelatedModel('author', 'name'), transformRelatedModelProp('data', 'communicationDate'), @@ -540,6 +572,8 @@ const logTransformers = [ transformRelatedModelProp('data', 'purpose'), transformRelatedModelProp('data', 'notes'), transformRelatedModelProp('data', 'result'), + transformRelatedModelPropNested('data', 'goals'), + transformRelatedModelPropNested('data', 'otherStaff'), transformRelatedModel('files', 'originalFileName'), transformRelatedModel('recipientNextSteps', 'note'), transformRelatedModel('specialistNextSteps', 'note'), diff --git a/src/routes/communicationLog/handlers.ts b/src/routes/communicationLog/handlers.ts index 66661a4b59..5748178f66 100644 --- a/src/routes/communicationLog/handlers.ts +++ b/src/routes/communicationLog/handlers.ts @@ -177,6 +177,7 @@ const communicationLogsByRecipientId = async (req: Request, res: Response) => { String(direction), scopes, ); + res.type('text/csv'); res.send(logs); return; } @@ -223,6 +224,7 @@ const communicationLogs = async (req: Request, res: Response) => { String(direction), scopes, ); + res.type('text/csv'); res.send(logs); return; } From e84ae35674634306d4a3ae49fefc9ee5d4fc70f4 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 11 Feb 2025 11:15:47 -0500 Subject: [PATCH 3/7] Remove comment --- frontend/src/hooks/useAsyncWidgetExport.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/hooks/useAsyncWidgetExport.js b/frontend/src/hooks/useAsyncWidgetExport.js index 2235d725e8..16cc9a1abc 100644 --- a/frontend/src/hooks/useAsyncWidgetExport.js +++ b/frontend/src/hooks/useAsyncWidgetExport.js @@ -2,12 +2,6 @@ import { useCallback } from 'react'; import { IS } from '../Constants'; import { blobToCsvDownload, checkboxesToIds } from '../utils'; -/* -export const getCommunicationLogs = async ( - sortBy, direction, offset, limit = 10, filters = [], format = 'json', -) => { -*/ - export default function useAsyncWidgetExport( checkboxes, exportName, From 435b8155742ca1b8f90d8493c8254ad9041c8b43 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 11 Feb 2025 11:16:24 -0500 Subject: [PATCH 4/7] Fix backend tests for comm log export --- src/lib/transform.js | 4 ++-- src/lib/transform.test.js | 20 ++++++++++++++------ src/routes/communicationLog/handlers.test.js | 1 + src/services/communicationLog.test.js | 5 +++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/lib/transform.js b/src/lib/transform.js index 8ef9a2fe03..adf979330f 100644 --- a/src/lib/transform.js +++ b/src/lib/transform.js @@ -575,8 +575,8 @@ const logTransformers = [ transformRelatedModelPropNested('data', 'goals'), transformRelatedModelPropNested('data', 'otherStaff'), transformRelatedModel('files', 'originalFileName'), - transformRelatedModel('recipientNextSteps', 'note'), - transformRelatedModel('specialistNextSteps', 'note'), + transformRelatedModelPropNested('data', 'recipientNextSteps', 'note'), + transformRelatedModelPropNested('data', 'specialistNextSteps', 'note'), ]; /** diff --git a/src/lib/transform.test.js b/src/lib/transform.test.js index eaebd42596..e187f91fe6 100644 --- a/src/lib/transform.test.js +++ b/src/lib/transform.test.js @@ -806,17 +806,17 @@ describe('communicationLogToCsvRecord', () => { purpose: 'Inquiry', notes: 'Lorem ipsum', result: 'Successful', + recipientNextSteps: [{ + note: 'Follow up with client', + }], + specialistNextSteps: [{ + note: 'Schedule a meeting', + }], }, files: [ { originalFileName: 'file1.txt' }, { originalFileName: 'file2.txt' }, ], - recipientNextSteps: { - note: 'Follow up with client', - }, - specialistNextSteps: { - note: 'Schedule a meeting', - }, }; it('should transform the log into a CSV record', () => { @@ -832,6 +832,9 @@ describe('communicationLogToCsvRecord', () => { files: 'file1.txt\nfile2.txt', recipientNextSteps: 'Follow up with client', specialistNextSteps: 'Schedule a meeting', + otherStaff: '', + goals: '', + regionId: '', }; expect(communicationLogToCsvRecord(log)).toEqual(expectedRecord); @@ -859,6 +862,11 @@ describe('communicationLogToCsvRecord', () => { notes: '', purpose: '', result: '', + goals: '', + otherStaff: '', + recipientNextSteps: '', + specialistNextSteps: '', + regionId: '', }); }); }); diff --git a/src/routes/communicationLog/handlers.test.js b/src/routes/communicationLog/handlers.test.js index 59950ff934..10858f201d 100644 --- a/src/routes/communicationLog/handlers.test.js +++ b/src/routes/communicationLog/handlers.test.js @@ -100,6 +100,7 @@ describe('communicationLog handlers', () => { json: statusJson, send: jest.fn(), })), + type: jest.fn(), }; afterAll(() => sequelize.close()); diff --git a/src/services/communicationLog.test.js b/src/services/communicationLog.test.js index a6b1744840..f9d8b86fe3 100644 --- a/src/services/communicationLog.test.js +++ b/src/services/communicationLog.test.js @@ -115,6 +115,11 @@ describe('communicationLog services', () => { purpose: '', result: '', recipients: expect.any(String), + goals: '', + recipientNextSteps: '', + regionId: '', + specialistNextSteps: '', + otherStaff: '', }, ], { header: true, quoted: true, quoted_empty: true }); }); From fe39896283ed45d3a7a215806921c43e8a737c59 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Tue, 11 Feb 2025 11:19:25 -0500 Subject: [PATCH 5/7] Add test for new hook --- .../hooks/__tests__/useAsyncWidgetExport.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useAsyncWidgetExport.js diff --git a/frontend/src/hooks/__tests__/useAsyncWidgetExport.js b/frontend/src/hooks/__tests__/useAsyncWidgetExport.js new file mode 100644 index 0000000000..8aeb3a36c2 --- /dev/null +++ b/frontend/src/hooks/__tests__/useAsyncWidgetExport.js @@ -0,0 +1,72 @@ +/* eslint-disable max-len */ +import { renderHook, act } from '@testing-library/react-hooks'; +import useAsyncWidgetExport from '../useAsyncWidgetExport'; +import { blobToCsvDownload, checkboxesToIds } from '../../utils'; +import { IS, NOOP } from '../../Constants'; + +jest.mock('../../utils', () => ({ + blobToCsvDownload: jest.fn(), + checkboxesToIds: jest.fn(), +})); + +describe('useAsyncWidgetExport', () => { + const mockFetcher = jest.fn(); + const mockCheckboxes = [{ id: 1 }, { id: 2 }]; + const mockExportName = 'test-export'; + const mockSortConfig = { sortBy: 'name', direction: 'asc' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call fetcher with correct parameters when exportType is "selected"', async () => { + checkboxesToIds.mockReturnValue([1, 2]); + const { result } = renderHook(() => useAsyncWidgetExport(mockCheckboxes, mockExportName, mockSortConfig, mockFetcher)); + + await act(async () => { + await result.current.exportRows('selected'); + }); + + expect(mockFetcher).toHaveBeenCalledWith( + 'name', + 'asc', + 0, + false, + [{ topic: 'id', condition: IS, query: [1, 2] }], + 'csv', + ); + expect(blobToCsvDownload).toHaveBeenCalled(); + }); + + it('should call fetcher with correct parameters when exportType is not "selected"', async () => { + const { result } = renderHook(() => useAsyncWidgetExport(mockCheckboxes, mockExportName, mockSortConfig, mockFetcher)); + + await act(async () => { + await result.current.exportRows('all'); + }); + + expect(mockFetcher).toHaveBeenCalledWith( + 'name', + 'asc', + 0, + false, + [], + 'csv', + ); + expect(blobToCsvDownload).toHaveBeenCalled(); + }); + + it('should handle fetcher error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(NOOP); + mockFetcher.mockRejectedValue(new Error('Fetch error')); + + const { result } = renderHook(() => useAsyncWidgetExport(mockCheckboxes, mockExportName, mockSortConfig, mockFetcher)); + + await act(async () => { + await result.current.exportRows('all'); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith(new Error('Fetch error')); + consoleErrorSpy.mockRestore(); + }); +}); From ce1f5d555ef0ed0a1bd60880c7c85f9f4748eb8d Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 14 Feb 2025 14:51:27 -0500 Subject: [PATCH 6/7] Update files to properly pass filters --- frontend/src/hooks/useAsyncWidgetExport.js | 24 ++++++++++-------- .../RecipientRecord/pages/CommunicationLog.js | 25 ++++++++++++++++--- .../components/RegionalCommLogTable.js | 1 + 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/frontend/src/hooks/useAsyncWidgetExport.js b/frontend/src/hooks/useAsyncWidgetExport.js index 16cc9a1abc..afa755e19c 100644 --- a/frontend/src/hooks/useAsyncWidgetExport.js +++ b/frontend/src/hooks/useAsyncWidgetExport.js @@ -1,23 +1,27 @@ import { useCallback } from 'react'; import { IS } from '../Constants'; -import { blobToCsvDownload, checkboxesToIds } from '../utils'; +import { blobToCsvDownload } from '../utils'; export default function useAsyncWidgetExport( checkboxes, exportName, sortConfig, fetcher, + filters = [], ) { const exportRows = useCallback(async (exportType) => { - const filters = []; + // Clone the filters to avoid mutating the original array + const fs = filters.map((filter) => ({ ...filter })); if (exportType === 'selected') { - const selectedRowsIds = checkboxesToIds(checkboxes); - // Filter the recipients to export to only include the selected rows. - filters.push({ - topic: 'id', - condition: IS, - query: selectedRowsIds, + const selectedRowsIds = Object.keys(checkboxes).filter((key) => Number(checkboxes[key])); + + selectedRowsIds.forEach((id) => { + fs.push({ + topic: 'id', + condition: IS, + query: id, + }); }); } @@ -27,7 +31,7 @@ export default function useAsyncWidgetExport( sortConfig.direction, 0, false, - filters, + fs, 'csv', ); blobToCsvDownload(blob, exportName); @@ -35,7 +39,7 @@ export default function useAsyncWidgetExport( // eslint-disable-next-line no-console console.error(error); } - }, [checkboxes, exportName, fetcher, sortConfig.direction, sortConfig.sortBy]); + }, [checkboxes, exportName, fetcher, filters, sortConfig.direction, sortConfig.sortBy]); return { exportRows, diff --git a/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js b/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js index a03e9e8aed..f824ecedeb 100644 --- a/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js +++ b/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js @@ -1,9 +1,11 @@ -import React, { useState, useContext, useRef } from 'react'; +import React, { + useState, useContext, useRef, useCallback, +} from 'react'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import useDeepCompareEffect from 'use-deep-compare-effect'; import { useHistory } from 'react-router-dom'; -import { deleteCommunicationLogById, getCommunicationLogs, getCommunicationLogsByRecipientId } from '../../../fetchers/communicationLog'; +import { deleteCommunicationLogById, getCommunicationLogsByRecipientId } from '../../../fetchers/communicationLog'; import AppLoadingContext from '../../../AppLoadingContext'; import WidgetContainer from '../../../components/WidgetContainer'; import HorizontalTableWidget from '../../../widgets/HorizontalTableWidget'; @@ -135,7 +137,24 @@ export default function CommunicationLog({ regionId, recipientId }) { checkboxes, 'Communication_Log_Export', sortConfig, - getCommunicationLogs, + useCallback(async ( + sortBy, + direction, + limit, + offset, + dataFilters, + format, + ) => getCommunicationLogsByRecipientId( + String(regionId), + String(recipientId), + sortBy, + direction, + offset, + limit, + dataFilters, + format, + ), [recipientId, regionId]), + filters, ); const menuItems = useWidgetMenuItems( diff --git a/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js b/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js index e9e7f2a0d8..588c7e555e 100644 --- a/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js +++ b/frontend/src/pages/RegionalCommunicationLogDashboard/components/RegionalCommLogTable.js @@ -105,6 +105,7 @@ export default function RegionalCommLogTable({ filters }) { 'Communication_Log_Export', sortConfig, getCommunicationLogs, + filters, ); const menuItems = useWidgetMenuItems( From e0a1fe9d52708ee74d051e057f9d7684292c0b19 Mon Sep 17 00:00:00 2001 From: Matt Bevilacqua Date: Fri, 14 Feb 2025 14:58:03 -0500 Subject: [PATCH 7/7] Update test --- .../hooks/__tests__/useAsyncWidgetExport.js | 19 ++++++++++++++----- frontend/src/hooks/useAsyncWidgetExport.js | 3 +-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/__tests__/useAsyncWidgetExport.js b/frontend/src/hooks/__tests__/useAsyncWidgetExport.js index 8aeb3a36c2..4c65f4f357 100644 --- a/frontend/src/hooks/__tests__/useAsyncWidgetExport.js +++ b/frontend/src/hooks/__tests__/useAsyncWidgetExport.js @@ -1,17 +1,16 @@ /* eslint-disable max-len */ import { renderHook, act } from '@testing-library/react-hooks'; import useAsyncWidgetExport from '../useAsyncWidgetExport'; -import { blobToCsvDownload, checkboxesToIds } from '../../utils'; +import { blobToCsvDownload } from '../../utils'; import { IS, NOOP } from '../../Constants'; jest.mock('../../utils', () => ({ blobToCsvDownload: jest.fn(), - checkboxesToIds: jest.fn(), })); describe('useAsyncWidgetExport', () => { const mockFetcher = jest.fn(); - const mockCheckboxes = [{ id: 1 }, { id: 2 }]; + const mockCheckboxes = { 1: true, 2: true, 3: false }; const mockExportName = 'test-export'; const mockSortConfig = { sortBy: 'name', direction: 'asc' }; @@ -20,7 +19,6 @@ describe('useAsyncWidgetExport', () => { }); it('should call fetcher with correct parameters when exportType is "selected"', async () => { - checkboxesToIds.mockReturnValue([1, 2]); const { result } = renderHook(() => useAsyncWidgetExport(mockCheckboxes, mockExportName, mockSortConfig, mockFetcher)); await act(async () => { @@ -32,7 +30,18 @@ describe('useAsyncWidgetExport', () => { 'asc', 0, false, - [{ topic: 'id', condition: IS, query: [1, 2] }], + [ + { + topic: 'id', + condition: IS, + query: '1', + }, + { + topic: 'id', + condition: IS, + query: '2', + }, + ], 'csv', ); expect(blobToCsvDownload).toHaveBeenCalled(); diff --git a/frontend/src/hooks/useAsyncWidgetExport.js b/frontend/src/hooks/useAsyncWidgetExport.js index afa755e19c..a71283f7da 100644 --- a/frontend/src/hooks/useAsyncWidgetExport.js +++ b/frontend/src/hooks/useAsyncWidgetExport.js @@ -14,8 +14,7 @@ export default function useAsyncWidgetExport( const fs = filters.map((filter) => ({ ...filter })); if (exportType === 'selected') { - const selectedRowsIds = Object.keys(checkboxes).filter((key) => Number(checkboxes[key])); - + const selectedRowsIds = Object.keys(checkboxes).filter((key) => checkboxes[key]); selectedRowsIds.forEach((id) => { fs.push({ topic: 'id',