Skip to content

Commit

Permalink
Add DateRangeFilter
Browse files Browse the repository at this point in the history
Create a filter widget based on DateRangeFilter developed
for Forklift plugin.
Use the new widget to filter migration waves on both start and end date.

Key points:
1. filter values are stored as strings in ISO 8601 time interval
   format (date part only) i.e. "2024-04-01/2024-05-01".
2. date range is a closed range (both ends are included)
3. browser local time zone is used
4. date format is hard-coded to "MM/DD/YYYY" to match the format
   used in the UI

Additional changes:
1. force TZ=UTC for jest unit tests to ensure the same test results
2. initialize dayjs plugins for tests

Reference-Url: kubev2v/forklift-console-plugin#754
Reference-Url: https://en.wikipedia.org/wiki/ISO_8601#Time_intervals

Signed-off-by: Radoslaw Szwajkowski <rszwajko@redhat.com>
  • Loading branch information
rszwajko committed May 29, 2024
1 parent 66b06b0 commit 62cd555
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 6 deletions.
2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"build": "NODE_ENV=production webpack --config ./config/webpack.prod.ts",
"build:dev": "NODE_ENV=development webpack --config ./config/webpack.dev.ts",
"start:dev": "NODE_ENV=development webpack serve --config ./config/webpack.dev.ts",
"test": "NODE_ENV=test jest --rootDir=. --config=./config/jest.config.ts",
"test": "NODE_ENV=test TZ=UTC jest --rootDir=. --config=./config/jest.config.ts",
"lint": "eslint .",
"tsc": "tsc -p ./tsconfig.json"
},
Expand Down
4 changes: 2 additions & 2 deletions client/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"delete": "Delete",
"discardAssessment": "Discard assessment(s)",
"discardReview": "Discard review",

"downloadCsvTemplate": "Download CSV template",
"download": "Download {{what}}",
"duplicate": "Duplicate",
Expand Down Expand Up @@ -105,7 +104,6 @@
"dialog": {
"message": {
"applicationsBulkDelete": "The selected application(s) will be deleted.",

"delete": "This action cannot be undone.",
"discardAssessment": "The assessment(s) for <1>{{applicationName}}</1> will be discarded. Do you wish to continue?",
"discardReview": "The review for <1>{{applicationName}}</1> will be discarded. Do you wish to continue?",
Expand Down Expand Up @@ -331,6 +329,7 @@
"effort": "Effort",
"effortEstimate": "Effort estimate",
"email": "Email",
"endDate": "End date",
"error": "Error",
"errorReport": "Error report",
"explanation": "Explanation",
Expand Down Expand Up @@ -433,6 +432,7 @@
"stakeholderGroupDeleted": "Stakeholder group deleted",
"stakeholderGroups": "Stakeholder groups",
"stakeholders": "Stakeholders",
"startDate": "Start date",
"status": "Status",
"suggestedAdoptionPlan": "Suggested adoption plan",
"svnConfig": "Subversion configuration",
Expand Down
135 changes: 135 additions & 0 deletions client/src/app/components/FilterToolbar/DateRangeFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 @@ -81,7 +82,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
isInClosedRange,
isValidAmericanShortDate,
isValidInterval,
parseInterval,
toISODateInterval,
localizeInterval,
} from "../dateUtils";

describe("isValidAmericanShortDate", () => {
test("short format: 10/31/2023", () =>
expect(isValidAmericanShortDate("10/31/2023")).toBeTruthy());

test("invalid string", () =>
expect(isValidAmericanShortDate("31/broken10/2023")).toBeFalsy());

test("invalid number of days", () =>
expect(isValidAmericanShortDate("06/60/2022")).toBeFalsy());
});

describe("isInClosedRange(no time, no zone)", () => {
test("date is lower bound", () =>
expect(
isInClosedRange("2023-10-30/2023-10-31", "2023-10-30")
).toBeTruthy());

test("date is upper bound", () =>
expect(
isInClosedRange("2023-10-30/2023-10-31", "2023-10-31")
).toBeTruthy());

test("date after range", () =>
expect(isInClosedRange("2023-10-30/2023-10-31", "2023-11-01")).toBeFalsy());

test("date before range", () =>
expect(isInClosedRange("2023-10-31/2023-11-01", "2023-10-30")).toBeFalsy());
});

describe("isInClosedRange(full ISO with zone)", () => {
test("date in range(positive TZ offset)", () =>
expect(
isInClosedRange("2023-10-30/2023-10-31", "2023-11-01T01:30:00.000+02:00")
).toBeTruthy());

test("date after range (negative TZ offset)", () =>
expect(
isInClosedRange("2023-10-30/2023-10-31", "2023-10-31T22:30:00.000-02:00")
).toBeFalsy());

test("date before range", () =>
expect(
isInClosedRange("2023-10-31/2023-11-01", "2023-10-31T01:30:00.000+02:00")
).toBeFalsy());
});

describe("isValidInterval", () => {
test("2023-10-30/2023-10-31", () =>
expect(
isValidInterval(parseInterval("2023-10-30/2023-10-31"))
).toBeTruthy());

test("invalid format", () =>
expect(
isValidInterval(parseInterval("2023-foo-30/2023-10-31"))
).toBeFalsy());

test("invalid days", () =>
expect(
isValidInterval(parseInterval("2023-10-60/2023-10-31"))
).toBeFalsy());
});

describe("toISODateInterval", () => {
test("unix epoch as start and end", () =>
expect(toISODateInterval(new Date(0), new Date(0))).toBe(
"1970-01-01/1970-01-01"
));
});

describe("localizeInterval", () => {
test("2023-10-30/2023-10-31", () =>
expect(localizeInterval("2023-10-30/2023-10-31")).toEqual([
"10/30-10/31",
"10/30/2023-10/31/2023",
]));
});
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")}`,
];
};
17 changes: 15 additions & 2 deletions client/src/app/pages/migration-waves/migration-waves.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
useFetchMigrationWaves,
useUpdateMigrationWaveMutation,
} from "@app/queries/migration-waves";
import { MigrationWave, Ref, Ticket } from "@app/api/models";
import { MigrationWave, Ref, Ticket, WaveWithStatus } from "@app/api/models";
import { FilterToolbar, FilterType } from "@app/components/FilterToolbar";
import { useLocalTableControls } from "@app/hooks/table-controls";
import { SimplePagination } from "@app/components/SimplePagination";
Expand All @@ -69,6 +69,7 @@ import { ToolbarBulkSelector } from "@app/components/ToolbarBulkSelector";
import { ConfirmDialog } from "@app/components/ConfirmDialog";
import { toRefs } from "@app/utils/model-utils";
import { useFetchTickets } from "@app/queries/tickets";
import { isInClosedRange } from "@app/components/FilterToolbar/dateUtils";

export const MigrationWaves: React.FC = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -179,7 +180,7 @@ export const MigrationWaves: React.FC = () => {
updateMigrationWave(payload);
};

const tableControls = useLocalTableControls({
const tableControls = useLocalTableControls<WaveWithStatus, string, string>({
tableName: "migration-waves-table",
idProperty: "id",
items: migrationWaves,
Expand Down Expand Up @@ -211,6 +212,18 @@ export const MigrationWaves: React.FC = () => {
return item?.name || "";
},
},
{
categoryKey: "startDate",
title: t("terms.startDate"),
type: FilterType.dateRange,
matcher: (interval, item) => isInClosedRange(interval, item.startDate),
},
{
categoryKey: "endDate",
title: t("terms.endDate"),
type: FilterType.dateRange,
matcher: (interval, item) => isInClosedRange(interval, item.endDate),
},
],
sortableColumns: ["name", "startDate", "endDate"],
getSortValues: (migrationWave) => ({
Expand Down
Loading

0 comments on commit 62cd555

Please sign in to comment.