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/__tests__/useAsyncWidgetExport.js b/frontend/src/hooks/__tests__/useAsyncWidgetExport.js new file mode 100644 index 0000000000..4c65f4f357 --- /dev/null +++ b/frontend/src/hooks/__tests__/useAsyncWidgetExport.js @@ -0,0 +1,81 @@ +/* eslint-disable max-len */ +import { renderHook, act } from '@testing-library/react-hooks'; +import useAsyncWidgetExport from '../useAsyncWidgetExport'; +import { blobToCsvDownload } from '../../utils'; +import { IS, NOOP } from '../../Constants'; + +jest.mock('../../utils', () => ({ + blobToCsvDownload: jest.fn(), +})); + +describe('useAsyncWidgetExport', () => { + const mockFetcher = jest.fn(); + const mockCheckboxes = { 1: true, 2: true, 3: false }; + 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 () => { + 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', + }, + { + topic: 'id', + condition: IS, + query: '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(); + }); +}); diff --git a/frontend/src/hooks/useAsyncWidgetExport.js b/frontend/src/hooks/useAsyncWidgetExport.js new file mode 100644 index 0000000000..a71283f7da --- /dev/null +++ b/frontend/src/hooks/useAsyncWidgetExport.js @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { IS } from '../Constants'; +import { blobToCsvDownload } from '../utils'; + +export default function useAsyncWidgetExport( + checkboxes, + exportName, + sortConfig, + fetcher, + filters = [], +) { + const exportRows = useCallback(async (exportType) => { + // Clone the filters to avoid mutating the original array + const fs = filters.map((filter) => ({ ...filter })); + + if (exportType === 'selected') { + const selectedRowsIds = Object.keys(checkboxes).filter((key) => checkboxes[key]); + selectedRowsIds.forEach((id) => { + fs.push({ + topic: 'id', + condition: IS, + query: id, + }); + }); + } + + try { + const blob = await fetcher( + sortConfig.sortBy, + sortConfig.direction, + 0, + false, + fs, + 'csv', + ); + blobToCsvDownload(blob, exportName); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + }, [checkboxes, exportName, fetcher, filters, sortConfig.direction, sortConfig.sortBy]); + + return { + exportRows, + }; +} 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/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/RecipientRecord/pages/CommunicationLog.js b/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js index ae338f6759..f824ecedeb 100644 --- a/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js +++ b/frontend/src/pages/RecipientRecord/pages/CommunicationLog.js @@ -1,4 +1,6 @@ -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'; @@ -17,11 +19,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 +44,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 +119,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 +133,38 @@ export default function CommunicationLog({ regionId, recipientId }) { EMPTY_ARRAY, // keyColumns ); + const { exportRows } = useAsyncWidgetExport( + checkboxes, + 'Communication_Log_Export', + sortConfig, + 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( + 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 661117c7ba..588c7e555e 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 { UserGroupIcon } from '../../../components/icons'; import HorizontalTableWidget from '../../../widgets/HorizontalTableWidget'; +import useAsyncWidgetExport from '../../../hooks/useAsyncWidgetExport'; const COMMUNICATION_LOG_PER_PAGE = 10; @@ -23,7 +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, @@ -87,22 +86,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 +100,22 @@ export default function RegionalCommLogTable({ filters }) { EMPTY_ARRAY, // keyColumns ); + const { exportRows } = useAsyncWidgetExport( + checkboxes, + 'Communication_Log_Export', + sortConfig, + getCommunicationLogs, + filters, + ); + + 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/utils.js b/frontend/src/utils.js index 570c5b9c12..63fd6acec9 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -277,3 +277,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 a7f7a97acb..435a5fc460 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,9 +572,11 @@ 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'), + 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/routes/communicationLog/handlers.ts b/src/routes/communicationLog/handlers.ts index 0e6e5f226e..a24f06511b 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; } @@ -217,6 +218,7 @@ const communicationLogs = async (req: Request, res: Response) => { String(direction), scopes, ); + res.type('text/csv'); res.send(logs); return; } 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), 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 }); });