diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 7d2d2bfc60..4c19ba9c8f 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -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): + """ 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' diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index a34a362c03..a2951ce254 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -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 { @@ -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 @@ -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) => { @@ -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 || @@ -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 } diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 731130a03f..5892e04529 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -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) diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index fd0f0f6719..c9b9139bc1 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -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): @@ -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 @@ -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 @@ -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 ) @@ -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 Dict[str, Any]: properties = super()._get_properties(doc) @@ -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]: