Skip to content

Commit 3496d08

Browse files
Improving date/time picker for time range filters in paginated data table. (#21704)
* Extracting `DateTimePicker` component. * Reusing `DateTimePicker` in `DateRangeForm`. * Disable picker when checkbox is clicked. * Adjusting tests. * Adding license header.
1 parent a494fb4 commit 3496d08

File tree

4 files changed

+148
-109
lines changed

4 files changed

+148
-109
lines changed

graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,11 @@ describe('<EntityFilters />', () => {
347347

348348
const timeRangeForm = await screen.findByTestId('time-range-form');
349349

350-
const fromInput = within(timeRangeForm).getByRole('textbox', { name: /from/i });
351-
userEvent.clear(fromInput);
352-
userEvent.paste(fromInput, '2020-01-01 00:55:00.000');
350+
const fromPicker = await screen.findByTestId('date-picker-from');
351+
userEvent.click(await within(fromPicker).findByRole('gridcell', { name: /Jan 13 2020/i }));
352+
userEvent.type(await screen.findByRole('spinbutton', { name: /from hour/i }), '{selectall}13');
353+
userEvent.type(await screen.findByRole('spinbutton', { name: /from minutes/i }), '{selectall}42');
354+
userEvent.type(await screen.findByRole('spinbutton', { name: /from seconds/i }), '{selectall}23');
353355

354356
const submitButton = within(timeRangeForm).getByRole('button', {
355357
name: /create filter/i,
@@ -361,18 +363,18 @@ describe('<EntityFilters />', () => {
361363
OrderedMap({
362364
created_at: [
363365
{
364-
title: '2020-01-01 00:55:00.000 - Now',
365-
value: '2019-12-31T23:55:00.000+00:00><',
366+
title: '2020-01-13 13:42:23 - Now',
367+
value: '2020-01-13T12:42:23.000+00:00><',
366368
},
367369
],
368370
}),
369-
OrderedMap({ created_at: ['2019-12-31T23:55:00.000+00:00><'] }),
371+
OrderedMap({ created_at: ['2020-01-13T12:42:23.000+00:00><'] }),
370372
),
371373
);
372374

373375
await waitFor(() =>
374376
expect(setUrlQueryFilters).toHaveBeenCalledWith(
375-
OrderedMap({ created_at: ['2019-12-31T23:55:00.000+00:00><'] }),
377+
OrderedMap({ created_at: ['2020-01-13T12:42:23.000+00:00><'] }),
376378
),
377379
);
378380
await waitFor(() => dropdownIsHidden('create created filter'));
@@ -401,7 +403,11 @@ describe('<EntityFilters />', () => {
401403
});
402404
userEvent.click(toggleFilterButton);
403405

404-
userEvent.type(await screen.findByRole('textbox', { name: /from/i }), '{backspace}1');
406+
const fromPicker = await screen.findByTestId('date-picker-from');
407+
userEvent.click(await within(fromPicker).findByRole('gridcell', { name: /Jan 13 2020/i }));
408+
userEvent.type(await screen.findByRole('spinbutton', { name: /from hour/i }), '{selectall}13');
409+
userEvent.type(await screen.findByRole('spinbutton', { name: /from minutes/i }), '{selectall}42');
410+
userEvent.type(await screen.findByRole('spinbutton', { name: /from seconds/i }), '{selectall}23');
405411

406412
const timeRangeForm = await screen.findByTestId('time-range-form');
407413
const submitButton = within(timeRangeForm).getByRole('button', {
@@ -414,18 +420,18 @@ describe('<EntityFilters />', () => {
414420
OrderedMap({
415421
created_at: [
416422
{
417-
title: '2020-01-01 00:55:00.001 - Now',
418-
value: '2019-12-31T23:55:00.001+00:00><',
423+
title: '2020-01-13 13:42:23 - Now',
424+
value: '2020-01-13T12:42:23.000+00:00><',
419425
},
420426
],
421427
}),
422-
OrderedMap({ created_at: ['2019-12-31T23:55:00.001+00:00><'] }),
428+
OrderedMap({ created_at: ['2020-01-13T12:42:23.000+00:00><'] }),
423429
),
424430
);
425431

426432
await waitFor(() =>
427433
expect(setUrlQueryFilters).toHaveBeenCalledWith(
428-
OrderedMap({ created_at: ['2019-12-31T23:55:00.001+00:00><'] }),
434+
OrderedMap({ created_at: ['2020-01-13T12:42:23.000+00:00><'] }),
429435
),
430436
);
431437
await waitFor(() => dropdownIsHidden('edit created filter'));

graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/DateRangeForm.tsx

Lines changed: 53 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,23 @@
1414
* along with this program. If not, see
1515
* <http://www.mongodb.com/licensing/server-side-public-license>.
1616
*/
17-
import React from 'react';
17+
import * as React from 'react';
18+
import { useCallback } from 'react';
1819
import styled, { css } from 'styled-components';
19-
import { Formik, Form, Field } from 'formik';
20+
import { Formik, Form, useField } from 'formik';
2021
import moment from 'moment/moment';
2122

2223
import useUserDateTime from 'hooks/useUserDateTime';
23-
import AbsoluteDateInput from 'views/components/searchbar/time-range-filter/time-range-picker/AbsoluteDateInput';
24-
import { ModalSubmit } from 'components/common';
24+
import { ModalSubmit, Icon } from 'components/common';
2525
import { Checkbox } from 'components/bootstrap';
2626
import { isValidDate, toUTCFromTz, adjustFormat } from 'util/DateTime';
2727
import {
2828
DATE_SEPARATOR,
2929
extractRangeFromString,
3030
timeRangeTitle,
3131
} from 'components/common/EntityFilters/helpers/timeRange';
32+
import DateTimePicker from 'views/components/searchbar/time-range-filter/time-range-picker/DateTimePicker';
33+
import StringUtils from 'util/StringUtils';
3234

3335
import type { Filter } from '../types';
3436

@@ -39,7 +41,7 @@ type FormValues = {
3941

4042
const Container = styled.div`
4143
padding: 3px 10px;
42-
max-width: 250px;
44+
max-width: fit-content;
4345
`;
4446

4547
const Info = styled.p(
@@ -49,16 +51,6 @@ const Info = styled.p(
4951
`,
5052
);
5153

52-
const Sections = styled.div`
53-
margin-bottom: 10px;
54-
`;
55-
56-
const Section = styled.div`
57-
&:not(:last-child) {
58-
margin-bottom: 10px;
59-
}
60-
`;
61-
6254
const SectionHeader = styled.div`
6355
display: flex;
6456
align-items: center;
@@ -76,54 +68,6 @@ const StyledCheckbox = styled(Checkbox)`
7668
}
7769
`;
7870

79-
const DateTimeFormat = styled.code`
80-
padding: 0;
81-
`;
82-
83-
const ErrorMessage = styled.span(
84-
({ theme }) => css`
85-
color: ${theme.colors.variant.dark.danger};
86-
font-size: ${theme.fonts.size.small};
87-
font-style: italic;
88-
padding: 3px 3px 9px;
89-
height: 1.5em;
90-
`,
91-
);
92-
93-
const DateConfiguration = ({
94-
name: fieldName,
95-
label,
96-
checkboxLabel,
97-
}: {
98-
name: string;
99-
label: string;
100-
checkboxLabel: string;
101-
}) => {
102-
const { formatTime } = useUserDateTime();
103-
104-
return (
105-
<Field name={fieldName}>
106-
{({ field: { value, onChange, name }, meta: { error } }) => {
107-
const _onChange = (newValue: string) => onChange({ target: { name, value: newValue } });
108-
const onChangeAllTime = () => _onChange(value ? undefined : formatTime(new Date(), 'complete'));
109-
110-
return (
111-
<div>
112-
<SectionHeader>
113-
<StyledLabel htmlFor={`date-input-${name}`}>{label}</StyledLabel>
114-
<StyledCheckbox onChange={onChangeAllTime} checked={!value}>
115-
{checkboxLabel}
116-
</StyledCheckbox>
117-
</SectionHeader>
118-
<AbsoluteDateInput name="from" value={value} disabled={value === undefined} onChange={_onChange} />
119-
{error && <ErrorMessage>{error}</ErrorMessage>}
120-
</div>
121-
);
122-
}}
123-
</Field>
124-
);
125-
};
126-
12771
const useInitialValues = (filter: Filter | undefined) => {
12872
const { formatTime } = useUserDateTime();
12973

@@ -170,6 +114,45 @@ const validate = (values: FormValues) => {
170114
return errors;
171115
};
172116

117+
const PickerContainer = styled.div`
118+
display: flex;
119+
align-items: center;
120+
flex-direction: row;
121+
gap: 10px;
122+
`;
123+
124+
const PickerWrap = styled.div`
125+
max-width: 240px;
126+
`;
127+
128+
type PickerProps = { name: 'from' | 'until' };
129+
const Picker = ({ name }: PickerProps) => {
130+
const { formatTime } = useUserDateTime();
131+
const label = StringUtils.capitalizeFirstLetter(name);
132+
const [{ onChange, value }, meta] = useField(name);
133+
const _onChange = useCallback(
134+
(newValue: string) => onChange({ target: { name, value: newValue } }),
135+
[onChange, name],
136+
);
137+
const onChangeAllTime = () => _onChange(value ? undefined : formatTime(new Date(), 'complete'));
138+
const checkboxLabel = name === 'from' ? 'All Time' : 'Now';
139+
const isChecked = !value;
140+
141+
return (
142+
<PickerWrap data-testid={`date-picker-${name}`}>
143+
<SectionHeader>
144+
<StyledLabel htmlFor={`date-input-${name}`}>{label}</StyledLabel>
145+
<StyledCheckbox onChange={onChangeAllTime} checked={isChecked}>
146+
{checkboxLabel}
147+
</StyledCheckbox>
148+
</SectionHeader>
149+
<DateTimePicker disabled={isChecked} error={meta.error} onChange={_onChange} value={value} range={label} />
150+
</PickerWrap>
151+
);
152+
};
153+
const FromPicker = () => <Picker name="from" />;
154+
const UntilPicker = () => <Picker name="until" />;
155+
173156
type Props = {
174157
onSubmit: (filter: { title: string; value: string }) => void;
175158
filter: Filter | undefined;
@@ -195,16 +178,14 @@ const DateRangeForm = ({ filter, onSubmit }: Props) => {
195178
<Formik initialValues={initialValues} onSubmit={_onSubmit} validate={validate}>
196179
{({ isValid }) => (
197180
<Form>
198-
<Sections>
199-
<Section>
200-
<DateConfiguration name="from" label="From" checkboxLabel="All time" />
201-
</Section>
202-
<Section>
203-
<DateConfiguration name="until" label="Until" checkboxLabel="Now" />
204-
</Section>
205-
</Sections>
181+
<PickerContainer>
182+
<FromPicker />
183+
184+
<Icon name="arrow_right_alt" />
185+
186+
<UntilPicker />
187+
</PickerContainer>
206188
<Info>
207-
Format: <DateTimeFormat>YYYY-MM-DD [HH:mm:ss[.SSS]]</DateTimeFormat>.<br />
208189
All timezones using: <b>{userTimezone}</b>.
209190
</Info>
210191
<ModalSubmit

graylog2-web-interface/src/views/components/searchbar/time-range-filter/time-range-picker/AbsoluteCalendar.tsx

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,45 +15,24 @@
1515
* <http://www.mongodb.com/licensing/server-side-public-license>.
1616
*/
1717
import * as React from 'react';
18-
import styled, { css } from 'styled-components';
1918
import { Field } from 'formik';
2019

2120
import type { AbsoluteTimeRange } from 'views/logic/queries/Query';
22-
23-
import AbsoluteDatePicker from './AbsoluteDatePicker';
24-
import AbsoluteTimeInput from './AbsoluteTimeInput';
21+
import DateTimePicker from 'views/components/searchbar/time-range-filter/time-range-picker/DateTimePicker';
2522

2623
type Props = {
2724
startDate?: Date;
2825
range: 'to' | 'from';
2926
timeRange: AbsoluteTimeRange;
3027
};
3128

32-
const ErrorMessage = styled.span(
33-
({ theme }) => css`
34-
color: ${theme.colors.variant.dark.danger};
35-
font-size: ${theme.fonts.size.small};
36-
font-style: italic;
37-
padding: 3px 3px 9px;
38-
height: 1.5em;
39-
`,
40-
);
41-
42-
const AbsoluteCalendar = ({ startDate, timeRange, range }: Props) => (
29+
const AbsoluteCalendar = ({ startDate = undefined, timeRange, range }: Props) => (
4330
<Field name={`timeRangeTabs.absolute.${range}`}>
4431
{({ field: { value, onChange, name }, meta: { error } }) => {
45-
const _onChange = (newValue) => onChange({ target: { name, value: newValue } });
32+
const _onChange = (newValue: string) => onChange({ target: { name, value: newValue } });
4633
const dateTime = error ? timeRange[range] : value || timeRange[range];
4734

48-
return (
49-
<>
50-
<AbsoluteDatePicker onChange={_onChange} startDate={startDate} dateTime={dateTime} />
51-
52-
<AbsoluteTimeInput onChange={_onChange} range={range} dateTime={dateTime} />
53-
54-
<ErrorMessage>{error}</ErrorMessage>
55-
</>
56-
);
35+
return <DateTimePicker error={error} onChange={_onChange} value={dateTime} range={range} startDate={startDate} />;
5736
}}
5837
</Field>
5938
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
import * as React from 'react';
18+
import styled, { css } from 'styled-components';
19+
20+
import AbsoluteDatePicker from 'views/components/searchbar/time-range-filter/time-range-picker/AbsoluteDatePicker';
21+
import AbsoluteTimeInput from 'views/components/searchbar/time-range-filter/time-range-picker/AbsoluteTimeInput';
22+
23+
const ErrorMessage = styled.span(
24+
({ theme }) => css`
25+
color: ${theme.colors.variant.dark.danger};
26+
font-size: ${theme.fonts.size.small};
27+
font-style: italic;
28+
padding: 3px 3px 9px;
29+
height: 1.5em;
30+
`,
31+
);
32+
33+
const Overlay = styled.div`
34+
opacity: 0.1;
35+
`;
36+
const Disabled = ({ disabled, children = undefined }: React.PropsWithChildren<{ disabled: boolean }>) =>
37+
disabled ? (
38+
<Overlay>
39+
<div inert="">{children}</div>
40+
</Overlay>
41+
) : (
42+
children
43+
);
44+
45+
type Props = {
46+
disabled?: boolean;
47+
error: string;
48+
onChange: (newValue: string) => void;
49+
startDate?: Date;
50+
value: string;
51+
range?: string;
52+
};
53+
54+
const DateTimePicker = ({
55+
disabled = false,
56+
error,
57+
onChange,
58+
startDate = undefined,
59+
value,
60+
range = 'Range',
61+
}: Props) => (
62+
<>
63+
<Disabled disabled={disabled}>
64+
<AbsoluteDatePicker onChange={onChange} startDate={startDate} dateTime={value} />
65+
66+
<AbsoluteTimeInput onChange={onChange} range={range} dateTime={value} />
67+
</Disabled>
68+
69+
<ErrorMessage>{error}</ErrorMessage>
70+
</>
71+
);
72+
73+
export default DateTimePicker;

0 commit comments

Comments
 (0)