From 4d88374f9dd303743822db256473b2cd8896302d Mon Sep 17 00:00:00 2001 From: Theom <49269671+TheoMathurin@users.noreply.github.com> Date: Mon, 11 Dec 2023 15:30:15 +0100 Subject: [PATCH] Add DateRangePicker widget (#6027) --- .../reference/widgets/DateRangePicker.ipynb | 104 ++++++++++++++++++ panel/tests/widgets/test_input.py | 44 +++++++- panel/widgets/__init__.py | 9 +- panel/widgets/input.py | 91 ++++++++++++++- 4 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 examples/reference/widgets/DateRangePicker.ipynb diff --git a/examples/reference/widgets/DateRangePicker.ipynb b/examples/reference/widgets/DateRangePicker.ipynb new file mode 100644 index 0000000000..4a3bbf850f --- /dev/null +++ b/examples/reference/widgets/DateRangePicker.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``DateRangePicker`` widget allows selecting a date range using a text box and the browser's date-picking utility.\n", + "\n", + "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\n", + "\n", + "#### Parameters:\n", + "\n", + "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "\n", + "##### Core\n", + "\n", + "* **``end``** (date): The latest selectable date\n", + "* **``start``** (date): The earliest selectable date\n", + "* **``value``** (tuple): Tuple of upper and lower bounds of the selected range expressed as date types\n", + "\n", + "##### Display\n", + "\n", + "* **``disabled``** (boolean): Whether the widget is editable\n", + "* **``name``** (str): The title of the widget\n", + "* **``disabled_dates``** (list): dates to make unavailable for selection; others will be available\n", + "* **``enabled_dates``** (list): dates to make available for selection; others will be unavailable\n", + "\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "``DateRangePicker`` uses a browser-dependent calendar widget to select the date range:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "date_range_picker = pn.widgets.DateRangePicker(name='Date Range Picker')\n", + "\n", + "date_range_picker" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "``DateRangePicker.value`` returns a tuple of date values type that can be read out or set like other widgets:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "date_range_picker.value" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Controls\n", + "\n", + "The `DateRangePicker` widget exposes a number of options which can be changed from both Python and Javascript. Try out \n", + "the effect of these parameters interactively:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.Row(date_range_picker.controls(jslink=True), date_range_picker)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/tests/widgets/test_input.py b/panel/tests/widgets/test_input.py index a3e5140697..c235a18d3e 100644 --- a/panel/tests/widgets/test_input.py +++ b/panel/tests/widgets/test_input.py @@ -8,9 +8,9 @@ from panel import config from panel.widgets import ( - ArrayInput, Checkbox, DatePicker, DatetimeInput, DatetimePicker, - DatetimeRangeInput, DatetimeRangePicker, FileInput, FloatInput, IntInput, - LiteralInput, StaticText, TextInput, + ArrayInput, Checkbox, DatePicker, DateRangePicker, DatetimeInput, + DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, FileInput, + FloatInput, IntInput, LiteralInput, StaticText, TextInput, ) @@ -58,6 +58,42 @@ def test_date_picker(document, comm): assert widget.value == '2018-09-04' +def test_daterange_picker(document, comm): + date_range_picker = DateRangePicker(name='DateRangePicker', + value=(date(2018, 9, 2), date(2018, 9, 3)), + start=date(2018, 9, 1), + end=date(2018, 9, 10)) + + widget = date_range_picker.get_root(document, comm=comm) + + assert isinstance(widget, date_range_picker._widget_type) + assert widget.title == 'DateRangePicker' + assert widget.value == ('2018-09-02', '2018-09-03') + assert widget.min_date == '2018-09-01' + assert widget.max_date == '2018-09-10' + + date_range_picker._process_events({'value': ('2018-09-03', '2018-09-04')}) + assert date_range_picker.value == (date(2018, 9, 3), date(2018, 9, 4)) + + date_range_picker._process_events({'value': ('2018-09-05', '2018-09-08')}) + assert date_range_picker.value == (date(2018, 9, 5), date(2018, 9, 8)) + + value = date_range_picker._process_param_change({'value': (date(2018, 9, 4), date(2018, 9, 5))}) + assert value['value'] == ('2018-09-04', '2018-09-05') + + value = date(2018, 9, 4) + assert date_range_picker._convert_date_to_string(value) == '2018-09-04' + assert date_range_picker._convert_string_to_date(date_range_picker._convert_date_to_string(value)) == value + + # Check start value + with pytest.raises(ValueError): + date_range_picker._process_events({'value': ('2018-08-31', '2018-09-01')}) + + # Check end value + with pytest.raises(ValueError): + date_range_picker._process_events({'value': ('2018-09-10', '2018-09-11')}) + + def test_datetime_picker(document, comm): datetime_picker = DatetimePicker( name='DatetimePicker', value=datetime(2018, 9, 2, 1, 5), @@ -94,7 +130,6 @@ def test_datetime_picker(document, comm): datetime_picker._process_events({'value': '2018-09-10 00:00:01'}) - 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)), @@ -130,7 +165,6 @@ def test_datetime_range_picker(document, comm): datetime_range_picker._process_events({'value': '2018-09-10 00:00:01'}) - def test_file_input(document, comm): file_input = FileInput(accept='.txt') diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 90f4b4e56f..a30d1a301a 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -43,10 +43,10 @@ TooltipIcon, Tqdm, Trend, ) from .input import ( # noqa - ArrayInput, Checkbox, ColorPicker, DatePicker, DatetimeInput, - DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, FileInput, - FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, Spinner, - StaticText, Switch, TextAreaInput, TextInput, + ArrayInput, Checkbox, ColorPicker, DatePicker, DateRangePicker, + DatetimeInput, DatetimePicker, DatetimeRangeInput, DatetimeRangePicker, + FileInput, FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, + Spinner, StaticText, Switch, TextAreaInput, TextInput, ) from .misc import FileDownload, JSONEditor, VideoStream # noqa from .player import DiscretePlayer, Player # noqa @@ -83,6 +83,7 @@ "CrossSelector", "DataFrame", "DatePicker", + "DateRangePicker", "DateRangeSlider", "DatetimeRangeSlider", "DateSlider", diff --git a/panel/widgets/input.py b/panel/widgets/input.py index 26d811161a..05b58f35a4 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -19,9 +19,10 @@ from bokeh.models.formatters import TickFormatter from bokeh.models.widgets import ( Checkbox as _BkCheckbox, ColorPicker as _BkColorPicker, - DatePicker as _BkDatePicker, Div as _BkDiv, FileInput as _BkFileInput, - NumericInput as _BkNumericInput, PasswordInput as _BkPasswordInput, - Spinner as _BkSpinner, Switch as _BkSwitch, TextInput as _BkTextInput, + DatePicker as _BkDatePicker, DateRangePicker as _BkDateRangePicker, + Div as _BkDiv, FileInput as _BkFileInput, NumericInput as _BkNumericInput, + PasswordInput as _BkPasswordInput, Spinner as _BkSpinner, + Switch as _BkSwitch, TextInput as _BkTextInput, ) from ..config import config @@ -286,7 +287,7 @@ def _process_param_change(self, msg): class DatePicker(Widget): """ - The `DatePicker` allows selecting selecting a `date` value using a text box + The `DatePicker` allows selecting a `date` value using a text box and a date-picking utility. Reference: https://panel.holoviz.org/reference/widgets/DatePicker.html @@ -339,6 +340,88 @@ def _process_property_change(self, msg): return msg +class DateRangePicker(Widget): + """ + The `DateRangePicker` allows selecting a `date` range using a text box + and a date-picking utility. + + Reference: https://panel.holoviz.org/reference/widgets/DateRangePicker.html + + :Example: + + >>> DateRangePicker( + ... value=(date(2025,1,1), date(2025,1,5)), + ... start=date(2025,1,1), end=date(2025,12,31), + ... name='Date range' + ... ) + """ + + value = param.DateRange(default=None, doc=""" + The current value""") + + start = param.CalendarDate(default=None, doc=""" + Inclusive lower bound of the allowed date selection""") + + end = param.CalendarDate(default=None, doc=""" + Inclusive upper bound of the allowed date selection""") + + disabled_dates = param.List(default=None, item_type=(date, str)) + + enabled_dates = param.List(default=None, item_type=(date, str)) + + width = param.Integer(default=300, allow_None=True, doc=""" + Width of this component. If sizing_mode is set to stretch + or scale mode this will merely be used as a suggestion.""") + + description = param.String(default=None, doc=""" + An HTML string describing the function of this component.""") + + _source_transforms: ClassVar[Mapping[str, str | None]] = {} + + _rename: ClassVar[Mapping[str, str | None]] = { + 'start': 'min_date', 'end': 'max_date' + } + + _widget_type: ClassVar[Type[Model]] = _BkDateRangePicker + + def __init__(self, **params): + super().__init__(**params) + self._update_value_bounds() + + @param.depends('start', 'end', watch=True) + def _update_value_bounds(self): + self.param.value.bounds = (self.start, self.end) + self.param.value._validate(self.value) + + def _process_property_change(self, msg): + msg = super()._process_property_change(msg) + for p in ('start', 'end', 'value'): + if p not in msg: + continue + value = msg[p] + if isinstance(value, tuple): + msg[p] = tuple(self._convert_string_to_date(v) for v in value) + return msg + + def _process_param_change(self, msg): + msg = super()._process_param_change(msg) + if 'value' in msg: + msg['value'] = tuple(self._convert_date_to_string(v) for v in msg['value']) + if 'min_date' in msg: + msg['min_date'] = self._convert_date_to_string(msg['min_date']) + if 'max_date' in msg: + msg['max_date'] = self._convert_date_to_string(msg['max_date']) + return msg + + @staticmethod + def _convert_string_to_date(v): + return datetime.strptime(v, '%Y-%m-%d').date() + + @staticmethod + def _convert_date_to_string(v): + return v.strftime('%Y-%m-%d') + + class _DatetimePickerBase(Widget): disabled_dates = param.List(default=None, item_type=(date, str), doc="""