diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b18d8f0..2cc3a9740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements +* `input_date()`, `input_date_range()`, `update_date()`, and `update_date_range()` now supports `""` for values, mins, and maxes. In this case, no date will be specified on the client. (#1713) (#1689) + * Restricted the allowable types of the `choices` parameter of `input_select()`, `input_selectize()`, `update_select()`, and `update_selectize()` to actual set of allowable types (previously, the type was suggesting HTML-like values were supported). (#2048) * Improved the styling and readability of markdown tables rendered by `ui.Chat()` and `ui.MarkdownStream()`. (#1973) diff --git a/shiny/ui/_input_date.py b/shiny/ui/_input_date.py index 1399bdbad..8a2ac4e06 100644 --- a/shiny/ui/_input_date.py +++ b/shiny/ui/_input_date.py @@ -45,7 +45,7 @@ def input_date( value The starting date. Either a :class:`~datetime.date` object, or a string in `yyyy-mm-dd` format. If None (the default), will use the current date in the - client's time zone. + client's time zone. If an empty string is passed, the date picker will be blank. min The minimum allowed date. Either a :class:`~datetime.date` object, or a string in yyyy-mm-dd format. @@ -310,6 +310,9 @@ def _date_input_tag( def _as_date_attr(x: Optional[date | str]) -> Optional[str]: if x is None: return None - if isinstance(x, date): - return str(x) - return str(date.fromisoformat(x)) + if isinstance(x, str): + if len(x) == 0: + return x + x = date.fromisoformat(x) + # Ensure we return a date (not datetime) string + return x.strftime("%Y-%m-%d") diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index 7395e764b..2aa93cc35 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -463,12 +463,11 @@ def update_date( label An input label. value - The starting date. Either a `date()` object, or a string in yyyy-mm-dd format. - If ``None`` (the default), will use the current date in the client's time zone. + The starting date. Either a `date()` object, or a string in yyyy-mm-dd format. If an empty string is provided, the value will be cleared. min - The minimum allowed value. + The minimum allowed value. Either a `date()` object, or a string in yyyy-mm-dd format. An empty string will clear the minimum constraint. max - The maximum allowed value. + The maximum allowed value. Either a `date()` object, or a string in yyyy-mm-dd format. An empty string will clear the maximum constraint. session A :class:`~shiny.Session` instance. If not provided, it is inferred via :func:`~shiny.session.get_current_session`. @@ -483,13 +482,27 @@ def update_date( """ session = require_active_session(session) + msg = { "label": session._process_ui(label) if label is not None else None, "value": _as_date_attr(value), "min": _as_date_attr(min), "max": _as_date_attr(max), } - session.send_input_message(id, drop_none(msg)) + + msg = drop_none(msg) + + # Handle the special case of "", which means the value should be cleared + # (i.e., go from a specified date to no specified date). + # This is equivalent to the NA case in R + if value == "": + msg["value"] = None + if min == "": + msg["min"] = None + if max == "": + msg["max"] = None + + session.send_input_message(id, msg) @add_example() @@ -514,17 +527,15 @@ def update_date_range( label An input label. start - The initial start date. Either a :class:`~datetime.date` object, or a string in - yyyy-mm-dd format. If ``None`` (the default), will use the current date in the - client's time zone. + The starting date. Either a `date()` object, or a string in yyyy-mm-dd format. + If an empty string is provided, the value will be cleared. end - The initial end date. Either a :class:`~datetime.date` object, or a string in - yyyy-mm-dd format. If ``None`` (the default), will use the current date in the - client's time zone. + The ending date. Either a `date()` object, or a string in yyyy-mm-dd format. + If an empty string is provided, the value will be cleared. min - The minimum allowed value. + The minimum allowed value. If an empty string is passed there will be no minimum date. max - The maximum allowed value. + The maximum allowed value. If an empty string is passed there will be no maximum date. session A :class:`~shiny.Session` instance. If not provided, it is inferred via :func:`~shiny.session.get_current_session`. @@ -539,14 +550,41 @@ def update_date_range( """ session = require_active_session(session) - value = {"start": _as_date_attr(start), "end": _as_date_attr(end)} + msg = { "label": session._process_ui(label) if label is not None else None, - "value": drop_none(value), "min": _as_date_attr(min), "max": _as_date_attr(max), } - session.send_input_message(id, drop_none(msg)) + + msg = drop_none(msg) + + # Handle the special case of "", which means the value should be cleared + # (i.e., go from a specified date to no specified date). + # This is equivalent to the NA case in R + if min == "": + msg["min"] = None + if max == "": + msg["max"] = None + + value = { + "start": _as_date_attr(start), + "end": _as_date_attr(end), + } + + value = drop_none(value) + + # Handle the special case of "", which means the value should be cleared + # (i.e., go from a specified date to no specified date) + # This is equivalent to the NA case in R + if start == "": + value["start"] = None + if end == "": + value["end"] = None + + msg["value"] = value + + session.send_input_message(id, msg) # ----------------------------------------------------------------------------- diff --git a/tests/playwright/shiny/inputs/input_datepicker/app.py b/tests/playwright/shiny/inputs/input_datepicker/app.py new file mode 100644 index 000000000..31fc29ddc --- /dev/null +++ b/tests/playwright/shiny/inputs/input_datepicker/app.py @@ -0,0 +1,87 @@ +from datetime import date + +from dateutil.relativedelta import relativedelta + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +min_date = date.fromisoformat("2011-11-04") +max_date = min_date + relativedelta(days=10) + +app_ui = ui.page_fluid( + ui.input_date( + "start_date_picker", + "Date Type Input:", + value=max_date, + min=min_date, + max=max_date, + format="dd.mm.yyyy", + language="en", + ), + ui.output_text("start"), + ui.input_date( + "min_date_picker", + "Date Type Input:", + value=min_date, + min=min_date, + max=max_date, + format="dd.mm.yyyy", + language="en", + ), + ui.output_text("min"), + ui.input_date( + "str_date_picker", + "String Type Input:", + value="2023-10-05", + min="2023-10-01", + max="2023-10-07", + format="dd-mm-yyyy", + language="en", + ), + ui.output_text("str_format"), + ui.input_date("none_date_picker", "None Type Input:"), + ui.output_text("none_format"), + ui.input_date( + "empty_date_picker", + "empty Type Input:", + value="", + min=None, + max=None, + format="dd-mm-yyyy", + language="en", + ), + ui.output_text("empty_format"), + ui.input_action_button("update", "Update Dates"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @render.text + def start(): + return "Date Picker Value: " + str(input.start_date_picker()) + + @render.text + def min(): + return "Date Picker Value: " + str(input.min_date_picker()) + + @render.text + def str_format(): + return "Date Picker Value: " + str(input.str_date_picker()) + + @render.text + def none_format(): + return "Date Picker Value: " + str(input.none_date_picker()) + + @render.text + def empty_format(): + return "Date Picker Value: " + str(input.empty_date_picker()) + + @reactive.effect + @reactive.event(input.update, ignore_none=True, ignore_init=True) + def _(): + d = date.fromisoformat("2011-11-05") + ui.update_date("start_date_picker", value=d) + ui.update_date("min_date_picker", value="", min="") + ui.update_date("str_date_picker", value="2023-10-03", max="2023-10-20") + + +app = App(app_ui, server, debug=True) diff --git a/tests/playwright/shiny/inputs/input_datepicker/test_datepicker.py b/tests/playwright/shiny/inputs/input_datepicker/test_datepicker.py new file mode 100644 index 000000000..3660401c8 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_datepicker/test_datepicker.py @@ -0,0 +1,68 @@ +import datetime + +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_dynamic_navs(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + # Check the initial state of the date picker where value = max + date1 = controller.InputDate(page, "start_date_picker") + date1.expect_value("14.11.2011") + + # Can't find a way to inspect the actual display date in the datepicker, + # as even in the broken example, the correct attribute was still set. + # To work around this, we will check the displayed value to ensure the + # correct value is being passed to the server. + date_output = controller.OutputText(page, "start") + date_output.expect_value("Date Picker Value: 2011-11-14") + + # Test the date picker with the min date = initial value (unchanged case) + date2 = controller.InputDate(page, "min_date_picker") + date2.expect_value("04.11.2011") + + date_output2 = controller.OutputText(page, "min") + date_output2.expect_value("Date Picker Value: 2011-11-04") + + # Test the date picker with the date set as a string instead of as a date object + date3 = controller.InputDate(page, "str_date_picker") + date3.expect_value("05-10-2023") + + date_output3 = controller.OutputText(page, "str_format") + date_output3.expect_value("Date Picker Value: 2023-10-05") + + # Test the date picker with a None value inputted + # None defaults to today's date + input = datetime.date.today().strftime("%Y-%m-%d") + date4 = controller.InputDate(page, "none_date_picker") + date4.expect_value(input) + + date_output4 = controller.OutputText(page, "none_format") + date_output4.expect_value("Date Picker Value: " + input) + + # Test the default value when an empty string is passed is correctly blank + date5 = controller.InputDate(page, "empty_date_picker") + date5.expect_value("") + + date_output5 = controller.OutputText(page, "empty_format") + date_output5.expect_value("Date Picker Value: None") + + controller.InputActionButton(page, "update").click() + + date1.expect_value("05.11.2011") + date_output.expect_value("Date Picker Value: 2011-11-05") + + date2.expect_value("") + date_output2.expect_value("Date Picker Value: None") + # This is updating min and max dates, but the playwright locater cannot find it. + # It appears that the disabled dates are correct. + # Expect that means the calcuated values for disabled dates are updating correctly + # but the attribute is not updated. + + # date2.expect_min_date(None) + + date3.expect_value("03-10-2023") + date_output3.expect_value("Date Picker Value: 2023-10-03") diff --git a/tests/playwright/shiny/inputs/input_daterange/app.py b/tests/playwright/shiny/inputs/input_daterange/app.py new file mode 100644 index 000000000..d387c4915 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_daterange/app.py @@ -0,0 +1,103 @@ +from datetime import date + +from dateutil.relativedelta import relativedelta + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +min_date = date.fromisoformat("2011-11-04") +max_date = min_date + relativedelta(days=10) + +app_ui = ui.page_fluid( + ui.input_date_range( + "standard_date_picker", + "Normal Input:", + start=min_date, + end=max_date, + min=min_date, + max=max_date, + format="yyyy-mm-dd", + language="en", + ), + ui.output_text("standard"), + ui.br(), + ui.br(), + ui.input_date_range( + "none_date_picker", + "None Input:", + start=None, + end=None, + min=None, + max=None, + format="yyyy-mm-dd", + language="en", + ), + ui.output_text("none"), + ui.br(), + ui.br(), + ui.input_date_range( + "empty_date_picker", + "Empty Input:", + start="", + end="", + min=None, + max=None, + format="yyyy-mm-dd", + language="en", + ), + ui.output_text("empty"), + ui.input_action_button("update", "Update Dates"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @render.text + def standard(): + return ( + "Date Picker Value: " + + str(input.standard_date_picker()[0]) + + " to " + + str(input.standard_date_picker()[1]) + ) + + @render.text + def none(): + return ( + "Date Picker Value: " + + str(input.none_date_picker()[0]) + + " to " + + str(input.none_date_picker()[1]) + ) + + @render.text + def empty(): + return ( + "Date Picker Value: " + + str(input.empty_date_picker()[0]) + + " to " + + str(input.empty_date_picker()[1]) + ) + + @reactive.effect + @reactive.event(input.update, ignore_none=True, ignore_init=True) + def _(): + d = date.fromisoformat("2011-11-05") + + # Take the existing date rang input and blank it out + ui.update_date_range( + "standard_date_picker", + start="", + end="", + min="", + max="", + ) + # Take the None date range input and set it + ui.update_date_range( + "none_date_picker", + start=d - relativedelta(days=3), + end=d + relativedelta(days=10), + min=d - relativedelta(days=4), + max=d + relativedelta(days=11), + ) + + +app = App(app_ui, server, debug=True) diff --git a/tests/playwright/shiny/inputs/input_daterange/test_date_range.py b/tests/playwright/shiny/inputs/input_daterange/test_date_range.py new file mode 100644 index 000000000..a1cf6a2d9 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_daterange/test_date_range.py @@ -0,0 +1,54 @@ +import datetime + +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_dynamic_navs(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + # Check the normal date range input + date1 = controller.InputDateRange(page, "standard_date_picker") + date1.expect_value(("2011-11-04", "2011-11-14")) + date1.expect_max_date("2011-11-14") + date1.expect_min_date("2011-11-04") + date_output = controller.OutputText(page, "standard") + date_output.expect_value("Date Picker Value: 2011-11-04 to 2011-11-14") + + # Check the None date range input + date2 = controller.InputDateRange(page, "none_date_picker") + input = datetime.date.today().strftime("%Y-%m-%d") + date2.expect_value((input, input)) + date2.expect_max_date(None) + date2.expect_min_date(None) + date_output_none = controller.OutputText(page, "none") + date_output_none.expect_value("Date Picker Value: " + input + " to " + input) + + # Check the empty date range input + date3 = controller.InputDateRange(page, "empty_date_picker") + date3.expect_value(("", "")) + date3.expect_max_date(None) + date3.expect_min_date(None) + date_output_empty = controller.OutputText(page, "empty") + date_output_empty.expect_value("Date Picker Value: None to None") + + controller.InputActionButton(page, "update").click() + + # This is updating min and max dates, but the playwright locater cannot find it. + # It appears that the disabled dates are correct. + # Expect that means the calcuated values for disabled dates are updating correctly + # but the attribute is not updated. + date2.expect_value(("2011-11-02", "2011-11-15")) + # date2.expect_max_date("2011-11-16") + # date2.expect_min_date("2011-11-01") + date_output = controller.OutputText(page, "none") + date_output.expect_value("Date Picker Value: 2011-11-02 to 2011-11-15") + + # Check that the updated standard date range input is blanked + date1.expect_value(("", "")) + # date1.expect_max_date(None) + # date1.expect_min_date(None) + date_output = controller.OutputText(page, "standard") + date_output.expect_value("Date Picker Value: None to None")