map historical image slider

pull/672/head
gabrielburnworth 2018-02-20 16:09:40 -08:00
parent 49c61134da
commit 49d5f4509f
10 changed files with 359 additions and 240 deletions

View File

@ -313,11 +313,16 @@
display: flex;
flex-direction: column;
align-items: center;
fieldset {
width: 100%;
}
label {
margin-bottom: 0;
}
button {
float: none;
margin-bottom: 0.5rem;
margin-left: 2rem;
}
}
.caret-menu-button {
@ -452,7 +457,17 @@
}
.image-filter-menu {
width: 32rem;
th {
text-align: center;
}
.pt-slider {
margin-left: 3rem;
margin-top: 1rem;
width: 25rem;
}
.pt-slider-label {
white-space: nowrap;
text-align: center;
}
}

View File

@ -8,9 +8,11 @@ import { mount } from "enzyme";
import { Props } from "../interfaces";
import { GardenMapLegendProps } from "../map/interfaces";
import { bot } from "../../__test_support__/fake_state/bot";
import { fakeImage } from "../../__test_support__/fake_state/resources";
describe("<FarmDesigner/>", () => {
function fakeProps(): Props {
return {
dispatch: jest.fn(),
selectedPlant: undefined,
@ -53,7 +55,7 @@ describe("<FarmDesigner/>", () => {
it("loads default map settings", () => {
localStorage["showPoints"] = "false";
const wrapper = mount(<FarmDesigner { ...fakeProps() } />);
const wrapper = mount(<FarmDesigner {...fakeProps()} />);
const legendProps = wrapper.find("GardenMapLegend").props() as GardenMapLegendProps;
expect(legendProps.legendMenuOpen).toBeFalsy();
expect(legendProps.showPlants).toBeTruthy();
@ -62,14 +64,28 @@ describe("<FarmDesigner/>", () => {
expect(legendProps.showFarmbot).toBeTruthy();
expect(legendProps.showImages).toBeFalsy();
expect(legendProps.botOriginQuadrant).toEqual(2);
expect(legendProps.imageAgeInfo).toEqual({ newestDate: "", toOldest: 1 });
// tslint:disable-next-line:no-any
const gardenMapProps = wrapper.find("GardenMap").props() as any;
expect(gardenMapProps.gridSize.x).toEqual(2900);
expect(gardenMapProps.gridSize.y).toEqual(1400);
});
it("loads image info", () => {
const p = fakeProps();
const image1 = fakeImage();
const image2 = fakeImage();
image1.body.created_at = "2001-01-03T00:00:00.000Z";
image2.body.created_at = "2001-01-01T00:00:00.000Z";
p.latestImages = [image1, image2];
const wrapper = mount(<FarmDesigner {...p} />);
const legendProps = wrapper.find("GardenMapLegend").props() as GardenMapLegendProps;
expect(legendProps.imageAgeInfo)
.toEqual({ newestDate: "2001-01-03T00:00:00.000Z", toOldest: 2 });
});
it("renders nav titles", () => {
const wrapper = mount(<FarmDesigner { ...fakeProps() } />);
const wrapper = mount(<FarmDesigner {...fakeProps()} />);
["Designer", "Plants", "Farm Events"].map(string =>
expect(wrapper.text()).toContain(string));
});

View File

@ -10,11 +10,12 @@ import { Plants } from "./plants/plant_inventory";
import { GardenMapLegend } from "./map/garden_map_legend";
import { Session, safeBooleanSettting } from "../session";
import { NumericSetting, BooleanSetting } from "../session_keys";
import { isUndefined } from "lodash";
import { isUndefined, last } from "lodash";
import { AxisNumberProperty, BotSize } from "./map/interfaces";
import { getBotSize } from "./map/util";
import { catchErrors } from "../util";
import { calcZoomLevel, getZoomLevelIndex, saveZoomLevelIndex } from "./map/zoom";
import * as moment from "moment";
export const getDefaultAxisLength = (): AxisNumberProperty => {
if (Session.deprecatedGetBool(BooleanSetting.map_xl)) {
@ -125,6 +126,15 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
y: !!this.props.botMcuParams.movement_stop_at_home_y
};
const newestImage = this.props.latestImages[0];
const oldestImage = last(this.props.latestImages);
const newestDate = newestImage ? newestImage.body.created_at : "";
const toOldest = oldestImage && newestDate
? Math.abs(moment(oldestImage.body.created_at)
.diff(moment(newestDate).clone(), "days"))
: 1;
const imageAgeInfo = { newestDate, toOldest };
return <div className="farm-designer">
<GardenMapLegend
@ -140,7 +150,8 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
showImages={show_images}
dispatch={this.props.dispatch}
tzOffset={this.props.tzOffset}
getConfigValue={this.props.getConfigValue} />
getConfigValue={this.props.getConfigValue}
imageAgeInfo={imageAgeInfo} />
<div className="panel-header gray-panel designer-nav">
<div className="panel-tabs">

View File

@ -28,6 +28,7 @@ describe("<GardenMapLegend />", () => {
dispatch: jest.fn(),
tzOffset: 0,
getConfigValue: jest.fn(),
imageAgeInfo: { newestDate: "", toOldest: 1 },
};
}

View File

@ -0,0 +1,114 @@
import * as React from "react";
import { ImageFilterMenu, ImageFilterMenuProps } from "../image_filter_menu";
import { shallow, mount } from "enzyme";
import { fakeWebAppConfig } from "../../../__test_support__/fake_state/resources";
import { StringConfigKey } from "../../../config_storage/web_app_configs";
import { setWebAppConfigValue } from "../../../config_storage/actions";
const mockConfig = fakeWebAppConfig();
jest.mock("../../../resources/selectors", () => {
return {
getWebAppConfig: () => mockConfig,
assertUuid: jest.fn()
};
});
jest.mock("../../../config_storage/actions", () => {
return {
setWebAppConfigValue: jest.fn()
};
});
describe("<ImageFilterMenu />", () => {
beforeEach(function () {
jest.clearAllMocks();
});
mockConfig.body.photo_filter_begin = "";
mockConfig.body.photo_filter_end = "";
const fakeProps = (): ImageFilterMenuProps => {
return {
tzOffset: 0,
dispatch: jest.fn(),
getConfigValue: jest.fn(x => mockConfig.body[x as StringConfigKey]),
imageAgeInfo: { newestDate: "", toOldest: 1 }
};
};
it("renders", () => {
const p = fakeProps();
const wrapper = shallow(<ImageFilterMenu {...p} />);
["Date", "Time", "Newer than", "Older than"].map(string =>
expect(wrapper.text()).toContain(string));
});
const testFilterSetDate =
(filter: "beginDate" | "endDate",
key: "photo_filter_begin" | "photo_filter_end",
i: number) => {
it(`sets filter: ${filter}`, () => {
const p = fakeProps();
const wrapper = shallow(<ImageFilterMenu {...p} />);
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "2001-01-03" }
});
expect(wrapper.state()[filter]).toEqual("2001-01-03");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T00:00:00.000Z");
});
};
testFilterSetDate("beginDate", "photo_filter_begin", 0);
testFilterSetDate("endDate", "photo_filter_end", 2);
const testFilterSetTime =
(filter: "beginTime" | "endTime",
key: "photo_filter_begin" | "photo_filter_end",
i: number) => {
it(`sets filter: ${filter}`, () => {
const p = fakeProps();
const wrapper = shallow(<ImageFilterMenu {...p} />);
wrapper.setState({ beginDate: "2001-01-03", endDate: "2001-01-03" });
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "05:00" }
});
expect(wrapper.state()[filter]).toEqual("05:00");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T05:00:00.000Z");
});
};
testFilterSetTime("beginTime", "photo_filter_begin", 1);
testFilterSetTime("endTime", "photo_filter_end", 3);
it("loads values from config", () => {
mockConfig.body.photo_filter_begin = "2001-01-03T05:00:00.000Z";
mockConfig.body.photo_filter_end = "2001-01-03T06:00:00.000Z";
const wrapper = shallow(<ImageFilterMenu {...fakeProps()} />);
expect(wrapper.state()).toEqual({
beginDate: "2001-01-03", beginTime: "05:00",
endDate: "2001-01-03", endTime: "06:00", slider: NaN
});
});
it("changes slider", () => {
const p = fakeProps();
p.imageAgeInfo.newestDate = "2001-01-03T05:00:00.000Z";
const wrapper = shallow(<ImageFilterMenu {...p} />);
wrapper.find("Slider").simulate("change", 1);
expect(wrapper.state().slider).toEqual(1);
expect(setWebAppConfigValue)
.toHaveBeenCalledWith("photo_filter_begin", "2001-01-02T00:00:00.000Z");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith("photo_filter_end", "2001-01-03T00:00:00.000Z");
});
it("displays slider labels", () => {
const p = fakeProps();
p.imageAgeInfo.newestDate = "2001-01-03T00:00:00.000Z";
const wrapper = mount(<ImageFilterMenu {...p} />);
["Jan-1", "Jan-2", "Jan-3"].map(date =>
expect(wrapper.text()).toContain(date));
});
});

View File

@ -4,7 +4,7 @@ import { LayerToggle } from "./layer_toggle";
import { GardenMapLegendProps } from "./interfaces";
import { history } from "../../history";
import { atMaxZoom, atMinZoom } from "./zoom";
import { ImageFilterMenu } from "./layers/image_layer";
import { ImageFilterMenu } from "./image_filter_menu";
export function GardenMapLegend(props: GardenMapLegendProps) {
@ -22,6 +22,7 @@ export function GardenMapLegend(props: GardenMapLegendProps) {
dispatch,
tzOffset,
getConfigValue,
imageAgeInfo,
} = props;
const plusBtnClass = atMaxZoom() ? "disabled" : "";
@ -76,7 +77,8 @@ export function GardenMapLegend(props: GardenMapLegendProps) {
popover={<ImageFilterMenu
tzOffset={tzOffset}
dispatch={dispatch}
getConfigValue={getConfigValue} />} />
getConfigValue={getConfigValue}
imageAgeInfo={imageAgeInfo} />} />
</div>
<div className="farmbot-origin">
<label>

View File

@ -0,0 +1,187 @@
import * as React from "react";
import { BlurableInput } from "../../ui/index";
import { t } from "i18next";
import { offsetTime } from "../farm_events/edit_fe_form";
import { setWebAppConfigValue, GetWebAppConfigValue } from "../../config_storage/actions";
import * as moment from "moment";
import { formatDate, formatTime } from "../farm_events/map_state_to_props_add_edit";
import { Slider } from "@blueprintjs/core";
interface ImageFilterMenuState {
beginDate: string | undefined;
beginTime: string | undefined;
endDate: string | undefined;
endTime: string | undefined;
slider: number;
}
export interface ImageFilterMenuProps {
tzOffset: number;
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
imageAgeInfo: { newestDate: string, toOldest: number };
}
export class ImageFilterMenu
extends React.Component<ImageFilterMenuProps, Partial<ImageFilterMenuState>> {
constructor(props: ImageFilterMenuProps) {
super(props);
this.state = {};
}
componentWillMount() {
const { newestDate, toOldest } = this.props.imageAgeInfo;
const beginDatetime = this.props.getConfigValue("photo_filter_begin");
this.setState({
slider: toOldest + 1 - (beginDatetime
? Math.abs(moment(beginDatetime.toString())
.diff(moment(newestDate).clone(), "days")) : 0)
});
this.updateState();
}
componentWillReceiveProps() {
this.updateState();
}
updateState = () => {
const beginDatetime = this.props.getConfigValue("photo_filter_begin");
const endDatetime = this.props.getConfigValue("photo_filter_end");
const { tzOffset } = this.props;
this.setState({
beginDate: beginDatetime
? formatDate(beginDatetime.toString(), tzOffset) : undefined,
beginTime: beginDatetime
? formatTime(beginDatetime.toString(), tzOffset) : undefined,
endDate: endDatetime
? formatDate(endDatetime.toString(), tzOffset) : undefined,
endTime: endDatetime
? formatTime(endDatetime.toString(), tzOffset) : undefined,
});
}
setDatetime = (datetime: keyof ImageFilterMenuState) => {
return (e: React.SyntheticEvent<HTMLInputElement>) => {
const input = e.currentTarget.value;
this.setState({ [datetime]: input });
const { beginDate, beginTime, endDate, endTime } = this.state;
const { dispatch, tzOffset } = this.props;
let value = undefined;
switch (datetime) {
case "beginDate":
value = offsetTime(input, beginTime || "00:00", tzOffset);
dispatch(setWebAppConfigValue("photo_filter_begin", value));
break;
case "beginTime":
if (beginDate) {
value = offsetTime(beginDate, input, tzOffset);
dispatch(setWebAppConfigValue("photo_filter_begin", value));
}
break;
case "endDate":
value = offsetTime(input, endTime || "00:00", tzOffset);
dispatch(setWebAppConfigValue("photo_filter_end", value));
break;
case "endTime":
if (endDate) {
value = offsetTime(endDate, input, tzOffset);
dispatch(setWebAppConfigValue("photo_filter_end", value));
}
break;
}
};
};
sliderChange = (slider: number) => {
const { newestDate, toOldest } = this.props.imageAgeInfo;
this.setState({ slider });
const { dispatch, tzOffset } = this.props;
const calcDate = (day: number) =>
moment(newestDate).subtract(toOldest - day, "days").toISOString();
const begin = offsetTime(calcDate(slider - 1), "00:00", tzOffset);
const end = offsetTime(calcDate(slider), "00:00", tzOffset);
dispatch(setWebAppConfigValue("photo_filter_begin", begin));
dispatch(setWebAppConfigValue("photo_filter_end", end));
}
renderLabel = (day: number) => {
const { newestDate, toOldest } = this.props.imageAgeInfo;
return moment(newestDate)
.utcOffset(this.props.tzOffset)
.subtract(toOldest + 1 - day, "days")
.format("MMM-D");
}
get labelStepSize() {
return Math.max(Math.round(this.props.imageAgeInfo.toOldest / 5), 1);
}
render() {
const { beginDate, beginTime, endDate, endTime, slider } = this.state;
return <div className={"image-filter-menu"}>
<table>
<thead>
<tr>
<th />
<th><label>{t("Date")}</label></th>
<th><label>{t("Time")}</label></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<label>{t("Newer than")}</label>
</td>
<td>
<BlurableInput
type="date"
name="beginDate"
value={beginDate || ""}
allowEmpty={true}
onCommit={this.setDatetime("beginDate")} />
</td>
<td>
<BlurableInput
type="time"
name="beginTime"
value={beginTime || ""}
allowEmpty={true}
disabled={!beginDate}
onCommit={this.setDatetime("beginTime")} />
</td>
</tr>
<tr>
<td>
<label>{t("Older than")}</label>
</td>
<td>
<BlurableInput
type="date"
name="endDate"
value={endDate || ""}
allowEmpty={true}
onCommit={this.setDatetime("endDate")} />
</td>
<td>
<BlurableInput
type="time"
name="endTime"
value={endTime || ""}
allowEmpty={true}
disabled={!endDate}
onCommit={this.setDatetime("endTime")} />
</td>
</tr>
</tbody>
</table>
<Slider
min={0}
max={this.props.imageAgeInfo.toOldest + 1}
labelStepSize={this.labelStepSize}
value={slider}
onChange={this.sliderChange}
renderLabel={this.renderLabel}
showTrackFill={false} />
</div>;
}
}

View File

@ -39,6 +39,7 @@ export interface GardenMapLegendProps {
dispatch: Function;
tzOffset: number;
getConfigValue: GetWebAppConfigValue;
imageAgeInfo: { newestDate: string, toOldest: number };
}
export type MapTransformProps = {

View File

@ -1,11 +1,7 @@
import * as React from "react";
import {
ImageLayer, ImageLayerProps, ImageFilterMenu, ImageFilterMenuProps
} from "../image_layer";
import { ImageLayer, ImageLayerProps } from "../image_layer";
import { shallow } from "enzyme";
import { fakeImage, fakeWebAppConfig } from "../../../../__test_support__/fake_state/resources";
import { Actions } from "../../../../constants";
import { StringConfigKey } from "../../../../config_storage/web_app_configs";
const mockConfig = fakeWebAppConfig();
jest.mock("../../../../resources/selectors", () => {
@ -40,7 +36,7 @@ describe("<ImageLayer/>", () => {
it("shows images", () => {
const p = fakeProps();
const wrapper = shallow(<ImageLayer {...p } />);
const wrapper = shallow(<ImageLayer {...p} />);
const layer = wrapper.find("#image-layer");
expect(layer.find("MapImage").html()).toContain("x=\"0\"");
});
@ -48,7 +44,7 @@ describe("<ImageLayer/>", () => {
it("toggles visibility off", () => {
const p = fakeProps();
p.visible = false;
const wrapper = shallow(<ImageLayer {...p } />);
const wrapper = shallow(<ImageLayer {...p} />);
const layer = wrapper.find("#image-layer");
expect(layer.find("MapImage").length).toEqual(0);
});
@ -57,92 +53,8 @@ describe("<ImageLayer/>", () => {
const p = fakeProps();
p.images[0].body.created_at = "2018-01-22T05:00:00.000Z";
p.getConfigValue = () => "2018-01-23T05:00:00.000Z";
const wrapper = shallow(<ImageLayer {...p } />);
const wrapper = shallow(<ImageLayer {...p} />);
const layer = wrapper.find("#image-layer");
expect(layer.find("MapImage").length).toEqual(0);
});
});
describe("<ImageFilterMenu />", () => {
beforeEach(function () {
jest.clearAllMocks();
});
const getState = jest.fn(() => ({ resources: { index: {} } }));
mockConfig.body.photo_filter_begin = "";
mockConfig.body.photo_filter_end = "";
const fakeProps = (): ImageFilterMenuProps => {
return {
tzOffset: 0,
dispatch: jest.fn(),
getConfigValue: jest.fn(x => mockConfig.body[x as StringConfigKey])
};
};
it("renders", () => {
const p = fakeProps();
const wrapper = shallow(<ImageFilterMenu {...p } />);
["Date", "Time", "Newer than", "Older than"].map(string =>
expect(wrapper.text()).toContain(string));
});
const testFilterSetDate =
(filter: "beginDate" | "endDate",
key: "photo_filter_begin" | "photo_filter_end",
i: number) => {
it(`sets filter: ${filter}`, () => {
const p = fakeProps();
const wrapper = shallow(<ImageFilterMenu {...p } />);
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "2001-01-03" }
});
expect(wrapper.state()[filter]).toEqual("2001-01-03");
(p.dispatch as jest.Mock).mock.calls[0][0](p.dispatch, getState);
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.EDIT_RESOURCE,
payload: expect.objectContaining({
update: { [key]: "2001-01-03T00:00:00.000Z" }
})
});
});
};
testFilterSetDate("beginDate", "photo_filter_begin", 0);
testFilterSetDate("endDate", "photo_filter_end", 2);
const testFilterSetTime =
(filter: "beginTime" | "endTime",
key: "photo_filter_begin" | "photo_filter_end",
i: number) => {
it(`sets filter: ${filter}`, () => {
const p = fakeProps();
const wrapper = shallow(<ImageFilterMenu {...p } />);
wrapper.setState({ beginDate: "2001-01-03", endDate: "2001-01-03" });
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "05:00" }
});
expect(wrapper.state()[filter]).toEqual("05:00");
(p.dispatch as jest.Mock).mock.calls[0][0](p.dispatch, getState);
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.EDIT_RESOURCE,
payload: expect.objectContaining({
update: { [key]: "2001-01-03T05:00:00.000Z" }
})
});
});
};
testFilterSetTime("beginTime", "photo_filter_begin", 1);
testFilterSetTime("endTime", "photo_filter_end", 3);
it("loads values from config", () => {
mockConfig.body.photo_filter_begin = "2001-01-03T05:00:00.000Z";
mockConfig.body.photo_filter_end = "2001-01-03T06:00:00.000Z";
const wrapper = shallow(<ImageFilterMenu {...fakeProps() } />);
expect(wrapper.state()).toEqual({
beginDate: "2001-01-03", beginTime: "05:00",
endDate: "2001-01-03", endTime: "06:00"
});
});
});

View File

@ -4,12 +4,8 @@ import { CameraCalibrationData } from "../../interfaces";
import { TaggedImage } from "../../../resources/tagged_resources";
import { MapImage } from "../map_image";
import { reverse, cloneDeep } from "lodash";
import { BlurableInput } from "../../../ui/index";
import { t } from "i18next";
import { offsetTime } from "../../farm_events/edit_fe_form";
import { setWebAppConfigValue, GetWebAppConfigValue } from "../../../config_storage/actions";
import { GetWebAppConfigValue } from "../../../config_storage/actions";
import * as moment from "moment";
import { formatDate, formatTime } from "../../farm_events/map_state_to_props_add_edit";
export interface ImageLayerProps {
visible: boolean;
@ -24,7 +20,7 @@ export function ImageLayer(props: ImageLayerProps) {
const {
visible, images, mapTransformProps, cameraCalibrationData, sizeOverride,
getConfigValue
} = props;
} = props;
const imageFilterBegin = getConfigValue("photo_filter_begin");
const imageFilterEnd = getConfigValue("photo_filter_end");
return <g id="image-layer">
@ -44,139 +40,3 @@ export function ImageLayer(props: ImageLayerProps) {
)}
</g>;
}
interface ImageFilterMenuState {
beginDate: string | undefined;
beginTime: string | undefined;
endDate: string | undefined;
endTime: string | undefined;
}
export interface ImageFilterMenuProps {
tzOffset: number;
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
}
export class ImageFilterMenu
extends React.Component<ImageFilterMenuProps, Partial<ImageFilterMenuState>> {
constructor(props: ImageFilterMenuProps) {
super(props);
this.state = {};
}
componentWillMount() {
this.updateState();
}
componentWillReceiveProps() {
this.updateState();
}
updateState = () => {
const beginDatetime = this.props.getConfigValue("photo_filter_begin");
const endDatetime = this.props.getConfigValue("photo_filter_end");
const { tzOffset } = this.props;
this.setState({
beginDate: beginDatetime
? formatDate(beginDatetime.toString(), tzOffset) : undefined,
beginTime: beginDatetime
? formatTime(beginDatetime.toString(), tzOffset) : undefined,
endDate: endDatetime
? formatDate(endDatetime.toString(), tzOffset) : undefined,
endTime: endDatetime
? formatTime(endDatetime.toString(), tzOffset) : undefined,
});
}
setDatetime = (datetime: keyof ImageFilterMenuState) => {
return (e: React.SyntheticEvent<HTMLInputElement>) => {
const input = e.currentTarget.value;
this.setState({ [datetime]: input });
const { beginDate, beginTime, endDate, endTime } = this.state;
const { dispatch, tzOffset } = this.props;
let value = undefined;
switch (datetime) {
case "beginDate":
value = offsetTime(input, beginTime || "00:00", tzOffset);
dispatch(setWebAppConfigValue("photo_filter_begin", value));
break;
case "beginTime":
if (beginDate) {
value = offsetTime(beginDate, input, tzOffset);
dispatch(setWebAppConfigValue("photo_filter_begin", value));
}
break;
case "endDate":
value = offsetTime(input, endTime || "00:00", tzOffset);
dispatch(setWebAppConfigValue("photo_filter_end", value));
break;
case "endTime":
if (endDate) {
value = offsetTime(endDate, input, tzOffset);
dispatch(setWebAppConfigValue("photo_filter_end", value));
}
break;
}
};
};
render() {
const { beginDate, beginTime, endDate, endTime } = this.state;
return <table className={"image-filter-menu"}>
<thead>
<tr>
<th />
<th><label>{t("Date")}</label></th>
<th><label>{t("Time")}</label></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<label>{t("Newer than")}</label>
</td>
<td>
<BlurableInput
type="date"
name="beginDate"
value={beginDate || ""}
allowEmpty={true}
onCommit={this.setDatetime("beginDate")} />
</td>
<td>
<BlurableInput
type="time"
name="beginTime"
value={beginTime || ""}
allowEmpty={true}
disabled={!beginDate}
onCommit={this.setDatetime("beginTime")} />
</td>
</tr>
<tr>
<td>
<label>{t("Older than")}</label>
</td>
<td>
<BlurableInput
type="date"
name="endDate"
value={endDate || ""}
allowEmpty={true}
onCommit={this.setDatetime("endDate")} />
</td>
<td>
<BlurableInput
type="time"
name="endTime"
value={endTime || ""}
allowEmpty={true}
disabled={!endDate}
onCommit={this.setDatetime("endTime")} />
</td>
</tr>
</tbody>
</table>;
}
}