Skip to content
Snippets Groups Projects
Commit 2a8dc4f5 authored by Ryan Senior's avatar Ryan Senior
Browse files

Add frontend support for time fields

This primarily focuses on a time picker rather than the date picker
when users are attempting to filter based on a time column. There are
also some minor changes to how time field results are displayed/formatted.
parent 1d6d83bf
No related branches found
No related tags found
No related merge requests found
Showing
with 179 additions and 49 deletions
......@@ -8,6 +8,7 @@ import { FieldIDDimension } from "../Dimension";
import { getFieldValues } from "metabase/lib/query/field";
import {
isDate,
isTime,
isNumber,
isNumeric,
isBoolean,
......@@ -41,6 +42,9 @@ export default class Field extends Base {
isDate() {
return isDate(this);
}
isTime() {
return isTime(this);
}
isNumber() {
return isNumber(this);
}
......
......@@ -10,7 +10,7 @@ import ExternalLink from "metabase/components/ExternalLink.jsx";
import { isDate, isNumber, isCoordinate, isLatitude, isLongitude } from "metabase/lib/schema_metadata";
import { isa, TYPE } from "metabase/lib/types";
import { parseTimestamp } from "metabase/lib/time";
import { parseTimestamp,parseTime } from "metabase/lib/time";
import { rangeForValue } from "metabase/lib/dataset";
import { getFriendlyName } from "metabase/visualizations/lib/utils";
import { decimalCount } from "metabase/visualizations/lib/numeric";
......@@ -191,6 +191,15 @@ export function formatTimeWithUnit(value: Value, unit: DatetimeUnit, options: Fo
}
}
export function formatTimeValue(value: Value) {
let m = parseTime(value);
if (!m.isValid()){
return String(value);
} else {
return m.format("LT");
}
}
// https://github.com/angular/angular.js/blob/v1.6.3/src/ng/directive/input.js#L27
const EMAIL_WHITELIST_REGEX = /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
......@@ -263,6 +272,8 @@ export function formatValue(value: Value, options: FormattingOptions = {}) {
return formatUrl(value, options);
} else if (column && isa(column.special_type, TYPE.Email)) {
return formatEmail(value, options);
} else if (column && isa(column.base_type, TYPE.Time)) {
return formatTimeValue(value);
} else if (column && column.unit != null) {
return formatTimeWithUnit(value, column.unit, options);
} else if (isDate(column) || moment.isDate(value) || moment.isMoment(value) || moment(value, ["YYYY-MM-DD'T'HH:mm:ss.SSSZ"], true).isValid()) {
......
......@@ -119,6 +119,8 @@ export const isNumericBaseType = (field) => isa(field && field.base_type, TYPE.N
// ZipCode, ID, etc derive from Number but should not be formatted as numbers
export const isNumber = (field) => field && isNumericBaseType(field) && (field.special_type == null || field.special_type === TYPE.Number);
export const isTime = (field) => isa(field && field.base_type, TYPE.Time);
export const isAddress = (field) => isa(field && field.special_type, TYPE.Address);
export const isState = (field) => isa(field && field.special_type, TYPE.State);
export const isCountry = (field) => isa(field && field.special_type, TYPE.Country);
......
......@@ -14,3 +14,13 @@ export function parseTimestamp(value, unit) {
return moment.utc(value);
}
}
export function parseTime(value) {
if (moment.isMoment(value)) {
return value;
} else if (typeof value === "string"){
return moment(value, ["HH:mm:SS.sssZZ", "HH:mm:SS.sss", "HH:mm:SS.sss", "HH:mm:SS", "HH:mm"])
} else {
return moment.utc(value);
}
}
......@@ -7,6 +7,7 @@ import FieldList from "../FieldList.jsx";
import OperatorSelector from "./OperatorSelector.jsx";
import FilterOptions from "./FilterOptions";
import DatePicker from "./pickers/DatePicker.jsx";
import TimePicker from "./pickers/TimePicker.jsx";
import NumberPicker from "./pickers/NumberPicker.jsx";
import SelectPicker from "./pickers/SelectPicker.jsx";
import TextPicker from "./pickers/TextPicker.jsx";
......@@ -14,7 +15,7 @@ import TextPicker from "./pickers/TextPicker.jsx";
import Icon from "metabase/components/Icon.jsx";
import Query from "metabase/lib/query";
import { isDate } from "metabase/lib/schema_metadata";
import { isDate, isTime } from "metabase/lib/schema_metadata";
import { formatField, singularize } from "metabase/lib/formatting";
import cx from "classnames";
......@@ -276,7 +277,13 @@ export default class FilterPopover extends Component {
<h3 className="mx1">-</h3>
<h3 className="text-default">{formatField(field)}</h3>
</div>
{ isDate(field) ?
{ isTime(field) ?
<TimePicker
className="mt1 border-top"
filter={filter}
onFilterChange={this.setFilter}
/>
: isDate(field) ?
<DatePicker
className="mt1 border-top"
filter={filter}
......
......@@ -67,7 +67,7 @@ export default class FilterWidget extends Component {
// $FlowFixMe: not understanding maxDisplayValues is provided by defaultProps
if (operator && operator.multi && values.length > maxDisplayValues) {
formattedValues = [values.length + " selections"];
} else if (dimension.field().isDate()) {
} else if (dimension.field().isDate() && !dimension.field().isTime()) {
formattedValues = generateTimeFilterValuesDescriptions(filter);
} else {
// TODO Atte Keinänen 7/16/17: Move formatValue to metabase-lib
......
/* @flow */
import React, { Component } from "react";
import PropTypes from "prop-types";
import { t } from 'c-3po';
import cx from 'classnames';
import moment from "moment";
......@@ -65,9 +66,13 @@ const MultiDatePicker = ({ filter: [op, field, startValue, endValue], onFilterCh
const PreviousPicker = (props) =>
<RelativeDatePicker {...props} formatter={(value) => value * -1} />
PreviousPicker.horizontalLayout = true;
const NextPicker = (props) =>
<RelativeDatePicker {...props} />
NextPicker.horizontalLayout = true;
type CurrentPickerProps = {
filter: TimeIntervalFilter,
onFilterChange: (filter: TimeIntervalFilter) => void
......@@ -85,6 +90,8 @@ class CurrentPicker extends Component {
showUnits: false
};
static horizontalLayout = true;
render() {
const { filter: [operator, field, intervals, unit], onFilterChange } = this.props
return (
......@@ -105,7 +112,6 @@ class CurrentPicker extends Component {
}
}
const getIntervals = ([op, field, value, unit]) => mbqlEq(op, "time-interval") && typeof value === "number" ? Math.abs(value) : 30;
const getUnit = ([op, field, value, unit]) => mbqlEq(op, "time-interval") && unit ? unit : "day";
const getOptions = ([op, field, value, unit, options]) => mbqlEq(op, "time-interval") && options || {};
......@@ -129,7 +135,7 @@ function getDateTimeField(field: ConcreteField, bucketing: ?DatetimeUnit): Concr
}
}
function getDateTimeFieldTarget(field: ConcreteField): LocalFieldReference|ForeignFieldReference|ExpressionReference {
export function getDateTimeFieldTarget(field: ConcreteField): LocalFieldReference|ForeignFieldReference|ExpressionReference {
if (Query.isDatetimeField(field)) {
// $FlowFixMe:
return (field[1]: LocalFieldReference|ForeignFieldReference|ExpressionReference);
......@@ -221,7 +227,6 @@ export const DATE_OPERATORS: Operator[] = [
test: ([op]) => mbqlEq(op, "between"),
widget: MultiDatePicker,
},
];
export const EMPTINESS_OPERATORS: Operator[] = [
......@@ -252,6 +257,7 @@ type Props = {
hideEmptinessOperators?: boolean, // Don't show is empty / not empty dialog
hideTimeSelectors?: boolean,
includeAllTime?: boolean,
operators?: Operator[],
}
type State = {
......@@ -264,8 +270,20 @@ export default class DatePicker extends Component {
operators: []
};
static propTypes = {
filter: PropTypes.array.isRequired,
onFilterChange: PropTypes.func.isRequired,
className: PropTypes.string,
hideEmptinessOperators: PropTypes.bool,
hideTimeSelectors: PropTypes.bool,
operators: PropTypes.array,
};
componentWillMount() {
const operators = this.props.hideEmptinessOperators ? DATE_OPERATORS : ALL_OPERATORS;
let operators = this.props.operators || DATE_OPERATORS;
if (!this.props.hideEmptinessOperators) {
operators = operators.concat(EMPTINESS_OPERATORS);
}
const operator = getOperator(this.props.filter, operators) || operators[0];
this.props.onFilterChange(operator.init(this.props.filter));
......@@ -283,19 +301,10 @@ export default class DatePicker extends Component {
const operator = getOperator(this.props.filter, operators);
const Widget = operator && operator.widget;
// certain types of operators need to have a horizontal layout
// where the value is chosen next to the operator selector
// TODO - there's no doubt a cleaner _ way to do this
const needsHorizontalLayout = operator && (
operator.name === "current" ||
operator.name === "previous" ||
operator.name === "next"
);
return (
<div
// apply flex to align the operator selector and the "Widget" if necessary
className={cx("border-top pt2", { "flex align-center": needsHorizontalLayout })}
className={cx("border-top pt2", { "flex align-center": Widget && Widget.horizontalLayout })}
style={{ minWidth: 380 }}
>
<DateOperatorSelector
......
import React from "react";
import NumericInput from "./NumericInput";
import Icon from "metabase/components/Icon";
import cx from "classnames";
const HoursMinutesInput = ({ hours, minutes, onChangeHours, onChangeMinutes, onClear }) =>
<div className="flex align-center">
<NumericInput
className="input"
style={{ height: 36 }}
size={2}
maxLength={2}
value={(hours % 12) === 0 ? "12" : String(hours % 12)}
onChange={(value) => onChangeHours((hours >= 12 ? 12 : 0) + value) }
/>
<span className="px1">:</span>
<NumericInput
className="input"
style={{ height: 36 }}
size={2}
maxLength={2}
value={(minutes < 10 ? "0" : "") + minutes}
onChange={(value) => onChangeMinutes(value) }
/>
<div className="flex align-center pl1">
<span className={cx("text-purple-hover mr1", { "text-purple": hours < 12, "cursor-pointer": hours >= 12 })} onClick={hours >= 12 ? () => onChangeHours(hours - 12) : null}>AM</span>
<span className={cx("text-purple-hover mr1", { "text-purple": hours >= 12, "cursor-pointer": hours < 12 })} onClick={hours < 12 ? () => onChangeHours(hours + 12) : null}>PM</span>
</div>
{ onClear &&
<Icon
className="text-grey-2 cursor-pointer text-grey-4-hover ml-auto"
name="close"
onClick={onClear}
/>
}
</div>
export default HoursMinutesInput;
......@@ -8,7 +8,7 @@ import Input from "metabase/components/Input";
import Icon from "metabase/components/Icon";
import ExpandingContent from "metabase/components/ExpandingContent";
import Tooltip from "metabase/components/Tooltip";
import NumericInput from "./NumericInput.jsx";
import HoursMinutesInput from "./HoursMinutesInput";
import moment from "moment";
import cx from "classnames";
......@@ -146,8 +146,8 @@ export default class SpecificDatePicker extends Component {
Add a time
</div>
:
<HoursMinutes
clear={() => this.onChange(date, null, null)}
<HoursMinutesInput
onClear={() => this.onChange(date, null, null)}
hours={hours}
minutes={minutes}
onChangeHours={hours => this.onChange(date, hours, minutes)}
......@@ -160,31 +160,3 @@ export default class SpecificDatePicker extends Component {
)
}
}
const HoursMinutes = ({ hours, minutes, onChangeHours, onChangeMinutes, clear }) =>
<div className="flex align-center">
<NumericInput
className="input"
size={2}
maxLength={2}
value={(hours % 12) === 0 ? "12" : String(hours % 12)}
onChange={(value) => onChangeHours((hours >= 12 ? 12 : 0) + value) }
/>
<span className="px1">:</span>
<NumericInput
className="input"
size={2}
maxLength={2}
value={minutes}
onChange={(value) => onChangeMinutes(value) }
/>
<div className="flex align-center pl1">
<span className={cx("text-purple-hover mr1", { "text-purple": hours < 12, "cursor-pointer": hours >= 12 })} onClick={hours >= 12 ? () => onChangeHours(hours - 12) : null}>AM</span>
<span className={cx("text-purple-hover mr1", { "text-purple": hours >= 12, "cursor-pointer": hours < 12 })} onClick={hours < 12 ? () => onChangeHours(hours + 12) : null}>PM</span>
</div>
<Icon
className="text-grey-2 cursor-pointer text-grey-4-hover ml-auto"
name="close"
onClick={() => clear() }
/>
</div>
import React from "react";
import { t } from 'c-3po';
import DatePicker, { getDateTimeFieldTarget } from "./DatePicker";
import HoursMinutesInput from "./HoursMinutesInput";
import { mbqlEq } from "metabase/lib/query/util";
import { parseTime } from "metabase/lib/time";
const TimeInput = ({ value, onChange }) => {
const time = parseTime(value);
return (
<HoursMinutesInput
hours={time.hour()}
minutes={time.minute()}
onChangeHours={(hours) => onChange(time.hour(hours).format("HH:mm:00.000"))}
onChangeMinutes={(minutes) => onChange(time.minute(minutes).format("HH:mm:00.000"))}
/>
);
}
const SingleTimePicker = ({ filter, onFilterChange }) =>
<div className="mx2 mb2">
<TimeInput value={getTime(filter[2])} onChange={(time) => onFilterChange([filter[0], filter[1], time])} />
</div>
SingleTimePicker.horizontalLayout = true;
const MultiTimePicker = ({ filter, onFilterChange }) =>
<div className="flex align-center justify-between mx2 mb1" style={{ minWidth: 480 }}>
<TimeInput value={getTime(filter[2])} onChange={(time) => onFilterChange([filter[0], filter[1], ...sortTimes(time, filter[3])])} />
<span className="h3">and</span>
<TimeInput value={getTime(filter[3])} onChange={(time) => onFilterChange([filter[0], filter[1], ...sortTimes(filter[2], time)])} />
</div>
const sortTimes = (a, b) => {
console.log(parseTime(a).isAfter(parseTime(b)))
return parseTime(a).isAfter(parseTime(b)) ? [b, a] : [a, b];
}
const getTime = (value) => {
if (typeof value === "string" && /^\d+:\d+(:\d+(.\d+(\+\d+:\d+)?)?)?$/.test(value)) {
return value;
} else {
return "00:00:00.000+00:00"
}
}
export const TIME_OPERATORS: Operator[] = [
{
name: "before",
displayName: t`Before`,
init: (filter) => ["<", getDateTimeFieldTarget(filter[1]), getTime(filter[2])],
test: ([op]) => op === "<",
widget: SingleTimePicker,
},
{
name: "after",
displayName: t`After`,
init: (filter) => [">", getDateTimeFieldTarget(filter[1]), getTime(filter[2])],
test: ([op]) => op === ">",
widget: SingleTimePicker,
},
{
name: "between",
displayName: t`Between`,
init: (filter) => ["BETWEEN", getDateTimeFieldTarget(filter[1]), getTime(filter[2]), getTime(filter[3])],
test: ([op]) => mbqlEq(op, "between"),
widget: MultiTimePicker,
},
]
const TimePicker = (props) =>
<DatePicker {...props} operators={TIME_OPERATORS} />
export default TimePicker;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment