Skip to content

Commit

Permalink
[ASAP-345]analytics csv export (#4281)
Browse files Browse the repository at this point in the history
* first iteration of analytics csv export

* commit missing file

* fix definitions in tests

* more tests

* lint

* more tests and new csv naming

* fix errors

* add button container styles

* fix export button position

* allow params when csv export

* update button style
  • Loading branch information
lctrt authored May 21, 2024
1 parent f36082e commit f802dbb
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 64 deletions.
25 changes: 23 additions & 2 deletions apps/crn-frontend/src/analytics/leadership/Leadership.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { createCsvFileStream } from '@asap-hub/frontend-utils';
import {
LeadershipAndMembershipSortingDirection,
initialSortingDirection,
SortLeadershipAndMembership,
} from '@asap-hub/model';
import { AnalyticsLeadershipPageBody } from '@asap-hub/react-components';
import { analytics } from '@asap-hub/routing';
import { format } from 'date-fns';
import { FC, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';

import { getAnalyticsLeadership } from './api';
import { algoliaResultsToStream, leadershipToCSV } from './export';
import { usePagination, usePaginationParams, useSearch } from '../../hooks';
import { useAnalyticsLeadership } from './state';

Expand Down Expand Up @@ -67,7 +70,7 @@ const Leadership: FC<Record<string, never>> = () => {
const { currentPage, pageSize } = usePaginationParams();

const { debouncedSearchQuery, searchQuery, setSearchQuery } = useSearch();
const { items, total } = useAnalyticsLeadership({
const { items, total, client } = useAnalyticsLeadership({
sort,
currentPage,
pageSize,
Expand All @@ -77,6 +80,23 @@ const Leadership: FC<Record<string, never>> = () => {

const { numberOfPages, renderPageHref } = usePagination(total, pageSize);

const exportResults = () =>
algoliaResultsToStream(
createCsvFileStream(
`leadership_${metric}_${format(new Date(), 'MMddyy')}.csv`,
{
header: true,
},
),
(paginationParams) =>
getAnalyticsLeadership(client, {
filters: new Set(),
searchQuery,
...paginationParams,
}),
leadershipToCSV(metric),
);

return (
<AnalyticsLeadershipPageBody
metric={metric}
Expand All @@ -87,6 +107,7 @@ const Leadership: FC<Record<string, never>> = () => {
setSort={setSort}
sortingDirection={sortingDirection}
setSortingDirection={setSortingDirection}
exportResults={exportResults}
data={getDataForMetric(items, metric)}
currentPageIndex={currentPage}
numberOfPages={numberOfPages}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AlgoliaSearchClient,
algoliaSearchClientFactory,
} from '@asap-hub/algolia';
import { createCsvFileStream } from '@asap-hub/frontend-utils';
import {
Auth0Provider,
WhenReady,
Expand All @@ -27,6 +28,21 @@ jest.mock('@asap-hub/algolia', () => ({

jest.mock('../api');

jest.mock('@asap-hub/frontend-utils', () => {
const original = jest.requireActual('@asap-hub/frontend-utils');
return {
...original,
createCsvFileStream: jest
.fn()
.mockImplementation(() => ({ write: jest.fn(), end: jest.fn() })),
};
});

jest.mock('../api');

const mockCreateCsvFileStream = createCsvFileStream as jest.MockedFunction<
typeof createCsvFileStream
>;
afterEach(() => {
jest.clearAllMocks();
});
Expand Down Expand Up @@ -151,3 +167,33 @@ it('calls algolia client with the right index name', async () => {
);
});
});

describe('csv export', () => {
it('exports analytics for working groups', async () => {
mockGetMemberships.mockResolvedValue(data);
await renderPage();
userEvent.click(screen.getByText(/csv/i));
expect(mockCreateCsvFileStream).toHaveBeenCalledWith(
expect.stringMatching(/leadership_working-group_\d+\.csv/),
expect.anything(),
);
expect(mockAlgoliaSearchClientFactory).toHaveBeenCalled();
});

it('exports analytics for interest groups', async () => {
mockGetMemberships.mockResolvedValue(data);
const label = 'Interest Group Leadership & Membership';

await renderPage();
const input = screen.getByRole('textbox', { hidden: false });

userEvent.click(input);
userEvent.click(screen.getByText(label));
userEvent.click(screen.getByText(/csv/i));
expect(mockCreateCsvFileStream).toHaveBeenCalledWith(
expect.stringMatching(/leadership_interest-group_\d+\.csv/),
expect.anything(),
);
expect(mockAlgoliaSearchClientFactory).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Stringifier } from 'csv-stringify';
import { leadershipToCSV, algoliaResultsToStream } from '../export';

describe('leadershipToCSV', () => {
it('handles basic data', () => {
const data = {
id: '1',
displayName: 'Team 1',
workingGroupLeadershipRoleCount: 1,
workingGroupPreviousLeadershipRoleCount: 2,
workingGroupMemberCount: 3,
workingGroupPreviousMemberCount: 4,
interestGroupLeadershipRoleCount: 5,
interestGroupPreviousLeadershipRoleCount: 6,
interestGroupMemberCount: 7,
interestGroupPreviousMemberCount: 8,
};

expect(leadershipToCSV('working-group')(data)).toEqual({
team: 'Team 1',
currentlyInALeadershipRole: '1',
previouslyInALeadershipRole: '2',
currentlyAMember: '3',
previouslyAMember: '4',
});
expect(leadershipToCSV('interest-group')(data)).toEqual({
team: 'Team 1',
currentlyInALeadershipRole: '5',
previouslyInALeadershipRole: '6',
currentlyAMember: '7',
previouslyAMember: '8',
});
});
});

describe('algoliaResultsToStream', () => {
const mockCsvStream = {
write: jest.fn(),
end: jest.fn(),
};

it('streams results', async () => {
await algoliaResultsToStream(
mockCsvStream as unknown as Stringifier,
() =>
Promise.resolve({
total: 2,
items: [
{
id: '1',
displayName: 'Team 1',
workingGroupLeadershipRoleCount: 1,
workingGroupPreviousLeadershipRoleCount: 2,
workingGroupMemberCount: 3,
workingGroupPreviousMemberCount: 4,
interestGroupLeadershipRoleCount: 5,
interestGroupPreviousLeadershipRoleCount: 6,
interestGroupMemberCount: 7,
interestGroupPreviousMemberCount: 8,
},
{
id: '2',
displayName: 'Team 2',
workingGroupLeadershipRoleCount: 2,
workingGroupPreviousLeadershipRoleCount: 3,
workingGroupMemberCount: 4,
workingGroupPreviousMemberCount: 5,
interestGroupLeadershipRoleCount: 4,
interestGroupPreviousLeadershipRoleCount: 3,
interestGroupMemberCount: 2,
interestGroupPreviousMemberCount: 1,
},
],
}),
(a) => a,
);

expect(mockCsvStream.write).toHaveBeenCalledTimes(2);

expect(mockCsvStream.end).toHaveBeenCalledTimes(1);
});

it('handles undefined response', async () => {
const transformSpy = jest.fn();
await algoliaResultsToStream(
mockCsvStream as unknown as Stringifier,
() => Promise.resolve(undefined),
transformSpy,
);
expect(transformSpy).not.toHaveBeenCalled();
});
});
57 changes: 57 additions & 0 deletions apps/crn-frontend/src/analytics/leadership/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { CSVValue, GetListOptions } from '@asap-hub/frontend-utils';
import {
AnalyticsTeamLeadershipDataObject,
AnalyticsTeamLeadershipResponse,
ListAnalyticsTeamLeadershipResponse,
} from '@asap-hub/model';
import { Stringifier } from 'csv-stringify/browser/esm';

type LeadershipRowCSV = Record<string, CSVValue>;

export const leadershipToCSV =
(metric: 'working-group' | 'interest-group') =>
(data: AnalyticsTeamLeadershipDataObject): LeadershipRowCSV => {
const metricPrefix =
metric === 'working-group' ? 'workingGroup' : 'interestGroup';
return {
team: data.displayName,
currentlyInALeadershipRole:
data[`${metricPrefix}LeadershipRoleCount`].toString(),
previouslyInALeadershipRole:
data[`${metricPrefix}PreviousLeadershipRoleCount`].toString(),
currentlyAMember: data[`${metricPrefix}MemberCount`].toString(),
previouslyAMember: data[`${metricPrefix}PreviousMemberCount`].toString(),
};
};

export const algoliaResultsToStream = async (
csvStream: Stringifier,
getResults: ({
currentPage,
pageSize,
}: Pick<GetListOptions, 'currentPage' | 'pageSize'>) => Readonly<
Promise<ListAnalyticsTeamLeadershipResponse | undefined>
>,
transform: (
result: AnalyticsTeamLeadershipResponse,
) => Record<string, unknown>,
) => {
let morePages = true;
let currentPage = 0;
while (morePages) {
// eslint-disable-next-line no-await-in-loop
const data = await getResults({
currentPage,
pageSize: 10,
});
if (data) {
const nbPages = data.total / 10;
data.items.map(transform).forEach((row) => csvStream.write(row));
currentPage += 1;
morePages = currentPage <= nbPages;
} else {
morePages = false;
}
}
csvStream.end();
};
5 changes: 4 additions & 1 deletion apps/crn-frontend/src/analytics/leadership/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,8 @@ export const useAnalyticsLeadership = (options: Options) => {
if (leadership instanceof Error) {
throw leadership;
}
return leadership;
return {
...leadership,
client: algoliaClient.client,
};
};
66 changes: 66 additions & 0 deletions packages/react-components/src/molecules/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ToastContext } from '@asap-hub/react-context';
import { css } from '@emotion/react';
import { useContext } from 'react';
import { Button } from '../atoms';
import { ExportIcon } from '../icons';
import { mobileScreen, rem, tabletScreen } from '../pixels';

const exportSectionStyles = css({
display: 'flex',
alignItems: 'center',
gap: rem(15),

[`@media (max-width: ${tabletScreen.min}px)`]: {
flexDirection: 'column',
alignItems: 'flex-start',
marginTop: rem(24),
width: '100%',
},
});

const exportButtonStyles = css({
gap: rem(8),
height: '100%',
alignItems: 'center',
padding: rem(9),
paddingRight: rem(15),
[`@media (max-width: ${tabletScreen.min}px)`]: {
width: '100%',
},

[`@media (min-width:${tabletScreen.min}px) and (max-width: ${mobileScreen.max}px)`]:
{
minWidth: 'auto',
},
});

const exportIconStyles = css({ display: 'flex' });
type ExportButtonProps = {
readonly exportResults?: () => Promise<void>;
};

const ExportButton: React.FC<ExportButtonProps> = ({ exportResults }) => {
const toast = useContext(ToastContext);
return exportResults ? (
<span css={exportSectionStyles}>
<strong>Export as:</strong>
<Button
noMargin
small
onClick={() =>
exportResults().catch(() => {
toast('There was an issue exporting to CSV. Please try again.');
})
}
overrideStyles={exportButtonStyles}
>
<>
<div css={exportIconStyles}>{ExportIcon}</div>
CSV
</>
</Button>
</span>
) : null;
};

export default ExportButton;
1 change: 1 addition & 0 deletions packages/react-components/src/molecules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { default as EventOwner } from './EventOwner';
export { default as EventTeams } from './EventTeams';
export { default as EventTime } from './EventTime';
export { default as ExternalLink } from './ExternalLink';
export { default as ExportButton } from './ExportButton';
export { default as FormCard } from './FormCard';
export { default as GoogleSigninButton } from './GoogleSigninButton';
export { default as Header } from './Header';
Expand Down
Loading

0 comments on commit f802dbb

Please sign in to comment.