diff --git a/client/package.json b/client/package.json
index dbc3a3fe32..672f22c011 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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"
},
diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json
index 13485e05a5..bbce9e047f 100644
--- a/client/public/locales/en/translation.json
+++ b/client/public/locales/en/translation.json
@@ -31,7 +31,6 @@
"delete": "Delete",
"discardAssessment": "Discard assessment(s)",
"discardReview": "Discard review",
-
"downloadCsvTemplate": "Download CSV template",
"download": "Download {{what}}",
"duplicate": "Duplicate",
@@ -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?",
@@ -331,6 +329,7 @@
"effort": "Effort",
"effortEstimate": "Effort estimate",
"email": "Email",
+ "endDate": "End date",
"error": "Error",
"errorReport": "Error report",
"explanation": "Explanation",
@@ -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",
diff --git a/client/src/app/components/FilterToolbar/DateRangeFilter.tsx b/client/src/app/components/FilterToolbar/DateRangeFilter.tsx
new file mode 100644
index 0000000000..9c5c463503
--- /dev/null
+++ b/client/src/app/components/FilterToolbar/DateRangeFilter.tsx
@@ -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**:
+ * 1) filterValue - date range encoded as ISO 8601 time interval string ("dateFrom/dateTo"). Only date part is used (no time).
+ * 2) setFilterValue - accepts the list of ranges.
+ *
+ */
+
+export const DateRangeFilter = ({
+ category,
+ filterValue,
+ setFilterValue,
+ showToolbarItem,
+ isDisabled = false,
+}: React.PropsWithChildren<
+ IFilterControlProps
+>): JSX.Element | null => {
+ const selectedFilters = filterValue ?? [];
+
+ const validFilters =
+ selectedFilters?.filter((interval) =>
+ isValidInterval(parseInterval(interval))
+ ) ?? [];
+ const [from, setFrom] = useState();
+ const [to, setTo] = useState();
+
+ const rangeToOption = (range: string) => {
+ const [abbrRange, fullRange] = localizeInterval(range);
+ return {
+ key: range,
+ node: (
+
+ {abbrRange ?? ""}
+
+ ),
+ };
+ };
+
+ const clearSingleRange = (
+ category: string | ToolbarChipGroup,
+ option: string | ToolbarChip
+ ) => {
+ const target = (option as ToolbarChip)?.key;
+ setFilterValue([...validFilters.filter((range) => range !== target)]);
+ };
+
+ const onFromDateChange = (
+ event: FormEvent,
+ value: string
+ ) => {
+ if (isValidAmericanShortDate(value)) {
+ setFrom(parseAmericanDate(value));
+ setTo(undefined);
+ }
+ };
+
+ const onToDateChange = (even: FormEvent, 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 (
+ setFilterValue([])}
+ categoryName={category.title}
+ showToolbarItem={showToolbarItem}
+ >
+
+
+
+
+
+ );
+};
diff --git a/client/src/app/components/FilterToolbar/FilterControl.tsx b/client/src/app/components/FilterToolbar/FilterControl.tsx
index 52a0f73b44..71940b6084 100644
--- a/client/src/app/components/FilterToolbar/FilterControl.tsx
+++ b/client/src/app/components/FilterToolbar/FilterControl.tsx
@@ -11,6 +11,7 @@ import {
import { SelectFilterControl } from "./SelectFilterControl";
import { SearchFilterControl } from "./SearchFilterControl";
import { MultiselectFilterControl } from "./MultiselectFilterControl";
+import { DateRangeFilter } from "./DateRangeFilter";
export interface IFilterControlProps {
category: FilterCategory;
@@ -58,5 +59,8 @@ export const FilterControl = ({
/>
);
}
+ if (category.type === FilterType.dateRange) {
+ return ;
+ }
return null;
};
diff --git a/client/src/app/components/FilterToolbar/FilterToolbar.tsx b/client/src/app/components/FilterToolbar/FilterToolbar.tsx
index f07740afb4..404642aa72 100644
--- a/client/src/app/components/FilterToolbar/FilterToolbar.tsx
+++ b/client/src/app/components/FilterToolbar/FilterToolbar.tsx
@@ -18,6 +18,7 @@ export enum FilterType {
multiselect = "multiselect",
search = "search",
numsearch = "numsearch",
+ dateRange = "dateRange",
}
export type FilterValue = string[] | undefined | null;
@@ -81,7 +82,8 @@ export interface ISearchFilterCategory
export type FilterCategory =
| IMultiselectFilterCategory
| ISelectFilterCategory
- | ISearchFilterCategory;
+ | ISearchFilterCategory
+ | IBasicFilterCategory;
export type IFilterValues = Partial<
Record
diff --git a/client/src/app/components/FilterToolbar/__tests__/dateUtils.test.ts b/client/src/app/components/FilterToolbar/__tests__/dateUtils.test.ts
new file mode 100644
index 0000000000..82c49ad0ce
--- /dev/null
+++ b/client/src/app/components/FilterToolbar/__tests__/dateUtils.test.ts
@@ -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",
+ ]));
+});
diff --git a/client/src/app/components/FilterToolbar/dateUtils.ts b/client/src/app/components/FilterToolbar/dateUtils.ts
new file mode 100644
index 0000000000..71de562c72
--- /dev/null
+++ b/client/src/app/components/FilterToolbar/dateUtils.ts
@@ -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")}`,
+ ];
+};
diff --git a/client/src/app/pages/migration-waves/migration-waves.tsx b/client/src/app/pages/migration-waves/migration-waves.tsx
index 323301eb7d..6438273278 100644
--- a/client/src/app/pages/migration-waves/migration-waves.tsx
+++ b/client/src/app/pages/migration-waves/migration-waves.tsx
@@ -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";
@@ -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();
@@ -179,7 +180,7 @@ export const MigrationWaves: React.FC = () => {
updateMigrationWave(payload);
};
- const tableControls = useLocalTableControls({
+ const tableControls = useLocalTableControls({
tableName: "migration-waves-table",
idProperty: "id",
items: migrationWaves,
@@ -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) => ({
diff --git a/client/src/app/test-config/setupTests.ts b/client/src/app/test-config/setupTests.ts
index cf3fab95cc..d065510935 100644
--- a/client/src/app/test-config/setupTests.ts
+++ b/client/src/app/test-config/setupTests.ts
@@ -1,5 +1,6 @@
import "@testing-library/jest-dom";
import { server } from "@mocks/server";
+import "@app/dayjs";
const mockInitialized = false;