diff --git a/packages/react-material-ui/__tests__/DateRangePicker.spec.tsx b/packages/react-material-ui/__tests__/DateRangePicker.spec.tsx new file mode 100644 index 00000000..e0d5db73 --- /dev/null +++ b/packages/react-material-ui/__tests__/DateRangePicker.spec.tsx @@ -0,0 +1,60 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom'; +import React from 'react'; +import { render } from '@testing-library/react'; +import DateRangePicker from '../src/components/DateRangePicker'; + +describe('DateRangePicker Component', () => { + test('should render correctly', () => { + const { getByRole } = render(); + const field = getByRole('group'); + + expect(field).toBeInTheDocument(); + }); + + test('should render correctly with label', () => { + const { getByText, getByRole } = render( + , + ); + const field = getByRole('group'); + const legend = getByText('Date Range'); + + expect(field).toBeInTheDocument(); + expect(legend).toBeInTheDocument(); + }); + + test('should render correctly with label and display two inputs', () => { + const { getByText, getByRole, getByTestId } = render( + , + ); + const field = getByRole('group'); + const legend = getByText('Date Range'); + const startDateInput = getByTestId('start-date-input'); + const endDateInput = getByTestId('end-date-input'); + + expect(field).toBeInTheDocument(); + expect(legend).toBeInTheDocument(); + expect(startDateInput).toBeInTheDocument(); + expect(endDateInput).toBeInTheDocument(); + }); + + test('should set input values when prop is passed', () => { + const { getByTestId } = render( + , + ); + const startDateInput = getByTestId('start-date-input'); + const endDateInput = getByTestId('end-date-input'); + + expect(startDateInput).toHaveValue('2024-12-10'); + expect(endDateInput).toHaveValue('2025-01-08'); + }); +}); diff --git a/packages/react-material-ui/package.json b/packages/react-material-ui/package.json index da1e8dd3..b849f1db 100644 --- a/packages/react-material-ui/package.json +++ b/packages/react-material-ui/package.json @@ -40,6 +40,7 @@ "@rjsf/mui": "^5.0.0-beta.13", "@rjsf/utils": "^5.0.0-beta.13", "@rjsf/validator-ajv6": "^5.0.0-beta.13", + "date-fns": "^4.1.0", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/react-material-ui/src/components/DateRangePicker/DateInput/Styles.ts b/packages/react-material-ui/src/components/DateRangePicker/DateInput/Styles.ts new file mode 100644 index 00000000..ccb3fd1c --- /dev/null +++ b/packages/react-material-ui/src/components/DateRangePicker/DateInput/Styles.ts @@ -0,0 +1,17 @@ +import { styled } from '@mui/material/styles'; + +export const CustomInput = styled('input')({ + border: 'none', + textAlign: 'center', + textTransform: 'uppercase', + outline: 'none', + fontFamily: 'inherit', + fontSize: '1rem', + width: '112px', + '&::-webkit-calendar-picker-indicator': { + display: 'none', + }, + '&::-moz-calendar-picker-indicator': { + display: 'none', + }, +}); diff --git a/packages/react-material-ui/src/components/DateRangePicker/DateInput/index.tsx b/packages/react-material-ui/src/components/DateRangePicker/DateInput/index.tsx new file mode 100644 index 00000000..abd15d2e --- /dev/null +++ b/packages/react-material-ui/src/components/DateRangePicker/DateInput/index.tsx @@ -0,0 +1,10 @@ +import React, { InputHTMLAttributes, forwardRef } from 'react'; +import { CustomInput } from './styles'; + +type Props = InputHTMLAttributes; + +const DateInput = forwardRef((props: Props, ref: any) => { + return ; +}); + +export default DateInput; diff --git a/packages/react-material-ui/src/components/DateRangePicker/README.md b/packages/react-material-ui/src/components/DateRangePicker/README.md new file mode 100644 index 00000000..796d8454 --- /dev/null +++ b/packages/react-material-ui/src/components/DateRangePicker/README.md @@ -0,0 +1,29 @@ +# DateRangePicker + +The DateRangePicker is a custom set of inputs that deal with a range of dates. It is composed by a fieldset with two inputs with type date inside, each one handling half of the date range. + +## Example + +The following example describes the full composition that mounts the Filter component: + +```tsx +import { DateRangePicker } from '@concepta/react-material-ui'; + + setDateRangeOnState(range)} +/>; +``` + +## Props + +| Name | Type | Description | Optional | +| --- | --- | --- | --- | +| label | `string` | The label of the field, similar to MUI's `Input` | No +| error | `string` | Error message displayed on the bottom of the field | No +| sx | `object` | Custom styles to be applied to the fieldset container | No +| onRangeUpdate | `function` | Handler for updates in the date range. Returns an object containing `startDate` and `endDate` | No + +> The rest of the DateRangePicker props extend from [HTML `fieldset`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset). diff --git a/packages/react-material-ui/src/components/DateRangePicker/Styles.ts b/packages/react-material-ui/src/components/DateRangePicker/Styles.ts new file mode 100644 index 00000000..f17c20db --- /dev/null +++ b/packages/react-material-ui/src/components/DateRangePicker/Styles.ts @@ -0,0 +1,8 @@ +import { styled } from '@mui/material/styles'; + +export const CustomCalendarHeaderRoot = styled('div')({ + display: 'flex', + justifyContent: 'space-between', + padding: '8px 16px', + alignItems: 'center', +}); diff --git a/packages/react-material-ui/src/components/DateRangePicker/index.tsx b/packages/react-material-ui/src/components/DateRangePicker/index.tsx new file mode 100644 index 00000000..34394262 --- /dev/null +++ b/packages/react-material-ui/src/components/DateRangePicker/index.tsx @@ -0,0 +1,503 @@ +import React, { + useState, + useRef, + useEffect, + FieldsetHTMLAttributes, +} from 'react'; +import { + Box, + Popover, + Button, + Typography, + Grid, + IconButton, + SxProps, + ClickAwayListener, + FormHelperText, + Stack, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { + DateCalendar, + PickersDay, + PickersDayProps, + PickersCalendarHeaderProps, +} from '@mui/x-date-pickers'; +import { + isWithinInterval, + isSameDay, + format, + startOfDay, + endOfDay, + addMonths, + addDays, + subMonths, + isBefore, + isAfter, +} from 'date-fns'; +import ChevronLeft from '@mui/icons-material/ChevronLeft'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import DateInput from './DateInput'; +import { CustomCalendarHeaderRoot } from './styles'; + +export interface DateRange { + startDate: Date | null; + endDate: Date | null; +} + +enum DateSelectionMode { + FROM = 'from', + TO = 'to', +} + +export type DateRangePickerProps = { + type?: string; + label?: string; + value?: DateRange; + sx?: SxProps; + error?: string; + onRangeUpdate?: (range: DateRange) => void; +} & FieldsetHTMLAttributes; + +const DateRangePicker = ({ + label, + error, + value, + onRangeUpdate, + ...props +}: DateRangePickerProps) => { + const fieldsetRef = useRef(null); + const previousMonthButtonRef = useRef(null); + const nextMonthButtonRef = useRef(null); + const startDateInputRef = useRef(null); + const endDateInputRef = useRef(null); + + const [anchorEl, setAnchorEl] = useState(null); + const [dateRange, setDateRange] = useState({ + startDate: null, + endDate: null, + }); + const [startDateInputValue, setStartDateInputValue] = useState(''); + const [endDateInputValue, setEndDateInputValue] = useState(''); + const [hoveredDate, setHoveredDate] = useState(null); + const [dateSelectionMode, setDateSelectionMode] = useState( + DateSelectionMode.FROM, + ); + const [errorMessage, setErrorMessage] = useState(''); + + const handleOpen = () => { + startDateInputRef?.current?.focus(); + setAnchorEl(fieldsetRef.current); + }; + + const handleClose = () => { + setAnchorEl(null); + setHoveredDate(null); + }; + + const open = Boolean(anchorEl); + + const isDateInRange = (date: Date) => { + const { startDate, endDate } = dateRange; + + if (startDate && endDate) { + return isWithinInterval(date, { + start: startOfDay(startDate), + end: endOfDay(endDate), + }); + } + + if (startDate && hoveredDate) { + return isWithinInterval(date, { + start: startOfDay(startDate), + end: endOfDay(hoveredDate), + }); + } + + return false; + }; + + const handleStartDateChange = (date: string) => { + const isAfterEndDate = + dateRange.endDate && + isAfter(addDays(new Date(date), 1), dateRange.endDate); + + setStartDateInputValue(date); + setDateRange({ + ...dateRange, + startDate: addDays(new Date(date), 1), + }); + setErrorMessage(isAfterEndDate ? 'Invalid range' : ''); + + if (onRangeUpdate) { + onRangeUpdate({ + ...dateRange, + startDate: addDays(new Date(date), 1), + }); + } + }; + + const handleEndDateChange = (date: string) => { + const isBeforeStartDate = + dateRange.startDate && + isBefore(addDays(new Date(date), 1), dateRange.startDate); + + setEndDateInputValue(date); + setDateRange({ + ...dateRange, + endDate: addDays(new Date(date), 1), + }); + setErrorMessage(isBeforeStartDate ? 'Invalid range' : ''); + + if (onRangeUpdate) { + onRangeUpdate({ + ...dateRange, + startDate: addDays(new Date(date), 1), + }); + } + }; + + const handleDateSelection = (date: Date | null) => { + setDateRange((prev) => { + if (dateSelectionMode === DateSelectionMode.FROM) { + const isAfterEndDate = prev.endDate && date && date > prev.endDate; + + setErrorMessage(isAfterEndDate ? 'Invalid range' : ''); + setStartDateInputValue(date ? format(date, 'yyyy-MM-dd') : ''); + + if (onRangeUpdate) { + onRangeUpdate({ ...prev, startDate: date }); + } + + return { ...prev, startDate: date }; + } else if (dateSelectionMode === DateSelectionMode.TO) { + const isBeforeStartDate = + prev.startDate && date && date < prev.startDate; + + setErrorMessage(isBeforeStartDate ? 'Invalid range' : ''); + setEndDateInputValue(date ? format(date, 'yyyy-MM-dd') : ''); + + if (onRangeUpdate) { + onRangeUpdate({ ...prev, endDate: date }); + } + + return { ...prev, endDate: date }; + } + + return prev; + }); + + setDateSelectionMode((prev) => + prev === DateSelectionMode.FROM + ? DateSelectionMode.TO + : DateSelectionMode.FROM, + ); + setHoveredDate(null); + }; + + const onClearButtonClick = () => { + setDateRange({ + startDate: null, + endDate: null, + }); + setStartDateInputValue(''); + setEndDateInputValue(''); + + if (onRangeUpdate) { + onRangeUpdate({ + startDate: null, + endDate: null, + }); + } + }; + + useEffect(() => { + if (value?.startDate && !startDateInputValue) { + setStartDateInputValue(format(value.startDate, 'yyyy-MM-dd')); + } + + if (value?.startDate && !dateRange.startDate) { + setDateRange({ + ...dateRange, + startDate: new Date(value.startDate), + }); + } + + if (value?.endDate && !endDateInputValue) { + setEndDateInputValue(format(value.endDate, 'yyyy-MM-dd')); + } + + if (value?.endDate && !dateRange.endDate) { + setDateRange({ + ...dateRange, + endDate: new Date(value.endDate), + }); + } + }, [value, dateRange]); + + const renderDay = (props: PickersDayProps) => { + const isSelected = + (dateRange.startDate && isSameDay(props.day, dateRange.startDate)) || + (dateRange.endDate && isSameDay(props.day, dateRange.endDate)); + const isInRange = isDateInRange(props.day); + + return ( + { + if (dateRange.startDate && !dateRange.endDate) { + setHoveredDate(props.day); + } + }} + /> + ); + }; + + return ( + + `1px solid ${ + error || errorMessage + ? theme.palette.error.main + : alpha(theme.palette.common.black, 0.23) + }`, + borderRadius: '4px', + height: '40px', + fontSize: '1rem', + padding: '8px', + position: 'relative', + }} + onClick={handleOpen} + aria-invalid={Boolean(error)} + > + {label && ( + + `${ + error || errorMessage + ? theme.palette.error.main + : alpha(theme.palette.common.black, 0.6) + }`, + height: '14px', + float: 'unset', + position: 'absolute', + top: '-8px', + background: 'white', + }} + > + {label} + + )} + + + handleStartDateChange(event.target.value)} + data-testid="start-date-input" + /> + alpha(theme.palette.common.black, 0.23) }} + > + - + + handleEndDateChange(event.target.value)} + data-testid="end-date-input" + /> + + + {error || errorMessage ? ( + theme.palette.error.main, + }} + > + {error || errorMessage} + + ) : null} + + + + + + Select Date Range + + + + + , + ) => { + const { currentMonth, onMonthChange } = props; + + const selectPreviousMonth = () => { + onMonthChange(subMonths(currentMonth, 1), 'right'); + previousMonthButtonRef?.current?.click(); + }; + + const selectNextMonth = () => + onMonthChange(addMonths(currentMonth, 1), 'left'); + + return ( + + + + + + + + + {format(currentMonth, 'MMMM yyyy')} + + + ); + }, + day: (date) => renderDay(date), + }} + referenceDate={dateRange.startDate || new Date()} + minDate={ + dateSelectionMode === DateSelectionMode.TO + ? dateRange.startDate || undefined + : undefined + } + sx={{ + '.MuiDayCalendar-header': { + justifyContent: 'space-between', + }, + '.MuiDayCalendar-weekContainer': { + justifyContent: 'space-between', + }, + }} + /> + + + + , + ) => { + const { currentMonth, onMonthChange } = props; + + const selectNextMonth = () => { + onMonthChange(addMonths(currentMonth, 1), 'left'); + nextMonthButtonRef?.current?.click(); + }; + + const selectPreviousMonth = () => + onMonthChange(subMonths(currentMonth, 1), 'right'); + + return ( + + + {format(currentMonth, 'MMMM yyyy')} + + + + + + + + + ); + }, + day: (date) => renderDay(date), + }} + referenceDate={addMonths( + dateRange.startDate || new Date(), + 1, + )} + minDate={ + dateSelectionMode === DateSelectionMode.TO + ? dateRange.startDate || undefined + : undefined + } + sx={{ + '.MuiDayCalendar-header': { + justifyContent: 'space-between', + }, + '.MuiDayCalendar-weekContainer': { + justifyContent: 'space-between', + }, + }} + /> + + + + + + + + + + + ); +}; + +export default DateRangePicker; diff --git a/packages/react-material-ui/src/components/Filter/Filter.tsx b/packages/react-material-ui/src/components/Filter/Filter.tsx index d107c035..d3838f6a 100644 --- a/packages/react-material-ui/src/components/Filter/Filter.tsx +++ b/packages/react-material-ui/src/components/Filter/Filter.tsx @@ -16,6 +16,9 @@ import { SearchFieldProps } from '../../components/SearchField/SearchField'; import { OrderableDropDown, ListItem } from '../OrderableDropDown'; import { DatePickerProps } from '@mui/x-date-pickers'; import DatePickerField from '../../components/DatePickerField'; +import DateRangePicker, { + DateRangePickerProps, +} from '../../components/DateRangePicker'; /** * Type of filter variants available. @@ -25,7 +28,8 @@ export type FilterVariant = | 'autocomplete' | 'select' | 'multiSelect' - | 'date'; + | 'date' + | 'dateRange'; /** * Common properties for all filters. @@ -64,6 +68,16 @@ type DateFilter = { } & FilterCommon & DatePickerProps; +/** + * Properties for the date range filter. + */ +type DateRangeFilter = { + type: 'dateRange'; + onChange?: (value: Date | null) => void; + onDebouncedSearchChange?: (value: Date) => void; +} & FilterCommon & + DateRangePickerProps; + /** * Properties for the autocomplete filter. */ @@ -110,6 +124,7 @@ type MultiSelectFilter = { export type FilterType = | TextFilter | DateFilter + | DateRangeFilter | AutocompleteFilter | SelectFilter | MultiSelectFilter; @@ -156,6 +171,15 @@ const renderComponent = (filter: FilterType) => { /> ); + case 'dateRange': + return ( + + ); + case 'select': return ( { const onFilterChange = ( id: string, - value: string | string[] | Date | null, + value: string | string[] | Date | null | DateRange, updateFilter?: boolean, reference?: FilterDetails['reference'], referenceValidationFn?: FilterDetails['referenceValidationFn'], @@ -265,6 +266,15 @@ const FilterSubmodule = (props: Props) => { onFilterChange(id, val, true, reference, referenceValidationFn), }; + case 'dateRange': + return { + ...commonFields, + type, + value: value as DateRange, + onRangeUpdate: (dateRange: DateRange) => + onFilterChange(id, dateRange, true), + }; + default: break; } diff --git a/packages/react-material-ui/src/index.ts b/packages/react-material-ui/src/index.ts index 1d6b27c2..8bd7a67c 100644 --- a/packages/react-material-ui/src/index.ts +++ b/packages/react-material-ui/src/index.ts @@ -89,3 +89,8 @@ export { default as OtpInput } from './components/OtpInput'; export { default as Breadcrumbs } from './components/Breadcrumbs'; export { FormLabel, FormLabelProps } from './components/FormLabel'; + +export { + default as DateRangePicker, + DateRangePickerProps, +} from './components/DateRangePicker'; diff --git a/packages/react-material-ui/src/modules/crud/README.md b/packages/react-material-ui/src/modules/crud/README.md index 60602ef3..2eae7bf5 100644 --- a/packages/react-material-ui/src/modules/crud/README.md +++ b/packages/react-material-ui/src/modules/crud/README.md @@ -134,6 +134,12 @@ To filter table items, the `filters` prop can be passed to the `tableProps` obje ], columns: 3, }, + { + id: 'range', + label: 'Date range', + type: 'dateRange', + columns: 4, + }, ], }} /> @@ -147,9 +153,37 @@ Each filter can have the following set of attributes: - `columns`: number of columns occupied by the input in a grid of 12 columns; - `size`: overall size of the input, small or medium; - `operator`: string that describes how much of the input value should match the data value; -- `type`: the type of the filter input, one of text, autocomplete or select; +- `type`: the type of the filter input, one of text, autocomplete, select, multiSelect, date and dateRange; - `options`: array of options displayed in the autocomplete or select inputs. +### Filter types + +#### text + +Simple text field, filtering data based on a single string. + +#### autocomplete + +Select field with the ability to search the items listed based on a text field. + +### select + +Standard select field, filtering data based on a selected option. + +### multiSelect + +Another approach to the Select field, with the ability to select multiple items. + +### date + +Standard date picker, filtering data based on a timestamp value. + +### dateRange + +Another approach to the date picker, with the ability to select start and end date through a single field. + +## External search + A callback can be passed to the module props if the current state of filters is needed after each input change. This callback is called `filterCallback` and is passed outside of the `tableProps`, as follows: ```jsx diff --git a/packages/react-material-ui/src/modules/crud/useCrudRoot.tsx b/packages/react-material-ui/src/modules/crud/useCrudRoot.tsx index 212ee6f7..ea408e42 100644 --- a/packages/react-material-ui/src/modules/crud/useCrudRoot.tsx +++ b/packages/react-material-ui/src/modules/crud/useCrudRoot.tsx @@ -2,8 +2,12 @@ import { createContext, useContext } from 'react'; import { UseTableResult } from '../../components/Table/useTable'; import { Search, SimpleFilter } from '../../components/Table/types'; import { FilterDetails } from '../../components/submodules/Filter'; +import { DateRange } from '../../components/DateRangePicker'; -export type FilterValues = Record; +export type FilterValues = Record< + string, + string | string[] | Date | null | DateRange +>; export type CrudContextProps = { /** diff --git a/yarn.lock b/yarn.lock index 81f50241..7c6afccf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1734,6 +1734,7 @@ __metadata: "@types/lodash": "npm:^4.14.198" "@types/react": "npm:^18.2.0" "@types/react-dom": "npm:^18.2.0" + date-fns: "npm:^4.1.0" lodash: "npm:^4.17.21" peerDependencies: "@concepta/react-auth-provider": ^2.0.0-alpha.10 @@ -8321,6 +8322,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8 + languageName: node + linkType: hard + "dateformat@npm:^3.0.0": version: 3.0.3 resolution: "dateformat@npm:3.0.3"