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"