Skip to content
Merged
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
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# shiny (development version)

* Fixed #4281, #3251: `dateInput()`'s `datesdisabled` now works when `format` is not the default `yyyy-mm-dd`. The dates are now converted to real dates on the client, the same way `min`/`max` already are, instead of being parsed by bootstrap-datepicker with the display format. (#4434)

* Added an agent skill (`inst/skills/shiny-for-r/`) following the
[Agent Skills](https://agentskills.io) convention. Coding agents using
[btw](https://posit-dev.github.io/btw/) discover it automatically when
Expand Down
7 changes: 4 additions & 3 deletions R/input-date.R
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ dateInput <- function(inputId, label, value = NULL, min = NULL, max = NULL,
`data-max-date` = max,
`data-initial-date` = value,
`data-date-autoclose` = if (autoclose) "true" else "false",
`data-date-dates-disabled` =
# Ensure NULL is not sent as `{}` but as 'null'
jsonlite::toJSON(datesdisabled, null = 'null'),
# Not `data-date-*`: bootstrap-datepicker would parse it with the display
# `format`. Handled in date.ts instead, like min/max (#4281).
`data-dates-disabled` =
if (!is.null(datesdisabled)) jsonlite::toJSON(datesdisabled),
`data-date-days-of-week-disabled` =
jsonlite::toJSON(daysofweekdisabled, null = 'null')
),
Expand Down
13 changes: 13 additions & 0 deletions inst/www/shared/shiny.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions inst/www/shared/shiny.js.map

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions inst/www/shared/shiny.min.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions inst/www/shared/shiny.min.js.map

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions srcts/src/bindings/input/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare global {
bsDatepicker(methodName: "getStartDate"): Date | -1e9999; // eslint-disable-line no-loss-of-precision

bsDatepicker(methodName: "getEndDate"): Date | 1e9999; // eslint-disable-line no-loss-of-precision
bsDatepicker(methodName: "setDatesDisabled", params: Date[]): void;
bsDatepicker(methodName: string): void;
bsDatepicker(methodName: string, params: Date | null): void;
}
Expand Down Expand Up @@ -78,6 +79,16 @@ class DateInputBindingBase extends InputBinding {
// on Dates as soon as possible so that we know we're always working with
// consistent objects.

// Disabled dates always use yyyy-mm-dd format (like min-date and max-date
// below), instead of bootstrap-datepicker's built-in support for
// data-date-dates-disabled, which parses strings using the display format
// (#4281). This must run before setValue(): setDatesDisabled() calls
// update(), which re-parses the input's text with the display format and
// misreads 2-digit years (eternicode/bootstrap-datepicker#2010).
if ($input.data("dates-disabled") !== undefined) {
this._setDatesDisabled($input[0], $input.data("dates-disabled"));
}

let date = $input.data("initial-date");
// If initial_date is null, set to current date

Expand Down Expand Up @@ -186,6 +197,23 @@ class DateInputBindingBase extends InputBinding {
$(el).bsDatepicker("setUTCDate", curValue);
}
}
// Given an array of unambiguous date strings and/or Date objects, disable
// those dates on the datepicker. null/undefined entries are dropped.
protected _setDatesDisabled(
el: HTMLElement,
dates: Array<Date | string | null | undefined> | null,
): void {
if (!dates) {
$(el).bsDatepicker("setDatesDisabled", []);
return;
}

const parsedDates = dates
.map((date) => (date == null ? null : this._newDate(date)))
.filter((date): date is Date => date !== null);

$(el).bsDatepicker("setDatesDisabled", parsedDates);
}
// Given a date string of format yyyy-mm-dd, return a Date object with
// that date at 12AM UTC.
// If date is a Date object, return it unchanged.
Expand Down
2 changes: 2 additions & 0 deletions srcts/types/src/bindings/input/date.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare global {
bsDatepicker(methodName: "getUTCDate"): Date;
bsDatepicker(methodName: "getStartDate"): Date | -1e9999;
bsDatepicker(methodName: "getEndDate"): Date | 1e9999;
bsDatepicker(methodName: "setDatesDisabled", params: Date[]): void;
bsDatepicker(methodName: string): void;
bsDatepicker(methodName: string, params: Date | null): void;
}
Expand Down Expand Up @@ -32,6 +33,7 @@ declare class DateInputBindingBase extends InputBinding {
}): string;
protected _setMin(el: HTMLElement, date: Date | null): void;
protected _setMax(el: HTMLElement, date: Date | null): void;
protected _setDatesDisabled(el: HTMLElement, dates: Array<Date | string | null | undefined> | null): void;
protected _newDate(date: Date | never | string): Date | null;
protected _floorDateTime(date: Date): Date;
protected _dateAsUTC(date: Date): Date;
Expand Down
65 changes: 65 additions & 0 deletions tests/testthat/test-input-date.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# For issue #4281: `datesdisabled` silently did nothing when `format` was
# not the default `yyyy-mm-dd`, because it was rendered as a
# `data-date-*`-prefixed attribute, which bootstrap-datepicker picks up
# automatically and parses using the (possibly non-default) display format.
# `min`/`max` avoid this by using plain `data-min-date`/`data-max-date`
# attributes instead; `datesdisabled` should do the same.

test_that("dateInput() datesdisabled attribute is not consumed by bootstrap-datepicker's own data-date-* parsing", {
input_attribs <- function(...) {
tag <- dateInput(...)
input_tag <- tag$children[[2]]
input_tag$attribs
}

attribs <- input_attribs(
"date8", "Date:",
value = "2012-02-29",
format = "dd/mm/yyyy",
datesdisabled = c("2012-03-01", "2012-03-02")
)

# Must NOT use the `data-date-` prefix -- that's reserved for options that
# bootstrap-datepicker parses itself using the display `format`.
expect_null(attribs[["data-date-dates-disabled"]])

# Instead, datesdisabled should be sent as plain yyyy-mm-dd strings (like
# min/max), for the client-side binding to convert before handing off to
# bootstrap-datepicker, regardless of the display `format`.
expect_identical(
unclass(attribs[["data-dates-disabled"]]),
as.character(jsonlite::toJSON(c("2012-03-01", "2012-03-02")))
)

# Same JSON payload no matter what `format` is used for display.
attribs_default_format <- input_attribs(
"date8", "Date:",
value = "2012-02-29",
datesdisabled = c("2012-03-01", "2012-03-02")
)
expect_identical(
attribs[["data-dates-disabled"]],
attribs_default_format[["data-dates-disabled"]]
)
})

test_that("dateInput() omits the datesdisabled attribute when unused", {
tag <- dateInput("date8", "Date:")
input_tag <- tag$children[[2]]

expect_null(input_tag$attribs[["data-dates-disabled"]])
})

test_that("dateInput() datesdisabled accepts Date objects", {
tag <- dateInput(
"date8", "Date:",
format = "dd/mm/yyyy",
datesdisabled = as.Date(c("2012-03-01", "2012-03-02"))
)
input_tag <- tag$children[[2]]

expect_identical(
unclass(input_tag$attribs[["data-dates-disabled"]]),
as.character(jsonlite::toJSON(c("2012-03-01", "2012-03-02")))
)
})
51 changes: 51 additions & 0 deletions tests/testthat/test-zzz-st2-input-date.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
skip_if_not_shinytest2()
library(shinytest2)

# For #4281: `datesdisabled` must work with a non-default `format`, and must
# not corrupt the value of inputs with a 2-digit-year `format` (bootstrap-
# datepicker's setDatesDisabled() re-parses the input text with the display
# format, which turns "12" into year 12 AD).
app <- launch_test_app(function() {
library(shiny)

dd <- c("2012-03-01", "2012-03-02")

ui <- fluidPage(
dateInput("long", "dd/mm/yyyy + datesdisabled",
value = "2012-02-29", format = "dd/mm/yyyy", datesdisabled = dd),
dateInput("yy_plain", "mm/dd/yy, no datesdisabled",
value = "2012-02-29", format = "mm/dd/yy"),
dateInput("yy_dd", "mm/dd/yy + datesdisabled",
value = "2012-02-29", format = "mm/dd/yy", datesdisabled = dd)
)

server <- function(input, output, session) {}

shinyApp(ui, server)
})

# Open the picker, go to the next month (March 2012), and return the
# (in-month) days that are shown as disabled.
disabled_days_in_next_month <- function(id) {
app$get_js(sprintf(
"(function() {
var $i = $('#%s input');
$i.bsDatepicker('show');
$('.datepicker-days .next').last().click();
var days = $('.datepicker-days td.day.disabled')
.not('.old, .new').map(function() { return $(this).text(); }).get();
$i.bsDatepicker('hide');
return days;
})()", id
))
}

test_that("dateInput() keeps its value and disables dates for any format", {
expect_equal(app$get_value(input = "long"), as.Date("2012-02-29"))
expect_equal(app$get_value(input = "yy_plain"), as.Date("2012-02-29"))
expect_equal(app$get_value(input = "yy_dd"), as.Date("2012-02-29"))

expect_equal(unlist(disabled_days_in_next_month("long")), c("1", "2"))
expect_equal(unlist(disabled_days_in_next_month("yy_dd")), c("1", "2"))
expect_length(disabled_days_in_next_month("yy_plain"), 0)
})
Loading