Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable selection across different pages with pagination="remote" and selectable="checkbox" #5889

Merged
merged 15 commits into from
Nov 21, 2023
26 changes: 26 additions & 0 deletions panel/models/tabulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,32 @@ def __repr__(self):
f'value={self.value}, old={self.old})'
)

class SelectionEvent(ModelEvent):

event_name = 'selection-change'

def __init__(self, model, indices, selected):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of indices, selected, how about having selected, deselected, each one of the latter being a list (empty or not) of indices? Asking since it wasn't first obvious to me what selected would hold.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not the biggest fan of the names I have chosen.

Your suggestion could also be confusing since the indices are only updated ones and not a full list of selected/deselected indices.

@philippjfr, any preferences?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No real preference, a comment to clarify is probably more important than the naming here since these events won't be user facing.

""" Selection Event

Parameters
----------
model : ModelEvent
An event send when a selection is changed on the frontend.
indices : list[int]
A list of changed indices selected/deselected rows.
selected : bool
If true the rows were selected, if false they were deselected.
"""
self.indices = indices
self.selected = selected
super().__init__(model=model)

def __repr__(self):
return (
f'{type(self).__name__}(indices={self.indices}, selected={self.selected})'
)


class CellClickEvent(ModelEvent):

event_name = 'cell-click'
Expand Down
46 changes: 39 additions & 7 deletions panel/models/tabulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export class CellClickEvent extends ModelEvent {
}
}

export class SelectionEvent extends ModelEvent {
constructor(readonly indices: number[], readonly selected: boolean) {
super()
}

protected get event_values(): Attrs {
return {model: this.origin, indices: this.indices, selected: this.selected}
}

static {
this.prototype.event_name = "selection-change"
}
}

declare const Tabulator: any;

function find_group(key: any, value: string, records: any[]): any {
Expand Down Expand Up @@ -352,7 +366,12 @@ export class DataTabulatorView extends HTMLBoxView {
this.connect(p.frozen_rows.change, () => this.setFrozen())
this.connect(p.sorters.change, () => this.setSorters())
this.connect(p.theme_classes.change, () => this.setCSSClasses(this.tabulator.element))
this.connect(this.model.source.properties.data.change, () => this.setData())
this.connect(this.model.source.properties.data.change, () => {
this._selection_updating = true
this.setData()
this._selection_updating = false
this.postUpdate()
})
this.connect(this.model.source.streaming, () => this.addData())
this.connect(this.model.source.patching, () => {
const inds = this.model.source.selected.indices
Expand Down Expand Up @@ -475,7 +494,7 @@ export class DataTabulatorView extends HTMLBoxView {
}, 50, false))

// Sync state with model
this.tabulator.on("rowSelectionChanged", (data: any, rows: any) => this.rowSelectionChanged(data, rows))
this.tabulator.on("rowSelectionChanged", (data: any, rows: any, selected: any, deselected: any) => this.rowSelectionChanged(data, rows, selected, deselected))
this.tabulator.on("rowClick", (e: any, row: any) => this.rowClicked(e, row))
this.tabulator.on("cellEdited", (cell: any) => this.cellEdited(cell))
this.tabulator.on("dataFiltering", (filters: any) => {
Expand Down Expand Up @@ -1091,7 +1110,7 @@ export class DataTabulatorView extends HTMLBoxView {
return filtered
}

rowSelectionChanged(data: any, _: any): void {
rowSelectionChanged(data: any, _row: any, selected: any, deselected: any): void {
if (
this._selection_updating ||
this._initializing ||
Expand All @@ -1100,10 +1119,23 @@ export class DataTabulatorView extends HTMLBoxView {
this.model.configuration.dataTree
)
return
const indices: number[] = data.map((row: any) => row._index)
const filtered = this._filter_selected(indices)
this._selection_updating = indices.length === filtered.length
this.model.source.selected.indices = filtered
if (this.model.pagination === 'remote') {
let selected_indices = selected.map((x: any) => x._row.data._index)
let deselected_indices = deselected.map((x: any) => x._row.data._index)
if (selected_indices.length > 0) {
this._selection_updating = true
this.model.trigger_event(new SelectionEvent(selected_indices, selected=true))
}
if (deselected_indices.length > 0) {
this._selection_updating = true
this.model.trigger_event(new SelectionEvent(deselected_indices, selected=false))
}
} else {
const indices: number[] = data.map((row: any) => row._index)
const filtered = this._filter_selected(indices)
this._selection_updating = indices.length === filtered.length
this.model.source.selected.indices = filtered
}
this._selection_updating = false
}

Expand Down
144 changes: 144 additions & 0 deletions panel/tests/ui/widgets/test_tabulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3277,3 +3277,147 @@ def test_tabulator_update_hidden_columns(page):
(title.bounding_box()['x'] == cell.bounding_box()['x']) and
(title.bounding_box()['width'] == cell.bounding_box()['width'])
), page)


class Test_CheckboxSelection_RemotePagination:

def setup_method(self):
self.widget = Tabulator(
value=pd.DataFrame(np.arange(20) + 100),
disabled=True,
pagination="remote",
page_size=10,
selectable="checkbox",
header_filters=True,
)

def check_selected(self, page, expected, ui_count=None):
if ui_count is None:
ui_count = len(expected)

expect(page.locator('.tabulator-selected')).to_have_count(ui_count)
wait_until(lambda: self.widget.selection == expected, page)

def get_checkboxes(self, page):
return page.locator('input[type="checkbox"]')

def goto_page(self, page, page_number):
page.locator(f'button.tabulator-page[data-page="{page_number}"]').click()

def click_sorting(self, page):
page.locator('div.tabulator-col-title').get_by_text("index").click()
page.wait_for_timeout(100)

def set_filtering(self, page, number):
number_input = page.locator('input[type="number"]').first
number_input.fill(str(number))
number_input.press("Enter")

def test_full_firstpage(self, page):
serve_component(page, self.widget)
checkboxes = self.get_checkboxes(page)

# Select all items on page
checkboxes.nth(0).click()
self.check_selected(page, list(range(10)))

# Deselect last one
checkboxes.last.click()
self.check_selected(page, list(range(9)))

def test_one_item_first_page(self, page):
serve_component(page, self.widget)
checkboxes = self.get_checkboxes(page)

checkboxes.nth(1).click()
self.check_selected(page, [0])

checkboxes.nth(1).click()
self.check_selected(page, [])

def test_one_item_first_page_goto_second_page(self, page):
serve_component(page, self.widget)
checkboxes = self.get_checkboxes(page)

checkboxes.nth(1).click()
self.check_selected(page, [0], 1)

self.goto_page(page, 2)
self.check_selected(page, [0], 0)

self.goto_page(page, 1)
self.check_selected(page, [0], 1)

def test_one_item_both_pages_python(self, page):
serve_component(page, self.widget)

self.widget.selection = [0, 10]
self.check_selected(page, [0, 10], 1)

self.goto_page(page, 2)
self.check_selected(page, [0, 10], 1)

@pytest.mark.parametrize("selection", (0, 10), ids=["page1", "page2"])
def test_sorting(self, page, selection):
self.widget.selection = [selection]
serve_component(page, self.widget)
self.check_selected(page, [selection], int(selection == 0))

# First sort ascending
self.click_sorting(page)
self.check_selected(page, [selection], int(selection == 0))

# Then sort descending
self.click_sorting(page)
self.check_selected(page, [selection], int(selection == 10))

# Then back to ascending
self.click_sorting(page)
self.check_selected(page, [selection], int(selection == 0))

def test_sorting_all(self, page):
serve_component(page, self.widget)
checkboxes = self.get_checkboxes(page)

# Select all items on page
checkboxes.nth(0).click()

# First sort ascending
self.click_sorting(page)
self.check_selected(page, list(range(10)), 10)

# Then sort descending
self.click_sorting(page)
self.check_selected(page, list(range(10)), 0)

# Then back to ascending
self.click_sorting(page)
self.check_selected(page, list(range(10)), 10)

@pytest.mark.parametrize("selection", (0, 10), ids=["page1", "page2"])
def test_filtering(self, page, selection):
self.widget.selection = [selection]
serve_component(page, self.widget)
self.check_selected(page, [selection], int(selection == 0))

self.set_filtering(page, selection)
self.check_selected(page, [selection], 1)

self.set_filtering(page, 1)
self.check_selected(page, [selection], 0)

def test_filtering_all(self, page):
serve_component(page, self.widget)
checkboxes = self.get_checkboxes(page)

# Select all items on page
checkboxes.nth(0).click()

for n in range(10):
self.set_filtering(page, n)
self.check_selected(page, list(range(10)), 1)

for n in range(10, 20):
self.set_filtering(page, n)
self.check_selected(page, list(range(10)), 0)
expect(page.locator('.tabulator')).to_have_count(1)
52 changes: 38 additions & 14 deletions panel/widgets/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
from bokeh.models.sources import DataDict
from pyviz_comms import Comm

from ..models.tabulator import CellClickEvent, TableEditEvent
from ..models.tabulator import (
CellClickEvent, SelectionEvent, TableEditEvent,
)


def _convert_datetime_array_ignore_list(v):
Expand Down Expand Up @@ -1241,7 +1243,11 @@ def _cleanup(self, root: Model | None = None) -> None:
p._cleanup(root)
super()._cleanup(root)

def _process_event(self, event):
def _process_event(self, event) -> None:
if event.event_name == 'selection-change':
self._update_selection(event)
return

event_col = self._renamed_cols.get(event.column, event.column)
if self.pagination == 'remote':
nrows = self.page_size
Expand Down Expand Up @@ -1288,7 +1294,6 @@ def _get_theme(self, theme, resources=None):
return theme_url

def _update_columns(self, event, model):
print(event.name)
if event.name not in self._config_params:
super()._update_columns(event, model)
if (event.name in ('editors', 'formatters', 'sortable') and
Expand Down Expand Up @@ -1532,6 +1537,12 @@ def _update_cds(self, *events):
elif events and all(e.name in page_events for e in events) and not self.pagination:
self._processed, _ = self._get_data()
return
elif (
self.pagination == 'remote'
and isinstance(self.selectable, str)
and "checkbox" in self.selectable
):
self._processed = None
recompute = not all(
e.name in ('page', 'page_size', 'pagination') for e in events
)
Expand All @@ -1558,20 +1569,23 @@ def _update_max_page(self):
def _update_selected(self, *events: param.parameterized.Event, indices=None):
kwargs = {}
if self.pagination == 'remote' and self.value is not None:
# Compute integer indexes of the selected rows
# on the displayed page
index = self.value.iloc[self.selection].index
indices = []
for v in index.values:
for ind in index.values:
try:
iloc = self._processed.index.get_loc(v)
self._validate_iloc(v ,iloc)
indices.append(iloc)
iloc = self._processed.index.get_loc(ind)
self._validate_iloc(ind ,iloc)
indices.append((ind, iloc))
except KeyError:
continue
nrows = self.page_size
start = (self.page-1)*nrows
end = start+nrows
kwargs['indices'] = [ind-start for ind in indices
if ind>=start and ind<end]
p_range = self._processed.index[start:end]
kwargs['indices'] = [iloc - start for ind, iloc in indices
if ind in p_range]
super()._update_selected(*events, **kwargs)

def _update_column(self, column: str, array: np.ndarray):
Expand All @@ -1592,22 +1606,32 @@ def _update_column(self, column: str, array: np.ndarray):
with pd.option_context('mode.chained_assignment', None):
self._processed.loc[index, column] = array

def _update_selection(self, indices: List[int]):
def _update_selection(self, indices: List[int] | SelectionEvent):
if self.pagination != 'remote':
self.selection = indices
return
if isinstance(indices, list):
selected = True
ilocs = []
else: # SelectionEvent
selected = indices.selected
indices = indices.indices
ilocs = self.selection

nrows = self.page_size
start = (self.page-1)*nrows
index = self._processed.iloc[[start+ind for ind in indices]].index
indices = []
for v in index.values:
try:
iloc = self.value.index.get_loc(v)
self._validate_iloc(v, iloc)
indices.append(iloc)
except KeyError:
continue
self.selection = indices
if selected:
ilocs.append(iloc)
else:
ilocs.remove(iloc)
self.selection = list(dict.fromkeys(ilocs))

def _get_properties(self, doc: Document) -> Dict[str, Any]:
properties = super()._get_properties(doc)
Expand Down Expand Up @@ -1659,7 +1683,7 @@ def _get_model(
child_panels, doc, root, parent, comm
)
self._link_props(model, ['page', 'sorters', 'expanded', 'filters'], doc, root, comm)
self._register_events('cell-click', 'table-edit', model=model, doc=doc, comm=comm)
self._register_events('cell-click', 'table-edit', 'selection-change', model=model, doc=doc, comm=comm)
return model

def _get_filter_spec(self, column: TableColumn) -> Dict[str, Any]:
Expand Down