From 92070a3be983ca9886b12584a9a54a55170150a9 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Wed, 10 Jun 2026 10:28:23 -0400 Subject: [PATCH 1/7] Add negation search support in AE listing detail table Users can now prefix search terms with "!" to exclude matching rows (e.g., "!group A" filters out rows containing "group A"). Co-Authored-By: Claude Opus 4.6 --- R/ae_forestly.R | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/R/ae_forestly.R b/R/ae_forestly.R index 6477686..b9d30a6 100644 --- a/R/ae_forestly.R +++ b/R/ae_forestly.R @@ -279,6 +279,19 @@ ae_forestly <- function(outdata, resizable = TRUE, filterable = TRUE, searchable = TRUE, + searchMethod = reactable::JS( + "function(rows, columnIds, filterValue) { + var negate = filterValue.startsWith('!'); + var term = negate ? filterValue.slice(1).trim() : filterValue.trim(); + if (term === '') return rows; + return rows.filter(function(row) { + var match = columnIds.some(function(id) { + return String(row.values[id]).toLowerCase().indexOf(term.toLowerCase()) > -1; + }); + return negate ? !match : match; + }); + }" + ), showPageSizeOptions = TRUE, borderless = TRUE, striped = TRUE, From 8a416a5529483ac03c55a540072ae508b8981d11 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Wed, 10 Jun 2026 11:20:45 -0400 Subject: [PATCH 2/7] Support JS expressions in AE listing search bar Users can now type arbitrary JS expressions in the search bar using `x` as the cell value, e.g. `x != 'Placebo'`, `x > 80`, `x.includes('foo')`. Negation expressions filter with every() (exclude matching), positive expressions filter with some() (include matching). Plain text still works as substring search. Co-Authored-By: Claude Opus 4.6 --- R/ae_forestly.R | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/R/ae_forestly.R b/R/ae_forestly.R index b9d30a6..276200a 100644 --- a/R/ae_forestly.R +++ b/R/ae_forestly.R @@ -281,14 +281,32 @@ ae_forestly <- function(outdata, searchable = TRUE, searchMethod = reactable::JS( "function(rows, columnIds, filterValue) { - var negate = filterValue.startsWith('!'); - var term = negate ? filterValue.slice(1).trim() : filterValue.trim(); - if (term === '') return rows; + var v = filterValue.trim(); + if (v === '') return rows; + // If the input looks like a JS expression (contains an operator), + // evaluate it with `x` bound to each cell value. + var exprPattern = /[=!<>]|\\.(includes|startsWith|endsWith|match)\\s*\\(/; + if (exprPattern.test(v)) { + try { + var fn = new Function('x', 'try { return (' + v + '); } catch(e) { return false; }'); + // Negation expressions use every() (row passes if ALL cells satisfy), + // positive expressions use some() (row passes if ANY cell satisfies). + var isNegation = /^!|!=/.test(v); + var method = isNegation ? 'every' : 'some'; + return rows.filter(function(row) { + return columnIds[method](function(id) { + var x = row.values[id]; + if (x == null) return isNegation; + return fn(String(x)) || (isFinite(Number(x)) && fn(Number(x))); + }); + }); + } catch(e) { } + } + // Default: substring search return rows.filter(function(row) { - var match = columnIds.some(function(id) { - return String(row.values[id]).toLowerCase().indexOf(term.toLowerCase()) > -1; + return columnIds.some(function(id) { + return String(row.values[id]).toLowerCase().indexOf(v.toLowerCase()) > -1; }); - return negate ? !match : match; }); }" ), From c746801b13fa5594014fc96b7db377f22a9ee35e Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Fri, 28 Aug 2026 17:32:30 -0400 Subject: [PATCH 3/7] Fix negation search in AE listing detail table Restore the every()/some() split for JS-expression searches so that negation expressions filter correctly: - A negation expression (e.g. `x !== "Rash"`, `!x.includes("Rash")`) now keeps a row only when EVERY cell satisfies the test, so rows whose value matches the excluded term are dropped. Previously it used some(), so any non-matching cell (e.g. age) kept the whole row and nothing was filtered. - Positive expressions still keep a row when ANY cell satisfies the test. Also keeps the substring path with a leading `!` for simple negation (e.g. `!Rash`), which is disambiguated from expression mode by whether the term references the cell variable `x`. Verified in a headless browser (chromote) against a reactable with the exact searchMethod: `!Rash`, `Rash`, `x !== "Rash"`, `x > 50`, and `!x.includes("Rash")` all filter as expected. Co-Authored-By: Claude Opus 4.8 --- R/ae_forestly.R | 51 ++++++++++++++++++++++--------- tests/testthat/test-ae_forestly.R | 11 +++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/R/ae_forestly.R b/R/ae_forestly.R index 276200a..008b219 100644 --- a/R/ae_forestly.R +++ b/R/ae_forestly.R @@ -283,30 +283,51 @@ ae_forestly <- function(outdata, "function(rows, columnIds, filterValue) { var v = filterValue.trim(); if (v === '') return rows; - // If the input looks like a JS expression (contains an operator), - // evaluate it with `x` bound to each cell value. - var exprPattern = /[=!<>]|\\.(includes|startsWith|endsWith|match)\\s*\\(/; - if (exprPattern.test(v)) { + // JS expression mode: the term references the cell variable `x` + // (e.g. `x > 5`, `x.includes(\"A\")`, `x !== \"Rash\"`). + if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { + var fn; try { - var fn = new Function('x', 'try { return (' + v + '); } catch(e) { return false; }'); - // Negation expressions use every() (row passes if ALL cells satisfy), - // positive expressions use some() (row passes if ANY cell satisfies). - var isNegation = /^!|!=/.test(v); + fn = new Function('x', 'return (' + v + ');'); + } catch (e) { + fn = null; + } + if (fn) { + // Negation expressions (leading `!` or `!=`) keep a row only when + // EVERY cell satisfies the test (i.e. no cell matches the excluded + // value). Positive expressions keep a row when ANY cell satisfies. + var isNegation = /^\\s*!|!=/.test(v); var method = isNegation ? 'every' : 'some'; + var evalCell = function(raw) { + if (raw == null) return isNegation; + try { + var num = Number(raw); + return !!fn(String(raw)) || + (raw !== '' && isFinite(num) && !!fn(num)); + } catch (e) { + return false; + } + }; return rows.filter(function(row) { return columnIds[method](function(id) { - var x = row.values[id]; - if (x == null) return isNegation; - return fn(String(x)) || (isFinite(Number(x)) && fn(Number(x))); + return evalCell(row.values[id]); }); }); - } catch(e) { } + } } - // Default: substring search + // Substring mode with optional leading `!` for negation + // (e.g. `group A` keeps matching rows, `!group A` excludes them). + var negate = v.charAt(0) === '!'; + var term = negate ? v.slice(1).trim() : v; + if (term === '') return rows; + var needle = term.toLowerCase(); return rows.filter(function(row) { - return columnIds.some(function(id) { - return String(row.values[id]).toLowerCase().indexOf(v.toLowerCase()) > -1; + var match = columnIds.some(function(id) { + var raw = row.values[id]; + return raw != null && + String(raw).toLowerCase().indexOf(needle) > -1; }); + return negate ? !match : match; }); }" ), diff --git a/tests/testthat/test-ae_forestly.R b/tests/testthat/test-ae_forestly.R index bbd5444..ae4c0af 100644 --- a/tests/testthat/test-ae_forestly.R +++ b/tests/testthat/test-ae_forestly.R @@ -18,6 +18,17 @@ test_that("ae_forestly(): test filter and width option", { expect_true(grepl("Number of AE in One or More Treatment Groups", html$children[[1]], fixed = TRUE)) }) +test_that("ae_forestly(): detail table embeds custom search method", { + outdata <- test_ae_forestly() + html <- outdata |> ae_forestly() + html_text <- as.character(html) + + # Custom searchMethod supporting substring, `!` negation, and JS expressions + expect_true(grepl("searchMethod", html_text, fixed = TRUE)) + expect_true(grepl("var negate = v.charAt(0) === '!'", html_text, fixed = TRUE)) + expect_true(grepl("new Function('x'", html_text, fixed = TRUE)) +}) + test_that("ae_forestly(): toggle risk difference button is hidden by default", { outdata <- metalite.ae::meta_ae_example() |> prepare_ae_forestly( From 4bef38b33d2c3a51a1cc5d7edcc57bc1c97ba812 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Fri, 28 Aug 2026 18:00:45 -0400 Subject: [PATCH 4/7] Support negation and JS expressions in AE listing column filters The previous change only customized the table-wide `searchMethod` (the global search box), but the AE listing detail table exposes a filter box per column (`filterable = TRUE`). Those per-column boxes use the default substring `filterMethod`, so `!Rash` or `x !== "Rash"` matched nothing and returned an empty table. Add a custom `filterMethod` to every column definition mirroring the global search behavior for a single column: - Substring match, with a leading `!` to negate (e.g. `!Rash`). - JS expression evaluation when the term references the cell value `x` (e.g. `x > 5`, `x !== "Rash"`, `!x.includes("Rash")`), tried against both the string and, when applicable, numeric form of the cell. Verified in a headless browser (chromote) against the real ae_forestly() widget: expanding a detail row and typing into the Adverse Event and Gender column filters, `F`/`!F`, `x === "F"`/`x !== "F"`, and `!x.includes(...)` all filter that column as expected. Co-Authored-By: Claude Opus 4.8 --- R/ae_forestly.R | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/R/ae_forestly.R b/R/ae_forestly.R index 008b219..0c21188 100644 --- a/R/ae_forestly.R +++ b/R/ae_forestly.R @@ -256,6 +256,49 @@ ae_forestly <- function(outdata, # Extract labels for use in column definitions labels <- lapply(t_details, function(x) attr(x, "label")) + # Per-column filter supporting substring search, a leading `!` for + # negation (e.g. `!Rash`), and JS expressions referencing the cell + # value `x` (e.g. `x > 5`, `x !== "Rash"`, `!x.includes("Rash")`). + col_filter_method <- reactable::JS( + "function(rows, columnId, filterValue) { + var v = filterValue.trim(); + if (v === '') return rows; + // JS expression mode: the term references the cell variable `x`. + if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { + var fn; + try { + fn = new Function('x', 'return (' + v + ');'); + } catch (e) { + fn = null; + } + if (fn) { + return rows.filter(function(row) { + var raw = row.values[columnId]; + if (raw == null) return false; + try { + var num = Number(raw); + return !!fn(String(raw)) || + (raw !== '' && isFinite(num) && !!fn(num)); + } catch (e) { + return false; + } + }); + } + } + // Substring mode with optional leading `!` for negation. + var negate = v.charAt(0) === '!'; + var term = negate ? v.slice(1).trim() : v; + if (term === '') return rows; + var needle = term.toLowerCase(); + return rows.filter(function(row) { + var raw = row.values[columnId]; + var match = raw != null && + String(raw).toLowerCase().indexOf(needle) > -1; + return negate ? !match : match; + }); + }" + ) + # Create named column definitions using the labels col_defs <- stats::setNames( lapply(names(t_details), function(name) { @@ -265,7 +308,8 @@ ae_forestly <- function(outdata, header = label_name, # Use header instead of name cell = function(value) format(value, nsmall = 1), align = "center", - minWidth = 70 + minWidth = 70, + filterMethod = col_filter_method ) }), names(t_details) From 2e794af825eebe41cb49f3fd85754444f2b1b5c1 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Fri, 28 Aug 2026 18:14:47 -0400 Subject: [PATCH 5/7] Support negation and JS expressions in the Adverse Event column filter The main forest-plot table (built via reactable2() from format_ae_forestly()) renders a per-column filter box for the Adverse Event column. This is the column where excluding a term is actually meaningful, since it lists every AE (Rash, Pruritus, Erythema, ...). Previous commits added the custom filter/search behavior only to the table-wide search box and to the nested detail table, so typing `!Rash` or `x !== "Rash"` in the Adverse Event filter fell through to reactable's default substring matcher: it searched for the literal text and returned an empty table (even `x === "Rash"` matched nothing). Attach a custom `filterMethod` to the `name` (Adverse Event) column definition supporting: - substring match, with a leading `!` to negate (e.g. `!Rash`), and - JS expression evaluation when the term references the cell value `x` (e.g. `x !== "Rash"`, `!x.includes("site")`). Verified in a headless browser (chromote) against the ae_forestly() widget produced by the documented pipeline: `Rash` -> 5 AEs, `!Rash` and `x !== "Rash"` -> all AEs, `x === "Rash"` -> 1, `!x.includes("site")` -> all non-"Application site *" AEs. Co-Authored-By: Claude Opus 4.8 --- R/format_ae_forestly.R | 46 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/R/format_ae_forestly.R b/R/format_ae_forestly.R index bcec76d..1b5949a 100644 --- a/R/format_ae_forestly.R +++ b/R/format_ae_forestly.R @@ -278,6 +278,49 @@ format_ae_forestly <- function( # Column Definition ---- + # Filter method for text columns: substring match, a leading `!` for + # negation (e.g. `!Rash`), and JS expressions referencing the cell value + # `x` (e.g. `x !== "Rash"`, `!x.includes("Rash")`). + text_filter_method <- reactable::JS( + "function(rows, columnId, filterValue) { + var v = filterValue.trim(); + if (v === '') return rows; + // JS expression mode: the term references the cell variable `x`. + if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { + var fn; + try { + fn = new Function('x', 'return (' + v + ');'); + } catch (e) { + fn = null; + } + if (fn) { + return rows.filter(function(row) { + var raw = row.values[columnId]; + if (raw == null) return false; + try { + var num = Number(raw); + return !!fn(String(raw)) || + (raw !== '' && isFinite(num) && !!fn(num)); + } catch (e) { + return false; + } + }); + } + } + // Substring mode with optional leading `!` for negation. + var negate = v.charAt(0) === '!'; + var term = negate ? v.slice(1).trim() : v; + if (term === '') return rows; + var needle = term.toLowerCase(); + return rows.filter(function(row) { + var raw = row.values[columnId]; + var match = raw != null && + String(raw).toLowerCase().indexOf(needle) > -1; + return negate ? !match : match; + }); + }" + ) + # Format variables for group col_var <- list( parameter = reactable::colDef( @@ -286,7 +329,8 @@ format_ae_forestly <- function( ), name = reactable::colDef( header = ae_col_header, - minWidth = width_term, align = "right" + minWidth = width_term, align = "right", + filterMethod = text_filter_method ), soc_name = reactable::colDef( header = "SOC Name", From 45331ac1b464d145e824c87d23138f46251e1312 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Fri, 28 Aug 2026 18:22:48 -0400 Subject: [PATCH 6/7] Refactor duplicated search/filter JS into a shared helper The negation/expression search logic was copy-pasted in three places: the table-wide searchMethod and the per-column filterMethod of the nested detail table (R/ae_forestly.R), and the Adverse Event column filter (R/format_ae_forestly.R). Extract a single internal helper, search_filter_js(scope), that emits the reactable::JS() callback for either a per-column filterMethod (scope = "column") or a table-wide searchMethod (scope = "table"). The two share one body: the searched column ids are normalized to an array (`[columnId]` vs `columnIds`), and some()/every() over that array yields the single-cell behavior for a column filter and the any/all-cell behavior for a table search. No behavior change. Verified in a headless browser (chromote) that the Adverse Event column filter and the nested detail table's column filter and search still handle substring, `!` negation, and `x`-expression terms identically. `devtools::test(filter = "ae_forestly")` passes (33 checks). Co-Authored-By: Claude Opus 4.8 --- R/ae_forestly.R | 98 ++---------------------------------- R/format_ae_forestly.R | 45 ++--------------- R/search_filter.R | 111 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 136 deletions(-) create mode 100644 R/search_filter.R diff --git a/R/ae_forestly.R b/R/ae_forestly.R index 0c21188..7d73bcd 100644 --- a/R/ae_forestly.R +++ b/R/ae_forestly.R @@ -256,48 +256,9 @@ ae_forestly <- function(outdata, # Extract labels for use in column definitions labels <- lapply(t_details, function(x) attr(x, "label")) - # Per-column filter supporting substring search, a leading `!` for - # negation (e.g. `!Rash`), and JS expressions referencing the cell - # value `x` (e.g. `x > 5`, `x !== "Rash"`, `!x.includes("Rash")`). - col_filter_method <- reactable::JS( - "function(rows, columnId, filterValue) { - var v = filterValue.trim(); - if (v === '') return rows; - // JS expression mode: the term references the cell variable `x`. - if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { - var fn; - try { - fn = new Function('x', 'return (' + v + ');'); - } catch (e) { - fn = null; - } - if (fn) { - return rows.filter(function(row) { - var raw = row.values[columnId]; - if (raw == null) return false; - try { - var num = Number(raw); - return !!fn(String(raw)) || - (raw !== '' && isFinite(num) && !!fn(num)); - } catch (e) { - return false; - } - }); - } - } - // Substring mode with optional leading `!` for negation. - var negate = v.charAt(0) === '!'; - var term = negate ? v.slice(1).trim() : v; - if (term === '') return rows; - var needle = term.toLowerCase(); - return rows.filter(function(row) { - var raw = row.values[columnId]; - var match = raw != null && - String(raw).toLowerCase().indexOf(needle) > -1; - return negate ? !match : match; - }); - }" - ) + # Per-column filter supporting substring search, `!` negation, and JS + # expressions referencing the cell value `x` (see search_filter_js()). + col_filter_method <- search_filter_js("column") # Create named column definitions using the labels col_defs <- stats::setNames( @@ -323,58 +284,7 @@ ae_forestly <- function(outdata, resizable = TRUE, filterable = TRUE, searchable = TRUE, - searchMethod = reactable::JS( - "function(rows, columnIds, filterValue) { - var v = filterValue.trim(); - if (v === '') return rows; - // JS expression mode: the term references the cell variable `x` - // (e.g. `x > 5`, `x.includes(\"A\")`, `x !== \"Rash\"`). - if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { - var fn; - try { - fn = new Function('x', 'return (' + v + ');'); - } catch (e) { - fn = null; - } - if (fn) { - // Negation expressions (leading `!` or `!=`) keep a row only when - // EVERY cell satisfies the test (i.e. no cell matches the excluded - // value). Positive expressions keep a row when ANY cell satisfies. - var isNegation = /^\\s*!|!=/.test(v); - var method = isNegation ? 'every' : 'some'; - var evalCell = function(raw) { - if (raw == null) return isNegation; - try { - var num = Number(raw); - return !!fn(String(raw)) || - (raw !== '' && isFinite(num) && !!fn(num)); - } catch (e) { - return false; - } - }; - return rows.filter(function(row) { - return columnIds[method](function(id) { - return evalCell(row.values[id]); - }); - }); - } - } - // Substring mode with optional leading `!` for negation - // (e.g. `group A` keeps matching rows, `!group A` excludes them). - var negate = v.charAt(0) === '!'; - var term = negate ? v.slice(1).trim() : v; - if (term === '') return rows; - var needle = term.toLowerCase(); - return rows.filter(function(row) { - var match = columnIds.some(function(id) { - var raw = row.values[id]; - return raw != null && - String(raw).toLowerCase().indexOf(needle) > -1; - }); - return negate ? !match : match; - }); - }" - ), + searchMethod = search_filter_js("table"), showPageSizeOptions = TRUE, borderless = TRUE, striped = TRUE, diff --git a/R/format_ae_forestly.R b/R/format_ae_forestly.R index 1b5949a..f8faabd 100644 --- a/R/format_ae_forestly.R +++ b/R/format_ae_forestly.R @@ -278,48 +278,9 @@ format_ae_forestly <- function( # Column Definition ---- - # Filter method for text columns: substring match, a leading `!` for - # negation (e.g. `!Rash`), and JS expressions referencing the cell value - # `x` (e.g. `x !== "Rash"`, `!x.includes("Rash")`). - text_filter_method <- reactable::JS( - "function(rows, columnId, filterValue) { - var v = filterValue.trim(); - if (v === '') return rows; - // JS expression mode: the term references the cell variable `x`. - if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { - var fn; - try { - fn = new Function('x', 'return (' + v + ');'); - } catch (e) { - fn = null; - } - if (fn) { - return rows.filter(function(row) { - var raw = row.values[columnId]; - if (raw == null) return false; - try { - var num = Number(raw); - return !!fn(String(raw)) || - (raw !== '' && isFinite(num) && !!fn(num)); - } catch (e) { - return false; - } - }); - } - } - // Substring mode with optional leading `!` for negation. - var negate = v.charAt(0) === '!'; - var term = negate ? v.slice(1).trim() : v; - if (term === '') return rows; - var needle = term.toLowerCase(); - return rows.filter(function(row) { - var raw = row.values[columnId]; - var match = raw != null && - String(raw).toLowerCase().indexOf(needle) > -1; - return negate ? !match : match; - }); - }" - ) + # Filter method for the Adverse Event column: substring match, `!` negation, + # and JS expressions referencing the cell value `x` (see search_filter_js()). + text_filter_method <- search_filter_js("column") # Format variables for group col_var <- list( diff --git a/R/search_filter.R b/R/search_filter.R new file mode 100644 index 0000000..75330e7 --- /dev/null +++ b/R/search_filter.R @@ -0,0 +1,111 @@ +# Copyright (c) 2023 Merck & Co., Inc., Rahway, NJ, USA and its affiliates. +# All rights reserved. +# +# This file is part of the forestly program. +# +# forestly is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +#' Build a reactable search / filter function supporting negation and expressions +#' +#' Returns a `reactable::JS()` callback that can be used either as a per-column +#' `filterMethod` or as a table-wide `searchMethod`. In both cases it supports: +#' +#' * Substring matching, with a leading `!` to negate (e.g. `!Rash` keeps rows +#' that do *not* contain "Rash"). +#' * JavaScript expression evaluation when the term references the cell value +#' `x` (e.g. `x > 5`, `x !== "Rash"`, `!x.includes("Rash")`). The expression +#' is tested against both the string and, when applicable, the numeric form of +#' each cell. +#' +#' A row is kept when *any* searched cell satisfies a positive test, and only +#' when *every* searched cell satisfies a negation test (so rows containing the +#' excluded value are dropped). For a single-column filter these two rules +#' collapse to the same thing. +#' +#' @param scope Either `"column"` for a per-column `filterMethod` +#' (signature `function(rows, columnId, filterValue)`) or `"table"` for a +#' table-wide `searchMethod` (signature +#' `function(rows, columnIds, filterValue)`). +#' +#' @return A `reactable::JS()` object. +#' +#' @noRd +search_filter_js <- function(scope = c("column", "table")) { + scope <- match.arg(scope) + + # The body is identical for both scopes once the set of searched column ids + # is normalized to an array: a per-column filter searches `[columnId]`, a + # table-wide search searches all `columnIds`. Iterating with some()/every() + # over a one-element array reduces to testing that single cell. + signature <- if (scope == "column") { + "function(rows, columnId, filterValue)" + } else { + "function(rows, columnIds, filterValue)" + } + ids <- if (scope == "column") "[columnId]" else "columnIds" + + reactable::JS(sprintf( + "%s { + var v = filterValue.trim(); + if (v === '') return rows; + var ids = %s; + // JS expression mode: the term references the cell variable `x` + // (e.g. `x > 5`, `x !== \"Rash\"`, `!x.includes(\"Rash\")`). + if (/(^|[^\\w$])x([^\\w$]|$)/.test(v)) { + var fn; + try { + fn = new Function('x', 'return (' + v + ');'); + } catch (e) { + fn = null; + } + if (fn) { + // Negation expressions (leading `!` or `!=`) keep a row only when + // every searched cell satisfies the test; positive expressions keep + // a row when any searched cell satisfies it. + var isNegation = /^\\s*!|!=/.test(v); + var method = isNegation ? 'every' : 'some'; + var evalCell = function(raw) { + if (raw == null) return isNegation; + try { + var num = Number(raw); + return !!fn(String(raw)) || + (raw !== '' && isFinite(num) && !!fn(num)); + } catch (e) { + return false; + } + }; + return rows.filter(function(row) { + return ids[method](function(id) { + return evalCell(row.values[id]); + }); + }); + } + } + // Substring mode with optional leading `!` for negation. + var negate = v.charAt(0) === '!'; + var term = negate ? v.slice(1).trim() : v; + if (term === '') return rows; + var needle = term.toLowerCase(); + return rows.filter(function(row) { + var match = ids.some(function(id) { + var raw = row.values[id]; + return raw != null && + String(raw).toLowerCase().indexOf(needle) > -1; + }); + return negate ? !match : match; + }); + }", + signature, ids + )) +} From d1ba134e893c046e1c837e9ef7fb85143e9af636 Mon Sep 17 00:00:00 2001 From: Yihui Xie Date: Fri, 28 Aug 2026 19:36:51 -0400 Subject: [PATCH 7/7] Document search box negation and expression syntax Add a user-facing "Searching and filtering" section to ?ae_forestly explaining the two search styles the interactive table understands beyond plain substring matching: - Negation: prefix a term with `!` to exclude matching rows (e.g. `!Rash`). - Expressions: a term mentioning `x` (the cell value) is evaluated as a small condition, e.g. `x > 5`, `x >= 18 && x <= 65`, `x !== "Rash"`, `x.includes("itch")`, `x.startsWith("Application")`. The section is written for R users who may not know JavaScript: it gives a two-column table of ready-to-type examples with plain-English meanings and notes on quoting, `&&`/`||`/`!`, the doubled comparison symbols, and case-sensitivity. Every example in the table was verified against the actual search implementation. Co-Authored-By: Claude Opus 4.8 --- R/ae_forestly.R | 49 ++++++++++++++++++++++++++++++++++++++++++++ man/ae_forestly.Rd | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/R/ae_forestly.R b/R/ae_forestly.R index 7d73bcd..66de637 100644 --- a/R/ae_forestly.R +++ b/R/ae_forestly.R @@ -31,6 +31,55 @@ #' @param max_page A numeric value of max page number shown in the table. #' @param dowload_button A logical value to display download button. #' +#' @section Searching and filtering: +#' The interactive table has a search box for each column (and, in the +#' expandable detail listing, a table-wide search box). In addition to a +#' plain substring match, the search terms understand two extra styles. +#' +#' **Negation with `!`.** Prefix a term with an exclamation mark to *exclude* +#' matching rows instead of keeping them: +#' +#' \itemize{ +#' \item `Rash` --- keep rows whose value contains "Rash". +#' \item `!Rash` --- keep rows whose value does *not* contain "Rash". +#' } +#' +#' **Expressions.** If the term mentions the letter `x` (which stands for the +#' value of the cell being searched), it is evaluated as a small expression and +#' the row is kept when the expression is true. `x` behaves like the value in +#' that column, so numeric columns can be compared with numbers and text +#' columns with quoted text. Common patterns: +#' +#' \tabular{ll}{ +#' \strong{Type this in the search box} \tab \strong{Keeps rows where} \cr +#' `x > 5` \tab the value is greater than 5 \cr +#' `x >= 5` \tab the value is 5 or more \cr +#' `x < 65` \tab the value is less than 65 \cr +#' `x >= 18 && x <= 65` \tab the value is between 18 and 65 (inclusive) \cr +#' `x == 0` \tab the value equals 0 \cr +#' `x === "Rash"` \tab the value is exactly "Rash" \cr +#' `x !== "Rash"` \tab the value is anything except exactly "Rash" \cr +#' `x.includes("itch")` \tab the text contains "itch" \cr +#' `!x.includes("itch")` \tab the text does not contain "itch" \cr +#' `x.startsWith("Application")` \tab the text starts with "Application" \cr +#' `x.endsWith("itis")` \tab the text ends with "itis" \cr +#' `x === "M" || x === "F"` \tab the value is either "M" or "F" \cr +#' } +#' +#' Notes for the expression style: +#' \itemize{ +#' \item Wrap text values in quotes (`"Rash"`); numbers need no quotes (`5`). +#' \item Use `&&` for "and", `||` for "or", and `!` in front of a condition +#' for "not". +#' \item Comparisons use doubled symbols: `===` (equal), `!==` (not equal), +#' together with `>`, `>=`, `<`, `<=`. +#' \item Matching is case-sensitive in the expression style, so `x === "m"` +#' will not match "M". Use the plain substring style (which ignores case) +#' when case does not matter. +#' \item A term that mentions `x` but is not a valid expression is treated as +#' an ordinary substring search, so everyday searches keep working. +#' } +#' #' @return An AE forest plot saved as a `shiny.tag.list` object. #' #' @export diff --git a/man/ae_forestly.Rd b/man/ae_forestly.Rd index 164076f..facd382 100644 --- a/man/ae_forestly.Rd +++ b/man/ae_forestly.Rd @@ -46,6 +46,57 @@ An AE forest plot saved as a \code{shiny.tag.list} object. \description{ Display interactive forest plot } +\section{Searching and filtering}{ + +The interactive table has a search box for each column (and, in the +expandable detail listing, a table-wide search box). In addition to a +plain substring match, the search terms understand two extra styles. + +\strong{Negation with \code{!}.} Prefix a term with an exclamation mark to \emph{exclude} +matching rows instead of keeping them: + +\itemize{ +\item \code{Rash} --- keep rows whose value contains "Rash". +\item \code{!Rash} --- keep rows whose value does \emph{not} contain "Rash". +} + +\strong{Expressions.} If the term mentions the letter \code{x} (which stands for the +value of the cell being searched), it is evaluated as a small expression and +the row is kept when the expression is true. \code{x} behaves like the value in +that column, so numeric columns can be compared with numbers and text +columns with quoted text. Common patterns: + +\tabular{ll}{ +\strong{Type this in the search box} \tab \strong{Keeps rows where} \cr +\code{x > 5} \tab the value is greater than 5 \cr +\code{x >= 5} \tab the value is 5 or more \cr +\code{x < 65} \tab the value is less than 65 \cr +\code{x >= 18 && x <= 65} \tab the value is between 18 and 65 (inclusive) \cr +\code{x == 0} \tab the value equals 0 \cr +\verb{x === "Rash"} \tab the value is exactly "Rash" \cr +\verb{x !== "Rash"} \tab the value is anything except exactly "Rash" \cr +\code{x.includes("itch")} \tab the text contains "itch" \cr +\code{!x.includes("itch")} \tab the text does not contain "itch" \cr +\code{x.startsWith("Application")} \tab the text starts with "Application" \cr +\code{x.endsWith("itis")} \tab the text ends with "itis" \cr +\verb{x === "M" || x === "F"} \tab the value is either "M" or "F" \cr +} + +Notes for the expression style: +\itemize{ +\item Wrap text values in quotes (\code{"Rash"}); numbers need no quotes (\code{5}). +\item Use \code{&&} for "and", \code{||} for "or", and \code{!} in front of a condition +for "not". +\item Comparisons use doubled symbols: \verb{===} (equal), \verb{!==} (not equal), +together with \code{>}, \code{>=}, \code{<}, \code{<=}. +\item Matching is case-sensitive in the expression style, so \verb{x === "m"} +will not match "M". Use the plain substring style (which ignores case) +when case does not matter. +\item A term that mentions \code{x} but is not a valid expression is treated as +an ordinary substring search, so everyday searches keep working. +} +} + \examples{ adsl <- forestly_adsl[1:100, ] adae <- forestly_adae[1:100, ]