diff --git a/client/package.json b/client/package.json index dbc3a3fe3..672f22c01 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 13485e05a..bbce9e047 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}} will be discarded. Do you wish to continue?", "discardReview": "The review for <1>{{applicationName}} 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 000000000..9c5c46350 --- /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 52a0f73b4..71940b608 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 f07740afb..404642aa7 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 000000000..82c49ad0c --- /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 000000000..71de562c7 --- /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 323301eb7..643827327 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 cf3fab95c..d06551093 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;