Skip to content

Commit

Permalink
Merge pull request #754 from rszwajko/rangeFilter
Browse files Browse the repository at this point in the history
Add Date range filter
  • Loading branch information
yaacov committed Oct 25, 2023
2 parents 249e1b1 + b56f16d commit 9a79f63
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 8 deletions.
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",
"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;
}

0 comments on commit 9a79f63

Please sign in to comment.