Skip to content

Commit 7a09e81

Browse files
authored
Merge pull request #37 from cynkra/f-improve-cell-message
F improve cell message
2 parents 214b67e + 75729c8 commit 7a09e81

File tree

10 files changed

+422
-4
lines changed

10 files changed

+422
-4
lines changed

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
export(add_cell_message)
34
export(cheetah)
45
export(cheetahOutput)
56
export(column_def)
67
export(column_group)
8+
export(js_ifelse)
79
export(renderCheetah)
810
import(htmlwidgets)
911
import(jsonlite)

R/add_cell_message.R

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#' Create a JavaScript cell message function for cheetahR widgets
2+
#'
3+
#' Generates a JS function (wrapped with `htmlwidgets::JS`) that,
4+
#' given a record (`rec`), returns an object with `type` and `message`.
5+
#'
6+
#' @param type A string that specifies the type of message.
7+
#' One of `"error"`, `"warning"`, or `"info"`. Default is `"error"`.
8+
#' @param message A string or JS expression. If it contains `rec.`, `?`, `:`,
9+
#' or a trailing `;`, it is treated as raw JS (no additional quoting).
10+
#' Otherwise, it is escaped and wrapped in single quotes.
11+
#'
12+
#' @return A `htmlwidgets::JS` object containing a JavaScript function definition:
13+
#'```js
14+
#' function(rec) {
15+
#' return {
16+
#' type: "<type>",
17+
#' message: <message>
18+
#' };
19+
#' }
20+
#'```
21+
#' Use this within `column_def()` for cell validation
22+
#'
23+
#' @examples
24+
#' set.seed(123)
25+
#' iris_rows <- sample(nrow(iris), 10)
26+
#' data <- iris[iris_rows, ]
27+
#'
28+
#' # Simple warning
29+
#' cheetah(
30+
#' data,
31+
#' columns = list(
32+
#' Species = column_def(
33+
#' message = add_cell_message(type = "info", message = "Ok")
34+
#' )
35+
#' )
36+
#' )
37+
#'
38+
#' # Conditional error using `js_ifelse()`
39+
#' cheetah(
40+
#' data,
41+
#' columns = list(
42+
#' Species = column_def(
43+
#' message = add_cell_message(
44+
#' message = js_ifelse(Species == "setosa", "", "Invalid")
45+
#' )
46+
#' )
47+
#' )
48+
#' )
49+
#'
50+
#' # Directly using a JS expression as string
51+
#' cheetah(
52+
#' data,
53+
#' columns = list(
54+
#' Sepal.Width = column_def(
55+
#' style = list(textAlign = "left"),
56+
#' message = add_cell_message(
57+
#' type = "warning",
58+
#' message = "rec['Sepal.Width'] <= 3 ? 'NarrowSepal' : 'WideSepal';"
59+
#' )
60+
#' )
61+
#' )
62+
#' )
63+
#'
64+
#' @export
65+
add_cell_message <- function(
66+
type = c("error", "warning", "info"),
67+
message = "message"
68+
) {
69+
type <- match.arg(type)
70+
71+
is_js_expr <- grepl("rec\\.|\\?|\\:|;$", message)
72+
js_msg <- if (is_js_expr) {
73+
sub(";$", "", message)
74+
} else {
75+
msg_esc <- gsub("'", "\\'", message)
76+
paste0("'", msg_esc, "'")
77+
}
78+
79+
js_fn <- paste0(
80+
"function(rec) {\n",
81+
" return {\n",
82+
" type: '",
83+
type,
84+
"',\n",
85+
" message: ",
86+
js_msg,
87+
"\n",
88+
" };\n",
89+
"}"
90+
)
91+
92+
htmlwidgets::JS(js_fn)
93+
}

R/cheetah_utils.R

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
#' takes `rec` as argument. It must return an object with two properties: `type` for the message
3939
#' type (`"info"`, `"warning"`, `"error"`) and the `message` that holds the text to display.
4040
#' The latter can leverage a JavaScript ternary operator involving `rec.<COLNAME>` (`COLNAME` being the name
41-
#' of the column for which we define the message) to check whether the predicate function is TRUE.
41+
#' of the column for which we define the message) to check whether the predicate function is TRUE. You can also
42+
#' use `add_cell_message()` to generated the expected JS expression.
4243
#' See details for example of usage.
4344
#'
4445
#' @details
@@ -57,6 +58,14 @@
5758
#' }")
5859
#' )
5960
#' ```
61+
#' Or use `add_cell_message()`:
62+
#' ```
63+
#' <COLNAME> = column_def(
64+
#' action = "input",
65+
#' message = add_cell_message(type = "info", message = "Ok")
66+
#' )
67+
#' ```
68+
#' See [add_cell_message()] for more details.
6069
#' @param sort Whether to sort the column. Default to FALSE. May also be
6170
#' a JS callback to create custom logic (does not work yet).
6271
#'

R/js_ifelse.R

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#' Convert an R logical expression into a JS ternary expression
2+
#'
3+
#' @param condition An R logical expression (supports %in% / %notin% / grepl() / comparisons / & |)
4+
#' @param if_true String to return when the condition is TRUE. Default is an empty string, which interprets as `null` in JS.
5+
#' @param if_false String to return when the condition is FALSE. Default is an empty string, which interprets as `null` in JS.
6+
#' @return A single character string containing a JavaScript ternary expression.
7+
#' @export
8+
js_ifelse <- function(condition, if_true = "", if_false = "") {
9+
# 1) Capture the unevaluated condition as a single string
10+
txt <- paste(deparse(substitute(condition)), collapse = " ")
11+
12+
# 2) Force all " to ' in the condition itself
13+
txt <- gsub("\"", "'", txt, fixed = TRUE)
14+
15+
# 3) Handle %notin% and %in%
16+
handle_in <- function(text) {
17+
pat <- "(\\b[[:alnum:]_.]+)\\s*%(not)?in%\\s*c\\(([^)]+)\\)"
18+
matches <- gregexpr(pat, text, perl = TRUE)[[1]]
19+
if (matches[1] == -1) return(text)
20+
21+
for (raw in regmatches(text, gregexpr(pat, text, perl = TRUE))[[1]]) {
22+
parts <- regmatches(raw, regexec(pat, raw, perl = TRUE))[[1]]
23+
var <- parts[2]
24+
neg <- !is.na(parts[3]) && nzchar(parts[3])
25+
vals <- strsplit(parts[4], ",")[[1]]
26+
vals <- trimws(gsub("^['\"]|['\"]$", "", vals))
27+
28+
# build a single-quoted JS array literal
29+
arr <- sprintf("['%s']", paste(vals, collapse = "','"))
30+
expr <- sprintf("%s.includes(rec['%s'])", arr, var)
31+
if (neg) expr <- paste0("!", expr)
32+
33+
text <- sub(pat, expr, text, perl = TRUE)
34+
}
35+
text
36+
}
37+
txt <- handle_in(txt)
38+
39+
# 4) Handle grepl() and !grepl(), capturing the inner regex
40+
handle_grep <- function(text) {
41+
pat <- "(!?)grepl\\((['\"])(.*?)\\2,\\s*([[:alnum:]_.]+)\\)"
42+
matches <- gregexpr(pat, text, perl = TRUE)[[1]]
43+
if (matches[1] == -1) return(text)
44+
45+
for (raw in regmatches(text, gregexpr(pat, text, perl = TRUE))[[1]]) {
46+
parts <- regmatches(raw, regexec(pat, raw, perl = TRUE))[[1]]
47+
notp <- nzchar(parts[2])
48+
pattern <- parts[4]
49+
var <- parts[5]
50+
51+
expr <- sprintf("%s/%s/.test(rec['%s'])",
52+
if (notp) "!" else "", pattern, var)
53+
text <- sub(pat, expr, text, perl = TRUE)
54+
}
55+
text
56+
}
57+
txt <- handle_grep(txt)
58+
59+
# 5) Replace R logical operators with JS equivalents
60+
txt <- gsub("!=", "!==", txt, fixed = TRUE)
61+
txt <- gsub("==", "===", txt, fixed = TRUE)
62+
txt <- gsub(" & ", " && ", txt, fixed = TRUE)
63+
txt <- gsub(" | ", " || ", txt, fixed = TRUE)
64+
65+
# 6) Prefix all remaining bare names with rec['...'], skipping 'rec' itself
66+
prefix_pat <- "(?<![\\w'\"/\\[\\]])\\b(?!rec\\b)([A-Za-z_][A-Za-z0-9_.]*)\\b(?![\\]\\('\"/])"
67+
txt <- gsub(prefix_pat, "rec['\\1']", txt, perl = TRUE)
68+
69+
# 7) Wrap the true/false results in single quotes (or null)
70+
wrap <- function(x) {
71+
x2 <- gsub("\"", "'", x, fixed = TRUE)
72+
if (x2 == "") "null" else sprintf("'%s'", x2)
73+
}
74+
tval <- wrap(if_true)
75+
fval <- wrap(if_false)
76+
77+
# 8) Assemble and return the JS ternary
78+
sprintf("%s ? %s : %s;", txt, tval, fval)
79+
}

man/add_cell_message.Rd

Lines changed: 75 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/column_def.Rd

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/js_ifelse.Rd

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-js_ifelse.R

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
test_that("js_ifelse conversion", {
2+
# Simple logic: numeric comparisons and equality
3+
expect_equal(
4+
js_ifelse(Sepal.Length > 5, "BigSepal", ""),
5+
"rec['Sepal.Length'] > 5 ? 'BigSepal' : null;"
6+
)
7+
expect_equal(
8+
js_ifelse(Species == "setosa", "IsSetosa", ""),
9+
"rec['Species'] === 'setosa' ? 'IsSetosa' : null;"
10+
)
11+
expect_equal(
12+
js_ifelse(Sepal.Width <= 3, "NarrowSepal", "WideSepal"),
13+
"rec['Sepal.Width'] <= 3 ? 'NarrowSepal' : 'WideSepal';"
14+
)
15+
16+
# Combined logic
17+
expr <- js_ifelse(Sepal.Length > 5 & Species %notin% c("setosa"), "E", "X")
18+
expect_true(grepl("rec['Sepal.Length'] > 5 && !['setosa'].includes", expr, fixed = TRUE))
19+
20+
# Truthiness of bare variable
21+
expect_equal(
22+
js_ifelse(Species, "", "Please check."),
23+
"rec['Species'] ? null : 'Please check.';"
24+
)
25+
26+
# Basic %in% and %notin%
27+
expect_equal(
28+
js_ifelse(Species %in% c("setosa", "virginica"), "Bad", ""),
29+
"['setosa',''virginica'].includes(rec['Species']) ? 'Bad' : null;"
30+
)
31+
expect_equal(
32+
js_ifelse(Species %notin% c("setosa"), "OK", ""),
33+
"!['setosa'].includes(rec['Species']) ? 'OK' : null;"
34+
)
35+
36+
# grepl() and !grepl()
37+
expect_equal(
38+
js_ifelse(grepl("^vir", Species), "Yes", ""),
39+
"/^vir/.test(rec['Species']) ? 'Yes' : null;"
40+
)
41+
expect_equal(
42+
js_ifelse(!grepl("set", Species), "NoSet", ""),
43+
"!/set/.test(rec['Species']) ? 'NoSet' : null;"
44+
)
45+
})

0 commit comments

Comments
 (0)