From 2e3cfaeb12f9fbdb12e54c8b7604636c33ae0987 Mon Sep 17 00:00:00 2001 From: adi-herwana-nus Date: Thu, 11 Jun 2026 11:19:01 +0800 Subject: [PATCH] feat(get-help-statistics): adjust date picker logic - clamp date ranges to valid values on user edit - maintain date range if new start > previous end or vice versa - adjust spreadsheet randomization management --- .../components/DateRandomizationManager.tsx | 6 +- .../NumericRandomizationManager.tsx | 10 +- .../SpreadsheetRandomizationManager.tsx | 58 ++- .../get_help/CourseGetHelpFilter.tsx | 298 ++-------------- .../get_help/CourseGetHelpStatistics.tsx | 24 +- .../components/misc/SystemGetHelpFilter.tsx | 295 ++-------------- .../pages/SystemGetHelpActivityIndex.tsx | 23 +- .../components/misc/InstanceGetHelpFilter.tsx | 295 ++-------------- .../pages/InstanceGetHelpActivityIndex.tsx | 23 +- .../app/lib/components/core/GetHelpFilter.tsx | 332 ++++++++++++++++++ .../core/__test__/GetHelpFilter.test.tsx | 275 +++++++++++++++ client/app/lib/translations/getHelp.ts | 6 +- client/locales/en.json | 5 +- client/locales/ko.json | 3 + client/locales/zh.json | 3 + 15 files changed, 782 insertions(+), 874 deletions(-) create mode 100644 client/app/lib/components/core/GetHelpFilter.tsx create mode 100644 client/app/lib/components/core/__test__/GetHelpFilter.test.tsx diff --git a/client/app/bundles/course/assessment/question/text-responses/components/DateRandomizationManager.tsx b/client/app/bundles/course/assessment/question/text-responses/components/DateRandomizationManager.tsx index f6783a467bc..701a4c47d6e 100644 --- a/client/app/bundles/course/assessment/question/text-responses/components/DateRandomizationManager.tsx +++ b/client/app/bundles/course/assessment/question/text-responses/components/DateRandomizationManager.tsx @@ -16,7 +16,7 @@ import translations from '../../../translations'; interface Props { config: CellRandomConfigBody<'date'>; onChange: (newConfig: Partial>) => void; - onBlur?: () => void; + onBlur?: (changedKey: 'min' | 'max') => void; } const DateRandomizationManager: FC = ({ config, onChange, onBlur }) => { @@ -33,7 +33,7 @@ const DateRandomizationManager: FC = ({ config, onChange, onBlur }) => { if (value?.isValid()) onChange({ min: value.toDate() }); }} slotProps={{ - textField: { size: 'small', onBlur }, + textField: { size: 'small', onBlur: () => onBlur?.('min') }, }} timezone="UTC" value={config.min ? moment.utc(config.min) : null} @@ -46,7 +46,7 @@ const DateRandomizationManager: FC = ({ config, onChange, onBlur }) => { if (value?.isValid()) onChange({ max: value.toDate() }); }} slotProps={{ - textField: { size: 'small', onBlur }, + textField: { size: 'small', onBlur: () => onBlur?.('max') }, }} timezone="UTC" value={config.max ? moment.utc(config.max) : null} diff --git a/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx b/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx index 50a281da6b6..a2618a540c7 100644 --- a/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx +++ b/client/app/bundles/course/assessment/question/text-responses/components/NumericRandomizationManager.tsx @@ -10,7 +10,7 @@ import translations from '../../../translations'; interface Props { config: CellRandomConfigBody<'numeric'>; onChange: (newConfig: Partial>) => void; - onBlur?: () => void; + onBlur?: (changedKey: 'min' | 'max') => void; } const NumericRandomizationManager: FC = (props) => { @@ -31,7 +31,7 @@ const NumericRandomizationManager: FC = (props) => { if (!maxFocused.current) setMaxText(String(config.max)); }, [config.max]); - const handleBlur = (): void => { + const handleBlur = (changedKey: 'min' | 'max'): void => { minFocused.current = false; maxFocused.current = false; const min = parseFloat(minText); @@ -40,7 +40,7 @@ const NumericRandomizationManager: FC = (props) => { else onChange({ min }); if (Number.isNaN(max)) setMaxText(String(config.max)); else onChange({ max }); - onBlur?.(); + onBlur?.(changedKey); }; return ( @@ -48,7 +48,7 @@ const NumericRandomizationManager: FC = (props) => { handleBlur('min')} onChange={(e) => { setMinText(e.target.value); const v = parseFloat(e.target.value); @@ -63,7 +63,7 @@ const NumericRandomizationManager: FC = (props) => { handleBlur('max')} onChange={(e) => { setMaxText(e.target.value); const v = parseFloat(e.target.value); diff --git a/client/app/bundles/course/assessment/question/text-responses/components/SpreadsheetRandomizationManager.tsx b/client/app/bundles/course/assessment/question/text-responses/components/SpreadsheetRandomizationManager.tsx index 83d36a9029c..967f1f55eaf 100644 --- a/client/app/bundles/course/assessment/question/text-responses/components/SpreadsheetRandomizationManager.tsx +++ b/client/app/bundles/course/assessment/question/text-responses/components/SpreadsheetRandomizationManager.tsx @@ -536,17 +536,28 @@ const SpreadsheetRandomizationManager: FC = ({ {activeMode === 'numeric' && ( { + onBlur={(changedKey) => { const { min, max, roundToInteger } = activeConfig.numeric!; - if (min > max) - updateCellConfigs({ - ...cellConfigs, - [popover.cellKey]: { - ...activeConfig, - numeric: { min: max, max: min, roundToInteger }, - }, - }); + if (min > max) { + if (changedKey === 'min') { + updateCellConfigs({ + ...cellConfigs, + [popover.cellKey]: { + ...activeConfig, + numeric: { min, max: min, roundToInteger }, + }, + }); + } else { + updateCellConfigs({ + ...cellConfigs, + [popover.cellKey]: { + ...activeConfig, + numeric: { min: max, max, roundToInteger }, + }, + }); + } + } }} onChange={(changed) => { updateCellConfigs({ @@ -562,16 +573,27 @@ const SpreadsheetRandomizationManager: FC = ({ {activeMode === 'date' && ( { + onBlur={(changedKey) => { const { min, max, roundToDay } = activeConfig.date!; - if (min > max) - updateCellConfigs({ - ...cellConfigs, - [popover.cellKey]: { - ...activeConfig, - date: { min: max, max: min, roundToDay }, - }, - }); + if (min > max) { + if (changedKey === 'min') { + updateCellConfigs({ + ...cellConfigs, + [popover.cellKey]: { + ...activeConfig, + date: { min, max: min, roundToDay }, + }, + }); + } else { + updateCellConfigs({ + ...cellConfigs, + [popover.cellKey]: { + ...activeConfig, + date: { min: max, max, roundToDay }, + }, + }); + } + } }} onChange={(changed) => { updateCellConfigs({ diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpFilter.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpFilter.tsx index 373f2d28159..f9776a016ff 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpFilter.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpFilter.tsx @@ -1,294 +1,40 @@ import { FC } from 'react'; import { MessageDescriptor } from 'react-intl'; -import { - Autocomplete, - Box, - Chip, - Grid, - Stack, - TextField, - Typography, -} from '@mui/material'; -import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; -import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import useTranslation from 'lib/hooks/useTranslation'; -import moment from 'lib/moment'; +import GetHelpFilter, { + GetHelpFilterFields, +} from 'lib/components/core/GetHelpFilter'; import translations from 'lib/translations/getHelp'; -export interface GetHelpFilter { +export interface CourseGetHelpFilterFields extends GetHelpFilterFields { assessment: { title: string } | null; - user: { name: string } | null; - startDate: string; - endDate: string; } -interface Props { +interface CourseGetHelpFilterProps { assessmentOptions: { title: string }[]; userOptions: { name: string }[]; - selectedFilter: GetHelpFilter; - setSelectedFilter: (newFilter: GetHelpFilter) => void; - onFilterChange?: (filter: GetHelpFilter) => void; + selectedFilter: CourseGetHelpFilterFields; + setSelectedFilter: (newFilter: CourseGetHelpFilterFields) => void; + onFilterChange?: (filter: CourseGetHelpFilterFields) => void; getDateValidationError: ( - filter: GetHelpFilter, + filter: CourseGetHelpFilterFields, t: (msg: MessageDescriptor) => string, ) => string; } -interface PresetDateRangeChipsProps { - setSelectedFilter: (newFilter: GetHelpFilter) => void; - selectedFilter: GetHelpFilter; - onFilterChange?: (filter: GetHelpFilter) => void; -} - -const PresetDateRangeChips: FC = ({ - setSelectedFilter, - selectedFilter, - onFilterChange, -}) => { - const { t } = useTranslation(); - const chips = [ - { - label: t(translations.lastSevenDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 6); - return { start, end }; - }, - }, - { - label: t(translations.lastFourteenDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 13); - return { start, end }; - }, - }, - { - label: t(translations.lastThirtyDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 29); - return { start, end }; - }, - }, - { - label: t(translations.lastSixMonths), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setMonth(end.getMonth() - 5); // 6 months including current - start.setDate(1); // Start from the 1st of the month - return { start, end }; - }, - }, - { - label: t(translations.lastTwelveMonths), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setMonth(end.getMonth() - 11); // 12 months including current - start.setDate(1); // Start from the 1st of the month - return { start, end }; - }, - }, - ]; - - // Helper to check if the current date filter matches a preset - const isPresetSelected = (start: Date, end: Date): boolean => { - const startStr = start.toISOString().slice(0, 10); - const endStr = end.toISOString().slice(0, 10); - return ( - selectedFilter.startDate === startStr && selectedFilter.endDate === endStr - ); - }; - - return ( - - {chips.map((chip) => { - const { start, end } = chip.getRange(); - const selected = isPresetSelected(start, end); - return ( - { - const newFilter = { - ...selectedFilter, - startDate: start.toISOString().slice(0, 10), - endDate: end.toISOString().slice(0, 10), - }; - setSelectedFilter(newFilter); - onFilterChange?.(newFilter); - }} - size="small" - variant={selected ? 'filled' : 'outlined'} - /> - ); - })} - - ); -}; - -const FilterFields: FC = ({ +const CourseGetHelpFilter: FC = ({ assessmentOptions, - userOptions, - selectedFilter, - setSelectedFilter, - onFilterChange, - getDateValidationError, -}) => { - const { t } = useTranslation(); - - const handleFilterChange = (newFilter: GetHelpFilter): void => { - setSelectedFilter(newFilter); - onFilterChange?.(newFilter); - }; - - const getDateValue = (dateString: string): moment.Moment | null => { - if (!dateString) return null; - const date = moment(dateString); - return date.isValid() ? date : null; - }; - - const handleDateChange = ( - newValue: moment.Moment | null, - field: 'startDate' | 'endDate', - ): void => { - const newFilter = { - ...selectedFilter, - [field]: newValue?.isValid() ? newValue.format('YYYY-MM-DD') : '', - }; - handleFilterChange(newFilter); - }; - - return ( - - - option.title} - onChange={(_, value): void => { - const newFilter = { - ...selectedFilter, - assessment: value, - }; - handleFilterChange(newFilter); - }} - options={assessmentOptions} - renderInput={(params): JSX.Element => ( - - )} - value={selectedFilter.assessment} - /> - - - option.name} - onChange={(_, value): void => { - const newFilter = { - ...selectedFilter, - user: value, - }; - handleFilterChange(newFilter); - }} - options={userOptions} - renderInput={(params): JSX.Element => ( - - )} - value={selectedFilter.user} - /> - - - - handleDateChange(newValue, 'startDate')} - slotProps={{ - textField: { - fullWidth: true, - InputLabelProps: { shrink: true }, - }, - }} - value={getDateValue(selectedFilter.startDate)} - /> - - - - - handleDateChange(newValue, 'endDate')} - slotProps={{ - textField: { - fullWidth: true, - InputLabelProps: { shrink: true }, - error: !!getDateValidationError(selectedFilter, t), - }, - }} - value={getDateValue(selectedFilter.endDate)} - /> - - - - ); -}; - -const CourseGetHelpFilter: FC = (props) => { - const { - assessmentOptions, - userOptions, - selectedFilter, - setSelectedFilter, - onFilterChange, - getDateValidationError, - } = props; - - const { t } = useTranslation(); - const helperText = getDateValidationError(selectedFilter, t); - const sortedAssessmentOptions = [...assessmentOptions].sort((a, b) => - a.title.localeCompare(b.title), - ); - const sortedUserOptions = [...userOptions].sort((a, b) => - a.name.localeCompare(b.name), - ); - - return ( - - - - - - - {helperText} - - - - - - - ); -}; + ...props +}) => ( + ({ ...filter, assessment: value }), + }} + /> +); export default CourseGetHelpFilter; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpStatistics.tsx index ca6f45dfdbe..2eaf5b1ed17 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpStatistics.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/get_help/CourseGetHelpStatistics.tsx @@ -9,7 +9,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import translations from 'lib/translations/getHelp'; import CourseGetHelpFilter, { - GetHelpFilter as FilterType, + CourseGetHelpFilterFields, } from './CourseGetHelpFilter'; import CourseGetHelpStatisticsTable from './CourseGetHelpStatisticsTable'; @@ -23,14 +23,14 @@ const getDefaultDateRange = (): { startDate: string; endDate: string } => { }; }; -const defaultFilter: FilterType = { +const defaultFilter: CourseGetHelpFilterFields = { assessment: null, user: null, ...getDefaultDateRange(), }; const getDateValidationError = ( - filter: FilterType, + filter: CourseGetHelpFilterFields, t: (message: MessageDescriptor) => string, ): string => { const { startDate, endDate } = filter; @@ -39,7 +39,14 @@ const getDateValidationError = ( const start = new Date(startDate); const end = new Date(endDate); - if (end < start) return t(translations.invalidDateSelection); + if ( + start.getTime() <= 0 || + end.getTime() <= 0 || + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) + ) + return t(translations.invalidDateSelection); + if (end < start) return t(translations.endDateBeforeStartDate); const dayDiff = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); return dayDiff > 365 ? t(translations.exceedDateRange) : ''; @@ -50,15 +57,16 @@ const CourseGetHelpStatistics: FC = () => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [selectedFilter, setSelectedFilter] = - useState(defaultFilter); - const [appliedFilter, setAppliedFilter] = useState(defaultFilter); + useState(defaultFilter); + const [appliedFilter, setAppliedFilter] = + useState(defaultFilter); const lastFetchedDateRange = useRef<{ startDate: string; endDate: string }>({ startDate: '', endDate: '', }); - const fetchData = useCallback(async (filter: FilterType) => { + const fetchData = useCallback(async (filter: CourseGetHelpFilterFields) => { setIsLoading(true); const params = { start_at: filter.startDate, @@ -77,7 +85,7 @@ const CourseGetHelpStatistics: FC = () => { }; }, []); - const handleApplyFilter = (filter: FilterType): void => { + const handleApplyFilter = (filter: CourseGetHelpFilterFields): void => { const validationError = getDateValidationError(filter, t); if (validationError) { // Don't apply the filter if there's a validation error diff --git a/client/app/bundles/system/admin/admin/components/misc/SystemGetHelpFilter.tsx b/client/app/bundles/system/admin/admin/components/misc/SystemGetHelpFilter.tsx index 71b98bfb78b..452e71858b6 100644 --- a/client/app/bundles/system/admin/admin/components/misc/SystemGetHelpFilter.tsx +++ b/client/app/bundles/system/admin/admin/components/misc/SystemGetHelpFilter.tsx @@ -1,291 +1,40 @@ import { FC } from 'react'; import { MessageDescriptor } from 'react-intl'; -import { - Autocomplete, - Box, - Chip, - Grid, - Stack, - TextField, - Typography, -} from '@mui/material'; -import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; -import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import useTranslation from 'lib/hooks/useTranslation'; -import moment from 'lib/moment'; +import GetHelpFilter, { + GetHelpFilterFields, +} from 'lib/components/core/GetHelpFilter'; import translations from 'lib/translations/getHelp'; -export interface GetHelpFilter { +export interface SystemGetHelpFilterFields extends GetHelpFilterFields { course: { title: string } | null; - user: { name: string } | null; - startDate: string; - endDate: string; } -interface Props { +interface SystemGetHelpFilterProps { courseOptions: { title: string }[]; userOptions: { name: string }[]; - selectedFilter: GetHelpFilter; - setSelectedFilter: (newFilter: GetHelpFilter) => void; - onFilterChange: (filter: GetHelpFilter) => void; + selectedFilter: SystemGetHelpFilterFields; + setSelectedFilter: (newFilter: SystemGetHelpFilterFields) => void; + onFilterChange: (filter: SystemGetHelpFilterFields) => void; getDateValidationError: ( - filter: GetHelpFilter, + filter: SystemGetHelpFilterFields, t: (msg: MessageDescriptor) => string, ) => string; } -interface PresetDateRangeChipsProps { - setSelectedFilter: (newFilter: GetHelpFilter) => void; - selectedFilter: GetHelpFilter; - onFilterChange: (filter: GetHelpFilter) => void; -} - -const PresetDateRangeChips: FC = ({ - setSelectedFilter, - selectedFilter, - onFilterChange, -}) => { - const { t } = useTranslation(); - const chips = [ - { - label: t(translations.lastSevenDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 6); - return { start, end }; - }, - }, - { - label: t(translations.lastFourteenDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 13); - return { start, end }; - }, - }, - { - label: t(translations.lastThirtyDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 29); - return { start, end }; - }, - }, - { - label: t(translations.lastSixMonths), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setMonth(end.getMonth() - 5); // 6 months including current - start.setDate(1); // Start from the 1st of the month - return { start, end }; - }, - }, - { - label: t(translations.lastTwelveMonths), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setMonth(end.getMonth() - 11); // 12 months including current - start.setDate(1); // Start from the 1st of the month - return { start, end }; - }, - }, - ]; - - // Helper to check if the current date filter matches a preset - const isPresetSelected = (start: Date, end: Date): boolean => { - const startStr = start.toISOString().slice(0, 10); - const endStr = end.toISOString().slice(0, 10); - return ( - selectedFilter.startDate === startStr && selectedFilter.endDate === endStr - ); - }; - - return ( - - {chips.map((chip) => { - const { start, end } = chip.getRange(); - const selected = isPresetSelected(start, end); - return ( - { - const newFilter = { - ...selectedFilter, - startDate: start.toISOString().slice(0, 10), - endDate: end.toISOString().slice(0, 10), - }; - setSelectedFilter(newFilter); - onFilterChange(newFilter); - }} - size="small" - variant={selected ? 'filled' : 'outlined'} - /> - ); - })} - - ); -}; - -const FilterFields: FC = ({ +const SystemGetHelpFilter: FC = ({ courseOptions, - userOptions, - selectedFilter, - setSelectedFilter, - onFilterChange, - getDateValidationError, -}) => { - const { t } = useTranslation(); - - const handleFilterChange = (newFilter: GetHelpFilter): void => { - setSelectedFilter(newFilter); - onFilterChange(newFilter); - }; - - const getDateValue = (dateString: string): moment.Moment | null => { - if (!dateString) return null; - const date = moment(dateString); - return date.isValid() ? date : null; - }; - - const handleDateChange = ( - newValue: moment.Moment | null, - field: 'startDate' | 'endDate', - ): void => { - const newFilter = { - ...selectedFilter, - [field]: newValue?.isValid() ? newValue.format('YYYY-MM-DD') : '', - }; - handleFilterChange(newFilter); - }; - - return ( - - - option.title} - onChange={(_, value): void => { - const newFilter = { - ...selectedFilter, - course: value, - }; - handleFilterChange(newFilter); - }} - options={courseOptions} - renderInput={(params): JSX.Element => ( - - )} - value={selectedFilter.course} - /> - - - option.name} - onChange={(_, value): void => { - const newFilter = { - ...selectedFilter, - user: value, - }; - handleFilterChange(newFilter); - }} - options={userOptions} - renderInput={(params): JSX.Element => ( - - )} - value={selectedFilter.user} - /> - - - - handleDateChange(newValue, 'startDate')} - slotProps={{ - textField: { - fullWidth: true, - InputLabelProps: { shrink: true }, - }, - }} - value={getDateValue(selectedFilter.startDate)} - /> - - - - - handleDateChange(newValue, 'endDate')} - slotProps={{ - textField: { - fullWidth: true, - InputLabelProps: { shrink: true }, - error: !!getDateValidationError(selectedFilter, t), - }, - }} - value={getDateValue(selectedFilter.endDate)} - /> - - - - ); -}; - -const SystemGetHelpFilter: FC = (props) => { - const { - courseOptions, - userOptions, - selectedFilter, - setSelectedFilter, - onFilterChange, - getDateValidationError, - } = props; - - const { t } = useTranslation(); - const helperText = getDateValidationError(selectedFilter, t); - const sortedCourseOptions = [...courseOptions].sort((a, b) => - a.title.localeCompare(b.title), - ); - const sortedUserOptions = [...userOptions].sort((a, b) => - a.name.localeCompare(b.name), - ); - - return ( - - - - - - - {helperText} - - - - - - - ); -}; + ...props +}) => ( + ({ ...filter, course: value }), + }} + /> +); export default SystemGetHelpFilter; diff --git a/client/app/bundles/system/admin/admin/pages/SystemGetHelpActivityIndex.tsx b/client/app/bundles/system/admin/admin/pages/SystemGetHelpActivityIndex.tsx index 40bb47f488e..6193ff13120 100644 --- a/client/app/bundles/system/admin/admin/pages/SystemGetHelpActivityIndex.tsx +++ b/client/app/bundles/system/admin/admin/pages/SystemGetHelpActivityIndex.tsx @@ -8,7 +8,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import translations from 'lib/translations/getHelp'; import SystemGetHelpFilter, { - GetHelpFilter, + SystemGetHelpFilterFields, } from '../components/misc/SystemGetHelpFilter'; import SystemGetHelpActivityTable from '../components/tables/SystemGetHelpActivityTable'; import { fetchSystemGetHelpActivity } from '../operations'; @@ -23,13 +23,13 @@ const getDefaultDateRange = (): { startDate: string; endDate: string } => { }; }; -const defaultFilter: GetHelpFilter = { +const defaultFilter: SystemGetHelpFilterFields = { course: null, user: null, ...getDefaultDateRange(), }; const getDateValidationError = ( - filter: GetHelpFilter, + filter: SystemGetHelpFilterFields, t: (message: MessageDescriptor) => string, ): string => { const { startDate, endDate } = filter; @@ -38,7 +38,14 @@ const getDateValidationError = ( const start = new Date(startDate); const end = new Date(endDate); - if (end < start) return t(translations.invalidDateSelection); + if ( + start.getTime() <= 0 || + end.getTime() <= 0 || + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) + ) + return t(translations.invalidDateSelection); + if (end < start) return t(translations.endDateBeforeStartDate); const dayDiff = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); return dayDiff > 365 ? t(translations.exceedDateRange) : ''; @@ -49,9 +56,9 @@ const SystemGetHelpActivityIndex: FC = () => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [selectedFilter, setSelectedFilter] = - useState(defaultFilter); + useState(defaultFilter); const [appliedFilter, setAppliedFilter] = - useState(defaultFilter); + useState(defaultFilter); // Track the last fetched date range const lastFetchedDateRange = useRef<{ startDate: string; endDate: string }>({ @@ -59,7 +66,7 @@ const SystemGetHelpActivityIndex: FC = () => { endDate: '', }); - const fetchData = useCallback(async (filter: GetHelpFilter) => { + const fetchData = useCallback(async (filter: SystemGetHelpFilterFields) => { setIsLoading(true); const params = { start_at: filter.startDate, @@ -78,7 +85,7 @@ const SystemGetHelpActivityIndex: FC = () => { }; }, []); - const handleApplyFilter = (filter: GetHelpFilter): void => { + const handleApplyFilter = (filter: SystemGetHelpFilterFields): void => { const validationError = getDateValidationError(filter, t); if (validationError) { // Don't apply the filter if there's a validation error diff --git a/client/app/bundles/system/admin/instance/instance/components/misc/InstanceGetHelpFilter.tsx b/client/app/bundles/system/admin/instance/instance/components/misc/InstanceGetHelpFilter.tsx index 791979033ad..148136186da 100644 --- a/client/app/bundles/system/admin/instance/instance/components/misc/InstanceGetHelpFilter.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/misc/InstanceGetHelpFilter.tsx @@ -1,291 +1,40 @@ import { FC } from 'react'; import { MessageDescriptor } from 'react-intl'; -import { - Autocomplete, - Box, - Chip, - Grid, - Stack, - TextField, - Typography, -} from '@mui/material'; -import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; -import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import useTranslation from 'lib/hooks/useTranslation'; -import moment from 'lib/moment'; +import GetHelpFilter, { + GetHelpFilterFields, +} from 'lib/components/core/GetHelpFilter'; import translations from 'lib/translations/getHelp'; -export interface GetHelpFilter { +export interface InstanceGetHelpFilterFields extends GetHelpFilterFields { course: { title: string } | null; - user: { name: string } | null; - startDate: string; - endDate: string; } -interface Props { +interface InstanceGetHelpFilterProps { courseOptions: { title: string }[]; userOptions: { name: string }[]; - selectedFilter: GetHelpFilter; - setSelectedFilter: (newFilter: GetHelpFilter) => void; - onFilterChange?: (filter: GetHelpFilter) => void; + selectedFilter: InstanceGetHelpFilterFields; + setSelectedFilter: (newFilter: InstanceGetHelpFilterFields) => void; + onFilterChange?: (filter: InstanceGetHelpFilterFields) => void; getDateValidationError: ( - filter: GetHelpFilter, + filter: InstanceGetHelpFilterFields, t: (msg: MessageDescriptor) => string, ) => string; } -interface PresetDateRangeChipsProps { - setSelectedFilter: (newFilter: GetHelpFilter) => void; - selectedFilter: GetHelpFilter; - onFilterChange?: (filter: GetHelpFilter) => void; -} - -const PresetDateRangeChips: FC = ({ - setSelectedFilter, - selectedFilter, - onFilterChange, -}) => { - const { t } = useTranslation(); - const chips = [ - { - label: t(translations.lastSevenDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 6); - return { start, end }; - }, - }, - { - label: t(translations.lastFourteenDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 13); - return { start, end }; - }, - }, - { - label: t(translations.lastThirtyDays), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 29); - return { start, end }; - }, - }, - { - label: t(translations.lastSixMonths), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setMonth(end.getMonth() - 5); // 6 months including current - start.setDate(1); // Start from the 1st of the month - return { start, end }; - }, - }, - { - label: t(translations.lastTwelveMonths), - getRange: (): { start: Date; end: Date } => { - const end = new Date(); - const start = new Date(); - start.setMonth(end.getMonth() - 11); // 12 months including current - start.setDate(1); // Start from the 1st of the month - return { start, end }; - }, - }, - ]; - - // Helper to check if the current date filter matches a preset - const isPresetSelected = (start: Date, end: Date): boolean => { - const startStr = start.toISOString().slice(0, 10); - const endStr = end.toISOString().slice(0, 10); - return ( - selectedFilter.startDate === startStr && selectedFilter.endDate === endStr - ); - }; - - return ( - - {chips.map((chip) => { - const { start, end } = chip.getRange(); - const selected = isPresetSelected(start, end); - return ( - { - const newFilter = { - ...selectedFilter, - startDate: start.toISOString().slice(0, 10), - endDate: end.toISOString().slice(0, 10), - }; - setSelectedFilter(newFilter); - onFilterChange?.(newFilter); - }} - size="small" - variant={selected ? 'filled' : 'outlined'} - /> - ); - })} - - ); -}; - -const FilterFields: FC = ({ +const InstanceGetHelpFilter: FC = ({ courseOptions, - userOptions, - selectedFilter, - setSelectedFilter, - onFilterChange, - getDateValidationError, -}) => { - const { t } = useTranslation(); - - const handleFilterChange = (newFilter: GetHelpFilter): void => { - setSelectedFilter(newFilter); - onFilterChange?.(newFilter); - }; - - const getDateValue = (dateString: string): moment.Moment | null => { - if (!dateString) return null; - const date = moment(dateString); - return date.isValid() ? date : null; - }; - - const handleDateChange = ( - newValue: moment.Moment | null, - field: 'startDate' | 'endDate', - ): void => { - const newFilter = { - ...selectedFilter, - [field]: newValue?.isValid() ? newValue.format('YYYY-MM-DD') : '', - }; - handleFilterChange(newFilter); - }; - - return ( - - - option.title} - onChange={(_, value): void => { - const newFilter = { - ...selectedFilter, - course: value, - }; - handleFilterChange(newFilter); - }} - options={courseOptions} - renderInput={(params): JSX.Element => ( - - )} - value={selectedFilter.course} - /> - - - option.name} - onChange={(_, value): void => { - const newFilter = { - ...selectedFilter, - user: value, - }; - handleFilterChange(newFilter); - }} - options={userOptions} - renderInput={(params): JSX.Element => ( - - )} - value={selectedFilter.user} - /> - - - - handleDateChange(newValue, 'startDate')} - slotProps={{ - textField: { - fullWidth: true, - InputLabelProps: { shrink: true }, - }, - }} - value={getDateValue(selectedFilter.startDate)} - /> - - - - - handleDateChange(newValue, 'endDate')} - slotProps={{ - textField: { - fullWidth: true, - InputLabelProps: { shrink: true }, - error: !!getDateValidationError(selectedFilter, t), - }, - }} - value={getDateValue(selectedFilter.endDate)} - /> - - - - ); -}; - -const InstanceGetHelpFilter: FC = (props) => { - const { - courseOptions, - userOptions, - selectedFilter, - setSelectedFilter, - onFilterChange, - getDateValidationError, - } = props; - - const { t } = useTranslation(); - const helperText = getDateValidationError(selectedFilter, t); - const sortedCourseOptions = [...courseOptions].sort((a, b) => - a.title.localeCompare(b.title), - ); - const sortedUserOptions = [...userOptions].sort((a, b) => - a.name.localeCompare(b.name), - ); - - return ( - - - - - - - {helperText} - - - - - - - ); -}; + ...props +}) => ( + ({ ...filter, course: value }), + }} + /> +); export default InstanceGetHelpFilter; diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceGetHelpActivityIndex.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceGetHelpActivityIndex.tsx index 5d5a5431427..6c3f7e1038f 100644 --- a/client/app/bundles/system/admin/instance/instance/pages/InstanceGetHelpActivityIndex.tsx +++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceGetHelpActivityIndex.tsx @@ -8,7 +8,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import translations from 'lib/translations/getHelp'; import InstanceGetHelpFilter, { - GetHelpFilter, + InstanceGetHelpFilterFields, } from '../components/misc/InstanceGetHelpFilter'; import InstanceGetHelpActivityTable from '../components/tables/InstanceGetHelpActivityTable'; import { fetchInstanceGetHelpActivity } from '../operations'; @@ -23,14 +23,14 @@ const getDefaultDateRange = (): { startDate: string; endDate: string } => { }; }; -const defaultFilter: GetHelpFilter = { +const defaultFilter: InstanceGetHelpFilterFields = { course: null, user: null, ...getDefaultDateRange(), }; const getDateValidationError = ( - filter: GetHelpFilter, + filter: InstanceGetHelpFilterFields, t: (message: MessageDescriptor) => string, ): string => { const { startDate, endDate } = filter; @@ -39,7 +39,14 @@ const getDateValidationError = ( const start = new Date(startDate); const end = new Date(endDate); - if (end < start) return t(translations.invalidDateSelection); + if ( + start.getTime() <= 0 || + end.getTime() <= 0 || + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) + ) + return t(translations.invalidDateSelection); + if (end < start) return t(translations.endDateBeforeStartDate); const dayDiff = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); return dayDiff > 365 ? t(translations.exceedDateRange) : ''; @@ -50,16 +57,16 @@ const InstanceGetHelpActivityIndex: FC = () => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [selectedFilter, setSelectedFilter] = - useState(defaultFilter); + useState(defaultFilter); const [appliedFilter, setAppliedFilter] = - useState(defaultFilter); + useState(defaultFilter); const lastFetchedDateRange = useRef<{ startDate: string; endDate: string }>({ startDate: '', endDate: '', }); - const fetchData = useCallback(async (filter: GetHelpFilter) => { + const fetchData = useCallback(async (filter: InstanceGetHelpFilterFields) => { setIsLoading(true); const params = { start_at: filter.startDate, @@ -78,7 +85,7 @@ const InstanceGetHelpActivityIndex: FC = () => { }; }, []); - const handleApplyFilter = (filter: GetHelpFilter): void => { + const handleApplyFilter = (filter: InstanceGetHelpFilterFields): void => { const validationError = getDateValidationError(filter, t); if (validationError) { // Don't apply the filter if there's a validation error diff --git a/client/app/lib/components/core/GetHelpFilter.tsx b/client/app/lib/components/core/GetHelpFilter.tsx new file mode 100644 index 00000000000..f168a53830b --- /dev/null +++ b/client/app/lib/components/core/GetHelpFilter.tsx @@ -0,0 +1,332 @@ +import { JSX } from 'react'; +import { MessageDescriptor } from 'react-intl'; +import { + Autocomplete, + Box, + Chip, + Grid, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; +import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; + +import useTranslation from 'lib/hooks/useTranslation'; +import moment from 'lib/moment'; +import translations from 'lib/translations/getHelp'; + +const DATE_FORMAT = 'YYYY-MM-DD'; +const MAX_RANGE_DAYS = 365; + +/** + * Fields shared by every Get Help filter. The "primary" entity (assessment for + * courses, course for system/instance) varies per consumer and is supplied via + * the `primaryField` prop, so it is intentionally not part of this base shape. + */ +export interface GetHelpFilterFields { + user: { name: string } | null; + startDate: string; + endDate: string; +} + +interface PrimaryFieldConfig { + label: MessageDescriptor; + options: { title: string }[]; + value: { title: string } | null; + setValue: (filter: F, value: { title: string } | null) => F; +} + +interface PresetDateRangeChipsProps { + setSelectedFilter: (newFilter: F) => void; + selectedFilter: F; + onFilterChange?: (filter: F) => void; +} + +const PresetDateRangeChips = ({ + setSelectedFilter, + selectedFilter, + onFilterChange, +}: PresetDateRangeChipsProps): JSX.Element => { + const { t } = useTranslation(); + const chips = [ + { + label: t(translations.lastSevenDays), + getRange: (): { start: Date; end: Date } => { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - 6); + return { start, end }; + }, + }, + { + label: t(translations.lastFourteenDays), + getRange: (): { start: Date; end: Date } => { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - 13); + return { start, end }; + }, + }, + { + label: t(translations.lastThirtyDays), + getRange: (): { start: Date; end: Date } => { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - 29); + return { start, end }; + }, + }, + { + label: t(translations.lastSixMonths), + getRange: (): { start: Date; end: Date } => { + const end = new Date(); + const start = new Date(); + start.setMonth(end.getMonth() - 5); // 6 months including current + start.setDate(1); // Start from the 1st of the month + return { start, end }; + }, + }, + { + label: t(translations.lastTwelveMonths), + getRange: (): { start: Date; end: Date } => { + const end = new Date(); + const start = new Date(); + start.setMonth(end.getMonth() - 11); // 12 months including current + start.setDate(1); // Start from the 1st of the month + return { start, end }; + }, + }, + ]; + + // Helper to check if the current date filter matches a preset + const isPresetSelected = (start: Date, end: Date): boolean => { + const startStr = start.toISOString().slice(0, 10); + const endStr = end.toISOString().slice(0, 10); + return ( + selectedFilter.startDate === startStr && selectedFilter.endDate === endStr + ); + }; + + return ( + + {chips.map((chip) => { + const { start, end } = chip.getRange(); + const selected = isPresetSelected(start, end); + return ( + { + const newFilter = { + ...selectedFilter, + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; + setSelectedFilter(newFilter); + onFilterChange?.(newFilter); + }} + size="small" + variant={selected ? 'filled' : 'outlined'} + /> + ); + })} + + ); +}; + +/** + * Returns the next filter after one date endpoint is changed, keeping the range + * valid. Returns `null` when `newValue` is missing/invalid (i.e. no change). + * + * When an endpoint is moved: + * - If it crosses the other endpoint, the other endpoint is shifted to preserve + * the current range length. + * - Otherwise the other endpoint is clamped so the range stays <= 365 days. + */ +export const getFilterForDateChange = ( + filter: F, + newValue: moment.Moment | null, + field: 'startDate' | 'endDate', +): F | null => { + if (!newValue?.isValid()) return null; + + if (field === 'startDate') { + const endDate = moment(filter.endDate); + if (newValue.isAfter(endDate)) { + const newEndDate = newValue + .clone() + .add(endDate.diff(moment(filter.startDate))); + return { + ...filter, + startDate: newValue.format(DATE_FORMAT), + endDate: newEndDate.format(DATE_FORMAT), + }; + } + const maxEndDate = newValue.clone().add(MAX_RANGE_DAYS, 'days'); + return { + ...filter, + startDate: newValue.format(DATE_FORMAT), + endDate: moment.min(endDate, maxEndDate).format(DATE_FORMAT), + }; + } + + const startDate = moment(filter.startDate); + if (newValue.isBefore(startDate)) { + const newStartDate = newValue + .clone() + .subtract(moment(filter.endDate).diff(startDate)); + return { + ...filter, + startDate: newStartDate.format(DATE_FORMAT), + endDate: newValue.format(DATE_FORMAT), + }; + } + const minStartDate = newValue.clone().subtract(MAX_RANGE_DAYS, 'days'); + return { + ...filter, + startDate: moment.max(startDate, minStartDate).format(DATE_FORMAT), + endDate: newValue.format(DATE_FORMAT), + }; +}; + +interface GetHelpFilterProps { + userOptions: { name: string }[]; + selectedFilter: F; + setSelectedFilter: (newFilter: F) => void; + onFilterChange?: (filter: F) => void; + getDateValidationError: ( + filter: F, + t: (msg: MessageDescriptor) => string, + ) => string; + primaryField: PrimaryFieldConfig; +} + +const GetHelpFilter = ({ + userOptions, + selectedFilter, + setSelectedFilter, + onFilterChange, + getDateValidationError, + primaryField, +}: GetHelpFilterProps): JSX.Element => { + const { t } = useTranslation(); + + const handleFilterChange = (newFilter: F): void => { + setSelectedFilter(newFilter); + onFilterChange?.(newFilter); + }; + + const getDateValue = (dateString: string): moment.Moment | null => { + if (!dateString) return null; + const date = moment(dateString); + return date.isValid() ? date : null; + }; + + const handleDateChange = ( + newValue: moment.Moment | null, + field: 'startDate' | 'endDate', + ): void => { + const nextFilter = getFilterForDateChange(selectedFilter, newValue, field); + if (nextFilter) handleFilterChange(nextFilter); + }; + + const helperText = getDateValidationError(selectedFilter, t); + const sortedPrimaryOptions = [...primaryField.options].sort((a, b) => + a.title.localeCompare(b.title), + ); + const sortedUserOptions = [...userOptions].sort((a, b) => + a.name.localeCompare(b.name), + ); + + return ( + + + + option.title} + onChange={(_, value): void => + handleFilterChange(primaryField.setValue(selectedFilter, value)) + } + options={sortedPrimaryOptions} + renderInput={(params): JSX.Element => ( + + )} + value={primaryField.value} + /> + + + option.name} + onChange={(_, value): void => + handleFilterChange({ ...selectedFilter, user: value }) + } + options={sortedUserOptions} + renderInput={(params): JSX.Element => ( + + )} + value={selectedFilter.user} + /> + + + + handleDateChange(newValue, 'startDate')} + slotProps={{ + textField: { + fullWidth: true, + InputLabelProps: { shrink: true }, + }, + }} + value={getDateValue(selectedFilter.startDate)} + /> + + + + + handleDateChange(newValue, 'endDate')} + slotProps={{ + textField: { + fullWidth: true, + InputLabelProps: { shrink: true }, + error: !!helperText, + }, + }} + value={getDateValue(selectedFilter.endDate)} + /> + + + + + + + + {helperText} + + + + + + + ); +}; + +export default GetHelpFilter; diff --git a/client/app/lib/components/core/__test__/GetHelpFilter.test.tsx b/client/app/lib/components/core/__test__/GetHelpFilter.test.tsx new file mode 100644 index 00000000000..36192a907d3 --- /dev/null +++ b/client/app/lib/components/core/__test__/GetHelpFilter.test.tsx @@ -0,0 +1,275 @@ +import { MessageDescriptor } from 'react-intl'; +import { fireEvent, render, screen } from 'test-utils'; + +import moment from 'lib/moment'; +import translations from 'lib/translations/getHelp'; + +import GetHelpFilter, { + getFilterForDateChange, + GetHelpFilterFields, +} from '../GetHelpFilter'; + +interface TestFilter extends GetHelpFilterFields { + assessment: { title: string } | null; +} + +interface Props { + userOptions: { name: string }[]; + selectedFilter: TestFilter; + setSelectedFilter: (filter: TestFilter) => void; + onFilterChange?: (filter: TestFilter) => void; + getDateValidationError: ( + filter: TestFilter, + t: (msg: MessageDescriptor) => string, + ) => string; + primaryField: { + label: MessageDescriptor; + options: { title: string }[]; + value: { title: string } | null; + setValue: ( + filter: TestFilter, + value: { title: string } | null, + ) => TestFilter; + }; +} + +// Shared references: MUI Autocomplete compares the selected value to its +// options by reference, so a selected value must be the same object as an +// option (as it would be in real usage, where it comes from `onChange`). +const ALPHA = { title: 'Alpha' }; +const BETA = { title: 'Beta' }; +const AMY = { name: 'Amy' }; +const ZOE = { name: 'Zoe' }; + +const baseFilter: TestFilter = { + assessment: null, + user: null, + startDate: '2024-06-09', + endDate: '2024-06-15', +}; + +const makeProps = (overrides: Partial = {}): Props => { + const selectedFilter = overrides.selectedFilter ?? baseFilter; + return { + userOptions: [ZOE, AMY], + setSelectedFilter: jest.fn(), + onFilterChange: jest.fn(), + getDateValidationError: jest.fn(() => ''), + ...overrides, + selectedFilter, + primaryField: { + label: translations.filterAssessmentLabel, + options: [BETA, ALPHA], + value: selectedFilter.assessment, + setValue: (filter, value) => ({ ...filter, assessment: value }), + ...overrides.primaryField, + }, + }; +}; + +// `render` mounts behind an async i18n loading gate; wait for the filter's +// first field to appear before asserting. +const renderFilter = async (overrides: Partial = {}): Promise => { + const props = makeProps(overrides); + render(); + await screen.findByLabelText('Start Date'); + return props; +}; + +describe('', () => { + it('renders the primary, student, and date fields with current values', async () => { + await renderFilter(); + + expect(screen.getByLabelText('Filter by Assessment')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by Student')).toBeInTheDocument(); + expect(screen.getByLabelText('Start Date')).toHaveValue('09/06/2024'); + expect(screen.getByLabelText('End Date')).toHaveValue('15/06/2024'); + }); + + it('reflects the selected primary and user values', async () => { + await renderFilter({ + selectedFilter: { + ...baseFilter, + assessment: ALPHA, + user: AMY, + }, + }); + + expect(screen.getByLabelText('Filter by Assessment')).toHaveValue('Alpha'); + expect(screen.getByLabelText('Filter by Student')).toHaveValue('Amy'); + }); + + it('renders all preset range chips', async () => { + await renderFilter(); + + [ + 'Last 7 Days', + 'Last 14 Days', + 'Last 30 Days', + 'Last 6 Months', + 'Last 12 Months', + ].forEach((label) => expect(screen.getByText(label)).toBeInTheDocument()); + }); + + it('applies a preset range when its chip is clicked', async () => { + const props = await renderFilter(); + + fireEvent.click(screen.getByText('Last 7 Days')); + + const applied = (props.setSelectedFilter as jest.Mock).mock.calls[0][0]; + const dayDiff = + (new Date(applied.endDate).getTime() - + new Date(applied.startDate).getTime()) / + (1000 * 60 * 60 * 24); + + expect(dayDiff).toBe(6); // 7 days inclusive + expect(props.setSelectedFilter).toHaveBeenCalledTimes(1); + expect(props.onFilterChange).toHaveBeenCalledTimes(1); + expect((props.onFilterChange as jest.Mock).mock.calls[0][0]).toBe(applied); + }); + + it('preserves the non-date fields when a preset is applied', async () => { + const props = await renderFilter({ + selectedFilter: { + ...baseFilter, + assessment: ALPHA, + user: AMY, + }, + }); + + fireEvent.click(screen.getByText('Last 30 Days')); + + const applied = (props.setSelectedFilter as jest.Mock).mock.calls[0][0]; + expect(applied.assessment).toEqual({ title: 'Alpha' }); + expect(applied.user).toEqual({ name: 'Amy' }); + const dayDiff = + (new Date(applied.endDate).getTime() - + new Date(applied.startDate).getTime()) / + (1000 * 60 * 60 * 24); + expect(dayDiff).toBe(29); // 30 days inclusive + }); + + it('does not throw when no onFilterChange handler is provided', async () => { + const props = await renderFilter({ onFilterChange: undefined }); + + fireEvent.click(screen.getByText('Last 7 Days')); + + expect(props.setSelectedFilter).toHaveBeenCalledTimes(1); + }); + + it('shows the validation error and marks the end date field invalid', async () => { + const message = 'Date range cannot exceed 365 days'; + await renderFilter({ getDateValidationError: jest.fn(() => message) }); + + expect(screen.getByText(message)).toBeInTheDocument(); + expect(screen.getByLabelText('End Date')).toBeInvalid(); + }); + + it('leaves the end date field valid when there is no error', async () => { + await renderFilter(); + + expect(screen.getByLabelText('End Date')).toBeValid(); + }); +}); + +describe('getFilterForDateChange', () => { + // 6-day range: 2024-06-09 .. 2024-06-15 + const filter: TestFilter = { + assessment: null, + user: null, + startDate: '2024-06-09', + endDate: '2024-06-15', + }; + + it('returns null for a missing or invalid date', () => { + expect(getFilterForDateChange(filter, null, 'startDate')).toBeNull(); + expect(getFilterForDateChange(filter, moment(NaN), 'endDate')).toBeNull(); + }); + + describe('when the start date changes', () => { + it('keeps the end date when the range stays within bounds', () => { + const result = getFilterForDateChange( + filter, + moment('2024-06-10'), + 'startDate', + ); + expect(result).toMatchObject({ + startDate: '2024-06-10', + endDate: '2024-06-15', + }); + }); + + it('shifts the end date to preserve the range length when start moves past end', () => { + const result = getFilterForDateChange( + filter, + moment('2024-06-20'), + 'startDate', + ); + // range was 6 days, so the end follows to keep it 6 days + expect(result).toMatchObject({ + startDate: '2024-06-20', + endDate: '2024-06-26', + }); + }); + + it('clamps the end date so the range cannot exceed 365 days', () => { + const result = getFilterForDateChange( + filter, + moment('2023-01-01'), + 'startDate', + ); + expect(result).toMatchObject({ + startDate: '2023-01-01', + endDate: '2024-01-01', + }); + }); + }); + + describe('when the end date changes', () => { + it('keeps the start date when the range stays within bounds', () => { + const result = getFilterForDateChange( + filter, + moment('2024-06-20'), + 'endDate', + ); + expect(result).toMatchObject({ + startDate: '2024-06-09', + endDate: '2024-06-20', + }); + }); + + it('shifts the start date to preserve the range length when end moves before start', () => { + const result = getFilterForDateChange( + filter, + moment('2024-06-05'), + 'endDate', + ); + expect(result).toMatchObject({ + startDate: '2024-05-30', + endDate: '2024-06-05', + }); + }); + + it('clamps the start date so the range cannot exceed 365 days', () => { + const result = getFilterForDateChange( + filter, + moment('2026-06-15'), + 'endDate', + ); + expect(result).toMatchObject({ + startDate: '2025-06-15', + endDate: '2026-06-15', + }); + }); + }); + + it('preserves the non-date fields', () => { + const result = getFilterForDateChange( + { ...filter, assessment: ALPHA, user: AMY }, + moment('2024-06-10'), + 'startDate', + ); + expect(result?.assessment).toBe(ALPHA); + expect(result?.user).toBe(AMY); + }); +}); diff --git a/client/app/lib/translations/getHelp.ts b/client/app/lib/translations/getHelp.ts index d583a3b417b..6be04994926 100644 --- a/client/app/lib/translations/getHelp.ts +++ b/client/app/lib/translations/getHelp.ts @@ -80,7 +80,11 @@ const translations = defineMessages({ }, invalidDateSelection: { id: 'lib.components.getHelp.validation.invalidDateSelection', - defaultMessage: 'End Date must be after or equal to Start Date', + defaultMessage: 'Invalid date', + }, + endDateBeforeStartDate: { + id: 'lib.components.getHelp.validation.endDateBeforeStartDate', + defaultMessage: 'End date must be after or equal to Start date', }, exceedDateRange: { id: 'lib.components.getHelp.validation.exceedDateRange', diff --git a/client/locales/en.json b/client/locales/en.json index c985d1e1030..dd6c94ad676 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8050,7 +8050,10 @@ "defaultMessage": "Instance" }, "lib.components.getHelp.validation.invalidDateSelection": { - "defaultMessage": "End Date must be after or equal to Start Date" + "defaultMessage": "Invalid date" + }, + "lib.components.getHelp.validation.endDateBeforeStartDate": { + "defaultMessage": "End date must be after or equal to Start date" }, "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "Date range cannot exceed 365 days" diff --git a/client/locales/ko.json b/client/locales/ko.json index 4e5335475ca..aa466d9880f 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8037,6 +8037,9 @@ "defaultMessage": "인스턴스" }, "lib.components.getHelp.validation.invalidDateSelection": { + "defaultMessage": "잘못된 날짜" + }, + "lib.components.getHelp.validation.endDateBeforeStartDate": { "defaultMessage": "종료일은 시작일 이후이거나 같아야 합니다" }, "lib.components.getHelp.validation.exceedDateRange": { diff --git a/client/locales/zh.json b/client/locales/zh.json index 4595d3f0cb6..9a82bf3ce6b 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8031,6 +8031,9 @@ "defaultMessage": "实例" }, "lib.components.getHelp.validation.invalidDateSelection": { + "defaultMessage": "无效日期" + }, + "lib.components.getHelp.validation.endDateBeforeStartDate": { "defaultMessage": "结束日期必须大于或等于开始日期" }, "lib.components.getHelp.validation.exceedDateRange": {