Skip to content

Commit da9795e

Browse files
Add TimeRangeControl component
In this commit we create a more generic version of ObservationDate control and ObservationDateInput and then use it for reusable TimeRangeControl component in TimetableVersions. Resolves HSLdevcom/jore4#1272
1 parent 4a171f6 commit da9795e

14 files changed

+301
-12
lines changed
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { DateTime } from 'luxon';
2+
import { QueryParameterName } from '../../hooks';
3+
import { useDateQueryParam } from '../../hooks/urlQuery/useDateQueryParam';
4+
import { DateInput } from './DateInput';
5+
6+
interface Props {
7+
label: string;
8+
className?: string;
9+
disabled?: boolean;
10+
testId: string;
11+
dateInputId: string;
12+
queryParamName: QueryParameterName;
13+
initialize?: boolean;
14+
}
15+
16+
/**
17+
* Date input which handles its own state in query params.
18+
* The query parameter name is required as parameter
19+
*/
20+
export const DateControl = ({
21+
label,
22+
className = '',
23+
disabled = false,
24+
testId,
25+
dateInputId,
26+
queryParamName,
27+
initialize,
28+
}: Props): JSX.Element => {
29+
const { date, setDateToUrl } = useDateQueryParam({
30+
queryParamName,
31+
initialize,
32+
});
33+
const onDateChange = (newDate: DateTime) => {
34+
// Do not allow setting empty value to date
35+
if (!date.isValid) {
36+
return;
37+
}
38+
39+
setDateToUrl(newDate);
40+
};
41+
42+
return (
43+
<DateInput
44+
label={label}
45+
value={date}
46+
onChange={onDateChange}
47+
className={className}
48+
required
49+
disabled={disabled}
50+
testId={testId}
51+
dateInputId={dateInputId}
52+
/>
53+
);
54+
};
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { DateTime } from 'luxon';
2+
import { Column } from '../../layoutComponents';
3+
4+
interface Props {
5+
value: DateTime;
6+
label: string;
7+
onChange: (value: DateTime) => void;
8+
className?: string;
9+
testId: string;
10+
required?: boolean;
11+
disabled?: boolean;
12+
dateInputId: string;
13+
}
14+
15+
export const DateInput = ({
16+
value,
17+
label,
18+
onChange,
19+
className = '',
20+
testId,
21+
required = false,
22+
disabled = false,
23+
dateInputId,
24+
}: Props): JSX.Element => {
25+
return (
26+
<Column className={className}>
27+
<label htmlFor={dateInputId}>{label}</label>
28+
<input
29+
type="date"
30+
value={value.toISODate()}
31+
onChange={(e) => onChange(DateTime.fromISO(e.target.value))}
32+
id={dateInputId}
33+
className={className}
34+
data-testid={testId}
35+
required={required}
36+
disabled={disabled}
37+
/>
38+
</Column>
39+
);
40+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useTranslation } from 'react-i18next';
2+
import { QueryParameterName, useTimeRangeQueryParams } from '../../hooks';
3+
import { Row } from '../../layoutComponents';
4+
import { ValidationError } from '../forms/common/ValidationErrorList';
5+
import { DateControl } from './DateControl';
6+
7+
export const TimeRangeControl = ({ className }: { className?: string }) => {
8+
const { t } = useTranslation();
9+
const testIds = {
10+
startDate: 'TimeRangeControl::startDate',
11+
endDate: 'TimeRangeControl::endDate',
12+
};
13+
const { isInvalidDateRange } = useTimeRangeQueryParams();
14+
return (
15+
<div className={className}>
16+
<Row className="space-x-8">
17+
<DateControl
18+
label={t('validityPeriod.validityStart')}
19+
dateInputId="startDate"
20+
className="max-w-max"
21+
testId={testIds.startDate}
22+
queryParamName={QueryParameterName.StartDate}
23+
/>
24+
<DateControl
25+
label={t('validityPeriod.validityEnd')}
26+
dateInputId="endDate"
27+
className="max-w-max"
28+
testId={testIds.endDate}
29+
queryParamName={QueryParameterName.EndDate}
30+
/>
31+
</Row>
32+
{isInvalidDateRange && (
33+
<ValidationError errorMessage={t('formValidation.timeRange')} />
34+
)}
35+
</div>
36+
);
37+
};

ui/src/components/common/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
export * from './DateControl';
2+
export * from './DateInput';
13
export * from './LineTableRow';
24
export * from './RedirectWithQuery';
35
export * from './RouteLineTableRow';
46
export * from './RouteTableRow';
5-
export * from './search';
7+
export * from './TimeRangeControl';
68
export * from './Tooltip';
9+
export * from './search';

ui/src/components/timetables/versions/TimetableVersionsPage.tsx

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import orderBy from 'lodash/orderBy';
2-
import { DateTime } from 'luxon';
32
import { useMemo } from 'react';
43
import { useTranslation } from 'react-i18next';
54
import { useParams } from 'react-router-dom';
65
import {
76
TimetableVersionRowData,
87
useGetJourneyPatternIdsByLineLabel,
98
useGetTimetableVersions,
9+
useTimeRangeQueryParams,
1010
useTimetableVersionsReturnToQueryParam,
1111
} from '../../../hooks';
1212
import { Container } from '../../../layoutComponents';
1313
import { TimetablePriority } from '../../../types/enums';
1414
import { CloseIconButton } from '../../../uiComponents';
15+
import { TimeRangeControl } from '../../common';
1516
import { FormColumn, FormRow } from '../../forms/common';
1617
import { TimetableVersionTable } from './TimetableVersionTable';
1718

@@ -22,12 +23,14 @@ const testIds = {
2223
export const TimetableVersionsPage = (): JSX.Element => {
2324
const { t } = useTranslation();
2425
const { label } = useParams<{ label: string }>();
26+
const { startDate, endDate } = useTimeRangeQueryParams();
2527

2628
// We first need to get the journey pattern ids for all line routes by line label
2729
const { journeyPatternIdsGroupedByRouteLabel, loading } =
2830
useGetJourneyPatternIdsByLineLabel({
29-
// TODO: Add timerange filter here also
3031
label,
32+
startDate,
33+
endDate,
3134
});
3235
// Then we can fetch the timetable versions using SQL functions
3336
const { versions } = useGetTimetableVersions({
@@ -38,9 +41,8 @@ export const TimetableVersionsPage = (): JSX.Element => {
3841
// eslint-disable-next-line react-hooks/exhaustive-deps
3942
[loading],
4043
),
41-
// TODO: Add timerange components and remove hardcoded values
42-
startDate: useMemo(() => DateTime.fromISO('2020-01-01'), []),
43-
endDate: useMemo(() => DateTime.fromISO('2023-12-31'), []),
44+
startDate,
45+
endDate,
4446
});
4547

4648
const timetablesExcludingDrafts =
@@ -84,6 +86,8 @@ export const TimetableVersionsPage = (): JSX.Element => {
8486
</FormColumn>
8587
</FormRow>
8688
<Container>
89+
<h3>{t('timetables.timeline')}</h3>
90+
<TimeRangeControl className="mb-8" />
8791
<h3>{t('timetables.operatingCalendar')}</h3>
8892
<TimetableVersionTable
8993
className="mb-8 w-full"

ui/src/hooks/urlQuery/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export * from './useDateQueryParam';
12
export * from './useMapQueryParams';
23
export * from './useObservationDateQueryParam';
4+
export * from './useTimeRangeQueryParams';
35
export * from './useUrlQuery';
+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { DateTime } from 'luxon';
2+
import { useCallback, useEffect, useMemo, useState } from 'react';
3+
import { QueryParameterName, useUrlQuery } from './useUrlQuery';
4+
5+
interface Props {
6+
initialize?: boolean;
7+
queryParamName: QueryParameterName;
8+
}
9+
10+
/**
11+
* Query parameter hook for setting and getting date query parameter. Initialization
12+
* of this query parameter can be set to false if you don't want to initialize it.
13+
* TODO: This is currently partly copypasted from useObservationDateQueryParam, and
14+
* these could maybe be combined at least from some parts.
15+
*/
16+
export const useDateQueryParam = ({
17+
initialize = true,
18+
queryParamName,
19+
}: Props) => {
20+
const { getDateTimeFromUrlQuery, setDateTimeToUrlQuery, queryParams } =
21+
useUrlQuery();
22+
23+
const [defaultDate] = useState(DateTime.now().startOf('day'));
24+
25+
/**
26+
* Sets date to URL query
27+
* replace flag can be given to replace the earlier url query instead
28+
* of pushing it. This affects how the back button or history.back() works.
29+
* If the history is replaced, it means that back button will not go to the
30+
* url which was replaced, but rather the one before it.
31+
*/
32+
const setDateToUrl = (date: DateTime, replace = false) => {
33+
setDateTimeToUrlQuery(
34+
{ paramName: queryParamName, value: date },
35+
{ replace },
36+
);
37+
};
38+
39+
// Memoize the actual value to prevent unnecessary updates
40+
const date = useMemo(() => {
41+
try {
42+
return getDateTimeFromUrlQuery(queryParamName) || defaultDate;
43+
} catch {
44+
// If parsing date fails, set default date
45+
setDateToUrl(defaultDate, true);
46+
return defaultDate;
47+
}
48+
// eslint-disable-next-line react-hooks/exhaustive-deps
49+
}, [defaultDate, getDateTimeFromUrlQuery]);
50+
51+
/** Determines and sets date to query parameters if it's not there */
52+
const initializeDate = useCallback(async () => {
53+
if (!queryParams[queryParamName] || !date) {
54+
setDateToUrl(defaultDate, true);
55+
}
56+
// eslint-disable-next-line react-hooks/exhaustive-deps
57+
}, [defaultDate, date, queryParams[queryParamName]]);
58+
59+
useEffect(() => {
60+
if (initialize) {
61+
initializeDate();
62+
}
63+
}, [initialize, initializeDate, queryParams]);
64+
65+
return {
66+
date,
67+
setDateToUrl,
68+
};
69+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useDateQueryParam } from './useDateQueryParam';
2+
import { QueryParameterName } from './useUrlQuery';
3+
4+
export const useTimeRangeQueryParams = () => {
5+
const { date: startDate } = useDateQueryParam({
6+
queryParamName: QueryParameterName.StartDate,
7+
});
8+
const { date: endDate } = useDateQueryParam({
9+
queryParamName: QueryParameterName.EndDate,
10+
});
11+
const isInvalidDateRange = startDate > endDate;
12+
return { startDate, endDate, isInvalidDateRange };
13+
};

ui/src/hooks/urlQuery/useUrlQuery.ts

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export enum QueryParameterName {
2727
RoutePriorities = 'routePriorities',
2828
TimetablesViewName = 'timetablesView',
2929
DayType = 'dayType',
30+
StartDate = 'startDate',
31+
EndDate = 'endDate',
3032
}
3133

3234
export type QueryParameter<TType> = { paramName: string; value: TType };

ui/src/hooks/useGetJourneyPatternIdsByRouteLabel.ts

+8
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { QueryResult, gql } from '@apollo/client';
22
import groupBy from 'lodash/groupBy';
33
import uniq from 'lodash/uniq';
44
import uniqWith from 'lodash/uniqWith';
5+
import { DateTime } from 'luxon';
56
import { pipe } from 'remeda';
67
import {
78
GetRouteInfoForTimetableVersionsQuery,
89
RouteInfoForTimetableVersionFragment,
910
useGetRouteInfoForTimetableVersionsQuery,
1011
} from '../generated/graphql';
1112
import {
13+
buildActiveDateRangeGqlFilter,
1214
buildRouteLineLabelGqlFilter,
1315
getRouteLabelVariantText,
1416
} from '../utils';
@@ -76,17 +78,23 @@ const extractDistinctJourneyPatternIdsGroupedByRouteLabel = (
7678
},
7779
{},
7880
);
81+
7982
/**
8083
* Fetches one journey patterns per route (only one direction is enough) by line label for timetable versions.
8184
* Returns object which has route labelAndVariant as key and distinct journey pattern ids as value
8285
*/
8386
export const useGetJourneyPatternIdsByLineLabel = ({
8487
label,
88+
startDate,
89+
endDate,
8590
}: {
8691
label: string;
92+
startDate: DateTime;
93+
endDate: DateTime;
8794
}) => {
8895
const routeFilters = {
8996
...buildRouteLineLabelGqlFilter(label),
97+
...buildActiveDateRangeGqlFilter(startDate, endDate),
9098
};
9199

92100
const result = useGetRouteInfoForTimetableVersionsQuery({

ui/src/hooks/useGetTimetableVersions.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,17 @@ export const useGetTimetableVersions = ({
179179
]);
180180

181181
useEffect(() => {
182-
fetchTimetableVersions();
183-
}, [fetchTimetableVersions, journeyPatternIdsGroupedByRouteLabel]);
182+
if (startDate <= endDate) {
183+
fetchTimetableVersions();
184+
} else {
185+
setVersions([]);
186+
}
187+
}, [
188+
endDate,
189+
fetchTimetableVersions,
190+
journeyPatternIdsGroupedByRouteLabel,
191+
startDate,
192+
]);
184193

185194
return {
186195
versions,

ui/src/locales/en-US/common.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@
229229
"formValidation": {
230230
"required": "Field is required",
231231
"tooSmall": "Value is too small",
232-
"tooBig": "Value is too big"
232+
"tooBig": "Value is too big",
233+
"timeRange": "Start date has to be before end date."
233234
},
234235
"errors": {
235236
"saveFailed": "Something went wrong",
@@ -304,7 +305,8 @@
304305
"interpolated": "Interpolated",
305306
"showArrivalTimes": "Show arrival times",
306307
"operatedLike": "Operated like {{ dayOfWeek }}",
307-
"noService": "No service"
308+
"noService": "No service",
309+
"timeline": "Timeline"
308310
},
309311
"timetablesPreview": {
310312
"preview": "Preview",

0 commit comments

Comments
 (0)