Skip to content

Commit

Permalink
Save date range
Browse files Browse the repository at this point in the history
  • Loading branch information
carlosthe19916 committed Oct 27, 2024
1 parent 613b149 commit 98cc677
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 6 deletions.
105 changes: 105 additions & 0 deletions client/src/app/components/FilterPanel/DateRangeFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { FormEvent, useState } from "react";

import {
DatePicker,
Form,
FormGroup,
isValidDate as isValidJSDate,
} from "@patternfly/react-core";

import {
americanDateFormat,
isValidAmericanShortDate,
isValidInterval,
parseAmericanDate,
parseInterval,
toISODateInterval,
} from "../FilterToolbar/dateUtils";
import { IFilterControlProps } from "./FilterControl";

/**
* This Filter type enables selecting an closed date range.
* Precisely given range [A,B] a date X in the range if A <= X <= B.
*
* **Props are interpreted as follows**:<br>
* 1) filterValue - date range encoded as ISO 8601 time interval string ("dateFrom/dateTo"). Only date part is used (no time).<br>
* 2) setFilterValue - accepts the list of ranges.<br>
*
*/

export const DateRangeFilter = <TItem,>({
category,
filterValue,
setFilterValue,
isDisabled = false,
}: React.PropsWithChildren<
IFilterControlProps<TItem, string>
>): JSX.Element | null => {
const selectedFilters = filterValue ?? [];

const validFilters =
selectedFilters?.filter((interval) =>
isValidInterval(parseInterval(interval))
) ?? [];
const [from, setFrom] = useState<Date>();
const [to, setTo] = useState<Date>();

const onFromDateChange = (
event: FormEvent<HTMLInputElement>,
value: string
) => {
if (isValidAmericanShortDate(value)) {
setFrom(parseAmericanDate(value));
setTo(undefined);
}
};

const onToDateChange = (even: FormEvent<HTMLInputElement>, value: string) => {
if (isValidAmericanShortDate(value)) {
const newTo = parseAmericanDate(value);
setTo(newTo);
const target = toISODateInterval(from, newTo);
if (target) {
setFilterValue([
...validFilters.filter((range) => range !== target),
target,
]);
}
}
};

return (
<Form>
<FormGroup role="group" isInline label="From">
<DatePicker
value={from ? americanDateFormat(from) : ""}
dateFormat={americanDateFormat}
dateParse={parseAmericanDate}
onChange={onFromDateChange}
aria-label="Interval start"
placeholder="MM/DD/YYYY"
// disable error text (no space in toolbar scenario)
invalidFormatText={""}
// default value ("parent") creates collision with sticky table header
appendTo={document.body}
isDisabled={isDisabled}
/>
</FormGroup>
<FormGroup role="group" isInline label="To">
<DatePicker
value={to ? americanDateFormat(to) : ""}
onChange={onToDateChange}
isDisabled={isDisabled || !isValidJSDate(from)}
dateFormat={americanDateFormat}
dateParse={parseAmericanDate}
// disable error text (no space in toolbar scenario)
invalidFormatText={""}
rangeStart={from}
aria-label="Interval end"
placeholder="MM/DD/YYYY"
appendTo={document.body}
/>
</FormGroup>
</Form>
);
};
4 changes: 4 additions & 0 deletions client/src/app/components/FilterPanel/FilterControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { SearchFilterControl } from "./SearchFilterControl";
import { RadioFilterControl } from "./RadioFilterControl";
import { CheckboxFilterControl } from "./CheckboxFilterControl";
import { DateRangeFilter } from "./DateRangeFilter";

export interface IFilterControlProps<TItem, TFilterCategoryKey extends string> {
category: FilterCategory<TItem, TFilterCategoryKey>;
Expand Down Expand Up @@ -56,5 +57,8 @@ export const FilterControl = <TItem, TFilterCategoryKey extends string>({
/>
);
}
if (category.type === FilterType.dateRange) {
return <DateRangeFilter category={category} {...props} />;
}
return null;
};
2 changes: 1 addition & 1 deletion client/src/app/components/FilterPanel/FilterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const FilterPanel = <TItem, TFilterCategoryKey extends string>({
return (
<Card key={category.categoryKey} isPlain>
<CardTitle>{category.title}</CardTitle>
<CardBody>
<CardBody >
<FilterControl<TItem, TFilterCategoryKey>
category={category}
filterValue={filterValues[category.categoryKey]}
Expand Down
136 changes: 136 additions & 0 deletions client/src/app/components/FilterToolbar/DateRangeFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { FormEvent, useState } from "react";

import {
DatePicker,
InputGroup,
isValidDate as isValidJSDate,
ToolbarChip,
ToolbarChipGroup,
ToolbarFilter,
Tooltip,
} from "@patternfly/react-core";

import { IFilterControlProps } from "./FilterControl";
import {
localizeInterval,
americanDateFormat,
isValidAmericanShortDate,
isValidInterval,
parseAmericanDate,
parseInterval,
toISODateInterval,
} from "./dateUtils";

/**
* This Filter type enables selecting an closed date range.
* Precisely given range [A,B] a date X in the range if A <= X <= B.
*
* **Props are interpreted as follows**:<br>
* 1) filterValue - date range encoded as ISO 8601 time interval string ("dateFrom/dateTo"). Only date part is used (no time).<br>
* 2) setFilterValue - accepts the list of ranges.<br>
*
*/

export const DateRangeFilter = <TItem,>({
category,
filterValue,
setFilterValue,
showToolbarItem,
isDisabled = false,
}: React.PropsWithChildren<
IFilterControlProps<TItem, string>
>): JSX.Element | null => {
const selectedFilters = filterValue ?? [];

const validFilters =
selectedFilters?.filter((interval) =>
isValidInterval(parseInterval(interval))
) ?? [];
const [from, setFrom] = useState<Date>();
const [to, setTo] = useState<Date>();

const rangeToOption = (range: string) => {
const [abbrRange, fullRange] = localizeInterval(range);
return {
key: range,
node: (
<Tooltip content={fullRange ?? range}>
<span>{abbrRange ?? ""}</span>
</Tooltip>
),
};
};

const clearSingleRange = (
category: string | ToolbarChipGroup,
option: string | ToolbarChip
) => {
const target = (option as ToolbarChip)?.key;
setFilterValue([...validFilters.filter((range) => range !== target)]);
};

const onFromDateChange = (
event: FormEvent<HTMLInputElement>,
value: string
) => {
if (isValidAmericanShortDate(value)) {
setFrom(parseAmericanDate(value));
setTo(undefined);
}
};

const onToDateChange = (even: FormEvent<HTMLInputElement>, value: string) => {
if (isValidAmericanShortDate(value)) {
const newTo = parseAmericanDate(value);
setTo(newTo);
const target = toISODateInterval(from, newTo);

if (target) {
setFilterValue([
...validFilters.filter((range) => range !== target),
target,
]);
}
}
};

return (
<ToolbarFilter
key={category.categoryKey}
chips={validFilters.map(rangeToOption)}
deleteChip={clearSingleRange}
deleteChipGroup={() => setFilterValue([])}
categoryName={category.title}
showToolbarItem={showToolbarItem}
>
<InputGroup>
<DatePicker
value={from ? americanDateFormat(from) : ""}
dateFormat={americanDateFormat}
dateParse={parseAmericanDate}
onChange={onFromDateChange}
aria-label="Interval start"
placeholder="MM/DD/YYYY"
// disable error text (no space in toolbar scenario)
invalidFormatText={""}
// default value ("parent") creates collision with sticky table header
appendTo={document.body}
isDisabled={isDisabled}
/>
<DatePicker
value={to ? americanDateFormat(to) : ""}
onChange={onToDateChange}
isDisabled={isDisabled || !isValidJSDate(from)}
dateFormat={americanDateFormat}
dateParse={parseAmericanDate}
// disable error text (no space in toolbar scenario)
invalidFormatText={""}
rangeStart={from}
aria-label="Interval end"
placeholder="MM/DD/YYYY"
appendTo={document.body}
/>
</InputGroup>
</ToolbarFilter>
);
};
4 changes: 4 additions & 0 deletions client/src/app/components/FilterToolbar/FilterControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { SelectFilterControl } from "./SelectFilterControl";
import { SearchFilterControl } from "./SearchFilterControl";
import { MultiselectFilterControl } from "./MultiselectFilterControl";
import { DateRangeFilter } from "./DateRangeFilter";

export interface IFilterControlProps<TItem, TFilterCategoryKey extends string> {
category: FilterCategory<TItem, TFilterCategoryKey>;
Expand Down Expand Up @@ -58,5 +59,8 @@ export const FilterControl = <TItem, TFilterCategoryKey extends string>({
/>
);
}
if (category.type === FilterType.dateRange) {
return <DateRangeFilter category={category} {...props} />;
}
return null;
};
4 changes: 3 additions & 1 deletion client/src/app/components/FilterToolbar/FilterToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum FilterType {
multiselect = "multiselect",
search = "search",
numsearch = "numsearch",
dateRange = "dateRange",
}

export type FilterValue = string[] | undefined | null;
Expand Down Expand Up @@ -83,7 +84,8 @@ export interface ISearchFilterCategory<TItem, TFilterCategoryKey extends string>
export type FilterCategory<TItem, TFilterCategoryKey extends string> =
| IMultiselectFilterCategory<TItem, TFilterCategoryKey>
| ISelectFilterCategory<TItem, TFilterCategoryKey>
| ISearchFilterCategory<TItem, TFilterCategoryKey>;
| ISearchFilterCategory<TItem, TFilterCategoryKey>
| IBasicFilterCategory<TItem, TFilterCategoryKey>;

export type IFilterValues<TFilterCategoryKey extends string> = Partial<
Record<TFilterCategoryKey, FilterValue>
Expand Down
51 changes: 51 additions & 0 deletions client/src/app/components/FilterToolbar/dateUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import dayjs from "dayjs";

/**
*
* @param interval ISO time interval with date part only (no time, no time zone) interpreted as closed range (both start and and included)
* @param date ISO date time
* @returns true if the provided date is in the time interval
*/
export const isInClosedRange = (interval: string, date: string): boolean => {
const [start, end] = parseInterval(interval);
if (!isValidInterval([start, end])) {
return false;
}
const target = dayjs(date);
return start.isSameOrBefore(target) && target.isSameOrBefore(end, "day");
};

export const isValidAmericanShortDate = (val: string) =>
dayjs(val, "MM/DD/YYYY", true).isValid();

export const americanDateFormat = (val: Date) =>
dayjs(val).format("MM/DD/YYYY");

export const parseAmericanDate = (val: string) =>
dayjs(val, "MM/DD/YYYY", true).toDate();

// i.e.'1970-01-01/1970-01-01'
export const toISODateInterval = (from?: Date, to?: Date) => {
const [start, end] = [dayjs(from), dayjs(to)];
if (!isValidInterval([start, end])) {
return undefined;
}
return `${start.format("YYYY-MM-DD")}/${end.format("YYYY-MM-DD")}`;
};

export const parseInterval = (interval: string): dayjs.Dayjs[] =>
interval?.split("/").map((it) => dayjs(it, "YYYY-MM-DD", true)) ?? [];

export const isValidInterval = ([from, to]: dayjs.Dayjs[]) =>
from?.isValid() && to?.isValid() && from?.isSameOrBefore(to);

export const localizeInterval = (interval: string) => {
const [start, end] = parseInterval(interval);
if (!isValidInterval([start, end])) {
return [];
}
return [
`${start.format("MM/DD")}-${end.format("MM/DD")}`,
`${start.format("MM/DD/YYYY")}-${end.format("MM/DD/YYYY")}`,
];
};
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ export const getFilterHubRequestParams = <
},
});
}
if (filterCategory.type === "dateRange") {
pushOrMergeFilter(filters, {
field: serverFilterField,
operator: "=",
value: {
list: serverFilterValue[0].split("/"),
operator: getFilterLogicOperator(filterCategory, "OR"),
},
});
}
});
}
if (implicitFilters) {
Expand Down
Loading

0 comments on commit 98cc677

Please sign in to comment.