Skip to content

Commit

Permalink
Merge pull request #2645 from HHS/mb/TTAHUB-3787/export-functionality
Browse files Browse the repository at this point in the history
[TTAHUB-3787] Enhance communication log CSV export functionality
  • Loading branch information
thewatermethod authored Feb 17, 2025
2 parents b48c2db + e0a1fe9 commit 665b7b0
Show file tree
Hide file tree
Showing 15 changed files with 299 additions and 75 deletions.
4 changes: 0 additions & 4 deletions frontend/src/components/WidgetContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export default function WidgetContainer(
displayTable,
setDisplayTable,
enableCheckboxes,
exportRows,

// slot components
SubtitleDrawer,
Expand Down Expand Up @@ -67,7 +66,6 @@ export default function WidgetContainer(
SubtitleDrawer={() => SubtitleDrawer || null}
menuItems={menuItems}
enableCheckboxes={enableCheckboxes}
exportRows={exportRows}
>
{titleSlot}
</WidgetContainerTitleGroup>
Expand Down Expand Up @@ -137,7 +135,6 @@ WidgetContainer.propTypes = {
widgetContainerTitleClass: PropTypes.string,
displayPaginationBoxOutline: PropTypes.bool,
enableCheckboxes: PropTypes.bool,
exportRows: PropTypes.func,
showFiltersNotApplicable: PropTypes.bool,
};

Expand All @@ -163,7 +160,6 @@ WidgetContainer.defaultProps = {
displayTable: false,
setDisplayTable: null,
enableCheckboxes: false,
exportRows: null,

// Drawer components
SubtitleDrawer: null,
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/hooks/__tests__/useAsyncWidgetExport.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions frontend/src/hooks/useAsyncWidgetExport.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
27 changes: 3 additions & 24 deletions frontend/src/hooks/useWidgetExport.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { DECIMAL_BASE } from '@ttahub/common';
import { blobToCsvDownload, checkboxesToIds } from '../utils';

export default function useWidgetExport(
data,
Expand All @@ -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));
}
Expand Down Expand Up @@ -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]);

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useWidgetMenuItems.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
}

Expand Down
55 changes: 36 additions & 19 deletions frontend/src/pages/RecipientRecord/pages/CommunicationLog.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading

0 comments on commit 665b7b0

Please sign in to comment.