From f802dbbf8d36da766a6c4138add679a10e5bcab1 Mon Sep 17 00:00:00 2001 From: Quentin Leonetti Date: Tue, 21 May 2024 15:47:31 +0200 Subject: [PATCH] [ASAP-345]analytics csv export (#4281) * 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 --- .../src/analytics/leadership/Leadership.tsx | 25 ++++- .../leadership/__tests__/Leadership.test.tsx | 46 ++++++++++ .../leadership/__tests__/export.test.ts | 92 +++++++++++++++++++ .../src/analytics/leadership/export.ts | 57 ++++++++++++ .../src/analytics/leadership/state.ts | 5 +- .../src/molecules/ExportButton.tsx | 66 +++++++++++++ .../react-components/src/molecules/index.ts | 1 + .../src/organisms/ResultList.tsx | 64 +------------ .../templates/AnalyticsLeadershipPageBody.tsx | 20 +++- .../AnalyticsLeadershipPageBody.test.tsx | 1 + .../__tests__/LeadershipPageBody.test.tsx | 1 + 11 files changed, 314 insertions(+), 64 deletions(-) create mode 100644 apps/crn-frontend/src/analytics/leadership/__tests__/export.test.ts create mode 100644 apps/crn-frontend/src/analytics/leadership/export.ts create mode 100644 packages/react-components/src/molecules/ExportButton.tsx diff --git a/apps/crn-frontend/src/analytics/leadership/Leadership.tsx b/apps/crn-frontend/src/analytics/leadership/Leadership.tsx index 678e02ee40..25c9770097 100644 --- a/apps/crn-frontend/src/analytics/leadership/Leadership.tsx +++ b/apps/crn-frontend/src/analytics/leadership/Leadership.tsx @@ -1,3 +1,4 @@ +import { createCsvFileStream } from '@asap-hub/frontend-utils'; import { LeadershipAndMembershipSortingDirection, initialSortingDirection, @@ -5,9 +6,11 @@ import { } 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'; @@ -67,7 +70,7 @@ const Leadership: FC> = () => { const { currentPage, pageSize } = usePaginationParams(); const { debouncedSearchQuery, searchQuery, setSearchQuery } = useSearch(); - const { items, total } = useAnalyticsLeadership({ + const { items, total, client } = useAnalyticsLeadership({ sort, currentPage, pageSize, @@ -77,6 +80,23 @@ const Leadership: FC> = () => { 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 ( > = () => { setSort={setSort} sortingDirection={sortingDirection} setSortingDirection={setSortingDirection} + exportResults={exportResults} data={getDataForMetric(items, metric)} currentPageIndex={currentPage} numberOfPages={numberOfPages} diff --git a/apps/crn-frontend/src/analytics/leadership/__tests__/Leadership.test.tsx b/apps/crn-frontend/src/analytics/leadership/__tests__/Leadership.test.tsx index bf756f9bf3..1c04e9d538 100644 --- a/apps/crn-frontend/src/analytics/leadership/__tests__/Leadership.test.tsx +++ b/apps/crn-frontend/src/analytics/leadership/__tests__/Leadership.test.tsx @@ -2,6 +2,7 @@ import { AlgoliaSearchClient, algoliaSearchClientFactory, } from '@asap-hub/algolia'; +import { createCsvFileStream } from '@asap-hub/frontend-utils'; import { Auth0Provider, WhenReady, @@ -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(); }); @@ -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(); + }); +}); diff --git a/apps/crn-frontend/src/analytics/leadership/__tests__/export.test.ts b/apps/crn-frontend/src/analytics/leadership/__tests__/export.test.ts new file mode 100644 index 0000000000..da7b072e7a --- /dev/null +++ b/apps/crn-frontend/src/analytics/leadership/__tests__/export.test.ts @@ -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(); + }); +}); diff --git a/apps/crn-frontend/src/analytics/leadership/export.ts b/apps/crn-frontend/src/analytics/leadership/export.ts new file mode 100644 index 0000000000..2b1e0a689b --- /dev/null +++ b/apps/crn-frontend/src/analytics/leadership/export.ts @@ -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; + +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) => Readonly< + Promise + >, + transform: ( + result: AnalyticsTeamLeadershipResponse, + ) => Record, +) => { + 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(); +}; diff --git a/apps/crn-frontend/src/analytics/leadership/state.ts b/apps/crn-frontend/src/analytics/leadership/state.ts index bd450c86c5..b6fd200a93 100644 --- a/apps/crn-frontend/src/analytics/leadership/state.ts +++ b/apps/crn-frontend/src/analytics/leadership/state.ts @@ -88,5 +88,8 @@ export const useAnalyticsLeadership = (options: Options) => { if (leadership instanceof Error) { throw leadership; } - return leadership; + return { + ...leadership, + client: algoliaClient.client, + }; }; diff --git a/packages/react-components/src/molecules/ExportButton.tsx b/packages/react-components/src/molecules/ExportButton.tsx new file mode 100644 index 0000000000..3e49073c6c --- /dev/null +++ b/packages/react-components/src/molecules/ExportButton.tsx @@ -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; +}; + +const ExportButton: React.FC = ({ exportResults }) => { + const toast = useContext(ToastContext); + return exportResults ? ( + + Export as: + + + ) : null; +}; + +export default ExportButton; diff --git a/packages/react-components/src/molecules/index.ts b/packages/react-components/src/molecules/index.ts index b4ca75165b..67fa9f378e 100644 --- a/packages/react-components/src/molecules/index.ts +++ b/packages/react-components/src/molecules/index.ts @@ -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'; diff --git a/packages/react-components/src/organisms/ResultList.tsx b/packages/react-components/src/organisms/ResultList.tsx index dce73b3e33..f04506f5be 100644 --- a/packages/react-components/src/organisms/ResultList.tsx +++ b/packages/react-components/src/organisms/ResultList.tsx @@ -1,16 +1,14 @@ -import React, { ComponentProps, useContext, useEffect } from 'react'; +import React, { ComponentProps, useEffect } from 'react'; import { css } from '@emotion/react'; -import { ToastContext } from '@asap-hub/react-context'; -import { ListControls, PageControls } from '../molecules'; -import { Button, Headline3, Paragraph } from '../atoms'; +import { ExportButton, ListControls, PageControls } from '../molecules'; +import { Headline3, Paragraph } from '../atoms'; import { perRem, vminLinearCalcClamped, mobileScreen, tabletScreen, } from '../pixels'; -import { ExportIcon } from '../icons'; import { charcoal } from '../colors'; const headerStyles = css({ @@ -27,37 +25,6 @@ const headerNoResultsStyles = css({ }, }); -const exportSectionStyles = css({ - display: 'flex', - alignItems: 'center', - gap: `${15 / perRem}em`, - - [`@media (max-width: ${tabletScreen.min}px)`]: { - flexDirection: 'column', - alignItems: 'flex-start', - marginTop: `${24 / perRem}em`, - width: '100%', - }, -}); - -const exportButton = css({ - gap: `${8 / perRem}em`, - height: '100%', - alignItems: 'center', - paddingRight: `${15 / perRem}em`, - - [`@media (max-width: ${tabletScreen.min}px)`]: { - width: '100%', - }, - - [`@media (min-width:${tabletScreen.min}px) and (max-width: ${mobileScreen.max}px)`]: - { - minWidth: 'auto', - }, -}); - -const exportIcon = css({ display: 'flex' }); - const resultsHeaderStyles = css({ display: 'flex', justifyContent: 'space-between', @@ -136,7 +103,6 @@ const ResultList: React.FC = ({ algoliaIndexName, ...pageControlsProps }) => { - const toast = useContext(ToastContext); useEffect(() => { if (algoliaIndexName) { window.dataLayer?.push({ event: 'Hits Viewed' }); @@ -161,29 +127,7 @@ const ResultList: React.FC = ({ listViewHref={listViewHref} /> )} - - {exportResults && ( - - Export as: - - - )} + )} diff --git a/packages/react-components/src/templates/AnalyticsLeadershipPageBody.tsx b/packages/react-components/src/templates/AnalyticsLeadershipPageBody.tsx index 52cd0f5f5f..6562baebf5 100644 --- a/packages/react-components/src/templates/AnalyticsLeadershipPageBody.tsx +++ b/packages/react-components/src/templates/AnalyticsLeadershipPageBody.tsx @@ -6,8 +6,9 @@ import { css } from '@emotion/react'; import { ComponentProps } from 'react'; import { PageControls, SearchField } from '..'; import { Dropdown, Headline3, Paragraph, Subtitle } from '../atoms'; +import { ExportButton } from '../molecules'; import { LeadershipMembershipTable } from '../organisms'; -import { rem } from '../pixels'; +import { rem, tabletScreen } from '../pixels'; type MetricOption = 'working-group' | 'interest-group'; type MetricData = { @@ -41,6 +42,7 @@ type LeadershipAndMembershipAnalyticsProps = ComponentProps< setSortingDirection: React.Dispatch< React.SetStateAction >; + exportResults: () => Promise; searchQuery: string; onChangeSearch: (newSearchQuery: string) => void; }; @@ -69,6 +71,18 @@ const searchContainerStyles = css({ const searchStyles = css({ flexGrow: 1, }); +const exportContainerStyles = css({ + display: 'flex', + justifyContent: 'right', + gap: rem(33), + paddingBottom: rem(33), + [`@media (max-width: ${tabletScreen.min}px)`]: { + flexDirection: 'column', + alignItems: 'flex-start', + width: '100%', + gap: 0, + }, +}); const LeadershipPageBody: React.FC = ({ sort, @@ -78,6 +92,7 @@ const LeadershipPageBody: React.FC = ({ metric, setMetric, data, + exportResults, searchQuery, onChangeSearch, ...pageControlProps @@ -92,6 +107,9 @@ const LeadershipPageBody: React.FC = ({ required /> + + +
{metricOptions[metric]} diff --git a/packages/react-components/src/templates/__tests__/AnalyticsLeadershipPageBody.test.tsx b/packages/react-components/src/templates/__tests__/AnalyticsLeadershipPageBody.test.tsx index 4da6037ec3..16330c4019 100644 --- a/packages/react-components/src/templates/__tests__/AnalyticsLeadershipPageBody.test.tsx +++ b/packages/react-components/src/templates/__tests__/AnalyticsLeadershipPageBody.test.tsx @@ -9,6 +9,7 @@ describe('AnalyticsLeadershipPageBody', () => { currentPageIndex: 0, renderPageHref: () => '', setMetric: () => {}, + exportResults: () => Promise.resolve(), data: [], metric: 'interest-group', sort: 'team_asc', diff --git a/packages/react-components/src/templates/__tests__/LeadershipPageBody.test.tsx b/packages/react-components/src/templates/__tests__/LeadershipPageBody.test.tsx index 12276729e4..747e4ccd09 100644 --- a/packages/react-components/src/templates/__tests__/LeadershipPageBody.test.tsx +++ b/packages/react-components/src/templates/__tests__/LeadershipPageBody.test.tsx @@ -14,6 +14,7 @@ it('renders the selected metric', () => { Promise.resolve()} metric={'interest-group'} data={[]} setMetric={() => {}}