Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding time picker component #654

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions solara/lab/components/input_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import datetime as dt
from typing import Callable, Dict, List, Optional, Union, cast

import ipyvue
import reacton

import solara
import solara.lab
from solara.components.input import _use_input_type


def use_close_menu(el: reacton.core.Element, is_open: solara.Reactive[bool]):
is_open_ref = solara.use_ref(is_open)
is_open_ref.current = is_open

def monitor_events():
def close_menu(*ignore_args):
is_open_ref.current.set(False)

widget = cast(ipyvue.VueWidget, solara.get_widget(el))
widget.on_event("keyup.enter", close_menu)
widget.on_event("keydown.tab", close_menu)

def cleanup():
widget.on_event("keyup.enter", close_menu, remove=True)
widget.on_event("keydown.tab", close_menu, remove=True)

return cleanup

solara.use_effect(monitor_events, [])
mikegiann01 marked this conversation as resolved.
Show resolved Hide resolved


@solara.component
def InputTime(
value: Union[solara.Reactive[Optional[dt.time]], Optional[dt.time]],
on_value: Optional[Callable[[Optional[dt.time]], None]] = None,
label: str = "Pick a time",
children: List[solara.Element] = [],
open_value: Union[solara.Reactive[bool], bool] = False,
on_open_value: Optional[Callable[[bool], None]] = None,
optional: bool = False,
allowed_minutes: Optional[List[int]] = None,
style: Optional[Union[str, Dict[str, str]]] = None,
classes: Optional[List[str]] = None,
):
"""
Show a textfield, which when clicked, opens a timepicker. The input time should be of type `datetime.time`.

## Basic Example

```solara
import solara
import solara.lab
import datetime as dt


@solara.component
def Page():
time = solara.use_reactive(dt.time(12, 0))

solara.lab.TimePickerWithSeconds(time, allowed_minutes=[0, 15, 30, 45])
solara.Text(str(time.value))
```

## Arguments

* value: Reactive variable of type `datetime.time`, or `None`. This time is selected the first time the component is rendered.
* on_value: a callback function for when value changes. The callback function receives the new value as an argument.
* label: Text used to label the text field that triggers the timepicker.
* children: List of Elements to be rendered under the timepicker. If empty, a close button is rendered.
* open_value: Controls and communicates the state of the timepicker. If True, the timepicker is open. If False, the timepicker is closed.
Intended to be used in conjunction with a custom set of controls to close the timepicker.
* on_open_value: a callback function for when open_value changes. Also receives the new value as an argument.
* optional: Determines whether to show an error when value is `None`. If `True`, no error is shown.
* allowed_minutes: List of allowed minutes for the timepicker. Restricts the input to specific minute intervals.
* style: CSS style to apply to the text field. Either a string or a dictionary of CSS properties (i.e. `{"property": "value"}`).
* classes: List of CSS classes to apply to the text field.
"""
time_format = "%H:%M:%S"
value_reactive = solara.use_reactive(value, on_value) # type: ignore
del value, on_value
timepicker_is_open = solara.use_reactive(open_value, on_open_value) # type: ignore
del open_value, on_open_value

def set_time_typed_cast(value: Optional[str]):
if value:
try:
time_value = dt.datetime.strptime(value, time_format).time()
return time_value
except ValueError:
raise ValueError(f"Time {value} does not match format {time_format.replace('%', '')}")
elif optional:
return None
else:
raise ValueError("Time cannot be empty")

def time_to_str(time: Optional[dt.time]) -> str:
if time is not None:
return time.strftime(time_format)
return ""

def set_time_cast(new_value: Optional[str]):
if new_value:
time_value = dt.datetime.strptime(new_value, time_format).time()
timepicker_is_open.set(False)
value_reactive.value = time_value

def standard_strfy(time: Optional[dt.time]):
if time is None:
return None
else:
return time.strftime(time_format)

time_standard_str = standard_strfy(value_reactive.value)

style_flat = solara.util._flatten_style(style)

internal_value, error_message, set_value_cast = _use_input_type(value_reactive, set_time_typed_cast, time_to_str)

if error_message:
label += f" ({error_message})"
input = solara.v.TextField(
label=label,
v_model=internal_value,
on_v_model=set_value_cast,
append_icon="mdi-clock",
error=bool(error_message),
style_="min-width: 290px;" + style_flat,
class_=", ".join(classes) if classes else "",
)

use_close_menu(input, timepicker_is_open)

with solara.lab.Menu(
activator=input,
close_on_content_click=False,
open_value=timepicker_is_open,
use_activator_width=False,
):
with solara.v.TimePicker(
v_model=time_standard_str,
on_v_model=set_time_cast,
format=time_format,
allowed_minutes=allowed_minutes,
use_seconds=True,
style_="width: 100%;",
):
if len(children) > 0:
solara.display(*children)
197 changes: 197 additions & 0 deletions tests/unit/input_time_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import datetime as dt
from typing import Optional
from unittest.mock import MagicMock
import ipyvuetify as vw
import solara
from solara.lab.components.input_time import InputTime

now = dt.time(14, 30)
later = dt.time(16, 45)


def test_input_time_typing():
def on_value(value: dt.time):
pass

InputTime(value=now, label="label", on_value=on_value)

def on_value2(value: Optional[dt.time]):
pass

InputTime(value=now, label="label", on_value=on_value2)


def test_input_time():
on_value = MagicMock()
on_v_model = MagicMock()
el = InputTime(value=now, label="label", on_value=on_value)
box, rc = solara.render(el, handle_error=False)
input = rc.find(vw.TextField)
input.widget.observe(on_v_model, "v_model")

input.widget.v_model = later.strftime("%H:%M")
assert on_value.call_count == 1
assert on_v_model.call_count == 1
on_v_model.reset_mock()
assert on_value.call_args[0][0] == later

input.widget.v_model = ""
assert on_value.call_count == 1
assert on_v_model.call_count == 1
assert on_value.call_args[0][0] == later
assert input.widget.error
assert input.widget.label == "label (Time cannot be empty)"

input.widget.v_model = "10:17"
assert on_value.call_count == 2
assert on_v_model.call_count == 2
assert on_value.call_args[0][0] == dt.time(10, 17)
assert not input.widget.error
assert input.widget.v_model == dt.time(10, 17).strftime("%H:%M")


def test_input_time_optional():
on_value = MagicMock()
on_v_model = MagicMock()
el = InputTime(value=now, label="label", on_value=on_value, optional=True)
box, rc = solara.render(el, handle_error=False)
input = rc.find(vw.TextField)
input.widget.observe(on_v_model, "v_model")

input.widget.v_model = later.strftime("%H:%M")
assert on_value.call_count == 1
assert on_v_model.call_count == 1
assert on_value.call_args[0][0] == later

input.widget.v_model = ""
assert on_value.call_count == 2
assert on_v_model.call_count == 2
assert on_value.call_args[0][0] is None
assert not input.widget.error

input.widget.v_model = "10:17"
assert on_value.call_count == 3
assert on_v_model.call_count == 3
assert on_value.call_args[0][0] == dt.time(10, 17)
assert not input.widget.error
assert input.widget.v_model == dt.time(10, 17).strftime("%H:%M")


def test_input_time_incomplete_entry():
on_value = MagicMock()
on_v_model = MagicMock()

@solara.component
def Test():
def update_value(value: dt.time):
on_value(value)
set_value(value)

value, set_value = solara.use_state(now)

InputTime(value=value, on_value=update_value, label="label")

el = Test()
box, rc = solara.render(el, handle_error=False)
input = rc.find(vw.TextField)
input.widget.observe(on_v_model, "v_model")

input.widget.v_model = "10"
assert on_value.call_count == 0
assert on_v_model.call_count == 1
assert on_value.call_args is None
assert input.widget.error
assert input.widget.v_model == "10"
assert on_v_model.call_count == 1

input.widget.v_model = "10:17"
assert on_value.call_count == 1
assert on_v_model.call_count == 2
assert on_value.call_args is not None
assert on_value.call_args[0][0] == dt.time(10, 17)
assert not input.widget.error
assert input.widget.v_model == dt.time(10, 17).strftime("%H:%M")


def test_input_time_invalid_format():
on_value = MagicMock()
on_v_model = MagicMock()

@solara.component
def Test():
def update_value(value: dt.time):
on_value(value)
set_value(value)

value, set_value = solara.use_state(now)

InputTime(value=value, on_value=update_value, label="label", time_format="%I:%M %p")

el = Test()
box, rc = solara.render(el, handle_error=False)
input = rc.find(vw.TextField)
input.widget.observe(on_v_model, "v_model")

input.widget.v_model = "2:30 PM"
assert on_value.call_count == 1
assert on_v_model.call_count == 1
assert on_value.call_args is not None
assert on_value.call_args[0][0] == dt.time(14, 30)
assert not input.widget.error

input.widget.v_model = "25:00 PM"
assert on_value.call_count == 1
assert on_v_model.call_count == 2 # This is still called to reflect change
assert on_value.call_args[0][0] == dt.time(14, 30) # Value remains unchanged due to invalid input
assert input.widget.error
assert input.widget.label == "label (Time 25:00 PM does not match format %I:%M %p)"


def test_input_time_on_open_value():
on_open_value = MagicMock()
on_value = MagicMock()
el = InputTime(value=now, label="label", on_value=on_value, on_open_value=on_open_value)
box, rc = solara.render(el, handle_error=False)
menu = rc.find(vw.Menu)
assert menu is not None

# Simulate opening the time picker
menu.widget.v_model = True
assert on_open_value.call_count == 1
assert on_open_value.call_args[0][0] is True

# Simulate closing the time picker
menu.widget.v_model = False
assert on_open_value.call_count == 2
assert on_open_value.call_args[0][0] is False
Comment on lines +150 to +166
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be tested by clicking on the input element, rather than by directly opening the menu?



def test_input_time_boundary_values():
on_value = MagicMock()
on_v_model = MagicMock()
el = InputTime(value=now, label="label", on_value=on_value)
box, rc = solara.render(el, handle_error=False)
input = rc.find(vw.TextField)
input.widget.observe(on_v_model, "v_model")

# Test boundary value '00:00'
input.widget.v_model = "00:00"
assert on_value.call_count == 1
assert on_v_model.call_count == 1
assert on_value.call_args[0][0] == dt.time(0, 0)
assert not input.widget.error

# Test boundary value '23:59'
input.widget.v_model = "23:59"
assert on_value.call_count == 2
assert on_v_model.call_count == 2
assert on_value.call_args[0][0] == dt.time(23, 59)
assert not input.widget.error

# Test invalid boundary value '24:00'
input.widget.v_model = "24:00"
assert on_value.call_count == 2
assert on_v_model.call_count == 3 # This is still called to reflect change
assert on_value.call_args[0][0] == dt.time(23, 59) # Value remains unchanged due to invalid input
assert input.widget.error
assert input.widget.label == "label (Time 24:00 does not match format %H:%M)"
Loading