From dd9a5e5187186a2abce0dc891b19f8443f455b38 Mon Sep 17 00:00:00 2001 From: adeiji Date: Tue, 5 May 2026 21:58:57 -0700 Subject: [PATCH 01/28] feat: Added the new menus and menu items for viewing security audit logs --- .../HeaderOptions/HeaderOptions.tsx | 50 ++++++++++++++++--- .../Dashboard/features/Managers/Managers.tsx | 3 ++ .../features/Managers/Managers.types.ts | 1 + .../features/Managers/Managers.utils.test.tsx | 3 ++ .../features/Managers/Managers.utils.tsx | 23 ++++++--- src/resources/app-en.json | 2 + src/resources/app-fr.json | 2 + src/svgSprite.ts | 10 ++++ 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx index f3b9cdc6ad..3c46279c24 100644 --- a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx +++ b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Button, IconButton } from '@mui/material'; import { Link, generatePath, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { page } from 'resources'; -import { Svg } from 'shared/components'; +import { Menu, Svg } from 'shared/components'; import { ExportDataSetting } from 'shared/features/AppletSettings'; import { StyledFlexTopCenter, variables } from 'shared/styles'; import { Mixpanel, checkIfCanAccessData, checkIfCanEdit, MixpanelEventType } from 'shared/utils'; @@ -17,32 +17,68 @@ export const HeaderOptions = () => { const isSettingsSelected = location.pathname.includes('settings'); const workspaceRoles = workspaces.useRolesData(); const roles = appletId ? workspaceRoles?.data?.[appletId] : undefined; + const [showExportMenu, setShowExportMenu] = useState(false); + const exportButtonRef = useRef(null); - const handleOpenExport = () => { + const handleOpenExportMenu = () => { + setShowExportMenu(true); + }; + + const handleOpenAuditLogs = () => { + // TODO: Implement view audit logs + } + + const handleOpenResponseData = () => { setIsExportOpen(true); Mixpanel.track({ action: MixpanelEventType.ExportDataClick }); - }; + } const handleCloseExport = () => { setIsExportOpen(false); - }; + } const canAccessData = checkIfCanAccessData(roles); const canEdit = checkIfCanEdit(roles); + const getExportActions = () => { + return [ + { + icon: , + action: handleOpenResponseData, + title: t('exportResponseData'), + 'data-testid': 'header-option-response-data-button', + }, + { + icon: , + action: handleOpenAuditLogs, + title: t('exportAuditLogs'), + 'data-testid': 'header-option-audit-logs-button', + }, + ]; + } + return canEdit || canAccessData ? ( {canAccessData && ( - )} + )} + {showExportMenu && ( + setShowExportMenu(false)} + menuItems={getExportActions()} + /> + )} {canEdit && ( { const [selectedManager, setSelectedManager] = useState(null); const actions: ManagersActions = { + exportAuditLogsAction: ({ context: user }: MenuActionProps) => { + // TODO: Implement export audit logs + }, removeTeamMemberAction: ({ context: user }: MenuActionProps) => { setSelectedManager(user || null); setRemoveAccessPopupVisible(true); diff --git a/src/modules/Dashboard/features/Managers/Managers.types.ts b/src/modules/Dashboard/features/Managers/Managers.types.ts index e18f921958..8daf925daa 100644 --- a/src/modules/Dashboard/features/Managers/Managers.types.ts +++ b/src/modules/Dashboard/features/Managers/Managers.types.ts @@ -6,4 +6,5 @@ export type ManagersActions = { editTeamMemberAction: ({ context }: MenuActionProps) => void; copyEmailAddressAction: ({ context }: MenuActionProps) => void; copyInvitationLinkAction: ({ context }: MenuActionProps) => void; + exportAuditLogsAction: ({ context }: MenuActionProps) => void; }; diff --git a/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx b/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx index 1f98e3f204..8b5670c2fc 100644 --- a/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx @@ -17,6 +17,7 @@ const removeTeamMemberAction = vi.fn(); const editTeamMemberAction = vi.fn(); const copyEmailAddressAction = vi.fn(); const copyInvitationLinkAction = vi.fn(); +const exportAuditLogsAction = vi.fn(); describe('Managers utils tests', () => { describe('getHeadCells function', () => { @@ -43,6 +44,7 @@ describe('Managers utils tests', () => { editTeamMemberAction, copyEmailAddressAction, copyInvitationLinkAction, + exportAuditLogsAction, }, mockedManager, ); @@ -101,6 +103,7 @@ describe('Managers utils tests', () => { editTeamMemberAction, copyEmailAddressAction, copyInvitationLinkAction, + exportAuditLogsAction, }, pendingManager, ); diff --git a/src/modules/Dashboard/features/Managers/Managers.utils.tsx b/src/modules/Dashboard/features/Managers/Managers.utils.tsx index ce5856c190..3c95c5c4c1 100644 --- a/src/modules/Dashboard/features/Managers/Managers.utils.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.utils.tsx @@ -8,7 +8,8 @@ import { variables } from 'shared/styles'; import { MenuItem, MenuItemType } from 'shared/components'; import { DateFormats } from 'shared/consts'; -import { ManagersActions } from './Managers.types'; +import { ExportMenuItems, ManagersActions } from './Managers.types'; +import { t } from 'i18next'; export const getHeadCells = (sortableColumns?: string[], appletId?: string): HeadCell[] => { const { t } = i18n; @@ -35,12 +36,12 @@ export const getHeadCells = (sortableColumns?: string[], appletId?: string): Hea }, ...(appletId ? [ - { - id: 'roles', - label: t('role'), - enableSort: sortableColumns?.includes('roles') ?? true, - }, - ] + { + id: 'roles', + label: t('role'), + enableSort: sortableColumns?.includes('roles') ?? true, + }, + ] : []), { id: 'email', @@ -104,6 +105,14 @@ export const getManagerActions = ( customItemColor: variables.palette.dark_error_container, 'data-testid': 'dashboard-managers-remove-access', }, + { type: MenuItemType.Divider }, + { + icon: , + action: actions.exportAuditLogsAction, + title: t('exportAuditLogs'), + context: manager, + 'data-testid': 'dashboard-managers-export-audit-logs', + } ); } diff --git a/src/resources/app-en.json b/src/resources/app-en.json index abeffdf540..fd237dc885 100644 --- a/src/resources/app-en.json +++ b/src/resources/app-en.json @@ -558,6 +558,8 @@ "editAccess": "Edit Access", "editTeamMember": "Edit Team Member", "removeTeamMember": "Remove Team Member", + "exportAuditLogs": "Security Audit Logs", + "exportResponseData": "Response Data", "editAccessNoRespondent": "Please add access to review the Data of at least one Respondent in the {{titles}} for the Reviewer role or remove the role.", "editActivity": "Edit Activity", "editActivitySchedule": "Edit Activity Schedule", diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json index c589cbd425..b74b19a0c0 100644 --- a/src/resources/app-fr.json +++ b/src/resources/app-fr.json @@ -557,6 +557,8 @@ "edit": "Modifier", "editAccess": "Modifier l'accès", "editTeamMember": "Modifier un membre de l'équipe", + "exportAuditLogs": "Audit des sécurité", + "exportResponseData": "Données de réponse", "removeTeamMember": "Supprimer un membre de l'équipe", "editAccessNoRespondent": "Veuillez ajouter l'accès pour examiner les données d'au moins un participant dans les {{titles}} pour le rôle de l'examinateur ou supprimer le rôle.", "editActivity": "Modifier l'activité", diff --git a/src/svgSprite.ts b/src/svgSprite.ts index e81a987c92..ecfbdc55a0 100644 --- a/src/svgSprite.ts +++ b/src/svgSprite.ts @@ -443,6 +443,16 @@ const icons = { `, + 'audit-logs': ` + + + + `, + 'response-data': ` + + + +`, 'edit-access': ` From 1a56e9c9be38367d20ebd0ddef583c9df5a7d3ee Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 6 May 2026 08:17:38 -0700 Subject: [PATCH 02/28] chore: added and updated tests to work with new menu updates --- .../HeaderOptions/HeaderOptions.test.tsx | 16 ++++++++++++++-- .../components/HeaderOptions/HeaderOptions.tsx | 1 - .../features/Managers/Managers.test.tsx | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.test.tsx b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.test.tsx index 00f155b4aa..4486b69949 100644 --- a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.test.tsx +++ b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.test.tsx @@ -52,10 +52,22 @@ describe('HeaderOptions', () => { renderWithProviders(, { preloadedState: getPreloadedState() }); }); - test('should open Export dialog when option is pressed', () => { + test('should open Export options menu when export button is pressed', () => { fireEvent.click(screen.getByTestId('header-option-export-button')); - expect(screen.queryByTestId('export-data-settings')).toBeInTheDocument(); + expect(screen.queryByTestId('header-option-export-menu')).toBeInTheDocument(); + }); + + test('should see Response Data option in export options menu', () => { + fireEvent.click(screen.getByTestId('header-option-export-button')); + + expect(screen.queryByTestId('header-option-response-data-button')).toBeInTheDocument(); + }); + + test('should see Audit Logs option in export options menu', () => { + fireEvent.click(screen.getByTestId('header-option-export-button')); + + expect(screen.queryByTestId('header-option-audit-logs-button')).toBeInTheDocument(); }); test('should contain link to settings page', () => { diff --git a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx index 3c46279c24..cebd7a4855 100644 --- a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx +++ b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx @@ -69,7 +69,6 @@ export const HeaderOptions = () => { > {t('export')} - )} {showExportMenu && ( { renderWithProviders(, { preloadedState, route, routePath }); await clickActionDots(); - const actionsDataTestIds = ['dashboard-managers-edit-user', 'dashboard-managers-remove-access']; + const actionsDataTestIds = ['dashboard-managers-edit-user', 'dashboard-managers-remove-access', 'dashboard-managers-export-audit-logs']; await waitFor(() => { actionsDataTestIds.forEach((dataTestId) => From 46851109e06987449ba9a67172f561dddeb44cad Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 6 May 2026 17:09:28 -0700 Subject: [PATCH 03/28] wip: added the audit logs export popup UI Moved date range functionality into a reusable component --- .../HeaderOptions/HeaderOptions.tsx | 71 ++++++---- .../features/Managers/Managers.test.tsx | 6 +- .../features/Managers/Managers.utils.tsx | 19 ++- src/resources/app-en.json | 18 ++- src/resources/app-fr.json | 2 - .../DateRangePicker/DateRangePicker.tsx | 133 ++++++++++++++++++ .../DateRangePicker/DateRangePicker.types.ts | 13 ++ .../DateRangePicker/DateRangePicker.utils.ts | 28 ++++ .../components/DateRangePicker/index.tsx | 3 + .../AuditLogsExportPopup.tsx | 101 +++++++++++++ .../AuditLogsExportPopup.types.ts | 9 ++ .../AuditLogsExportSetting.schema.ts | 26 ++++ .../AuditLogsExportSetting.tsx | 105 ++++++++++++++ .../AuditLogsExportSetting.types.ts | 22 +++ .../ExportSettingsPopup.tsx | 4 + src/shared/utils/mixpanel/mixpanel.types.ts | 6 + 16 files changed, 524 insertions(+), 42 deletions(-) create mode 100644 src/shared/components/DateRangePicker/DateRangePicker.tsx create mode 100644 src/shared/components/DateRangePicker/DateRangePicker.types.ts create mode 100644 src/shared/components/DateRangePicker/DateRangePicker.utils.ts create mode 100644 src/shared/components/DateRangePicker/index.tsx create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.schema.ts create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.types.ts diff --git a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx index cebd7a4855..0882180d53 100644 --- a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx +++ b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx @@ -9,9 +9,15 @@ import { ExportDataSetting } from 'shared/features/AppletSettings'; import { StyledFlexTopCenter, variables } from 'shared/styles'; import { Mixpanel, checkIfCanAccessData, checkIfCanEdit, MixpanelEventType } from 'shared/utils'; import { workspaces } from 'shared/state'; +import { AuditLogsExportSetting } from 'shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting'; export const HeaderOptions = () => { - const [isExportOpen, setIsExportOpen] = useState(false); + const [isResponseDataExportOpen, setIsResponseDataExportOpen] = useState(false); + const [isAuditLogsExportOpen, setIsAuditLogsExportOpen] = useState(false); + + const [isResponseDataExportPopupOpen, setIsResponseDataExportPopupOpen] = useState(false); + const [isAuditLogsExportPopupOpen, setIsAuditLogsExportPopupOpen] = useState(false); + const { t } = useTranslation('app'); const { appletId } = useParams(); const isSettingsSelected = location.pathname.includes('settings'); @@ -25,37 +31,40 @@ export const HeaderOptions = () => { }; const handleOpenAuditLogs = () => { - // TODO: Implement view audit logs - } + setIsAuditLogsExportOpen(true); + Mixpanel.track({ action: MixpanelEventType.ExportAuditLogsClick }); + }; const handleOpenResponseData = () => { - setIsExportOpen(true); + setIsResponseDataExportOpen(true); Mixpanel.track({ action: MixpanelEventType.ExportDataClick }); - } + }; - const handleCloseExport = () => { - setIsExportOpen(false); - } + const handleCloseResponseData = () => { + setIsResponseDataExportOpen(false); + }; + + const handleCloseAuditLogsExport = () => { + setIsAuditLogsExportOpen(false); + }; const canAccessData = checkIfCanAccessData(roles); const canEdit = checkIfCanEdit(roles); - const getExportActions = () => { - return [ - { - icon: , - action: handleOpenResponseData, - title: t('exportResponseData'), - 'data-testid': 'header-option-response-data-button', - }, - { - icon: , - action: handleOpenAuditLogs, - title: t('exportAuditLogs'), - 'data-testid': 'header-option-audit-logs-button', - }, - ]; - } + const getExportActions = () => [ + { + icon: , + action: handleOpenResponseData, + title: t('dataExport.responseData.menuCaption'), + 'data-testid': 'header-option-response-data-button', + }, + { + icon: , + action: handleOpenAuditLogs, + title: t('dataExport.auditLogs.menuCaption'), + 'data-testid': 'header-option-audit-logs-button', + }, + ]; return canEdit || canAccessData ? ( @@ -90,9 +99,17 @@ export const HeaderOptions = () => { )} + ) : null; diff --git a/src/modules/Dashboard/features/Managers/Managers.test.tsx b/src/modules/Dashboard/features/Managers/Managers.test.tsx index 71c8d049a3..ca7a6d4f79 100644 --- a/src/modules/Dashboard/features/Managers/Managers.test.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.test.tsx @@ -109,7 +109,11 @@ describe('Managers component tests', () => { renderWithProviders(, { preloadedState, route, routePath }); await clickActionDots(); - const actionsDataTestIds = ['dashboard-managers-edit-user', 'dashboard-managers-remove-access', 'dashboard-managers-export-audit-logs']; + const actionsDataTestIds = [ + 'dashboard-managers-edit-user', + 'dashboard-managers-remove-access', + 'dashboard-managers-export-audit-logs', + ]; await waitFor(() => { actionsDataTestIds.forEach((dataTestId) => diff --git a/src/modules/Dashboard/features/Managers/Managers.utils.tsx b/src/modules/Dashboard/features/Managers/Managers.utils.tsx index 3c95c5c4c1..4c0c3faeca 100644 --- a/src/modules/Dashboard/features/Managers/Managers.utils.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.utils.tsx @@ -8,8 +8,7 @@ import { variables } from 'shared/styles'; import { MenuItem, MenuItemType } from 'shared/components'; import { DateFormats } from 'shared/consts'; -import { ExportMenuItems, ManagersActions } from './Managers.types'; -import { t } from 'i18next'; +import { ManagersActions } from './Managers.types'; export const getHeadCells = (sortableColumns?: string[], appletId?: string): HeadCell[] => { const { t } = i18n; @@ -36,12 +35,12 @@ export const getHeadCells = (sortableColumns?: string[], appletId?: string): Hea }, ...(appletId ? [ - { - id: 'roles', - label: t('role'), - enableSort: sortableColumns?.includes('roles') ?? true, - }, - ] + { + id: 'roles', + label: t('role'), + enableSort: sortableColumns?.includes('roles') ?? true, + }, + ] : []), { id: 'email', @@ -109,10 +108,10 @@ export const getManagerActions = ( { icon: , action: actions.exportAuditLogsAction, - title: t('exportAuditLogs'), + title: t('dataExport.auditLogs.menuCaption'), context: manager, 'data-testid': 'dashboard-managers-export-audit-logs', - } + }, ); } diff --git a/src/resources/app-en.json b/src/resources/app-en.json index fd237dc885..e484b110e7 100644 --- a/src/resources/app-en.json +++ b/src/resources/app-en.json @@ -464,6 +464,22 @@ "dashboard": "Dashboard", "dataExport": { "title": "Data Export", + "responseData": { + "menuCaption": "Response Data" + }, + "auditLogs": { + "menuCaption": "Security Audit Logs", + "title": "{{name}} security audit logs", + "header": "Export Security Audit Logs", + "label": "Export: ", + "description": "Download logs containing applet activity for analysis in SIEM software.", + "button": "Export", + "supplementaryFiles": { + "includes": { + "tsv": "Include human readable TSV file" + } + } + }, "data": "Data", "dataExported": { "responsesOnly": "Responses Only", @@ -558,8 +574,6 @@ "editAccess": "Edit Access", "editTeamMember": "Edit Team Member", "removeTeamMember": "Remove Team Member", - "exportAuditLogs": "Security Audit Logs", - "exportResponseData": "Response Data", "editAccessNoRespondent": "Please add access to review the Data of at least one Respondent in the {{titles}} for the Reviewer role or remove the role.", "editActivity": "Edit Activity", "editActivitySchedule": "Edit Activity Schedule", diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json index b74b19a0c0..c589cbd425 100644 --- a/src/resources/app-fr.json +++ b/src/resources/app-fr.json @@ -557,8 +557,6 @@ "edit": "Modifier", "editAccess": "Modifier l'accès", "editTeamMember": "Modifier un membre de l'équipe", - "exportAuditLogs": "Audit des sécurité", - "exportResponseData": "Données de réponse", "removeTeamMember": "Supprimer un membre de l'équipe", "editAccessNoRespondent": "Veuillez ajouter l'accès pour examiner les données d'au moins un participant dans les {{titles}} pour le rôle de l'examinateur ou supprimer le rôle.", "editActivity": "Modifier l'activité", diff --git a/src/shared/components/DateRangePicker/DateRangePicker.tsx b/src/shared/components/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 0000000000..ac7cd3a87d --- /dev/null +++ b/src/shared/components/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { t } from 'i18next'; +import { addDays, endOfDay, startOfDay } from 'date-fns'; + +import { StyledFlexColumn, StyledFlexTopCenter } from 'shared/styles/styledComponents/Flex'; +import { StyledBodyLarge, theme } from 'shared/styles'; + +import { SelectController } from '../FormComponents'; +import { DateRangePickerType, DateRangePickerFormValues } from './DateRangePicker.types'; +import { getDateTypeOptions } from './DateRangePicker.utils'; +import { DatePicker } from '../DatePicker'; +import { DateType } from '../DatePicker/DatePicker.types'; + +interface DateRangePickerProps { + 'data-testid': string; + maxDate: Date; + minDate: Date; +} + +export const DateRangePicker = ({ + 'data-testid': dataTestid, + maxDate, + minDate, +}: DateRangePickerProps) => { + const { control, setValue, watch } = useFormContext(); + const dateType = watch('dateType'); + const fromDate = watch('fromDate'); + const toDate = watch('toDate'); + const hasCustomDate = dateType === DateRangePickerType.ChooseDates; + + const commonProps = { + maxDate, + control, + inputSx: { + '& .MuiInputLabel-outlined': { + textTransform: 'capitalize', + }, + }, + }; + + const normalizeFromDate = useCallback( + (date: DateType | undefined) => { + if (!date) return; + setValue('fromDate', startOfDay(date)); + }, + [setValue], + ); + + const normalizeToDate = useCallback( + (date: DateType | undefined) => { + if (!date) return; + setValue('toDate', endOfDay(date)); + }, + [setValue], + ); + + const onFromDatePickerClose = () => { + let newToDate = toDate; + if (toDate < fromDate) { + const increasedFromDate = addDays(fromDate, 1); + + newToDate = increasedFromDate <= maxDate ? increasedFromDate : maxDate; + } + normalizeToDate(newToDate); + }; + + useEffect(() => { + switch (dateType) { + case DateRangePickerType.AllTime: + normalizeFromDate(minDate); + normalizeToDate(maxDate); + break; + case DateRangePickerType.Last24h: + setValue('fromDate', addDays(maxDate, -1)); + setValue('toDate', maxDate); + break; + case DateRangePickerType.LastWeek: + normalizeFromDate(addDays(maxDate, -7)); + normalizeToDate(maxDate); + break; + case DateRangePickerType.LastMonth: + normalizeFromDate(addDays(maxDate, -30)); + normalizeToDate(maxDate); + break; + case DateRangePickerType.ChooseDates: + normalizeFromDate(minDate); + normalizeToDate(maxDate); + break; + } + }, [dateType, minDate, maxDate, normalizeFromDate, normalizeToDate, setValue]); + + return ( + <> + + + + {hasCustomDate && ( + + { + normalizeFromDate(date); + onFromDatePickerClose(); + }} + minDate={minDate} + label={t('startDate')} + data-testid={`${dataTestid}-from-date`} + inputWrapperSx={{ width: '100%' }} + /> + {t('smallTo')} + + + )} + + ); +}; diff --git a/src/shared/components/DateRangePicker/DateRangePicker.types.ts b/src/shared/components/DateRangePicker/DateRangePicker.types.ts new file mode 100644 index 0000000000..2394ea7606 --- /dev/null +++ b/src/shared/components/DateRangePicker/DateRangePicker.types.ts @@ -0,0 +1,13 @@ +export const enum DateRangePickerType { + AllTime = 'allTime', + Last24h = 'last24h', + LastWeek = 'lastWeek', + LastMonth = 'lastMonth', + ChooseDates = 'chooseDates', +} + +export type DateRangePickerFormValues = { + dateType: DateRangePickerType; + fromDate: Date; + toDate: Date; +}; diff --git a/src/shared/components/DateRangePicker/DateRangePicker.utils.ts b/src/shared/components/DateRangePicker/DateRangePicker.utils.ts new file mode 100644 index 0000000000..da58031f9b --- /dev/null +++ b/src/shared/components/DateRangePicker/DateRangePicker.utils.ts @@ -0,0 +1,28 @@ +import i18n from 'i18n'; + +import { DateRangePickerType } from './DateRangePicker.types'; + +const { t } = i18n; + +export const getDateTypeOptions = () => [ + { + value: DateRangePickerType.AllTime, + labelKey: t('exportDateRange.allTime'), + }, + { + value: DateRangePickerType.Last24h, + labelKey: t('exportDateRange.last24h'), + }, + { + value: DateRangePickerType.LastWeek, + labelKey: t('exportDateRange.lastWeek'), + }, + { + value: DateRangePickerType.LastMonth, + labelKey: t('exportDateRange.lastMonth'), + }, + { + value: DateRangePickerType.ChooseDates, + labelKey: t('exportDateRange.chooseDates'), + }, +]; diff --git a/src/shared/components/DateRangePicker/index.tsx b/src/shared/components/DateRangePicker/index.tsx new file mode 100644 index 0000000000..36654cd350 --- /dev/null +++ b/src/shared/components/DateRangePicker/index.tsx @@ -0,0 +1,3 @@ +export * from './DateRangePicker'; +export * from './DateRangePicker.types'; +export * from './DateRangePicker.utils'; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx new file mode 100644 index 0000000000..d8446f071c --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx @@ -0,0 +1,101 @@ +import { Button } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useFormContext } from 'react-hook-form'; + +import { Modal } from 'shared/components/Modal'; +import { Svg } from 'shared/components/Svg'; +import { + StyledBodyLarge, + StyledFlexAllCenter, + StyledFlexColumn, + StyledFlexTopCenter, + StyledModalWrapper, + StyledTitleBoldMedium, +} from 'shared/styles'; +import { DateRangePicker } from 'shared/components/DateRangePicker'; +import { CheckboxController } from 'shared/components/FormComponents'; + +import { AuditLogsExportPopupProps } from './AuditLogsExportPopup.types'; +import { + AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY, + AuditLogsExportFormValues, +} from '../AuditLogsExportSetting.types'; + +export const AuditLogsExportPopup = ({ + isOpen, + onClose, + onExport, + minDate, + maxDate, + contextItemName, + 'data-testid': dataTestId, +}: AuditLogsExportPopupProps) => { + const { t } = useTranslation('app'); + + const { control } = useFormContext(); + + return ( + + +
+ + + {t('dataExport.auditLogs.label')} + + {t('dataExport.auditLogs.title', { name: contextItemName })} + + + + + {t('dataExport.auditLogs.description')} + + + + + + {t(`dataExport.supplementaryFiles.description`)} + + + {t( + `dataExport.auditLogs.supplementaryFiles.includes.${AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY}`, + )} + + } + /> + + + + + + + +
+
+
+ ); +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts new file mode 100644 index 0000000000..ac6533237f --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts @@ -0,0 +1,9 @@ +export type AuditLogsExportPopupProps = { + isOpen: boolean; + onClose: () => void; + onExport: () => void; + minDate: Date; + maxDate: Date; + 'data-testid'?: string; + contextItemName: string; +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.schema.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.schema.ts new file mode 100644 index 0000000000..a69133369d --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.schema.ts @@ -0,0 +1,26 @@ +import * as yup from 'yup'; + +import i18n from 'i18n'; + +const dateSchema = (periodRequired: string) => + yup.date().when('dateType', ([dateType], schema) => { + if (dateType === 'chooseDates') { + return schema.required(periodRequired); + } + + return schema; + }); + +const { t } = i18n; +export const auditLogsExportSettingSchema = () => { + const fieldRequired = t('fieldRequired'); + const periodRequired = t('periodRequired'); + + return yup + .object({ + dateType: yup.string().required(fieldRequired), + fromDate: dateSchema(periodRequired), + toDate: dateSchema(periodRequired), + }) + .required(); +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx new file mode 100644 index 0000000000..be068a2291 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx @@ -0,0 +1,105 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { endOfDay, startOfDay } from 'date-fns'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ObjectSchema } from 'yup'; + +import { DataExportPopup } from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup'; +import { getNormalizedTimezoneDate } from 'shared/utils/dateTimezone'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; +import { applet } from 'shared/state/Applet'; + +import { + AuditLogsExportFormValues, + AuditLogsExportSettingProps, +} from './AuditLogsExportSetting.types'; +import { AuditLogsExportPopup } from './AuditLogsExportPopup/AuditLogsExportPopup'; +import { auditLogsExportSettingSchema } from './AuditLogsExportSetting.schema'; + +export const AuditLogsExportSetting = ({ + isExportSettingsOpen, + onExportSettingsClose, + onExportPopupClose, + chosenAppletData, + 'data-testid': dataTestId, +}: AuditLogsExportSettingProps) => { + const [dataIsExporting, setDataIsExporting] = useState(false); + const { result } = applet.useAppletData() ?? {}; + const appletData = chosenAppletData ?? result; + + const minDate = useMemo(() => new Date(appletData?.createdAt ?? ''), [appletData]); + const maxDate = useMemo(() => getNormalizedTimezoneDate(new Date().toString()), []); + + let appletName = ''; + let contextItemName = ''; + + if (appletData) { + if ('appletDisplayName' in appletData) { + appletName = appletData.appletDisplayName ?? ''; + } else if ('displayName' in appletData) { + appletName = appletData.displayName; + } + + contextItemName = appletName; + } + + const defaultValues: AuditLogsExportFormValues = useMemo( + () => ({ + dateType: DateRangePickerType.AllTime, + fromDate: startOfDay(minDate), + toDate: endOfDay(maxDate), + supplementaryFiles: { tsv: false }, + }), + [minDate, maxDate], + ); + const methods = useForm({ + resolver: yupResolver( + auditLogsExportSettingSchema() as ObjectSchema, + ), + defaultValues, + mode: 'onSubmit', + }); + + const resetDefaultValues = useCallback(() => { + methods.reset(defaultValues); + }, [defaultValues, methods]); + + useEffect(() => { + resetDefaultValues(); + }, [resetDefaultValues]); + + return ( + + {isExportSettingsOpen && ( + { + resetDefaultValues(); + onExportSettingsClose(); + }} + onExport={() => { + setDataIsExporting(true); + onExportSettingsClose(); + }} + minDate={minDate} + maxDate={maxDate} + data-testid={`${dataTestId}-settings`} + /> + )} + {dataIsExporting && ( + { + resetDefaultValues(); + onExportPopupClose?.(); + }} + /> + )} + + ); +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.types.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.types.ts new file mode 100644 index 0000000000..04ad709ae1 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.types.ts @@ -0,0 +1,22 @@ +import { ChosenAppletData } from 'modules/Dashboard/features'; +import { DateRangePickerFormValues } from 'shared/components/DateRangePicker'; +import { SingleApplet } from 'shared/state'; + +/** Checkbox field segment under `supplementaryFiles` */ +export const AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY = 'tsv' as const; + +export type AuditLogsSupplementaryFilesFormValues = { + [AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY]: boolean; +}; + +export type AuditLogsExportFormValues = DateRangePickerFormValues & { + supplementaryFiles: AuditLogsSupplementaryFilesFormValues; +}; + +export type AuditLogsExportSettingProps = { + isExportSettingsOpen: boolean; + onExportSettingsClose: () => void; + onExportPopupClose: () => void; + 'data-testid'?: string; + chosenAppletData?: ChosenAppletData | SingleApplet | null; +}; diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx index 12b3ec3e95..a955238c78 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx @@ -153,6 +153,8 @@ export const ExportSettingsPopup = ({ fullWidth /> + + {/* Date range picker */} {hasCustomDate && ( )} + {/* End of date range picker */} + {filteredSupplementaryFiles.length > 0 && ( {t(`dataExport.supplementaryFiles.description`)} diff --git a/src/shared/utils/mixpanel/mixpanel.types.ts b/src/shared/utils/mixpanel/mixpanel.types.ts index 88c231db58..0a658d6dc3 100644 --- a/src/shared/utils/mixpanel/mixpanel.types.ts +++ b/src/shared/utils/mixpanel/mixpanel.types.ts @@ -59,6 +59,7 @@ export enum MixpanelEventType { AppletEditSuccessful = 'Applet edit successful', AppletCreatedSuccessfully = 'Applet Created Successfully', ExportDataClick = 'Export Data click', + ExportAuditLogsClick = 'Export Audit Logs click', TakeNowDialogClosed = 'Take Now dialogue closed', MultiInformantStartActivityClick = 'Multi-informant Start Activity click', ProvidingResponsesDropdownOpened = '"Who will be providing responses" dropdown opened', @@ -228,6 +229,10 @@ export type ExportDataClickEvent = WithAppletId<{ action: MixpanelEventType.ExportDataClick; }>; +export type ExportAuditLogsClickEvent = WithAppletId<{ + action: MixpanelEventType.ExportAuditLogsClick; +}>; + type TakeNowEvent = WithFeature< WithAppletId<{ [MixpanelProps.MultiInformantAssessmentId]?: string | null; @@ -513,6 +518,7 @@ export type MixpanelEvent = | AppletEditSuccessfulEvent | AppletCreatedSuccessfullyEvent | ExportDataClickEvent + | ExportAuditLogsClickEvent | TakeNowDialogClosedEvent | MultiInformantStartActivityClickEvent | ProvidingResponsesDropdownOpenedEvent From 36f4828293574c9687fd8a7c6eadd906e54a5d05 Mon Sep 17 00:00:00 2001 From: adeiji Date: Fri, 8 May 2026 11:41:22 -0700 Subject: [PATCH 04/28] chore: added tests for DateRangePicker --- .../DateRangePicker/DateRangePicker.test.tsx | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/shared/components/DateRangePicker/DateRangePicker.test.tsx diff --git a/src/shared/components/DateRangePicker/DateRangePicker.test.tsx b/src/shared/components/DateRangePicker/DateRangePicker.test.tsx new file mode 100644 index 0000000000..d2edab13ba --- /dev/null +++ b/src/shared/components/DateRangePicker/DateRangePicker.test.tsx @@ -0,0 +1,271 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { addDays, endOfDay, startOfDay } from 'date-fns'; +import type { MutableRefObject } from 'react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm, UseFormReturn } from 'react-hook-form'; + +import { DateRangePicker } from './DateRangePicker'; +import { DateRangePickerFormValues, DateRangePickerType } from './DateRangePicker.types'; + +const testId = 'date-range-test'; + +const minDate = new Date('2025-07-01T08:00:00.000Z'); +const maxDate = new Date('2025-07-15T12:00:00.000Z'); + +type DateRangePickerHarnessProps = { + defaultDateType?: DateRangePickerType; + formRef?: MutableRefObject | null>; +}; + +const DateRangePickerHarness = ({ + defaultDateType = DateRangePickerType.AllTime, + formRef, +}: DateRangePickerHarnessProps) => { + + // useForm runs in the harness; FormProvider shares that instance with descendants so + // DateRangePicker can call useFormContext() and attach to the same form. + const methods = useForm({ + defaultValues: { + dateType: defaultDateType, + fromDate: minDate, + toDate: maxDate, + }, + }); + + if (formRef) { + formRef.current = methods; + } + + return ( + + + + ); +}; + +async function renderWithForm( + defaultDateType: DateRangePickerType = DateRangePickerType.AllTime, +): Promise> { + const formRef: MutableRefObject | null> = { + current: null, + }; + + render(); + + await waitFor(() => expect(formRef.current).not.toBeNull()); + + return formRef.current as UseFormReturn; +} + +async function selectDateRangeOption(dateType: DateRangePickerType) { + const dateRangeSelect = screen.getByTestId(`${testId}-date-range-picker`); + const combobox = dateRangeSelect.querySelector('[role="combobox"]'); + expect(combobox).toBeTruthy(); + fireEvent.mouseDown(combobox as Element); + + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBeGreaterThan(0); + }); + + const targetOption = screen + .getAllByRole('option') + .find((option) => option.getAttribute('data-value') === dateType); + + expect(targetOption).toBeDefined(); + fireEvent.click(targetOption as Element); +} + +async function openAndCloseDatePicker(pickerTestId: string) { + const pickerInput = screen.getByTestId(pickerTestId).querySelector('input'); + expect(pickerInput).toBeTruthy(); + + await userEvent.click(pickerInput as HTMLInputElement); + + await waitFor(() => { + expect(screen.getByTestId(`${pickerTestId}-popover`)).toBeInTheDocument(); + }); + + await userEvent.keyboard('{Escape}'); +} + +describe('DateRangePicker', () => { + it('should render date range select', () => { + render(); + + expect(screen.getByTestId(`${testId}-date-range-picker`)).toBeInTheDocument(); + }); + + it('sets from/to for All time preset', async () => { + const form = await renderWithForm(DateRangePickerType.Last24h); + await selectDateRangeOption(DateRangePickerType.AllTime); + + await waitFor(() => { + const { fromDate, toDate } = form.getValues(); + expect(fromDate).toEqual(startOfDay(minDate)); + expect(toDate).toEqual(endOfDay(maxDate)); + }); + }); + + it('should show from and to date pickers when Custom Date (choose dates) is selected', async () => { + render(); + + expect(screen.queryByTestId(`${testId}-from-date`)).not.toBeInTheDocument(); + expect(screen.queryByTestId(`${testId}-to-date`)).not.toBeInTheDocument(); + + await selectDateRangeOption(DateRangePickerType.ChooseDates); + + await waitFor(() => { + expect(screen.getByTestId(`${testId}-from-date`)).toBeVisible(); + expect(screen.getByTestId(`${testId}-to-date`)).toBeVisible(); + }); + }); + + it('does not render from/to date pickers when dateType is AllTime', async () => { + render(); + await selectDateRangeOption(DateRangePickerType.AllTime) + expect(screen.queryByTestId(`${testId}-from-date`)).not.toBeInTheDocument(); + }); + + it('sets from/to values when dateType changes to Last24h', async () => { + const form = await renderWithForm(DateRangePickerType.Last24h); + + await waitFor(() => { + const { fromDate, toDate } = form.getValues(); + expect(fromDate).toEqual(addDays(maxDate, -1)); + expect(toDate).toEqual(maxDate); + }); + }); + + it('sets from/to values when dateType changes to LastWeek', async () => { + const form = await renderWithForm(DateRangePickerType.Last24h); + await selectDateRangeOption(DateRangePickerType.LastWeek); + + await waitFor(() => { + const { fromDate, toDate } = form.getValues(); + expect(fromDate).toEqual(startOfDay(addDays(maxDate, -7))); + expect(toDate).toEqual(endOfDay(maxDate)); + }); + }); + + it('sets from/to values when dateType changes to LastMonth', async () => { + const form = await renderWithForm(DateRangePickerType.Last24h); + await selectDateRangeOption(DateRangePickerType.LastMonth); + + await waitFor(() => { + const { fromDate, toDate } = form.getValues(); + expect(fromDate).toEqual(startOfDay(addDays(maxDate, -30))); + expect(toDate).toEqual(endOfDay(maxDate)); + }); + }); + + it('clamps/adjusts toDate when fromDate is set after toDate (on from picker close)', async () => { + const form = await renderWithForm(DateRangePickerType.ChooseDates); + + const fromTestDate = new Date('2025-07-13T15:30:00.000Z'); + const toTestDate = new Date('2025-07-12T10:00:00.000Z'); // toDate < fromDate + + act(() => { + form.setValue('fromDate', fromTestDate); + form.setValue('toDate', toTestDate); + }); + + await openAndCloseDatePicker(`${testId}-from-date`); + + await waitFor(() => { + const { toDate } = form.getValues(); + const expectedTo = endOfDay(addDays(fromTestDate, 1)); + expect(toDate).toEqual(expectedTo); + }); + }); + + it('unmounts from/to date pickers when switching away from ChooseDates', async () => { + render(); + + await selectDateRangeOption(DateRangePickerType.ChooseDates); + expect(screen.getByTestId(`${testId}-from-date`)).toBeVisible(); + expect(screen.getByTestId(`${testId}-to-date`)).toBeVisible(); + + await selectDateRangeOption(DateRangePickerType.AllTime); + + await waitFor(() => { + expect(screen.queryByTestId(`${testId}-from-date`)).not.toBeInTheDocument(); + expect(screen.queryByTestId(`${testId}-to-date`)).not.toBeInTheDocument(); + }); + }); + + it('keeps from/to within minDate/maxDate bounds', async () => { + const form = await renderWithForm(DateRangePickerType.AllTime); + await selectDateRangeOption(DateRangePickerType.ChooseDates); + + await waitFor(() => { + const { fromDate, toDate } = form.getValues(); + expect(fromDate).toEqual(startOfDay(minDate)); + expect(toDate).toEqual(endOfDay(maxDate)); + }); + }); + + it('clamps toDate to maxDate when fromDate is at maxDate boundary', async () => { + const form = await renderWithForm(DateRangePickerType.ChooseDates); + + // Set fromDate to maxDate so addDays(fromDate, 1) exceeds maxDate + act(() => { + form.setValue('fromDate', maxDate); + form.setValue('toDate', new Date('2025-07-10T10:00:00.000Z')); + }); + + await openAndCloseDatePicker(`${testId}-from-date`); + + await waitFor(() => { + const { toDate } = form.getValues(); + expect(toDate).toEqual(endOfDay(maxDate)); + }); + }); + + it('normalizes toDate to endOfDay when to date picker closes', async () => { + const form = await renderWithForm(DateRangePickerType.ChooseDates); + + const midDayDate = new Date('2025-07-10T14:30:00.000Z'); + act(() => { + form.setValue('toDate', midDayDate); + }); + + await openAndCloseDatePicker(`${testId}-to-date`); + + await waitFor(() => { + const { toDate } = form.getValues(); + expect(toDate).toEqual(endOfDay(midDayDate)); + }); + }); + + it('restricts to date picker minDate to current fromDate', async () => { + const form = await renderWithForm(DateRangePickerType.ChooseDates); + + // Set fromDate to July 10 so days before July 10 should be disabled in the to picker + act(() => { + form.setValue('fromDate', new Date('2025-07-10T00:00:00.000Z')); + }); + + const toDateInput = screen.getByTestId(`${testId}-to-date`).querySelector('input'); + expect(toDateInput).toBeTruthy(); + + await userEvent.click(toDateInput as HTMLInputElement); + + await waitFor(() => { + expect(screen.getByTestId(`${testId}-to-date-popover`)).toBeInTheDocument(); + }); + + const popover = screen.getByTestId(`${testId}-to-date-popover`); + const disabledDays = popover.querySelectorAll('.react-datepicker__day--disabled'); + expect(disabledDays.length).toBeGreaterThan(0); + + // July 9 (before fromDate) should be disabled + const day9 = popover.querySelector('.react-datepicker__day--009'); + expect(day9).toBeTruthy(); + expect((day9 as Element).classList.contains('react-datepicker__day--disabled')).toBe(true); + + // July 10 (fromDate itself) should not be disabled + const day10 = popover.querySelector('.react-datepicker__day--010'); + expect(day10).toBeTruthy(); + expect((day10 as Element).classList.contains('react-datepicker__day--disabled')).toBe(false); + }); +}); From 45733c475e033cfbad0936ee8159d13cb8adad4c Mon Sep 17 00:00:00 2001 From: adeiji Date: Fri, 8 May 2026 11:47:13 -0700 Subject: [PATCH 05/28] refactor: Replace ExportSettingsPopup date logic with shared DateRangePicker component Remove ExportDateType in favor of DateRangePickerType and replaced inline date range logic with the reusable DateRangePicker component. Duplicate date-related tests removed as they are now covered by DateRangePicker.test.tsx. --- .../ExportDataSetting.test.tsx | 12 +- .../ExportDataSetting/ExportDataSetting.tsx | 5 +- .../ExportDataSetting.types.ts | 14 +- .../DataExportPopup/DataExportPopup.types.ts | 4 +- .../DataExportPopup.utils.test.ts | 14 +- .../DataExportPopup/DataExportPopup.utils.ts | 4 +- .../DataExportPopup/DataExportPopup_old.tsx | 4 +- .../ExportSettingsPopup.test.tsx | 227 ++---------------- .../ExportSettingsPopup.tsx | 127 +--------- .../ExportSettingsPopup.utils.ts | 25 +- 10 files changed, 51 insertions(+), 385 deletions(-) diff --git a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx index b2337f3416..79624e6c36 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.test.tsx @@ -9,7 +9,7 @@ import { renderWithProviders } from 'shared/utils/renderWithProviders'; import * as encryptionFunctions from 'shared/utils/encryption'; import { ExportDataSetting } from './ExportDataSetting'; -import { ExportDateType } from './ExportDataSetting.types'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; const createdDate = '2023-11-14T14:43:33.369902'; // Set a fixed "now" date for consistent test results @@ -100,10 +100,10 @@ describe('ExportDataSetting', () => { describe('should pass settings specified in settings popup to the export popup', () => { test.each` exportType | expectedFromTime | description - ${ExportDateType.AllTime} | ${startOfDay(new Date(createdDate))} | ${'use applet create time and now for all time'} - ${ExportDateType.Last24h} | ${addDays(mockedNow, -1)} | ${'use correct dates for last 24h'} - ${ExportDateType.LastWeek} | ${startOfDay(addDays(mockedNow, -7))} | ${'use correct dates for last week'} - ${ExportDateType.LastMonth} | ${startOfDay(addDays(mockedNow, -30))} | ${'use correct dates for last month'} + ${DateRangePickerType.AllTime} | ${startOfDay(new Date(createdDate))} | ${'use applet create time and now for all time'} + ${DateRangePickerType.Last24h} | ${addDays(mockedNow, -1)} | ${'use correct dates for last 24h'} + ${DateRangePickerType.LastWeek} | ${startOfDay(addDays(mockedNow, -7))} | ${'use correct dates for last week'} + ${DateRangePickerType.LastMonth} | ${startOfDay(addDays(mockedNow, -30))} | ${'use correct dates for last month'} `('$description', async ({ exportType, expectedFromTime }) => { const mockOnClose = vi.fn(); @@ -128,7 +128,7 @@ describe('ExportDataSetting', () => { expect(screen.queryByTestId(`${dataTestId}-settings`)).toBeInTheDocument(), ); - const dateTypeField = screen.getByTestId(`${`${dataTestId}-settings`}-dateType`); + const dateTypeField = screen.getByTestId(`${`${dataTestId}-settings`}-date-range-picker`); expect(dateTypeField).toBeVisible(); // Open the select dropdown diff --git a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx index b6aab37cbf..aa04a23236 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.tsx @@ -12,11 +12,12 @@ import { UniqueTuple } from 'shared/types'; import { getNormalizedTimezoneDate } from 'shared/utils/dateTimezone'; import { exportDataSettingSchema } from './ExportDataSetting.schema'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; + import { ExportDataExported, ExportDataFormValues, ExportDataSettingProps, - ExportDateType, SupplementaryFiles, SupplementaryFilesWithFeatureFlag, } from './ExportDataSetting.types'; @@ -46,7 +47,7 @@ export const ExportDataSetting = ({ dataExported: canExportEhrHealthData ? ExportDataExported.ResponsesAndEhrData : ExportDataExported.ResponsesOnly, - dateType: ExportDateType.AllTime, + dateType: DateRangePickerType.AllTime, fromDate: startOfDay(minDate), toDate: endOfDay(maxDate), supplementaryFiles: SupplementaryFiles.reduce( diff --git a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types.ts b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types.ts index 99d22e6345..ea6801cff5 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types.ts +++ b/src/shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types.ts @@ -1,16 +1,9 @@ import { ChosenAppletData } from 'modules/Dashboard/features'; +import { DateRangePickerFormValues } from 'shared/components/DateRangePicker'; import { SingleApplet } from 'shared/state'; import { UniqueTuple } from 'shared/types'; import { ExportDataFilters } from 'shared/utils'; -export const enum ExportDateType { - AllTime = 'allTime', - Last24h = 'last24h', - LastWeek = 'lastWeek', - LastMonth = 'lastMonth', - ChooseDates = 'chooseDates', -} - export const enum ExportDataExported { ResponsesOnly = 'responsesOnly', ResponsesAndEhrData = 'responsesAndEhrData', @@ -35,11 +28,8 @@ export type SupplementaryFilesFormValues = { [key in SupplementaryFiles]: boolean; }; -export type ExportDataFormValues = { +export type ExportDataFormValues = DateRangePickerFormValues & { dataExported: ExportDataExported; - dateType: ExportDateType; - fromDate: Date; - toDate: Date; supplementaryFiles: SupplementaryFilesFormValues; }; diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.types.ts b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.types.ts index 68b4662cad..5769377495 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.types.ts +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.types.ts @@ -2,7 +2,7 @@ import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { SingleApplet } from 'shared/state'; import { Encryption, ExportDataFilters } from 'shared/utils'; -import { ExportDateType } from 'shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; import { ChosenAppletData } from 'modules/Dashboard/features/Respondents/Respondents.types'; export type DataExportPopupProps = { @@ -33,7 +33,7 @@ export type ExportDataProps = ExecuteAllPagesOfExportData & { }; export type GetFormattedToDate = { - dateType?: ExportDateType; + dateType?: DateRangePickerType; formToDate?: Date; }; diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.test.ts b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.test.ts index 44f0b3357d..a2213e051e 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.test.ts +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.test.ts @@ -1,6 +1,6 @@ import { format } from 'date-fns'; -import { ExportDateType } from 'shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; import { getNormalizedTimezoneDate } from 'shared/utils/dateTimezone'; import { DateFormats } from 'shared/consts'; import { getFormattedToDate } from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils'; @@ -12,29 +12,29 @@ describe('getFormattedToDate', () => { test.each([ { dateType: undefined, formToDate: new Date('2023-06-22T00:00:00Z'), expected: undefined }, - { dateType: ExportDateType.ChooseDates, formToDate: undefined, expected: undefined }, + { dateType: DateRangePickerType.ChooseDates, formToDate: undefined, expected: undefined }, { - dateType: ExportDateType.AllTime, + dateType: DateRangePickerType.AllTime, formToDate, expected: formattedUtcDate, }, { - dateType: ExportDateType.Last24h, + dateType: DateRangePickerType.Last24h, formToDate, expected: formattedUtcDate, }, { - dateType: ExportDateType.LastMonth, + dateType: DateRangePickerType.LastMonth, formToDate, expected: formattedUtcDate, }, { - dateType: ExportDateType.ChooseDates, + dateType: DateRangePickerType.ChooseDates, formToDate, expected: format(formToDate, DateFormats.shortISO), }, { - dateType: ExportDateType.ChooseDates, + dateType: DateRangePickerType.ChooseDates, formToDate: getNormalizedTimezoneDate(new Date().toString()), expected: formattedUtcDate, }, diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.ts b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.ts index 5dacf3de06..d20740e204 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.ts +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.utils.ts @@ -1,6 +1,6 @@ import { format } from 'date-fns'; -import { ExportDateType } from 'shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; import { getNormalizedTimezoneDate } from 'shared/utils'; import { DateFormats } from 'shared/consts'; import { GetFormattedToDate } from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup.types'; @@ -16,7 +16,7 @@ export const getFormattedToDate = ({ const utcDate = getNormalizedTimezoneDate(new Date().toString()); const formattedUtcDate = format(utcDate, DateFormats.shortISO); - if (dateType !== ExportDateType.ChooseDates) { + if (dateType !== DateRangePickerType.ChooseDates) { return formattedUtcDate; } else if (formToDate) { return format(formToDate, DateFormats.DayMonthYear) === diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx index f34a363102..257529a33a 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx @@ -12,10 +12,10 @@ import { useDecryptedActivityData } from 'modules/Dashboard/hooks'; import { Modal } from 'shared/components/Modal'; import { EnterAppletPassword } from 'shared/components/Password'; import { DateFormats } from 'shared/consts'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; import { ExportDataExported, ExportDataFormValues, - ExportDateType, } from 'shared/features/AppletSettings/ExportDataSetting/ExportDataSetting.types'; import { DataExportPopupProps, @@ -139,7 +139,7 @@ export const DataExportPopup = ({ // Update the time for last 24 hours submissions // Converting to UTC because backend expects UTC but is not timezone-aware - if (dateType === ExportDateType.Last24h) { + if (dateType === DateRangePickerType.Last24h) { const currentTime = new Date(); const oneDayAgo = new Date(currentTime); oneDayAgo.setHours(currentTime.getHours() - 24); diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx index a6f0381063..807266fee2 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.test.tsx @@ -12,10 +12,11 @@ import { SettingParam } from 'shared/utils'; import { renderWithProviders } from 'shared/utils/renderWithProviders'; import { DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP } from '../../ExportDataSetting.const'; +import { DateRangePickerType } from 'shared/components/DateRangePicker'; + import { ExportDataExported, ExportDataFormValues, - ExportDateType, SupplementaryFiles, } from '../../ExportDataSetting.types'; import { ExportSettingsPopup } from './ExportSettingsPopup'; @@ -32,8 +33,6 @@ const preloadedState = { }, }; -const mockDateString = '2025-07-107T12:30:45'; - const mockOnClose = vi.fn(); const mockOnExport = vi.fn(); @@ -46,7 +45,7 @@ const FormComponent = ({ children, formRef }: FormComponentProps) => { const methods = useForm({ defaultValues: { dataExported: ExportDataExported.ResponsesOnly, - dateType: ExportDateType.AllTime, + dateType: DateRangePickerType.AllTime, fromDate: date, toDate: new Date(), supplementaryFiles: SupplementaryFiles.reduce( @@ -121,10 +120,10 @@ describe('ExportSettingsPopup', () => { describe('should appear export data popup for date range', () => { test.each` exportDataType | description - ${ExportDateType.Last24h} | ${'last 24h'} - ${ExportDateType.LastMonth} | ${'last month'} - ${ExportDateType.LastWeek} | ${'last week'} - ${ExportDateType.AllTime} | ${'all time'} + ${DateRangePickerType.Last24h} | ${'last 24h'} + ${DateRangePickerType.LastMonth} | ${'last month'} + ${DateRangePickerType.LastWeek} | ${'last week'} + ${DateRangePickerType.AllTime} | ${'all time'} `('$description', async ({ exportDataType }) => { renderWithProviders( @@ -134,7 +133,7 @@ describe('ExportSettingsPopup', () => { preloadedState, }, ); - const dateType = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-dateType`); + const dateType = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-date-range-picker`); const input = dateType.querySelector('input'); input && fireEvent.change(input, { target: { value: exportDataType } }); @@ -143,208 +142,10 @@ describe('ExportSettingsPopup', () => { }); }); - describe('should update the toDate for every date range export', () => { - // Skip these tests as they require complex Date mocking that conflicts with React 18 - // The Date constructor mocking breaks jsdom's internal event system (Date.now) - test.each` - exportDataType | description - ${ExportDateType.Last24h} | ${'last 24h'} - ${ExportDateType.LastMonth} | ${'last month'} - ${ExportDateType.LastWeek} | ${'last week'} - ${ExportDateType.AllTime} | ${'all time'} - `('$description', async ({ exportDataType }) => { - // Use vi.setSystemTime instead of mocking Date constructor - vi.useFakeTimers(); - vi.setSystemTime(new Date(mockDateString)); - - const formRef = { - current: null, - } as React.MutableRefObject | null>; - - await act(async () => { - renderWithProviders( - - - , - { - preloadedState, - }, - ); - }); - - const dateType = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-dateType`); - const input = dateType.querySelector('input'); - - await act(async () => { - input && fireEvent.change(input, { target: { value: exportDataType } }); - }); - - vi.useRealTimers(); - - // Wait for form to update after date type change - await waitFor(() => { - expect(input?.value).toBe(exportDataType); - }); - - const downloadBtn = screen.getByText('Download'); - - // Capture toDate before first click - const toDateBefore = formRef.current?.getValues().toDate.toString(); - - await fireEvent.click(downloadBtn); - expect(mockOnExport).toHaveBeenCalled(); - - // Verify that the date was captured correctly - // Since maxDate prop doesn't change, all export types will use the same toDate - expect(toDateBefore).toBeDefined(); - expect(toDateBefore?.length).toBeGreaterThan(0); - }); - }); - - describe('start/end of day processing', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - test.each` - exportDataType | description - ${ExportDateType.AllTime} | ${'all time'} - ${ExportDateType.LastMonth} | ${'last month'} - ${ExportDateType.LastWeek} | ${'last week'} - ${ExportDateType.ChooseDates} | ${'choose dates'} - `('initial normalization - $description', async ({ exportDataType }) => { - vi.setSystemTime(new Date(mockDateString)); - - const formRef = { - current: null, - } as React.MutableRefObject | null>; - await act(async () => { - renderWithProviders( - - - , - { preloadedState }, - ); - }); - - vi.useRealTimers(); - - // Wait for initial render and date normalization - await waitFor(() => { - expect(formRef.current).not.toBeNull(); - }); - - // Change to the specific date type to trigger normalization - const dateTypeInput = screen - .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-dateType`) - .querySelector('input'); - if (dateTypeInput && exportDataType !== ExportDateType.AllTime) { - await act(async () => { - fireEvent.change(dateTypeInput, { target: { value: exportDataType } }); - }); - } - - // Get form values directly (no need to wait as useEffect already ran) - const values = formRef.current?.getValues(); - expect(values).toBeDefined(); - const { fromDate, toDate } = values as ExportDataFormValues; - - expect(fromDate.getHours()).toBe(0); - expect(fromDate.getMinutes()).toBe(0); - expect(fromDate.getSeconds()).toBe(0); - expect(toDate.getHours()).toBe(23); - expect(toDate.getMinutes()).toBe(59); - expect(toDate.getSeconds()).toBe(59); - }); - }); - - it('should normalize choose dates after interaction', async () => { - const formRef = { - current: null, - } as React.MutableRefObject | null>; - - await act(async () => { - renderWithProviders( - - - , - { preloadedState }, - ); - }); - - // Wait for initial render - await waitFor(() => { - expect(formRef.current).not.toBeNull(); - }); - - // Switch to ChooseDates - const dateTypeInput = screen - .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-dateType`) - .querySelector('input'); - dateTypeInput && - fireEvent.change(dateTypeInput, { target: { value: ExportDateType.ChooseDates } }); - - // Wait for the form to update after date type change - await waitFor(() => { - expect(dateTypeInput?.value).toBe(ExportDateType.ChooseDates); - }); - - const testFromDate = new Date('2025-07-15T14:30:00'); - const testToDate = new Date('2025-07-20T16:45:00'); - - // Manually set the dates (simulating what happens during date selection) - act(() => { - formRef.current?.setValue('fromDate', testFromDate); - formRef.current?.setValue('toDate', testToDate); - }); - - // Trigger the normalization by simulating a popover close event - const fromDateInput = screen - .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-from-date`) - .querySelector('input'); - const toDateInput = screen - .getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-to-date`) - .querySelector('input'); - - if (fromDateInput && toDateInput) { - // Open and close the fromDate picker to trigger normalization - await userEvent.click(fromDateInput); - - // Wait for popover to open - await waitFor(() => { - expect( - screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-from-date-popover`), - ).toBeInTheDocument(); - }); - - // Close the popover by pressing Escape - this should trigger onCloseCallback - await userEvent.keyboard('{Escape}'); - - // Open and close the toDate picker to trigger normalization - await userEvent.click(toDateInput); - - // Wait for popover to open - await waitFor(() => { - expect( - screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-to-date-popover`), - ).toBeInTheDocument(); - }); - - // Close the popover by pressing Escape - this should trigger onCloseCallback - await userEvent.keyboard('{Escape}'); - - // Wait for normalization to complete - await waitFor(() => { - const values = formRef.current?.getValues(); - expect(values?.fromDate.getHours()).toBe(0); - expect(values?.toDate.getHours()).toBe(23); - }); - } - }); + // Date preset value tests (toDate per preset), start/end-of-day normalization tests, + // and choose-dates picker interaction tests were removed because the date range logic + // now lives in the shared DateRangePicker component and is covered by + // DateRangePicker.test.tsx. describe("should appear export data popup for 'choose dates' date range", () => { test.each` @@ -360,12 +161,12 @@ describe('ExportSettingsPopup', () => { { preloadedState, route, routePath }, ); }); - const dateType = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-dateType`); + const dateType = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-date-range-picker`); expect(dateType).toBeVisible(); expect(screen.getByTestId(DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP)).toBeVisible(); const input = dateType.querySelector('input'); - input && fireEvent.change(input, { target: { value: ExportDateType.ChooseDates } }); + input && fireEvent.change(input, { target: { value: DateRangePickerType.ChooseDates } }); const fromDate = screen.getByTestId(`${DATA_TESTID_EXPORT_DATA_SETTINGS_POPUP}-from-date`); const fromDateInput = fromDate.querySelector('input'); diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx index a955238c78..048f2ccb07 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx @@ -1,11 +1,9 @@ import { Button } from '@mui/material'; -import { addDays, endOfDay, startOfDay } from 'date-fns'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { DatePicker } from 'shared/components/DatePicker'; -import { DateType } from 'shared/components/DatePicker/DatePicker.types'; +import { DateRangePicker } from 'shared/components/DateRangePicker'; import { CheckboxController, SelectController } from 'shared/components/FormComponents'; import { Modal } from 'shared/components/Modal'; import { Svg } from 'shared/components/Svg'; @@ -16,16 +14,14 @@ import { StyledFlexTopCenter, StyledModalWrapper, StyledTitleBoldMedium, - theme, } from 'shared/styles'; import { ExportDataFormValues, - ExportDateType, SupplementaryFilesFormValues, } from '../../ExportDataSetting.types'; import { ExportSettingsPopupProps } from './ExportSettingsPopup.types'; -import { getDataExportedOptions, getDateTypeOptions } from './ExportSettingsPopup.utils'; +import { getDataExportedOptions } from './ExportSettingsPopup.utils'; export const ExportSettingsPopup = ({ isOpen, @@ -40,73 +36,8 @@ export const ExportSettingsPopup = ({ }: ExportSettingsPopupProps) => { const { t } = useTranslation('app'); - const { control, setValue, watch } = useFormContext() ?? {}; - const dateType = watch('dateType'); - const fromDate = watch('fromDate'); - const toDate = watch('toDate'); + const { control, watch } = useFormContext() ?? {}; const supplementaryFiles = watch('supplementaryFiles'); - const hasCustomDate = dateType === ExportDateType.ChooseDates; - - const commonProps = { - maxDate, - control, - inputSx: { - '& .MuiInputLabel-outlined': { - textTransform: 'capitalize', - }, - }, - }; - - const normalizeFromDate = useCallback( - (date: DateType | undefined) => { - if (!date) return; - setValue('fromDate', startOfDay(date)); - }, - [setValue], - ); - - const normalizeToDate = useCallback( - (date: DateType | undefined) => { - if (!date) return; - setValue('toDate', endOfDay(date)); - }, - [setValue], - ); - - const onFromDatePickerClose = () => { - let newToDate = toDate; - if (toDate < fromDate) { - const increasedFromDate = addDays(fromDate, 1); - - newToDate = increasedFromDate <= maxDate ? increasedFromDate : maxDate; - } - normalizeToDate(newToDate); - }; - - useEffect(() => { - switch (dateType) { - case ExportDateType.AllTime: - normalizeFromDate(minDate); - normalizeToDate(maxDate); - break; - case ExportDateType.Last24h: - setValue('fromDate', addDays(maxDate, -1)); - setValue('toDate', maxDate); - break; - case ExportDateType.LastWeek: - normalizeFromDate(addDays(maxDate, -7)); - normalizeToDate(maxDate); - break; - case ExportDateType.LastMonth: - normalizeFromDate(addDays(maxDate, -30)); - normalizeToDate(maxDate); - break; - case ExportDateType.ChooseDates: - normalizeFromDate(minDate); - normalizeToDate(maxDate); - break; - } - }, [dateType, minDate, maxDate, normalizeFromDate, normalizeToDate, setValue]); const filteredSupplementaryFiles = useMemo( () => @@ -133,8 +64,8 @@ export const ExportSettingsPopup = ({ {contextItemName} {t('dataExport.responses')} - - {canExportEhrHealthData && ( + {canExportEhrHealthData && ( + - )} - - - - {/* Date range picker */} - {hasCustomDate && ( - - { - normalizeFromDate(date); - onFromDatePickerClose(); - }} - label={t('startDate')} - minDate={minDate} - data-testid={`${dataTestId}-from-date`} - inputWrapperSx={{ width: '100%' }} - /> - - {t('smallTo')} - - - + )} - {/* End of date range picker */} + {filteredSupplementaryFiles.length > 0 && ( diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.utils.ts b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.utils.ts index b7e93af50e..549b0a1f34 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.utils.ts +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.utils.ts @@ -1,32 +1,9 @@ import i18n from 'i18n'; -import { ExportDateType, ExportDataExported } from '../../ExportDataSetting.types'; +import { ExportDataExported } from '../../ExportDataSetting.types'; const { t } = i18n; -export const getDateTypeOptions = () => [ - { - value: ExportDateType.AllTime, - labelKey: t('exportDateRange.allTime'), - }, - { - value: ExportDateType.Last24h, - labelKey: t('exportDateRange.last24h'), - }, - { - value: ExportDateType.LastWeek, - labelKey: t('exportDateRange.lastWeek'), - }, - { - value: ExportDateType.LastMonth, - labelKey: t('exportDateRange.lastMonth'), - }, - { - value: ExportDateType.ChooseDates, - labelKey: t('exportDateRange.chooseDates'), - }, -]; - export const getDataExportedOptions = () => [ { value: ExportDataExported.ResponsesOnly, From c949200665d15af4d479e21bd3669b018c55f2d5 Mon Sep 17 00:00:00 2001 From: adeiji Date: Fri, 8 May 2026 18:09:48 -0700 Subject: [PATCH 06/28] chore: removed security audit logs export from team members action menu --- src/modules/Dashboard/features/Managers/Managers.test.tsx | 1 - src/modules/Dashboard/features/Managers/Managers.tsx | 3 --- src/modules/Dashboard/features/Managers/Managers.types.ts | 1 - .../Dashboard/features/Managers/Managers.utils.test.tsx | 3 --- .../Dashboard/features/Managers/Managers.utils.tsx | 8 -------- 5 files changed, 16 deletions(-) diff --git a/src/modules/Dashboard/features/Managers/Managers.test.tsx b/src/modules/Dashboard/features/Managers/Managers.test.tsx index ca7a6d4f79..99e65efe65 100644 --- a/src/modules/Dashboard/features/Managers/Managers.test.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.test.tsx @@ -112,7 +112,6 @@ describe('Managers component tests', () => { const actionsDataTestIds = [ 'dashboard-managers-edit-user', 'dashboard-managers-remove-access', - 'dashboard-managers-export-audit-logs', ]; await waitFor(() => { diff --git a/src/modules/Dashboard/features/Managers/Managers.tsx b/src/modules/Dashboard/features/Managers/Managers.tsx index a5b1db8f72..e68d2539e7 100644 --- a/src/modules/Dashboard/features/Managers/Managers.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.tsx @@ -107,9 +107,6 @@ export const Managers = () => { const [selectedManager, setSelectedManager] = useState(null); const actions: ManagersActions = { - exportAuditLogsAction: ({ context: user }: MenuActionProps) => { - // TODO: Implement export audit logs - }, removeTeamMemberAction: ({ context: user }: MenuActionProps) => { setSelectedManager(user || null); setRemoveAccessPopupVisible(true); diff --git a/src/modules/Dashboard/features/Managers/Managers.types.ts b/src/modules/Dashboard/features/Managers/Managers.types.ts index 8daf925daa..e18f921958 100644 --- a/src/modules/Dashboard/features/Managers/Managers.types.ts +++ b/src/modules/Dashboard/features/Managers/Managers.types.ts @@ -6,5 +6,4 @@ export type ManagersActions = { editTeamMemberAction: ({ context }: MenuActionProps) => void; copyEmailAddressAction: ({ context }: MenuActionProps) => void; copyInvitationLinkAction: ({ context }: MenuActionProps) => void; - exportAuditLogsAction: ({ context }: MenuActionProps) => void; }; diff --git a/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx b/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx index 8b5670c2fc..1f98e3f204 100644 --- a/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.utils.test.tsx @@ -17,7 +17,6 @@ const removeTeamMemberAction = vi.fn(); const editTeamMemberAction = vi.fn(); const copyEmailAddressAction = vi.fn(); const copyInvitationLinkAction = vi.fn(); -const exportAuditLogsAction = vi.fn(); describe('Managers utils tests', () => { describe('getHeadCells function', () => { @@ -44,7 +43,6 @@ describe('Managers utils tests', () => { editTeamMemberAction, copyEmailAddressAction, copyInvitationLinkAction, - exportAuditLogsAction, }, mockedManager, ); @@ -103,7 +101,6 @@ describe('Managers utils tests', () => { editTeamMemberAction, copyEmailAddressAction, copyInvitationLinkAction, - exportAuditLogsAction, }, pendingManager, ); diff --git a/src/modules/Dashboard/features/Managers/Managers.utils.tsx b/src/modules/Dashboard/features/Managers/Managers.utils.tsx index 4c0c3faeca..ce5856c190 100644 --- a/src/modules/Dashboard/features/Managers/Managers.utils.tsx +++ b/src/modules/Dashboard/features/Managers/Managers.utils.tsx @@ -104,14 +104,6 @@ export const getManagerActions = ( customItemColor: variables.palette.dark_error_container, 'data-testid': 'dashboard-managers-remove-access', }, - { type: MenuItemType.Divider }, - { - icon: , - action: actions.exportAuditLogsAction, - title: t('dataExport.auditLogs.menuCaption'), - context: manager, - 'data-testid': 'dashboard-managers-export-audit-logs', - }, ); } From 93c7e35512a04e6f1c034d78e6afe9d9a3e429c9 Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 13 May 2026 13:47:38 -0500 Subject: [PATCH 07/28] chore: refactored exportTemplate and moved logging into separate functions and file --- src/shared/utils/exportTemplate.logging.ts | 194 +++++++++++++++++++++ src/shared/utils/exportTemplate.ts | 174 ++---------------- 2 files changed, 208 insertions(+), 160 deletions(-) create mode 100644 src/shared/utils/exportTemplate.logging.ts diff --git a/src/shared/utils/exportTemplate.logging.ts b/src/shared/utils/exportTemplate.logging.ts new file mode 100644 index 0000000000..3528ea7ba7 --- /dev/null +++ b/src/shared/utils/exportTemplate.logging.ts @@ -0,0 +1,194 @@ +// eslint-disable no-console + +/** Logs initial data size estimates and structure consistency checks. */ +export const logDataAnalysis = (fileName: string, data: unknown[]) => { + console.log(`[ExportTemplate] Starting export for ${fileName} with ${data.length} rows of data`); + + try { + const sampleSize = Math.min(5, data.length); + const sampleItems = data.slice(0, sampleSize); + const estimatedItemSize = sampleItems.length > 0 ? JSON.stringify(sampleItems[0]).length : 0; + const estimatedTotalSize = estimatedItemSize * data.length; + + console.log( + `[ExportTemplate] Estimated data size for ${fileName}: ~${Math.round( + estimatedTotalSize / 1024, + )} KB` + ` (${data.length} items, ~${estimatedItemSize} bytes per item)`, + ); + + if (estimatedTotalSize > 50 * 1024 * 1024) { + // 50MB + console.warn( + `[ExportTemplate] Warning: Large data set detected for ${fileName}. ` + + `Estimated size: ${Math.round(estimatedTotalSize / (1024 * 1024))} MB`, + ); + } + + if (data.length > 0) { + const firstItem = data[0] as Record; + const lastItem = data[data.length - 1] as Record; + console.log(`[ExportTemplate] First item keys: ${Object.keys(firstItem).join(', ')}`); + console.log(`[ExportTemplate] Last item keys: ${Object.keys(lastItem).join(', ')}`); + + const firstItemKeys = Object.keys(firstItem).sort().join(','); + const lastItemKeys = Object.keys(lastItem).sort().join(','); + + if (firstItemKeys !== lastItemKeys) { + console.warn( + `[ExportTemplate] Warning: Inconsistent data structure detected in ${fileName}. ` + + `First and last items have different keys.`, + ); + } + } + } catch (sizeError) { + console.error(`[ExportTemplate] Error analyzing data size: ${sizeError}`); + } +}; + +/** Logs current browser memory usage (Chrome-specific). */ +export const logMemoryUsage = () => { + // Log memory usage if available + if (typeof window !== 'undefined' && window.performance) { + try { + // Chrome-specific memory API - use type assertion for safety + const perf = window.performance as any; + if (perf.memory) { + console.log( + `[ExportTemplate] Current memory usage: ${Math.round( + perf.memory.usedJSHeapSize / (1024 * 1024), + )}MB ` + `/ ${Math.round(perf.memory.jsHeapSizeLimit / (1024 * 1024))}MB`, + ); + } + } catch (memoryError) { + // Silently ignore - memory API might not be available in all browsers + } + } +}; + +/** Logs worksheet creation timing and dimensions. */ +export const logWorksheetCreated = ( + fileName: string, + workSheet: { '!ref'?: string }, + worksheetCreationTime: number, +) => { + console.log( + `[ExportTemplate] Worksheet created successfully for ${fileName} in ${worksheetCreationTime}ms`, + ); + + // Check worksheet dimensions + if (workSheet['!ref']) { + console.log(`[ExportTemplate] Worksheet dimensions: ${workSheet['!ref']}`); + } +}; + +/** Logs detailed write-error diagnostics. */ +export const logWriteError = ( + fileName: string, + writeError: unknown, + data: unknown[], + shouldLogDataInDebugMode: boolean, +) => { + console.error(`[ExportTemplate] Error writing file ${fileName}:`, writeError); + if (writeError instanceof Error) { + console.error(`[ExportTemplate] Error details: ${writeError.name}: ${writeError.message}`); + + if (shouldLogDataInDebugMode) { + console.error(`[ExportTemplate] Stack trace: ${writeError.stack}`); + + // Log more detailed error information based on error type + if ( + writeError.name === 'QuotaExceededError' || + writeError.message.includes('quota') || + writeError.message.includes('storage') + ) { + console.error( + `[ExportTemplate] Storage quota error detected. This may be due to insufficient disk space.`, + ); + } + + if (writeError.message.includes('permission')) { + console.error(`[ExportTemplate] Permission error detected. Check file system permissions.`); + } + + console.error( + `[ExportTemplate] Data statistics: Size=${data.length}, Sample keys=${ + data[0] ? Object.keys(data[0]).slice(0, 5).join(', ') : 'N/A' + }`, + ); + } + } +}; + +/** Logs detailed worksheet-creation error diagnostics. */ +export const logWorksheetError = ( + fileName: string, + worksheetError: unknown, + data: unknown[], + shouldLogDataInDebugMode: boolean, +) => { + console.error(`[ExportTemplate] Error creating worksheet for ${fileName}:`, worksheetError); + if (worksheetError instanceof Error) { + console.error( + `[ExportTemplate] Worksheet error details: ${worksheetError.name}: ${worksheetError.message}`, + ); + + if (shouldLogDataInDebugMode) { + console.error(`[ExportTemplate] Worksheet error stack: ${worksheetError.stack}`); + + // Check for RangeError specifically + if (worksheetError.name === 'RangeError') { + if (worksheetError.message.includes('array length')) { + console.error( + `[ExportTemplate] Invalid array length detected. This may be due to data exceeding size limits.`, + ); + + // Try to identify which part of the data might be causing the issue + try { + let problematicIndex = -1; + let maxLength = 0; + + // Sample data to find potentially problematic items + const sampleStep = Math.max(1, Math.floor(data.length / 10)); + for (let i = 0; i < data.length; i += sampleStep) { + const itemString = JSON.stringify(data[i]); + if (itemString.length > maxLength) { + maxLength = itemString.length; + problematicIndex = i; + } + } + + if (problematicIndex >= 0) { + console.error( + `[ExportTemplate] Potentially problematic item found at index ${problematicIndex} ` + + `with size ${maxLength} bytes`, + ); + } + } catch (analysisError) { + console.error(`[ExportTemplate] Error analyzing problematic data: ${analysisError}`); + } + } + } + } + } +}; + +/** Logs critical top-level error diagnostics. */ +export const logCriticalError = ( + fileName: string, + error: unknown, + data: unknown[], + shouldLogDataInDebugMode: boolean, +) => { + console.error(`[ExportTemplate] Critical error in exportTemplate for ${fileName}:`, error); + if (error instanceof Error) { + console.error(`[ExportTemplate] Error details: ${error.name}: ${error.message}`); + if (shouldLogDataInDebugMode) { + console.error(`[ExportTemplate] Stack trace: ${error.stack}`); + console.error( + `[ExportTemplate] Data statistics: Size=${data.length}, Sample keys=${ + data[0] ? Object.keys(data[0]).slice(0, 5).join(', ') : 'N/A' + }`, + ); + } + } +}; diff --git a/src/shared/utils/exportTemplate.ts b/src/shared/utils/exportTemplate.ts index 56ed2c5e11..d7a7b4ed13 100644 --- a/src/shared/utils/exportTemplate.ts +++ b/src/shared/utils/exportTemplate.ts @@ -1,5 +1,13 @@ // eslint-disable no-console import { checkIfShouldLogging } from './logger'; +import { + logDataAnalysis, + logMemoryUsage, + logWorksheetCreated, + logWorksheetError, + logWriteError, + logCriticalError, +} from './exportTemplate.logging'; export const exportTemplate = async ({ data, @@ -15,49 +23,7 @@ export const exportTemplate = async ({ const shouldLogDataInDebugMode = checkIfShouldLogging(); if (shouldLogDataInDebugMode) { - console.log( - `[ExportTemplate] Starting export for ${fileName} with ${data.length} rows of data`, - ); - - try { - const sampleSize = Math.min(5, data.length); - const sampleItems = data.slice(0, sampleSize); - const estimatedItemSize = sampleItems.length > 0 ? JSON.stringify(sampleItems[0]).length : 0; - const estimatedTotalSize = estimatedItemSize * data.length; - - console.log( - `[ExportTemplate] Estimated data size for ${fileName}: ~${Math.round( - estimatedTotalSize / 1024, - )} KB` + ` (${data.length} items, ~${estimatedItemSize} bytes per item)`, - ); - - if (estimatedTotalSize > 50 * 1024 * 1024) { - // 50MB - console.warn( - `[ExportTemplate] Warning: Large data set detected for ${fileName}. ` + - `Estimated size: ${Math.round(estimatedTotalSize / (1024 * 1024))} MB`, - ); - } - - if (data.length > 0) { - const firstItem = data[0] as Record; - const lastItem = data[data.length - 1] as Record; - console.log(`[ExportTemplate] First item keys: ${Object.keys(firstItem).join(', ')}`); - console.log(`[ExportTemplate] Last item keys: ${Object.keys(lastItem).join(', ')}`); - - const firstItemKeys = Object.keys(firstItem).sort().join(','); - const lastItemKeys = Object.keys(lastItem).sort().join(','); - - if (firstItemKeys !== lastItemKeys) { - console.warn( - `[ExportTemplate] Warning: Inconsistent data structure detected in ${fileName}. ` + - `First and last items have different keys.`, - ); - } - } - } catch (sizeError) { - console.error(`[ExportTemplate] Error analyzing data size: ${sizeError}`); - } + logDataAnalysis(fileName, data); } try { @@ -69,23 +35,7 @@ export const exportTemplate = async ({ if (shouldLogDataInDebugMode) { console.log(`[ExportTemplate] Creating worksheet for ${fileName}`); - - // Log memory usage if available - if (typeof window !== 'undefined' && window.performance) { - try { - // Chrome-specific memory API - use type assertion for safety - const perf = window.performance as any; - if (perf.memory) { - console.log( - `[ExportTemplate] Current memory usage: ${Math.round( - perf.memory.usedJSHeapSize / (1024 * 1024), - )}MB ` + `/ ${Math.round(perf.memory.jsHeapSizeLimit / (1024 * 1024))}MB`, - ); - } - } catch (memoryError) { - // Silently ignore - memory API might not be available in all browsers - } - } + logMemoryUsage(); } // Track worksheet creation time for performance monitoring @@ -95,15 +45,7 @@ export const exportTemplate = async ({ const workSheet = defaultData ? utils.aoa_to_sheet([defaultData]) : utils.json_to_sheet(data); if (shouldLogDataInDebugMode) { - const worksheetCreationTime = Date.now() - worksheetStartTime; - console.log( - `[ExportTemplate] Worksheet created successfully for ${fileName} in ${worksheetCreationTime}ms`, - ); - - // Check worksheet dimensions - if (workSheet['!ref']) { - console.log(`[ExportTemplate] Worksheet dimensions: ${workSheet['!ref']}`); - } + logWorksheetCreated(fileName, workSheet, Date.now() - worksheetStartTime); } const workBook = utils.book_new(); @@ -129,106 +71,18 @@ export const exportTemplate = async ({ ); } } catch (writeError) { - console.error(`[ExportTemplate] Error writing file ${fileName}:`, writeError); - if (writeError instanceof Error) { - console.error( - `[ExportTemplate] Error details: ${writeError.name}: ${writeError.message}`, - ); - - if (shouldLogDataInDebugMode) { - console.error(`[ExportTemplate] Stack trace: ${writeError.stack}`); - - // Log more detailed error information based on error type - if ( - writeError.name === 'QuotaExceededError' || - writeError.message.includes('quota') || - writeError.message.includes('storage') - ) { - console.error( - `[ExportTemplate] Storage quota error detected. This may be due to insufficient disk space.`, - ); - } - - if (writeError.message.includes('permission')) { - console.error( - `[ExportTemplate] Permission error detected. Check file system permissions.`, - ); - } - - console.error( - `[ExportTemplate] Data statistics: Size=${data.length}, Sample keys=${ - data[0] ? Object.keys(data[0]).slice(0, 5).join(', ') : 'N/A' - }`, - ); - } - } + logWriteError(fileName, writeError, data, shouldLogDataInDebugMode); } setTimeout(() => { resolve(true); }); }); } catch (worksheetError) { - console.error(`[ExportTemplate] Error creating worksheet for ${fileName}:`, worksheetError); - if (worksheetError instanceof Error) { - console.error( - `[ExportTemplate] Worksheet error details: ${worksheetError.name}: ${worksheetError.message}`, - ); - - if (shouldLogDataInDebugMode) { - console.error(`[ExportTemplate] Worksheet error stack: ${worksheetError.stack}`); - - // Check for RangeError specifically - if (worksheetError.name === 'RangeError') { - if (worksheetError.message.includes('array length')) { - console.error( - `[ExportTemplate] Invalid array length detected. This may be due to data exceeding size limits.`, - ); - - // Try to identify which part of the data might be causing the issue - try { - let problematicIndex = -1; - let maxLength = 0; - - // Sample data to find potentially problematic items - const sampleStep = Math.max(1, Math.floor(data.length / 10)); - for (let i = 0; i < data.length; i += sampleStep) { - const itemString = JSON.stringify(data[i]); - if (itemString.length > maxLength) { - maxLength = itemString.length; - problematicIndex = i; - } - } - - if (problematicIndex >= 0) { - console.error( - `[ExportTemplate] Potentially problematic item found at index ${problematicIndex} ` + - `with size ${maxLength} bytes`, - ); - } - } catch (analysisError) { - console.error( - `[ExportTemplate] Error analyzing problematic data: ${analysisError}`, - ); - } - } - } - } - } + logWorksheetError(fileName, worksheetError, data, shouldLogDataInDebugMode); throw worksheetError; // Re-throw to be caught by the outer try-catch } } catch (error) { - console.error(`[ExportTemplate] Critical error in exportTemplate for ${fileName}:`, error); - if (error instanceof Error) { - console.error(`[ExportTemplate] Error details: ${error.name}: ${error.message}`); - if (shouldLogDataInDebugMode) { - console.error(`[ExportTemplate] Stack trace: ${error.stack}`); - console.error( - `[ExportTemplate] Data statistics: Size=${data.length}, Sample keys=${ - data[0] ? Object.keys(data[0]).slice(0, 5).join(', ') : 'N/A' - }`, - ); - } - } + logCriticalError(fileName, error, data, shouldLogDataInDebugMode); return true; } From efea5537ec7847a623d7de8988c007c204093c51 Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 13 May 2026 13:52:10 -0500 Subject: [PATCH 08/28] chore: added documentation for exportEncryptedDataSucceed and exportDecryptedDataSucceed and moved each function props into shared props --- .../utils/exportData/exportDataSucceed.ts | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/shared/utils/exportData/exportDataSucceed.ts b/src/shared/utils/exportData/exportDataSucceed.ts index 395f82593a..41ed4015d7 100644 --- a/src/shared/utils/exportData/exportDataSucceed.ts +++ b/src/shared/utils/exportData/exportDataSucceed.ts @@ -23,6 +23,27 @@ import { getReportZipName, ZipFile } from './getReportName'; import { ExportDataFilters, prepareEncryptedData, prepareDecryptedData } from './prepareData'; import { sanitizeCSVData } from '../csvSanitization'; +/** + * Shared options for {@link exportEncryptedDataSucceed} and {@link exportDecryptedDataSucceed} + * (file naming, which answers to include, feature flags, journey CSV). + */ +export type ExportDataSucceedBaseOptions = { + /** Appended to generated CSV and zip file names (e.g. subject or scope token). */ + suffix: string; + /** Optional scope for which answers to include (activity, flow, or subject ids). */ + filters?: ExportDataFilters; + /** Product feature flags that affect export output (e.g. report naming). */ + flags: FeatureFlags; + /** When true, also emits the user-journey report CSV when applicable. */ + shouldGenerateUserJourney: boolean; +}; + +/** Options for {@link exportEncryptedDataSucceed} (same as base plus decryption). */ +export type ExportEncryptedDataSucceedOptions = ExportDataSucceedBaseOptions & { + /** Decrypts response payloads when preparing rows for export. */ + getDecryptedAnswers: ReturnType; +}; + const exportProcessedData = async ({ reportData, activityJourneyData, @@ -76,6 +97,12 @@ const exportProcessedData = async ({ ]); }; +/** + * Returns an async function that exports applet response data from encrypted API results, + * then writes CSV files and supplementary zips in the browser. + * + * @remarks Configuration fields are documented on {@link ExportEncryptedDataSucceedOptions}. + */ export const exportEncryptedDataSucceed = ({ getDecryptedAnswers, @@ -83,13 +110,7 @@ export const exportEncryptedDataSucceed = filters, flags, shouldGenerateUserJourney, - }: { - getDecryptedAnswers: ReturnType; - suffix: string; - filters?: ExportDataFilters; - flags: FeatureFlags; - shouldGenerateUserJourney: boolean; - }) => + }: ExportEncryptedDataSucceedOptions) => async (result: ExportDataResult) => { if (!result) return; @@ -103,18 +124,14 @@ export const exportEncryptedDataSucceed = await exportProcessedData({ ...exportData, suffix, flags, shouldGenerateUserJourney }); }; +/** + * Returns an async function that exports applet response data when answers are already decrypted, + * then writes CSV files and supplementary zips in the browser. + * + * @remarks Configuration fields are documented on {@link ExportDataSucceedBaseOptions}. + */ export const exportDecryptedDataSucceed = - ({ - suffix, - filters, - flags, - shouldGenerateUserJourney, - }: { - suffix: string; - filters?: ExportDataFilters; - flags: FeatureFlags; - shouldGenerateUserJourney: boolean; - }) => + ({ suffix, filters, flags, shouldGenerateUserJourney }: ExportDataSucceedBaseOptions) => async (parsedAnswers: DecryptedActivityData[]) => { if (!parsedAnswers) return; From 4fd52f1cb801cbb16fa9dc58aeed7f4c1c369421 Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 13 May 2026 15:19:53 -0500 Subject: [PATCH 09/28] feat: implement audit logs export with paginated fetch and progress modal --- src/modules/Dashboard/api/api.ts | 14 +++ src/modules/Dashboard/api/api.types.ts | 8 ++ .../HeaderOptions/HeaderOptions.tsx | 4 +- .../AuditLogsExportPopup.tsx | 99 ++++++++++++++++ .../AuditLogsExportPopup.utils.ts | 6 + .../AuditLogsExportPopupProps.types.ts | 7 ++ .../useAuditLogsExport.tsx | 98 ++++++++++++++++ .../AuditLogsExportSettingsPopup.tsx | 107 ++++++++++++++++++ .../AuditLogsExportSettingsPopup.types.ts | 9 ++ src/shared/types/auditEvent.ts | 44 +++++++ 10 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.utils.ts create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopupProps.types.ts create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.types.ts create mode 100644 src/shared/types/auditEvent.ts diff --git a/src/modules/Dashboard/api/api.ts b/src/modules/Dashboard/api/api.ts index 58179b9775..9ce93ebb7f 100644 --- a/src/modules/Dashboard/api/api.ts +++ b/src/modules/Dashboard/api/api.ts @@ -86,9 +86,11 @@ import { DeviceScheduleHistoryData, FlowItemHistoryParams, FlowItemHistoryData, + ExportAuditLogs, } from './api.types'; import { DEFAULT_API_RESULTS_PER_PAGE } from './api.const'; import { SubjectDetailsWithDataAccess } from '../types'; +import { ExportAuditLogsResult } from 'shared/types/auditEvent'; export const getUserDetailsApi = (signal?: AbortSignal) => authApiClient.get('/users/me', { signal }); @@ -800,6 +802,18 @@ export const getAppletVersionChangesApi = ( signal?: AbortSignal, ) => authApiClient.get(`/applets/${appletId}/versions/${version}/changes`, { signal }); +/** + * Get audit logs from API given a date range and applet ID + * */ +export const getExportAuditLogsApi = ( + { appletId, fromDate, toDate, page = 1, limit = DEFAULT_API_RESULTS_PER_PAGE }: ExportAuditLogs, + signal?: AbortSignal, +) => + authApiClient.get>( + `/audit/applets/${appletId}/events`, + { signal, params: { fromDate, toDate, page, limit } }, + ); + export const getExportDataApi = ( { appletId, page = 1, limit = DEFAULT_API_RESULTS_PER_PAGE, ...rest }: ExportData, signal?: AbortSignal, diff --git a/src/modules/Dashboard/api/api.types.ts b/src/modules/Dashboard/api/api.types.ts index fc91c7c66e..73c64d4d8b 100644 --- a/src/modules/Dashboard/api/api.types.ts +++ b/src/modules/Dashboard/api/api.types.ts @@ -600,6 +600,14 @@ export type ExportData = AppletId & { includeEhr?: boolean; }; +/** This is the type params for the export audit logs API request */ +export type ExportAuditLogs = AppletId & { + fromDate?: string; + toDate?: string; + page?: number; + limit?: number; +}; + export type ScheduleHistoryParams = AppletId & { respondentIds?: string[]; subjectIds?: string[]; diff --git a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx index 0882180d53..2399226d3c 100644 --- a/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx +++ b/src/modules/Dashboard/components/HeaderOptions/HeaderOptions.tsx @@ -107,9 +107,7 @@ export const HeaderOptions = () => { isExportSettingsOpen={isAuditLogsExportOpen} onExportSettingsClose={handleCloseAuditLogsExport} data-testid={'audit-logs-export'} - onExportPopupClose={function (): void { - throw new Error('Function not implemented.'); - }} + onExportPopupClose={() => setIsAuditLogsExportOpen(false)} /> ) : null; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx new file mode 100644 index 0000000000..397df5eea8 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx @@ -0,0 +1,99 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Modal } from 'shared/components/Modal'; +import { + StyledBodyLarge, + StyledLinearProgress, + StyledModalWrapper, + theme, + variables, +} from 'shared/styles'; + +import { AuditLogsExportPopupProps } from './AuditLogsExportPopupProps.types'; +import { exportAuditLogsCsv } from './AuditLogsExportPopup.utils'; +import { useAuditLogsExport } from './useAuditLogsExport'; +import { applet } from 'redux/modules'; + +/** + * This handles the export logic for the audit logs export + */ +export const AuditLogsExportPopup = ({ + popupVisible, + setPopupVisible, + handlePopupClose, + 'data-testid': dataTestId, +}: AuditLogsExportPopupProps) => { + const { t } = useTranslation('app'); + + const { result: appletData } = applet.useAppletData() ?? {}; + const appletId = appletData?.id + const { isLoading, error, allAuditEvents, currentPage, totalPages, retry } = + useAuditLogsExport(appletId); + + useEffect(() => { + if (!allAuditEvents || !appletId) return; + + // Once all audit events are fetched, export them as a CSV file and close the popup. + const doExport = async () => { + await exportAuditLogsCsv(allAuditEvents, appletData?.displayName ?? 'applet'); + handlePopupClose(); + }; + + doExport(); + }, [allAuditEvents, setPopupVisible, handlePopupClose]); + + const exportingModal = ( + + + + {t('waitForRespondentDataDownload')} + {totalPages > 1 && ( + <> +
+
+ {t('dataProcessing', { + percentages: Math.floor((currentPage / totalPages) * 100), + })} + + )} +
+ +
+
+ ); + + const exportErrorModal = ( + + + + {t('exportFailed')} + + + + ); + + return ( + <> + {error && exportErrorModal} + {isLoading && exportingModal} + + ); +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.utils.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.utils.ts new file mode 100644 index 0000000000..16ad6a8d14 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.utils.ts @@ -0,0 +1,6 @@ +import { AuditEvent } from 'shared/types/auditEvent'; +import { exportTemplate } from 'shared/utils/exportTemplate'; + +export const exportAuditLogsCsv = async (auditEvents: AuditEvent[], appletName: string) => { + await exportTemplate({ data: auditEvents, fileName: `${appletName}-audit-logs-export` }); +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopupProps.types.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopupProps.types.ts new file mode 100644 index 0000000000..4aa8ccac36 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopupProps.types.ts @@ -0,0 +1,7 @@ +export type AuditLogsExportPopupProps = { + appletId: string; + popupVisible: boolean; + setPopupVisible: (visible: boolean) => void; + handlePopupClose: () => void; + 'data-testid'?: string; +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx new file mode 100644 index 0000000000..83a69a29a1 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx @@ -0,0 +1,98 @@ +import { getExportAuditLogsApi } from 'api'; +import { format } from 'date-fns'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { getExportPageAmount } from 'modules/Dashboard/api/api.utils'; +import { DateFormats } from 'shared/consts'; +import { AuditEvent } from 'shared/types/auditEvent'; + +import { AuditLogsExportFormValues } from '../../AuditLogsExportSetting.types'; + +export const useAuditLogsExport = (appletId: string | undefined) => { + const { getValues } = useFormContext(); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [allAuditEvents, setAllAuditEvents] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const abortControllerRef = useRef(null); + + const fetchAuditLogs = useCallback(async () => { + if (!appletId) return; + + // Cancel any in-flight export before starting a new one. + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + setError(null); + setAllAuditEvents(null); + setCurrentPage(0); + setTotalPages(0); + + try { + const { fromDate, toDate } = getValues(); + const params = { + appletId, + fromDate: format(fromDate, DateFormats.shortISO), + toDate: format(toDate, DateFormats.shortISO), + }; + + // Fetch the first page of audit logs and get the total number of pages + const firstPageResponse = await getExportAuditLogsApi( + { ...params, page: 1 }, + abortController.signal, + ); + const { result: firstPageData, count: totalCount = 0 } = firstPageResponse.data; + const pages = getExportPageAmount(totalCount); + + setTotalPages(pages); + setCurrentPage(1); + + const accumulatedEvents = [...firstPageData.auditEvents]; + + // Fetch the remaining pages of audit logs and accumulate the audit events + for (let page = 2; page <= pages; page++) { + if (abortController.signal.aborted) return; + + const nextPageResponse = await getExportAuditLogsApi( + { ...params, page }, + abortController.signal, + ); + accumulatedEvents.push(...nextPageResponse.data.result.auditEvents); + setCurrentPage(page); + } + + setAllAuditEvents(accumulatedEvents); + } catch (e) { + if ((e as Error).name !== 'AbortError') { + setError(e as Error); + } + } finally { + setIsLoading(false); + } + }, [appletId, getValues]); + + useEffect(() => { + if (!appletId) return; + + fetchAuditLogs(); + + return () => { + // Cancel the in-flight export when the component unmounts. + abortControllerRef.current?.abort(); + }; + }, [fetchAuditLogs]); + + return { + isLoading, + error, + allAuditEvents, + currentPage, + totalPages, + retry: fetchAuditLogs, + }; +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx new file mode 100644 index 0000000000..b92cbb3ec3 --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx @@ -0,0 +1,107 @@ +import { Button } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useFormContext } from 'react-hook-form'; + +import { Modal } from 'shared/components/Modal'; +import { Svg } from 'shared/components/Svg'; +import { + StyledBodyLarge, + StyledFlexAllCenter, + StyledFlexColumn, + StyledFlexTopCenter, + StyledModalWrapper, + StyledTitleBoldMedium, +} from 'shared/styles'; +import { DateRangePicker } from 'shared/components/DateRangePicker'; +import { CheckboxController } from 'shared/components/FormComponents'; + +import { AuditLogsExportSettingsPopupProps } from './AuditLogsExportSettingsPopup.types'; +import { + AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY, + AuditLogsExportFormValues, +} from '../../AuditLogsExportSetting.types'; + + +/** + * Audit Logs Export Popup - This is the popup that appears when the user clicks the "Export Audit Logs" button in the applet settings page. + * This is only responsible for rendering the popup UI. It does not handle the export logic. + * The export logic is handled in the {@link AuditLogsExportSetting} component. + */ +export const AuditLogsExportSettingsPopup = ({ + isOpen, + onClose, + onExport, + minDate, + maxDate, + contextItemName, + 'data-testid': dataTestId, +}: AuditLogsExportSettingsPopupProps) => { + const { t } = useTranslation('app'); + + const { control } = useFormContext(); + + return ( + + +
+ + + {t('dataExport.auditLogs.label')} + + {t('dataExport.auditLogs.title', { name: contextItemName })} + + + + + {t('dataExport.auditLogs.description')} + + + + + + {t(`dataExport.supplementaryFiles.description`)} + + + {t( + `dataExport.auditLogs.supplementaryFiles.includes.${AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY}`, + )} + + } + /> + + + + + + + +
+
+
+ ); +}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.types.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.types.ts new file mode 100644 index 0000000000..1534e3282f --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.types.ts @@ -0,0 +1,9 @@ +export type AuditLogsExportSettingsPopupProps = { + isOpen: boolean; + onClose: () => void; + onExport: () => void; + minDate: Date; + maxDate: Date; + 'data-testid'?: string; + contextItemName: string; +}; diff --git a/src/shared/types/auditEvent.ts b/src/shared/types/auditEvent.ts new file mode 100644 index 0000000000..725c1a7714 --- /dev/null +++ b/src/shared/types/auditEvent.ts @@ -0,0 +1,44 @@ +export type EventAction = {}; + +export type EventKind = {}; + +export type EventOutcome = {}; + +export type ExportAuditLogsResult = { + auditEvents: AuditEvent[]; +}; + +export type AuditEvent = { + timestamp: string; + errorType: string; + eventAction: EventAction; + eventId: string; + eventKind: EventKind; + eventOutcome: EventOutcome; + eventModule: string; + eventDataSet: string; + serviceName: string; + serviceEnvironment: string; + userId: string; + userRoles: string[]; + userTargetId: string; + userTargetEmail: string; + userTargetRoles: string[]; + clientIp: string; + httpRequestId: string; + httpRequestMethod: string; + httpResponseStatusCode: number; + traceId: string; + urlPath: string; + urlQuery: string; + userAgent: string; + filePath: string; + curiousAppletId: string[]; + curiousSubjectId: string[]; + curiousFlowId: string[]; + curiousActivityId: string[]; + curiousSubmitId: string[]; + curiousAnswerId: string[]; + eventCategory: string[]; + eventType: string; +}; From ad2e950654ccc21915abd2b96bd96172431182c4 Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 13 May 2026 15:21:56 -0500 Subject: [PATCH 10/28] chore: moved audit logs export popup file to different folder --- .../AuditLogsExportPopup.tsx | 101 ------------------ .../AuditLogsExportPopup.types.ts | 9 -- 2 files changed, 110 deletions(-) delete mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx delete mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx deleted file mode 100644 index d8446f071c..0000000000 --- a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Button } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { useFormContext } from 'react-hook-form'; - -import { Modal } from 'shared/components/Modal'; -import { Svg } from 'shared/components/Svg'; -import { - StyledBodyLarge, - StyledFlexAllCenter, - StyledFlexColumn, - StyledFlexTopCenter, - StyledModalWrapper, - StyledTitleBoldMedium, -} from 'shared/styles'; -import { DateRangePicker } from 'shared/components/DateRangePicker'; -import { CheckboxController } from 'shared/components/FormComponents'; - -import { AuditLogsExportPopupProps } from './AuditLogsExportPopup.types'; -import { - AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY, - AuditLogsExportFormValues, -} from '../AuditLogsExportSetting.types'; - -export const AuditLogsExportPopup = ({ - isOpen, - onClose, - onExport, - minDate, - maxDate, - contextItemName, - 'data-testid': dataTestId, -}: AuditLogsExportPopupProps) => { - const { t } = useTranslation('app'); - - const { control } = useFormContext(); - - return ( - - -
- - - {t('dataExport.auditLogs.label')} - - {t('dataExport.auditLogs.title', { name: contextItemName })} - - - - - {t('dataExport.auditLogs.description')} - - - - - - {t(`dataExport.supplementaryFiles.description`)} - - - {t( - `dataExport.auditLogs.supplementaryFiles.includes.${AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY}`, - )} - - } - /> - - - - - - - -
-
-
- ); -}; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts deleted file mode 100644 index ac6533237f..0000000000 --- a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportPopup/AuditLogsExportPopup.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type AuditLogsExportPopupProps = { - isOpen: boolean; - onClose: () => void; - onExport: () => void; - minDate: Date; - maxDate: Date; - 'data-testid'?: string; - contextItemName: string; -}; From 0f1947b1bc5bb25d84649558c3eede79c6ee2a63 Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 13 May 2026 15:24:08 -0500 Subject: [PATCH 11/28] chore: clean up and refactor of AuditLogsExportSetting.tsx file --- .../AuditLogsExportSetting.tsx | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx index be068a2291..c2b1338895 100644 --- a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { ObjectSchema } from 'yup'; -import { DataExportPopup } from 'shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup'; import { getNormalizedTimezoneDate } from 'shared/utils/dateTimezone'; import { DateRangePickerType } from 'shared/components/DateRangePicker'; import { applet } from 'shared/state/Applet'; @@ -13,36 +12,25 @@ import { AuditLogsExportFormValues, AuditLogsExportSettingProps, } from './AuditLogsExportSetting.types'; -import { AuditLogsExportPopup } from './AuditLogsExportPopup/AuditLogsExportPopup'; +import { AuditLogsExportSettingsPopup } from './Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup'; +import { AuditLogsExportPopup } from './Popups/AuditLogsExportPopup/AuditLogsExportPopup'; import { auditLogsExportSettingSchema } from './AuditLogsExportSetting.schema'; export const AuditLogsExportSetting = ({ isExportSettingsOpen, onExportSettingsClose, onExportPopupClose, - chosenAppletData, 'data-testid': dataTestId, }: AuditLogsExportSettingProps) => { const [dataIsExporting, setDataIsExporting] = useState(false); const { result } = applet.useAppletData() ?? {}; - const appletData = chosenAppletData ?? result; - - const minDate = useMemo(() => new Date(appletData?.createdAt ?? ''), [appletData]); + const appletId = result?.id; + const minDate = useMemo(() => new Date(result?.createdAt ?? ''), [result]); const maxDate = useMemo(() => getNormalizedTimezoneDate(new Date().toString()), []); - let appletName = ''; - let contextItemName = ''; - - if (appletData) { - if ('appletDisplayName' in appletData) { - appletName = appletData.appletDisplayName ?? ''; - } else if ('displayName' in appletData) { - appletName = appletData.displayName; - } - - contextItemName = appletName; - } + const contextItemName = result?.displayName ?? ''; + /** Sets our initial values for the audit-logs export form */ const defaultValues: AuditLogsExportFormValues = useMemo( () => ({ dateType: DateRangePickerType.AllTime, @@ -52,6 +40,7 @@ export const AuditLogsExportSetting = ({ }), [minDate, maxDate], ); + const methods = useForm({ resolver: yupResolver( auditLogsExportSettingSchema() as ObjectSchema, @@ -71,8 +60,7 @@ export const AuditLogsExportSetting = ({ return ( {isExportSettingsOpen && ( - { resetDefaultValues(); @@ -84,20 +72,20 @@ export const AuditLogsExportSetting = ({ }} minDate={minDate} maxDate={maxDate} + contextItemName={contextItemName} data-testid={`${dataTestId}-settings`} /> )} {dataIsExporting && ( - { resetDefaultValues(); onExportPopupClose?.(); }} + data-testid={`${dataTestId}-modal`} /> )} From a81bb291afb5bd333803441b3facc4658f0e2cb0 Mon Sep 17 00:00:00 2001 From: adeiji Date: Wed, 13 May 2026 15:25:40 -0500 Subject: [PATCH 12/28] chore: added documentation and small fixes --- .../Popups/DataExportPopup/DataExportPopup_old.tsx | 6 ++++++ .../Popups/ExportSettingsPopup/ExportSettingsPopup.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx index 257529a33a..7d9a0d84bf 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/DataExportPopup/DataExportPopup_old.tsx @@ -61,6 +61,12 @@ const formatDateAsUTC = (date: Date): string => { return format(utcDate, DateFormats.shortISO); }; +/** + * Orchestrates the applet response export modal flow. + * + * Handles password validation for encrypted applets, paginated export requests, + * progress state, optional supplementary exporters, analytics, retry, and error reporting. + */ export const DataExportPopup = ({ filters = {}, popupVisible, diff --git a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx index 048f2ccb07..2b33e19714 100644 --- a/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx +++ b/src/shared/features/AppletSettings/ExportDataSetting/Popups/ExportSettingsPopup/ExportSettingsPopup.tsx @@ -79,7 +79,7 @@ export const ExportSettingsPopup = ({ {filteredSupplementaryFiles.length > 0 && ( From c8628a790e837f94aef26db04bce733bc5423ddc Mon Sep 17 00:00:00 2001 From: adeiji Date: Mon, 18 May 2026 06:16:36 -0700 Subject: [PATCH 13/28] fix: audit logs export - correct API types, date format, and per-page CSV export - Fix date format sent to API to be date-only instead of datetime - Fix API response type to match flat array structure - Update AuditEvent type to match actual API dot-notation keys - Refactor export to per-page CSV download to avoid memory accumulation - Add isExportRef guard to prevent duplicate exports - Add French translations - Added test --- src/modules/Dashboard/api/api.ts | 4 +- src/resources/app-fr.json | 16 ++ .../AuditLogsExportSetting.test.tsx | 174 ++++++++++++++++++ .../AuditLogsExportPopup.tsx | 25 +-- .../AuditLogsExportPopup.utils.ts | 4 +- .../useAuditLogsExport.tsx | 54 +++--- .../AuditLogsExportSettingsPopup.tsx | 19 -- src/shared/types/auditEvent.ts | 75 ++++---- 8 files changed, 259 insertions(+), 112 deletions(-) create mode 100644 src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.test.tsx diff --git a/src/modules/Dashboard/api/api.ts b/src/modules/Dashboard/api/api.ts index 9ce93ebb7f..7be4c19ef8 100644 --- a/src/modules/Dashboard/api/api.ts +++ b/src/modules/Dashboard/api/api.ts @@ -90,7 +90,7 @@ import { } from './api.types'; import { DEFAULT_API_RESULTS_PER_PAGE } from './api.const'; import { SubjectDetailsWithDataAccess } from '../types'; -import { ExportAuditLogsResult } from 'shared/types/auditEvent'; +import { AuditEvent } from 'shared/types/auditEvent'; export const getUserDetailsApi = (signal?: AbortSignal) => authApiClient.get('/users/me', { signal }); @@ -809,7 +809,7 @@ export const getExportAuditLogsApi = ( { appletId, fromDate, toDate, page = 1, limit = DEFAULT_API_RESULTS_PER_PAGE }: ExportAuditLogs, signal?: AbortSignal, ) => - authApiClient.get>( + authApiClient.get>( `/audit/applets/${appletId}/events`, { signal, params: { fromDate, toDate, page, limit } }, ); diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json index c589cbd425..916ff812f5 100644 --- a/src/resources/app-fr.json +++ b/src/resources/app-fr.json @@ -464,6 +464,22 @@ "dashboard": "Tableau de bord", "dataExport": { "title": "Export de données", + "responseData": { + "menuCaption": "Données de réponse" + }, + "auditLogs": { + "menuCaption": "Journaux d'audit de sécurité", + "title": "Journaux d'audit de sécurité de {{name}}", + "header": "Exporter les journaux d'audit de sécurité", + "label": "Exporter : ", + "description": "Téléchargez les journaux contenant l'activité de l'applet pour analyse dans un logiciel SIEM.", + "button": "Exporter", + "supplementaryFiles": { + "includes": { + "tsv": "Inclure un fichier TSV lisible" + } + } + }, "data": "Données", "dataExported": { "responsesOnly": "Réponses uniquement", diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.test.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.test.tsx new file mode 100644 index 0000000000..ed313c16dd --- /dev/null +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/AuditLogsExportSetting.test.tsx @@ -0,0 +1,174 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { initialStateData } from 'redux/modules'; +import { mockedApplet } from 'shared/mock'; +import { renderWithProviders } from 'shared/utils/renderWithProviders'; + +import { AuditLogsExportSetting } from './AuditLogsExportSetting'; + +const createdDate = '2023-11-14T14:43:33.369902'; +const mockedNow = new Date('2023-11-14T16:10:00.000Z'); + +const preloadedState = { + applet: { + applet: { + ...initialStateData, + data: { result: { ...mockedApplet, createdAt: createdDate } }, + }, + }, +}; + +const mockedExportAuditLogsApi = vi.fn(); + +vi.mock('modules/Dashboard/api', () => ({ + getExportAuditLogsApi: (...args: unknown[]) => mockedExportAuditLogsApi(...args), +})); + +vi.mock('shared/utils/exportTemplate', () => ({ + exportTemplate: vi.fn().mockResolvedValue(true), +})); + +const dataTestId = 'audit-logs-export'; + +describe('AuditLogsExportSetting', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(mockedNow); + mockedExportAuditLogsApi.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should not render export settings popup if isExportSettingsOpen is false', async () => { + const mockOnClose = vi.fn(); + const mockOnExportClose = vi.fn(); + + renderWithProviders( + , + { preloadedState }, + ); + + await waitFor(() => { + expect(screen.queryByTestId(`${dataTestId}-settings`)).not.toBeInTheDocument(); + expect(screen.queryByTestId(`${dataTestId}-modal`)).not.toBeInTheDocument(); + }); + }); + + it('should call close callback and open the export popup if the settings download button is clicked', async () => { + const mockOnClose = vi.fn(); + const mockOnExportClose = vi.fn(); + + mockedExportAuditLogsApi.mockResolvedValueOnce({ + data: { result: [], count: 0 }, + }); + + renderWithProviders( + , + { preloadedState }, + ); + + await waitFor(() => + expect(screen.queryByTestId(`${dataTestId}-settings`)).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId(`${dataTestId}-settings-download-button`)); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('should only call the export API once even if the effect re-fires', async () => { + const mockOnClose = vi.fn(); + const mockOnExportClose = vi.fn(); + + mockedExportAuditLogsApi.mockResolvedValue({ + data: { result: [], count: 0 }, + }); + + renderWithProviders( + , + { preloadedState }, + ); + + fireEvent.click(screen.getByTestId(`${dataTestId}-settings-download-button`)); + + await waitFor(() => { + expect(mockedExportAuditLogsApi).toHaveBeenCalledTimes(1); + }); + }); + + it('should show error modal when the export API fails', async () => { + const mockOnClose = vi.fn(); + const mockOnExportClose = vi.fn(); + + mockedExportAuditLogsApi.mockRejectedValueOnce(new Error('Network error')); + + renderWithProviders( + , + { preloadedState }, + ); + + fireEvent.click(screen.getByTestId(`${dataTestId}-settings-download-button`)); + + await waitFor(() => { + expect(screen.getByTestId(`${dataTestId}-modal-error`)).toBeInTheDocument(); + }); + }); + + it('should retry the export when retry button is clicked after an error', async () => { + const mockOnClose = vi.fn(); + const mockOnExportClose = vi.fn(); + + mockedExportAuditLogsApi.mockRejectedValueOnce(new Error('Network error')); + + renderWithProviders( + , + { preloadedState }, + ); + + fireEvent.click(screen.getByTestId(`${dataTestId}-settings-download-button`)); + + await waitFor(() => { + expect(screen.getByTestId(`${dataTestId}-modal-error`)).toBeInTheDocument(); + }); + + mockedExportAuditLogsApi.mockResolvedValueOnce({ + data: { result: [], count: 0 }, + }); + + fireEvent.click(screen.getByTestId(`${dataTestId}-modal-error-submit-button`)); + + await waitFor(() => { + expect(mockedExportAuditLogsApi).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx index 397df5eea8..cfd9465edb 100644 --- a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/AuditLogsExportPopup.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from 'shared/components/Modal'; @@ -11,7 +11,6 @@ import { } from 'shared/styles'; import { AuditLogsExportPopupProps } from './AuditLogsExportPopupProps.types'; -import { exportAuditLogsCsv } from './AuditLogsExportPopup.utils'; import { useAuditLogsExport } from './useAuditLogsExport'; import { applet } from 'redux/modules'; @@ -21,27 +20,21 @@ import { applet } from 'redux/modules'; export const AuditLogsExportPopup = ({ popupVisible, setPopupVisible, - handlePopupClose, + handlePopupClose: providedCloseHandler, 'data-testid': dataTestId, }: AuditLogsExportPopupProps) => { const { t } = useTranslation('app'); const { result: appletData } = applet.useAppletData() ?? {}; - const appletId = appletData?.id - const { isLoading, error, allAuditEvents, currentPage, totalPages, retry } = - useAuditLogsExport(appletId); + const appletId = appletData?.id; - useEffect(() => { - if (!allAuditEvents || !appletId) return; + const handlePopupClose = useCallback(() => { + setPopupVisible(false); + providedCloseHandler?.(); + }, [providedCloseHandler, setPopupVisible]); - // Once all audit events are fetched, export them as a CSV file and close the popup. - const doExport = async () => { - await exportAuditLogsCsv(allAuditEvents, appletData?.displayName ?? 'applet'); - handlePopupClose(); - }; - - doExport(); - }, [allAuditEvents, setPopupVisible, handlePopupClose]); + const { isLoading, error, currentPage, totalPages, retry } = + useAuditLogsExport(appletId, appletData?.displayName ?? 'applet', handlePopupClose); const exportingModal = ( { - await exportTemplate({ data: auditEvents, fileName: `${appletName}-audit-logs-export` }); +export const exportAuditLogsCsv = async (auditEvents: AuditEvent[], fileName: string) => { + await exportTemplate({ data: auditEvents, fileName }); }; diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx index 83a69a29a1..0bb9e69b94 100644 --- a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportPopup/useAuditLogsExport.tsx @@ -5,31 +5,31 @@ import { useFormContext } from 'react-hook-form'; import { getExportPageAmount } from 'modules/Dashboard/api/api.utils'; import { DateFormats } from 'shared/consts'; -import { AuditEvent } from 'shared/types/auditEvent'; import { AuditLogsExportFormValues } from '../../AuditLogsExportSetting.types'; +import { exportAuditLogsCsv } from './AuditLogsExportPopup.utils'; -export const useAuditLogsExport = (appletId: string | undefined) => { +export const useAuditLogsExport = ( + appletId: string | undefined, + appletName: string, + handlePopupClose: () => void, +) => { const { getValues } = useFormContext(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [allAuditEvents, setAllAuditEvents] = useState(null); const [currentPage, setCurrentPage] = useState(0); const [totalPages, setTotalPages] = useState(0); - const abortControllerRef = useRef(null); + const handlePopupCloseRef = useRef(handlePopupClose); + handlePopupCloseRef.current = handlePopupClose; + const isExportingRef = useRef(false); const fetchAuditLogs = useCallback(async () => { - if (!appletId) return; - - // Cancel any in-flight export before starting a new one. - abortControllerRef.current?.abort(); - const abortController = new AbortController(); - abortControllerRef.current = abortController; + if (!appletId || isExportingRef.current) return; + isExportingRef.current = true; setIsLoading(true); setError(null); - setAllAuditEvents(null); setCurrentPage(0); setTotalPages(0); @@ -37,14 +37,13 @@ export const useAuditLogsExport = (appletId: string | undefined) => { const { fromDate, toDate } = getValues(); const params = { appletId, - fromDate: format(fromDate, DateFormats.shortISO), - toDate: format(toDate, DateFormats.shortISO), + fromDate: format(fromDate, DateFormats.YearMonthDay), + toDate: format(toDate, DateFormats.YearMonthDay), }; // Fetch the first page of audit logs and get the total number of pages const firstPageResponse = await getExportAuditLogsApi( { ...params, page: 1 }, - abortController.signal, ); const { result: firstPageData, count: totalCount = 0 } = firstPageResponse.data; const pages = getExportPageAmount(totalCount); @@ -52,45 +51,38 @@ export const useAuditLogsExport = (appletId: string | undefined) => { setTotalPages(pages); setCurrentPage(1); - const accumulatedEvents = [...firstPageData.auditEvents]; + const fileName = `${appletName}-audit-logs-export`; - // Fetch the remaining pages of audit logs and accumulate the audit events - for (let page = 2; page <= pages; page++) { - if (abortController.signal.aborted) return; + // Export first page immediately + await exportAuditLogsCsv(firstPageData, pages > 1 ? `${fileName}_page_1` : fileName); + // Fetch and export remaining pages one at a time + for (let page = 2; page <= pages; page++) { const nextPageResponse = await getExportAuditLogsApi( { ...params, page }, - abortController.signal, ); - accumulatedEvents.push(...nextPageResponse.data.result.auditEvents); + await exportAuditLogsCsv(nextPageResponse.data.result, `${fileName}_page_${page}`); setCurrentPage(page); } - setAllAuditEvents(accumulatedEvents); + handlePopupCloseRef.current(); } catch (e) { - if ((e as Error).name !== 'AbortError') { - setError(e as Error); - } + setError(e as Error); } finally { + isExportingRef.current = false; setIsLoading(false); } - }, [appletId, getValues]); + }, [appletId, appletName, getValues]); useEffect(() => { if (!appletId) return; fetchAuditLogs(); - - return () => { - // Cancel the in-flight export when the component unmounts. - abortControllerRef.current?.abort(); - }; }, [fetchAuditLogs]); return { isLoading, error, - allAuditEvents, currentPage, totalPages, retry: fetchAuditLogs, diff --git a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx index b92cbb3ec3..4a75f817f0 100644 --- a/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx +++ b/src/shared/features/AppletSettings/AuditLogsExportSetting/Popups/AuditLogsExportSettingsPopup/AuditLogsExportSettingsPopup.tsx @@ -68,25 +68,6 @@ export const AuditLogsExportSettingsPopup = ({ data-testid={`${dataTestId}-date-range-picker`} /> - - {t(`dataExport.supplementaryFiles.description`)} - - - {t( - `dataExport.auditLogs.supplementaryFiles.includes.${AUDIT_LOGS_SUPPLEMENTARY_FILE_KEY}`, - )} - - } - /> - - -