Skip to content

Commit

Permalink
Improve date and datetime picker functionality (#6152)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 authored Jan 10, 2024
1 parent b8c8c34 commit bcd2a92
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 15 deletions.
2 changes: 0 additions & 2 deletions panel/dist/css/widgetbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
overflow-x: hidden;
overflow-y: hidden;
}
34 changes: 34 additions & 0 deletions panel/tests/pane/test_holoviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,3 +743,37 @@ def test_holoviews_property_override(document, comm):

assert model.styles["background"] == 'red'
assert model.children[0].css_classes == ['test_class']


@hv_available
def test_holoviews_date_picker_widget(document, comm):
ds = {
"time": [np.datetime64("2000-01-01"), np.datetime64("2000-01-02")],
"x": [0, 1],
"y": [0, 1],
}
viz = hv.Dataset(ds, ["x", "time"], ["y"])
layout = pn.panel(viz.to(
hv.Scatter, ["x"], ["y"]), widgets={"time": pn.widgets.DatePicker}
)
widget_box = layout[0][1]
assert isinstance(layout, pn.Row)
assert isinstance(widget_box, pn.WidgetBox)
assert isinstance(widget_box[0], pn.widgets.DatePicker)


@hv_available
def test_holoviews_datetime_picker_widget(document, comm):
ds = {
"time": [np.datetime64("2000-01-01"), np.datetime64("2000-01-02")],
"x": [0, 1],
"y": [0, 1],
}
viz = hv.Dataset(ds, ["x", "time"], ["y"])
layout = pn.panel(viz.to(
hv.Scatter, ["x"], ["y"]), widgets={"time": pn.widgets.DatetimePicker}
)
widget_box = layout[0][1]
assert isinstance(layout, pn.Row)
assert isinstance(widget_box, pn.WidgetBox)
assert isinstance(widget_box[0], pn.widgets.DatetimePicker)
29 changes: 29 additions & 0 deletions panel/tests/ui/widgets/test_input.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime

import numpy as np
import pytest

pytest.importorskip("playwright")
Expand Down Expand Up @@ -603,6 +604,34 @@ def test_datetimepicker_remove_value(page, datetime_start_end):
wait_until(lambda: datetime_picker_widget.value is None, page)


def test_datetime_picker_start_end_datetime64(page):
datetime_picker_widget = DatetimePicker(
value=datetime.datetime(2021, 3, 2),
start=np.datetime64("2021-03-02"),
end=np.datetime64("2021-03-03")
)

serve_component(page, datetime_picker_widget)

datetime_picker = page.locator('.flatpickr-input')
datetime_picker.dblclick()

# locate by aria label March 1, 2021
prev_month_day = page.locator('[aria-label="March 1, 2021"]')
# assert class "flatpickr-day flatpickr-disabled"
assert "flatpickr-disabled" in prev_month_day.get_attribute("class"), "The date should be disabled"

# locate by aria label March 3, 2021
next_month_day = page.locator('[aria-label="March 3, 2021"]')
# assert not class "flatpickr-day flatpickr-disabled"
assert "flatpickr-disabled" not in next_month_day.get_attribute("class"), "The date should be enabled"

# locate by aria label March 4, 2021
next_next_month_day = page.locator('[aria-label="March 4, 2021"]')
# assert class "flatpickr-day flatpickr-disabled"
assert "flatpickr-disabled" in next_next_month_day.get_attribute("class"), "The date should be disabled"


def test_text_area_auto_grow_init(page):
text_area = TextAreaInput(auto_grow=True, value="1\n2\n3\n4\n")

Expand Down
20 changes: 20 additions & 0 deletions panel/tests/widgets/test_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ def test_date_picker(document, comm):
assert widget.value == '2018-09-04'


def test_date_picker_options(document, comm):
options = [date(2018, 9, 1), date(2018, 9, 2), date(2018, 9, 3)]
datetime_picker = DatePicker(
name='DatetimePicker', value=date(2018, 9, 2),
options=options
)
assert datetime_picker.value == date(2018, 9, 2)
assert datetime_picker.enabled_dates == options


def test_daterange_picker(document, comm):
date_range_picker = DateRangePicker(name='DateRangePicker',
value=(date(2018, 9, 2), date(2018, 9, 3)),
Expand Down Expand Up @@ -130,6 +140,16 @@ def test_datetime_picker(document, comm):
datetime_picker._process_events({'value': '2018-09-10 00:00:01'})


def test_datetime_picker_options(document, comm):
options = [datetime(2018, 9, 1), datetime(2018, 9, 2), datetime(2018, 9, 3)]
datetime_picker = DatetimePicker(
name='DatetimePicker', value=datetime(2018, 9, 2, 1, 5),
options=options
)
assert datetime_picker.value == datetime(2018, 9, 2, 1, 5)
assert datetime_picker.enabled_dates == options


def test_datetime_range_picker(document, comm):
datetime_range_picker = DatetimeRangePicker(
name='DatetimeRangePicker', value=(datetime(2018, 9, 2, 1, 5), datetime(2018, 9, 2, 1, 6)),
Expand Down
6 changes: 6 additions & 0 deletions panel/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,9 @@ def styler_update(styler, new_df):
todo = tuple(ops)
todos.append(todo)
return todos


def try_datetime64_to_datetime(value):
if isinstance(value, np.datetime64):
value = value.astype('datetime64[ms]').astype(datetime)
return value
71 changes: 58 additions & 13 deletions panel/widgets/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from base64 import b64decode
from datetime import date, datetime
from typing import (
TYPE_CHECKING, Any, ClassVar, Dict, Mapping, Optional, Tuple, Type,
TYPE_CHECKING, Any, ClassVar, Dict, Iterable, Mapping, Optional, Tuple,
Type,
)

import numpy as np
Expand All @@ -30,7 +31,7 @@
from ..models import (
DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput,
)
from ..util import param_reprs
from ..util import param_reprs, try_datetime64_to_datetime
from .base import CompositeWidget, Widget

if TYPE_CHECKING:
Expand Down Expand Up @@ -333,6 +334,20 @@ class DatePicker(Widget):

_widget_type: ClassVar[Type[Model]] = _BkDatePicker

def __init__(self, **params):
# Since options is the standard for other widgets,
# it makes sense to also support options here, converting
# it to enabled_dates
if 'options' in params:
options = list(params.pop('options'))
params['enabled_dates'] = options
if 'value' in params:
value = try_datetime64_to_datetime(params['value'])
if hasattr(value, "date"):
value = value.date()
params["value"] = value
super().__init__(**params)

def _process_property_change(self, msg):
msg = super()._process_property_change(msg)
for p in ('start', 'end', 'value'):
Expand Down Expand Up @@ -456,36 +471,65 @@ class _DatetimePickerBase(Widget):
description = param.String(default=None, doc="""
An HTML string describing the function of this component.""")

as_numpy_datetime64 = param.Boolean(default=None, doc="""
Whether to return values as numpy.datetime64. If left unset,
will be True if value is a numpy.datetime64, else False.""")

_source_transforms: ClassVar[Mapping[str, str | None]] = {
'value': None, 'start': None, 'end': None, 'mode': None
}

_rename: ClassVar[Mapping[str, str | None]] = {
'start': 'min_date', 'end': 'max_date'
'start': 'min_date', 'end': 'max_date', 'as_numpy_datetime64': None,
}

_widget_type: ClassVar[Type[Model]] = _bkDatetimePicker

__abstract = True

def __init__(self, **params):
# Since options is the standard for other widgets,
# it makes sense to also support options here, converting
# it to enabled_dates
if 'options' in params:
options = list(params.pop('options'))
params['enabled_dates'] = options
if params.get('as_numpy_datetime64', None) is None:
params['as_numpy_datetime64'] = isinstance(
params.get("value"), np.datetime64)
super().__init__(**params)
self._update_value_bounds()

@staticmethod
def _convert_to_datetime(v):
def _convert_to_datetime(self, v):
if v is None:
return

if isinstance(v, Iterable) and not isinstance(v, str):
container_type = type(v)
return container_type(
self._convert_to_datetime(vv)
for vv in v
)

v = try_datetime64_to_datetime(v)
if isinstance(v, datetime):
return v
elif isinstance(v, date):
return datetime(v.year, v.month, v.day)
elif isinstance(v, str):
return datetime.strptime(v, r'%Y-%m-%d %H:%M:%S')
else:
raise ValueError(f"Could not convert {v} to datetime")

@param.depends('start', 'end', watch=True)
def _update_value_bounds(self):
self.param.value.bounds = (
self._convert_to_datetime(self.start),
self._convert_to_datetime(self.end)
)
self.param.value._validate(self.value)
self.param.value._validate(
self._convert_to_datetime(self.value)
)

def _process_property_change(self, msg):
msg = super()._process_property_change(msg)
Expand All @@ -496,7 +540,7 @@ def _process_property_change(self, msg):
def _process_param_change(self, msg):
msg = super()._process_param_change(msg)
if 'value' in msg:
msg['value'] = self._deserialize_value(msg['value'])
msg['value'] = self._deserialize_value(self._convert_to_datetime(msg['value']))
if 'min_date' in msg:
msg['min_date'] = self._convert_to_datetime(msg['min_date'])
if 'max_date' in msg:
Expand Down Expand Up @@ -527,14 +571,15 @@ class DatetimePicker(_DatetimePickerBase):

def _serialize_value(self, value):
if isinstance(value, str) and value:
value = datetime.strptime(value, r'%Y-%m-%d %H:%M:%S')

if self.as_numpy_datetime64:
value = np.datetime64(value)
else:
value = datetime.strptime(value, r'%Y-%m-%d %H:%M:%S')
return value

def _deserialize_value(self, value):
if isinstance(value, (datetime, date)):
value = value.strftime(r'%Y-%m-%d %H:%M:%S')

return value


Expand Down Expand Up @@ -562,11 +607,11 @@ class DatetimeRangePicker(_DatetimePickerBase):
def _serialize_value(self, value):
if isinstance(value, str) and value:
value = [
datetime.strptime(value, r'%Y-%m-%d %H:%M:%S')
np.datetime64(value)
if self.as_numpy_datetime64
else datetime.strptime(value, r'%Y-%m-%d %H:%M:%S')
for value in value.split(' to ')
]


value = tuple(value)

return value
Expand Down

0 comments on commit bcd2a92

Please sign in to comment.