From 17aef78195ada0cd5d414c834bf5178ee1c1f0e2 Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 20:48:30 +0300 Subject: [PATCH 01/10] Update csv-utils and add daterange to filename --- src/lib/csv-utils.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/csv-utils.tsx b/src/lib/csv-utils.tsx index 65842f97..0f155bf3 100644 --- a/src/lib/csv-utils.tsx +++ b/src/lib/csv-utils.tsx @@ -6,11 +6,15 @@ type CoursesForCsv = { country?: { name: string } | null; title?: { name: string } | null; }>; - startDate: Date; - endDate: Date; + startDate: string; + endDate: string; }; -export function DownloadTrainingSessionsAsCSV(courses: CoursesForCsv[]) { +export function DownloadTrainingSessionsAsCSV( + fromDate: Date, + toDate: Date, + courses: CoursesForCsv[] +) { const csvHeader = 'Training Name,Trainer Name,Participant Name,Country,Title,Start Date, End Date\n'; @@ -23,8 +27,8 @@ export function DownloadTrainingSessionsAsCSV(courses: CoursesForCsv[]) { student.name, student.country?.name ?? '', student.title?.name ?? '', - course.startDate.toISOString().slice(0, 10), - course.endDate.toISOString().slice(0, 10), + course.startDate.slice(0, 10), + course.endDate.slice(0, 10), ].join(','); }); }) @@ -36,7 +40,13 @@ export function DownloadTrainingSessionsAsCSV(courses: CoursesForCsv[]) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); - link.setAttribute('download', 'training_sessions.csv'); + const file_name = + `training-data_${ + fromDate.toISOString().slice(0, 10) + }_${ + toDate.toISOString().slice(0, 10) + }.csv`; + link.setAttribute('download', file_name); document.body.appendChild(link); // Required for Firefox link.click(); document.body.removeChild(link); // Cleanup From ac674850261a6c8c42e06915c2151448d8ae3caf Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 20:50:39 +0300 Subject: [PATCH 02/10] Update statistics api route and add extra date validation --- src/app/api/course/statistics/route.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/course/statistics/route.ts b/src/app/api/course/statistics/route.ts index 654131fa..15d9a856 100644 --- a/src/app/api/course/statistics/route.ts +++ b/src/app/api/course/statistics/route.ts @@ -16,11 +16,19 @@ export async function GET(request: NextRequest) { statusCode: StatusCodeType.FORBIDDEN, }); } + const fromDate = new Date( - request.nextUrl.searchParams.get('fromDate') || '' + parseInt(request.nextUrl.searchParams.get('fromDate') || '') + ); + const toDate = new Date( + parseInt(request.nextUrl.searchParams.get('toDate') || '') ); - const toDate = new Date(request.nextUrl.searchParams.get('toDate') || ''); - if (!fromDate.valueOf() || !toDate.valueOf()) { + + if ( + !fromDate.valueOf() || + !toDate.valueOf() || + fromDate.valueOf() > toDate.valueOf() + ) { return errorResponse({ message: t('Statistics.invalidDateRange'), statusCode: StatusCodeType.UNPROCESSABLE_CONTENT, From 5b9388dae58fcd420d0e7369228405abd820f084 Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 20:57:02 +0300 Subject: [PATCH 03/10] Add translations for ExportForm --- src/app/[lang]/locales/en/admin.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/[lang]/locales/en/admin.json b/src/app/[lang]/locales/en/admin.json index a678bcbc..490c7626 100644 --- a/src/app/[lang]/locales/en/admin.json +++ b/src/app/[lang]/locales/en/admin.json @@ -37,6 +37,8 @@ }, "ExportStats": { "button": "Export CSV", + "downloadFailed": "Could not start download", + "downloadStarted": "Download started", "fromDate": "From:", "header": "Export course statistics", "toDate": "To:", From aa9c59811173eb275e565ca76560bd231de43453 Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 20:57:28 +0300 Subject: [PATCH 04/10] Add event handler to statistics export button --- src/components/ExportStats/ExportForm.tsx | 46 ++++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/components/ExportStats/ExportForm.tsx b/src/components/ExportStats/ExportForm.tsx index 38576891..ff278362 100644 --- a/src/components/ExportStats/ExportForm.tsx +++ b/src/components/ExportStats/ExportForm.tsx @@ -10,19 +10,61 @@ import { import { zodResolver } from '@hookform/resolvers/zod'; import FormFieldError from '../FormFieldError'; import { useTranslation } from '@/lib/i18n/client'; +import { useMessage } from '../Providers/MessageProvider'; +import { DownloadTrainingSessionsAsCSV } from '@/lib/csv-utils'; +import { MessageType } from '@/lib/response/responseUtil'; interface Props extends DictProps {} export default function ExportForm({ lang }: Props) { const { t } = useTranslation(lang, 'admin'); - const { register } = useForm(); + const { notify } = useMessage(); const { formState: { errors, isSubmitting }, + handleSubmit, + register, } = useForm({ resolver: zodResolver(exportStatsFormSchema), }); + + const submitForm = async (data: ExportStatsFormType) => { + const params = new URLSearchParams(); + params.set('fromDate', data.fromDate.valueOf().toString()); + // Adding the number of milliseconds in a day to the toDate value in order to + // include the full duration of the day + params.set( + 'toDate', + (data.toDate.valueOf() + 60 * 60 * 24 * 1000 - 1).toString() + ); + + const url = `/api/course/statistics?${params.toString()}`; + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) { + notify(await res.json()); + return; + } + const responseJson = await res.json(); + + try { + DownloadTrainingSessionsAsCSV( + data.fromDate, + data.toDate, + responseJson.data + ); + notify({ + message: t('ExportStats.downloadStarted'), + messageType: MessageType.SUCCESS, + }); + } catch (error) { + notify({ + message: t('ExportStats.downloadFailed'), + messageType: MessageType.ERROR, + }); + } + }; + return ( -
{}}> + Date: Wed, 17 Apr 2024 22:07:10 +0300 Subject: [PATCH 05/10] Allow get in fetchUtil to return data --- src/lib/response/fetchUtil.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/response/fetchUtil.ts b/src/lib/response/fetchUtil.ts index bd99fdc1..67931567 100644 --- a/src/lib/response/fetchUtil.ts +++ b/src/lib/response/fetchUtil.ts @@ -5,9 +5,7 @@ import { } from './responseUtil'; export const get = async (url: RequestInfo | URL) => { - const response = await fetch(url, { method: 'GET' }); - const responseAsJson = await responseToJson(response); - return responseAsJson; + return await fetch(url, { method: 'GET' }); }; export const post = async (url: RequestInfo | URL, data?: unknown) => { From 79bef0adb0a6202d4678fd8758d1e5973937bbfd Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 22:08:22 +0300 Subject: [PATCH 06/10] Change from fetch to fetchUtil get --- src/components/ExportStats/ExportForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ExportStats/ExportForm.tsx b/src/components/ExportStats/ExportForm.tsx index ff278362..8fe0f83a 100644 --- a/src/components/ExportStats/ExportForm.tsx +++ b/src/components/ExportStats/ExportForm.tsx @@ -13,6 +13,7 @@ import { useTranslation } from '@/lib/i18n/client'; import { useMessage } from '../Providers/MessageProvider'; import { DownloadTrainingSessionsAsCSV } from '@/lib/csv-utils'; import { MessageType } from '@/lib/response/responseUtil'; +import { get } from '@/lib/response/fetchUtil'; interface Props extends DictProps {} @@ -38,7 +39,7 @@ export default function ExportForm({ lang }: Props) { ); const url = `/api/course/statistics?${params.toString()}`; - const res = await fetch(url, { method: 'GET' }); + const res = await get(url); if (!res.ok) { notify(await res.json()); return; From 6e7f0f549f203b1420cc3bd6e625e57c1ad545a9 Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 22:08:54 +0300 Subject: [PATCH 07/10] Add mocks to tests and test new functionality --- .../ExportStats/ExportForm.test.tsx | 39 ++++++++++++++++++- src/components/ExportStats/index.test.tsx | 26 +++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/components/ExportStats/ExportForm.test.tsx b/src/components/ExportStats/ExportForm.test.tsx index d2020991..d1050b72 100644 --- a/src/components/ExportStats/ExportForm.test.tsx +++ b/src/components/ExportStats/ExportForm.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; import '@testing-library/jest-dom'; import ExportForm from '.'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { renderWithTheme } from '@/lib/test-utils'; +import { act } from 'react-dom/test-utils'; +import userEvent from '@testing-library/user-event'; jest.mock('next/navigation', () => ({ useRouter: () => ({ @@ -23,6 +25,32 @@ jest.mock('../../lib/i18n/client', () => ({ }, })); +jest.mock('../Providers/MessageProvider', () => ({ + useMessage() { + return { + notify: jest.fn(), + }; + }, +})); + +jest.mock('../../lib/response/responseUtil', () => ({ + MessageType: { + SUCCESS: 'success', + ERROR: 'error', + }, +})); + +const mockFetch = jest.fn((...args: any[]) => + Promise.resolve({ + json: () => Promise.resolve({ args: args }), + ok: true, + }) +); + +jest.mock('../../lib/response/fetchUtil', () => ({ + get: (...args: any[]) => mockFetch(...args), +})); + describe('ExportStats', () => { it('should render ExportStats', async () => { renderWithTheme(); @@ -50,4 +78,13 @@ describe('ExportStats', () => { renderWithTheme(); expect(screen.getByText('ExportStats.button')).toBeInTheDocument(); }); + + it('should call fetch when button is pressed', async () => { + renderWithTheme(); + const button = screen.getByText('ExportStats.button'); + await userEvent.click(button); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/ExportStats/index.test.tsx b/src/components/ExportStats/index.test.tsx index 8b3b246d..27b60b52 100644 --- a/src/components/ExportStats/index.test.tsx +++ b/src/components/ExportStats/index.test.tsx @@ -24,6 +24,32 @@ jest.mock('../../lib/i18n/client', () => ({ }, })); +jest.mock('../Providers/MessageProvider', () => ({ + useMessage() { + return { + notify: jest.fn(), + }; + }, +})); + +jest.mock('../../lib/response/responseUtil', () => ({ + MessageType: { + SUCCESS: 'success', + ERROR: 'error', + }, +})); + +const mockFetch = jest.fn((...args: any[]) => + Promise.resolve({ + json: () => Promise.resolve({ args: args }), + ok: true, + }) +); + +jest.mock('../../lib/response/fetchUtil', () => ({ + get: (...args: any[]) => mockFetch(...args), +})); + describe('ExportForm', () => { it('should render ExportForm', async () => { renderWithTheme(); From 0b353a70604923bb26fe42bdf016a0ad36503e19 Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 22:16:42 +0300 Subject: [PATCH 08/10] Remove unused import --- src/components/ExportStats/ExportForm.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ExportStats/ExportForm.test.tsx b/src/components/ExportStats/ExportForm.test.tsx index d1050b72..a462e0d8 100644 --- a/src/components/ExportStats/ExportForm.test.tsx +++ b/src/components/ExportStats/ExportForm.test.tsx @@ -3,7 +3,6 @@ import '@testing-library/jest-dom'; import ExportForm from '.'; import { screen, waitFor } from '@testing-library/react'; import { renderWithTheme } from '@/lib/test-utils'; -import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; jest.mock('next/navigation', () => ({ From 00c7ef1ce60cd13b079f83eeff890aa270c59946 Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 23:05:59 +0300 Subject: [PATCH 09/10] Prettier formatting --- src/lib/csv-utils.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lib/csv-utils.tsx b/src/lib/csv-utils.tsx index 0f155bf3..795ec1e4 100644 --- a/src/lib/csv-utils.tsx +++ b/src/lib/csv-utils.tsx @@ -40,12 +40,9 @@ export function DownloadTrainingSessionsAsCSV( const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); - const file_name = - `training-data_${ - fromDate.toISOString().slice(0, 10) - }_${ - toDate.toISOString().slice(0, 10) - }.csv`; + const file_name = `training-data_${fromDate + .toISOString() + .slice(0, 10)}_${toDate.toISOString().slice(0, 10)}.csv`; link.setAttribute('download', file_name); document.body.appendChild(link); // Required for Firefox link.click(); From 8286283b6f067f96a437723ab593bf91ce5b343d Mon Sep 17 00:00:00 2001 From: akskokki Date: Wed, 17 Apr 2024 23:27:02 +0300 Subject: [PATCH 10/10] Fix api tests --- src/app/api/course/statistics/route.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/api/course/statistics/route.test.ts b/src/app/api/course/statistics/route.test.ts index 5b0e2e08..020ed68a 100644 --- a/src/app/api/course/statistics/route.test.ts +++ b/src/app/api/course/statistics/route.test.ts @@ -24,12 +24,15 @@ jest.mock('../../../../lib/auth', () => ({ getServerAuthSession: jest.fn(), })); +const fromDate = new Date('2100-09-10T00:00:00Z'); +const toDate = new Date('2100-09-20T23:59:59Z'); + const mockGetRequest = () => { return createMocks({ method: 'GET', nextUrl: { searchParams: new URLSearchParams( - 'fromDate=2100-09-10T00:00:00Z&toDate=2100-09-20T23:59:59Z' + `fromDate=${fromDate.valueOf()}&toDate=${toDate.valueOf()}` ), }, }).req; @@ -39,7 +42,7 @@ const mockGetRequestWithoutStartDate = () => { return createMocks({ method: 'GET', nextUrl: { - searchParams: new URLSearchParams('toDate=2100-09-20T23:59:59Z'), + searchParams: new URLSearchParams(`toDate=${toDate.valueOf()}`), }, }).req; }; @@ -48,7 +51,7 @@ const mockGetRequestWithoutEndDate = () => { return createMocks({ method: 'GET', nextUrl: { - searchParams: new URLSearchParams('fromDate=2100-09-20T23:59:59Z'), + searchParams: new URLSearchParams(`toDate=${toDate.valueOf()}`), }, }).req; };