Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add DateRangeFilter #1913

Merged
merged 2 commits into from
Jun 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading