Skip to content

Commit f7b4621

Browse files
committed
9D: Cross-page select-all with inverted selection model
In backend modes, clicking select-all now sets a selectAllRows flag instead of enumerating all row IDs (which aren't all loaded). Individual deselections after select-all are tracked in a deselectedRowIds set. The inverted state is resolved transparently at API boundaries: - Shiny getReactableState('selected') resolves to an integer vector using the self-contained rowCount in the inverted payload - onStateChange/stateInfo.selected reports visible page's selections - Checkbox UI shows correct checked/indeterminate state Client-side tables (no backend) keep existing behavior unchanged. Known issue: per-row selections in backend mode only report visible page's selected indices to Shiny, not the full cross-page set.
1 parent be61277 commit f7b4621

File tree

5 files changed

+330
-25
lines changed

5 files changed

+330
-25
lines changed

R/shiny.R

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,14 @@ getReactableState <- function(outputId, name = NULL, session = NULL) {
296296

297297
getState <- function(outputId, name) {
298298
# NOTE: input IDs must always come first to work with Shiny modules
299-
session$input[[sprintf("%s__reactable__%s", outputId, name)]]
299+
value <- session$input[[sprintf("%s__reactable__%s", outputId, name)]]
300+
# Resolve inverted selection (selectAll mode) to a concrete integer vector.
301+
# The inverted payload includes rowCount so it's self-contained.
302+
if (name == "selected" && is.list(value) && isTRUE(value$selectAll)) {
303+
deselected <- as.integer(unlist(value$deselected))
304+
value <- setdiff(seq_len(value$rowCount), deselected)
305+
}
306+
value
300307
}
301308

302309
props <- c("page", "pageSize", "pages", "sorted", "selected")

srcjs/Reactable.js

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,14 +1384,25 @@ function Table({
13841384

13851385
const rowsById = instance.preFilteredRowsById || instance.rowsById
13861386
const selectedRowIndexes = React.useMemo(() => {
1387+
if (state.selectAllRows) {
1388+
// Inverted mode: all non-grouped rows are selected unless in deselectedRowIds.
1389+
// For backend mode, rowsById only has current page's rows, so this returns
1390+
// only the visible selected indices. Full resolution for Shiny happens separately.
1391+
return Object.values(rowsById).reduce((indexes, row) => {
1392+
if (!row.isGrouped && !state.deselectedRowIds[row.id]) {
1393+
indexes.push(row.index)
1394+
}
1395+
return indexes
1396+
}, [])
1397+
}
13871398
return Object.keys(state.selectedRowIds).reduce((indexes, id) => {
13881399
const row = rowsById[id]
13891400
if (row) {
13901401
indexes.push(row.index)
13911402
}
13921403
return indexes
13931404
}, [])
1394-
}, [state.selectedRowIds, rowsById])
1405+
}, [state.selectedRowIds, state.selectAllRows, state.deselectedRowIds, rowsById])
13951406

13961407
// Update Shiny on selected row changes (deprecated in v0.2.0)
13971408
React.useEffect(() => {
@@ -2260,28 +2271,41 @@ function Table({
22602271
sorted[sortInfo.id] = sortInfo.desc ? 'desc' : 'asc'
22612272
}
22622273

2274+
// For inverted selection (select-all active), send a self-contained inverted
2275+
// representation so R can resolve it without needing separate state storage.
2276+
let selected
2277+
if (state.selectAllRows) {
2278+
// Send deselected row indices (0-based __state.id values converted to 1-based R indices)
2279+
const deselectedIndexes = Object.keys(state.deselectedRowIds).map(id => Number(id) + 1)
2280+
selected = { selectAll: true, deselected: deselectedIndexes, rowCount: serverRowCount }
2281+
} else {
2282+
selected = selectedIndexes
2283+
}
2284+
22632285
// NOTE: any object arrays will be simplified into vectors by jsonlite by default. Avoid sending
22642286
// arrays without transforming them first, or adding a custom input type and input handler.
2265-
const state = {
2287+
const shinyState = {
22662288
page: page,
22672289
pageSize: stateInfo.pageSize,
22682290
pages: stateInfo.pages,
22692291
sorted: sorted,
2270-
selected: selectedIndexes
2292+
selected: selected
22712293
}
22722294
// Shiny.onInputChange has built-in debouncing, so it's not strictly necessary to
22732295
// debounce rapid state changes here.
2274-
Object.keys(state).forEach(prop => {
2296+
Object.keys(shinyState).forEach(prop => {
22752297
// NOTE: output IDs must always come first to work with Shiny modules
2276-
window.Shiny.onInputChange(`${outputId}__reactable__${prop}`, state[prop])
2298+
window.Shiny.onInputChange(`${outputId}__reactable__${prop}`, shinyState[prop])
22772299
})
22782300
}, [
22792301
nested,
22802302
stateInfo.page,
22812303
stateInfo.pageSize,
22822304
stateInfo.pages,
22832305
stateInfo.sorted,
2284-
stateInfo.selected
2306+
stateInfo.selected,
2307+
state.selectAllRows,
2308+
state.deselectedRowIds
22852309
])
22862310

22872311
// Getter for the latest page count

srcjs/__tests__/DuckDB.test.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,168 @@ describe('DuckDB backend', () => {
980980
const page1Checkboxes = getSelectRowCheckboxes(container)
981981
expect(page1Checkboxes[1].checked).toBe(true)
982982
})
983+
984+
it('select-all selects rows across all pages', async () => {
985+
const mockBackend = createMockBackend(10)
986+
987+
const firstPageData = {
988+
a: [1, 2, 3, 4, 5],
989+
b: ['row1', 'row2', 'row3', 'row4', 'row5']
990+
}
991+
992+
const { container } = render(
993+
<Reactable
994+
data={firstPageData}
995+
columns={baseColumns}
996+
backend="duckdb"
997+
arrowData="mock-base64-arrow-data"
998+
defaultPageSize={5}
999+
serverRowCount={10}
1000+
serverMaxRowCount={10}
1001+
selection="multiple"
1002+
/>
1003+
)
1004+
1005+
await waitFor(() => {
1006+
expect(mockBackend.init).toHaveBeenCalled()
1007+
})
1008+
1009+
// Click select-all checkbox (first checkbox in the header)
1010+
const checkboxes = getSelectRowCheckboxes(container)
1011+
const selectAllCheckbox = checkboxes[0]
1012+
fireEvent.click(selectAllCheckbox)
1013+
1014+
// All rows on current page should be selected
1015+
expect(selectAllCheckbox.checked).toBe(true)
1016+
for (let i = 1; i <= 5; i++) {
1017+
expect(checkboxes[i].checked).toBe(true)
1018+
}
1019+
1020+
// Navigate to page 2
1021+
await act(async () => {
1022+
fireEvent.click(getNextButton(container))
1023+
})
1024+
1025+
await waitFor(() => {
1026+
expect(mockBackend.query).toHaveBeenCalledWith(expect.objectContaining({ pageIndex: 1 }))
1027+
})
1028+
1029+
// Rows on page 2 should also be selected (cross-page select-all)
1030+
const page2Checkboxes = getSelectRowCheckboxes(container)
1031+
expect(page2Checkboxes[0].checked).toBe(true) // select-all still checked
1032+
for (let i = 1; i <= 5; i++) {
1033+
expect(page2Checkboxes[i].checked).toBe(true)
1034+
}
1035+
})
1036+
1037+
it('deselecting a row after select-all uses inverted model', async () => {
1038+
const mockBackend = createMockBackend(10)
1039+
1040+
const firstPageData = {
1041+
a: [1, 2, 3, 4, 5],
1042+
b: ['row1', 'row2', 'row3', 'row4', 'row5']
1043+
}
1044+
1045+
const { container } = render(
1046+
<Reactable
1047+
data={firstPageData}
1048+
columns={baseColumns}
1049+
backend="duckdb"
1050+
arrowData="mock-base64-arrow-data"
1051+
defaultPageSize={5}
1052+
serverRowCount={10}
1053+
serverMaxRowCount={10}
1054+
selection="multiple"
1055+
/>
1056+
)
1057+
1058+
await waitFor(() => {
1059+
expect(mockBackend.init).toHaveBeenCalled()
1060+
})
1061+
1062+
// Select all
1063+
const checkboxes = getSelectRowCheckboxes(container)
1064+
fireEvent.click(checkboxes[0])
1065+
expect(checkboxes[0].checked).toBe(true)
1066+
1067+
// Deselect the first row
1068+
fireEvent.click(checkboxes[1])
1069+
expect(checkboxes[1].checked).toBe(false)
1070+
1071+
// Select-all checkbox should now be indeterminate (not checked, not unchecked)
1072+
expect(checkboxes[0].checked).toBe(false)
1073+
1074+
// Other rows should still be selected
1075+
for (let i = 2; i <= 5; i++) {
1076+
expect(checkboxes[i].checked).toBe(true)
1077+
}
1078+
1079+
// Navigate to page 2 - rows should still be selected
1080+
await act(async () => {
1081+
fireEvent.click(getNextButton(container))
1082+
})
1083+
1084+
await waitFor(() => {
1085+
expect(mockBackend.query).toHaveBeenCalledWith(expect.objectContaining({ pageIndex: 1 }))
1086+
})
1087+
1088+
const page2Checkboxes = getSelectRowCheckboxes(container)
1089+
for (let i = 1; i <= 5; i++) {
1090+
expect(page2Checkboxes[i].checked).toBe(true)
1091+
}
1092+
})
1093+
1094+
it('deselect-all after select-all clears all selections', async () => {
1095+
const mockBackend = createMockBackend(10)
1096+
1097+
const firstPageData = {
1098+
a: [1, 2, 3, 4, 5],
1099+
b: ['row1', 'row2', 'row3', 'row4', 'row5']
1100+
}
1101+
1102+
const { container } = render(
1103+
<Reactable
1104+
data={firstPageData}
1105+
columns={baseColumns}
1106+
backend="duckdb"
1107+
arrowData="mock-base64-arrow-data"
1108+
defaultPageSize={5}
1109+
serverRowCount={10}
1110+
serverMaxRowCount={10}
1111+
selection="multiple"
1112+
/>
1113+
)
1114+
1115+
await waitFor(() => {
1116+
expect(mockBackend.init).toHaveBeenCalled()
1117+
})
1118+
1119+
// Select all, then deselect all
1120+
const checkboxes = getSelectRowCheckboxes(container)
1121+
fireEvent.click(checkboxes[0]) // select all
1122+
expect(checkboxes[0].checked).toBe(true)
1123+
fireEvent.click(checkboxes[0]) // deselect all
1124+
expect(checkboxes[0].checked).toBe(false)
1125+
1126+
// All rows should be deselected
1127+
for (let i = 1; i <= 5; i++) {
1128+
expect(checkboxes[i].checked).toBe(false)
1129+
}
1130+
1131+
// Navigate to page 2 - rows should also be deselected
1132+
await act(async () => {
1133+
fireEvent.click(getNextButton(container))
1134+
})
1135+
1136+
await waitFor(() => {
1137+
expect(mockBackend.query).toHaveBeenCalledWith(expect.objectContaining({ pageIndex: 1 }))
1138+
})
1139+
1140+
const page2Checkboxes = getSelectRowCheckboxes(container)
1141+
for (let i = 1; i <= 5; i++) {
1142+
expect(page2Checkboxes[i].checked).toBe(false)
1143+
}
1144+
})
9831145
})
9841146

9851147
// Unit tests for the DuckDBBackend class itself, with a mock conn.

0 commit comments

Comments
 (0)