Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TTAHUB-3787] Enhance communication log CSV export functionality #2645

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
72 changes: 72 additions & 0 deletions frontend/src/hooks/__tests__/useAsyncWidgetExport.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
43 changes: 43 additions & 0 deletions frontend/src/hooks/useAsyncWidgetExport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { IS } from '../Constants';
import { blobToCsvDownload, checkboxesToIds } from '../utils';

export default function useAsyncWidgetExport(
checkboxes,
exportName,
sortConfig,
fetcher,
) {
const exportRows = useCallback(async (exportType) => {
const filters = [];

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,
});
}

try {
const blob = await fetcher(
sortConfig.sortBy,
sortConfig.direction,
0,
false,
filters,
'csv',
);
blobToCsvDownload(blob, exportName);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}, [checkboxes, exportName, fetcher, 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
36 changes: 17 additions & 19 deletions frontend/src/pages/RecipientRecord/pages/CommunicationLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
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 { UsersIcon } 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,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);
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Loading