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 Date range filter #754

Merged
merged 5 commits into from
Oct 25, 2023
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
3 changes: 2 additions & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"start": "rollup -c --bundleConfigAsCjs --watch",
"lint": "eslint . && stylelint \"src/**/*.css\" --allow-empty-input",
"lint:fix": "eslint . --fix && stylelint \"src/**/*.css\" --allow-empty-input --fix",
"test": "TZ=UTC jest",
"test": "TZ=UTC jest && npm run test:TZ",
rszwajko marked this conversation as resolved.
Show resolved Hide resolved
"test:TZ": "TZ=UTC+02:00 jest src/utils/__tests__/dates.test.ts",
"test:coverage": "TZ=UTC jest --coverage",
"test:updateSnapshot": "TZ=UTC jest --updateSnapshot",
"storybook": "storybook dev -p 6006",
Expand Down
127 changes: 127 additions & 0 deletions packages/common/src/components/Filter/DateRangeFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, { FormEvent, useState } from 'react';
import { DateTime } from 'luxon';

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

import {
abbreviateInterval,
isValidDate,
isValidInterval,
parseISOtoJSDate,
toISODate,
toISODateInterval,
} from '../../utils';

import { FilterTypeProps } from './types';

/**
* This Filter type enables selecting an closed date range.
* Precisely given range [A,B] a date X in the range if A <= X <= B.
*
* **FilterTypeProps are interpreted as follows**:<br>
* 1) selectedFilters - date range encoded as ISO 8601 time interval string ("dateFrom/dateTo"). Only date part is used (no time).<br>
* 2) onFilterUpdate - accepts the list of ranges.<br>
*
* [<img src="static/media/src/components-stories/assets/github-logo.svg"><i class="fi fi-brands-github">
* <font color="green">View component source on GitHub</font>](https://github.com/kubev2v/forklift-console-plugin/blob/main/packages/common/src/components/Filter/DateRangeFilter.tsx)
*/
export const DateRangeFilter = ({
selectedFilters = [],
onFilterUpdate,
title,
filterId,
placeholderLabel,
showFilter = true,
helperText,
}: FilterTypeProps) => {
const validFilters = selectedFilters?.filter(isValidInterval) ?? [];

const [from, setFrom] = useState<Date>();
const [to, setTo] = useState<Date>();

const rangeToOption = (range: string) => {
const abbr = abbreviateInterval(range);
return {
key: range,
node: (
<Tooltip content={range}>
<span>{abbr ?? ''}</span>
</Tooltip>
),
};
};
const optionToRange = (option): string => option?.key;

const clearSingleRange = (option) => {
const target = optionToRange(option);
onFilterUpdate([...validFilters.filter((range) => range !== target)]);
};

const onFromDateChange = (even: FormEvent<HTMLInputElement>, value: string) => {
//see DateFilter onDateChange
if (value?.length === 10 && isValidDate(value)) {
setFrom(parseISOtoJSDate(value));
setTo(undefined);
}
};

const onToDateChange = (even: FormEvent<HTMLInputElement>, value: string) => {
//see DateFilter onDateChange
if (value?.length === 10 && isValidDate(value)) {
const newTo = parseISOtoJSDate(value);
setTo(newTo);
const target = toISODateInterval(from, newTo);
if (target) {
onFilterUpdate([...validFilters.filter((range) => range !== target), target]);
}
}
};
return (
<ToolbarFilter
key={filterId}
chips={validFilters.map(rangeToOption)}
deleteChip={(category, option) => clearSingleRange(option)}
deleteChipGroup={() => onFilterUpdate([])}
categoryName={title}
showToolbarItem={showFilter}
>
<InputGroup>
<DatePicker
value={toISODate(from)}
dateFormat={(date) => DateTime.fromJSDate(date).toISODate()}
dateParse={(str) => DateTime.fromISO(str).toJSDate()}
onChange={onFromDateChange}
aria-label="Interval start"
placeholder={placeholderLabel}
// disable error text (no space in toolbar scenario)
invalidFormatText={''}
// default value ("parent") creates collision with sticky table header
appendTo={document.body}
popoverProps={{
footerContent: helperText,
}}
/>
<DatePicker
value={toISODate(to)}
onChange={onToDateChange}
isDisabled={!isValidJSDate(from)}
// disable error text (no space in toolbar scenario)
invalidFormatText={''}
rangeStart={from}
aria-label="Interval end"
placeholder={placeholderLabel}
appendTo={document.body}
popoverProps={{
footerContent: helperText,
}}
/>
</InputGroup>
</ToolbarFilter>
);
};
1 change: 1 addition & 0 deletions packages/common/src/components/Filter/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @index(['./*', /__/g], f => `export * from '${f.path}';`)
export * from './DateFilter';
export * from './DateRangeFilter';
export * from './EnumFilter';
export * from './FreetextFilter';
export * from './GroupedEnumFilter';
Expand Down
18 changes: 16 additions & 2 deletions packages/common/src/components/FilterGroup/matchers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import jsonpath from 'jsonpath';

import { areSameDayInUTCZero, ResourceField } from '../../utils';
import { DateFilter, EnumFilter, FreetextFilter, GroupedEnumFilter, SwitchFilter } from '../Filter';
import { areSameDayInUTCZero, isInClosedRange, ResourceField } from '../../utils';
import {
DateFilter,
DateRangeFilter,
EnumFilter,
FreetextFilter,
GroupedEnumFilter,
SwitchFilter,
} from '../Filter';

import { FilterRenderer, ValueMatcher } from './types';

Expand Down Expand Up @@ -101,6 +108,11 @@ const dateMatcher = {
matchValue: (value: string) => (filter: string) => areSameDayInUTCZero(value, filter),
};

const dateRangeMatcher = {
filterType: 'dateRange',
matchValue: (value: string) => (filter: string) => isInClosedRange(filter, value),
};

const sliderMatcher = {
filterType: 'slider',
matchValue: (value: string) => (filter: string) => Boolean(value).toString() === filter || !value,
Expand All @@ -112,10 +124,12 @@ export const defaultValueMatchers: ValueMatcher[] = [
groupedEnumMatcher,
sliderMatcher,
dateMatcher,
dateRangeMatcher,
];

export const defaultSupportedFilters: Record<string, FilterRenderer> = {
date: DateFilter,
dateRange: DateRangeFilter,
enum: EnumFilter,
freetext: FreetextFilter,
groupedEnum: GroupedEnumFilter,
Expand Down
33 changes: 33 additions & 0 deletions packages/common/src/utils/__tests__/dates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import {
areSameDayInUTCZero,
changeFormatToISODate,
changeTimeZoneToUTCZero,
isInClosedRange,
isValidDate,
isValidInterval,
parseISOtoJSDate,
toISODate,
toISODateInterval,
} from '../dates';

describe('changeTimeZoneToUTCZero', () => {
Expand Down Expand Up @@ -75,3 +78,33 @@ describe('areSameDayInUTCZero', () => {
expect(areSameDayInUTCZero(undefined, '2023-foo')).toBeFalsy();
});
});

describe('isInClosedRange', () => {
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('2023-10-30/2023-10-31')).toBeTruthy();
});
test('invalid format', () => {
expect(isValidInterval('2023-foo-30/2023-10-31')).toBeFalsy();
});
test('invalid days', () => {
expect(isValidInterval('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');
});
});
45 changes: 42 additions & 3 deletions packages/common/src/utils/dates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DateTime } from 'luxon';
import { DateTime, Interval } from 'luxon';

/**
* Converts a given ISO date time string to UTC+00:00 time zone.
Expand Down Expand Up @@ -28,7 +28,7 @@ export const changeFormatToISODate = (isoDateString: string): string | undefined
* @param date
* @returns ISO date string if input is valid or undefined otherwise.
*/
export const toISODate = (date: Date): string => {
export const toISODate = (date: Date): string | undefined => {
const dt = DateTime.fromJSDate(date);
return dt.isValid ? dt.toISODate() : undefined;
};
Expand All @@ -40,7 +40,7 @@ export const isValidDate = (isoDateString: string) => DateTime.fromISO(isoDateSt
* @param isoDateString
* @returns JS Date instance if input is valid or undefined otherwise.
*/
export const parseISOtoJSDate = (isoDateString: string) => {
export const parseISOtoJSDate = (isoDateString: string): Date | undefined => {
const date = DateTime.fromISO(isoDateString);
return date.isValid ? date.toJSDate() : undefined;
};
Expand All @@ -56,3 +56,42 @@ export const areSameDayInUTCZero = (dateTime: string, calendarDate: string): boo
// which results in shifting to previous day for zones with positive offsets
return DateTime.fromISO(dateTime).toUTC().hasSame(DateTime.fromISO(calendarDate), 'day');
};

/**
*
* @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 } = Interval.fromISO(interval);
return Interval.fromDateTimes(start, end.plus({ days: 1 })).contains(
DateTime.fromISO(date).toUTC().setZone('local', { keepCalendarTime: true }),
);
};

/**
*
* @param interval ISO time interval
* @returns true if valid
*/
export const isValidInterval = (interval: string): boolean => Interval.fromISO(interval).isValid;

/**
*
* @param from start date (inclusive)
* @param to end date (exclusive)
* @returns ISO time interval with date part only (no time, no time zone)
*/
export const toISODateInterval = (from: Date, to: Date): string | undefined => {
const target = Interval.fromDateTimes(DateTime.fromJSDate(from), DateTime.fromJSDate(to));
return target.isValid ? target.toISODate() : undefined;
};

export const abbreviateInterval = (isoInterval: string): string | undefined => {
const interval = Interval.fromISO(isoInterval);
if (!interval.isValid) {
return undefined;
}
return `${interval.start.toFormat('MM-dd')}/${interval.end.toFormat('MM-dd')}`;
};
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"Custom certification used to verify the RH Virtualization REST API server, when empty use system certificate.": "Custom certification used to verify the RH Virtualization REST API server, when empty use system certificate.",
"Data centers": "Data centers",
"Data stores": "Data stores",
"Dates are compared in UTC. End of the interval is included.": "Dates are compared in UTC. End of the interval is included.",
"Default": "Default",
"Default Transfer Network": "Default Transfer Network",
"Defines the CPU limits allocated to the main container in the controller pod. The default value is 500 milliCPU.": "Defines the CPU limits allocated to the main container in the controller pod. The default value is 500 milliCPU.",
Expand Down
13 changes: 11 additions & 2 deletions packages/forklift-console-plugin/src/modules/Plans/PlansPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import { ResourceFieldFactory } from '@kubev2v/common';
import { MustGatherModal } from '@kubev2v/legacy/common/components/MustGatherModal';
import { PlanModel } from '@kubev2v/types';
import { useAccessReview } from '@openshift-console/dynamic-plugin-sdk';
import { Button } from '@patternfly/react-core';
import { Button, HelperText, HelperTextItem } from '@patternfly/react-core';

import { FlatPlan, useFlatPlans, useHasSufficientProviders } from './data';
import EmptyStatePlans from './EmptyStatePlans';
import PlanRow from './PlanRow';

import './styles.css';

export const fieldsMetadataFactory: ResourceFieldFactory = (t) => [
{
resourceFieldId: C.NAME,
Expand Down Expand Up @@ -48,8 +50,15 @@ export const fieldsMetadataFactory: ResourceFieldFactory = (t) => [
label: t('Migration started'),
isVisible: true,
filter: {
type: 'date',
type: 'dateRange',
placeholderLabel: 'YYYY-MM-DD',
helperText: (
<HelperText className="forklift-date-range-helper-text">
<HelperTextItem variant="indeterminate">
{t('Dates are compared in UTC. End of the interval is included.')}
</HelperTextItem>
</HelperText>
),
},
sortable: true,
},
Expand Down
15 changes: 15 additions & 0 deletions packages/forklift-console-plugin/src/modules/Plans/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,18 @@
max-width: 14rem;
max-height: 14rem;
}

.forklift-date-range-helper-text {
/* use the same padding as CalendarMonth widget in the DatePicker pop-up */
padding-left: var(--pf-global--spacer--lg);
padding-right: var(--pf-global--spacer--lg);

/* extra space below text */
padding-bottom: var(--pf-global--spacer--lg);

/* negative margin that compensates the setting used by DatePicker pop-up footer */
margin-top: calc(-1*(var(--pf-c-popover__footer--MarginTop)));

/* limit the width of the helper text to the typical width of the calendar. This prevents the text from expanding/widening the pop-up */
max-width: 22rem;
}