diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 4c19ba9c8f..b4a62a3e81 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -56,7 +56,7 @@ class SelectionEvent(ModelEvent): event_name = 'selection-change' - def __init__(self, model, indices, selected): + def __init__(self, model, indices, selected, flush): """ Selection Event Parameters @@ -67,14 +67,17 @@ def __init__(self, model, indices, selected): A list of changed indices selected/deselected rows. selected : bool If true the rows were selected, if false they were deselected. + flush : bool + Whether the current selection should be emptied before adding the new indices. """ self.indices = indices self.selected = selected + self.flush = flush super().__init__(model=model) def __repr__(self): return ( - f'{type(self).__name__}(indices={self.indices}, selected={self.selected})' + f'{type(self).__name__}(indices={self.indices}, selected={self.selected}, flush={self.flush})' ) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index a2951ce254..c7318144dd 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -44,12 +44,12 @@ export class CellClickEvent extends ModelEvent { } export class SelectionEvent extends ModelEvent { - constructor(readonly indices: number[], readonly selected: boolean) { + constructor(readonly indices: number[], readonly selected: boolean, readonly flush: boolean = false) { super() } protected get event_values(): Attrs { - return {model: this.origin, indices: this.indices, selected: this.selected} + return {model: this.origin, indices: this.indices, selected: this.selected, flush: this.flush} } static { @@ -1070,6 +1070,28 @@ export class DataTabulatorView extends HTMLBoxView { let indices: number[] = [] const selected = this.model.source.selected const index: number = row._row.data._index + + if (this.model.pagination === 'remote') { + const includes = this.model.source.selected.indices.indexOf(index) == -1 + const flush = !(e.ctrlKey || e.metaKey || e.shiftKey) + if (e.shiftKey && selected.indices.length) { + const start = selected.indices[selected.indices.length-1] + if (index>start) { + for (let i = start; i<=index; i++) + indices.push(i) + } else { + for (let i = start; i>=index; i--) + indices.push(i) + } + } else { + indices.push(index) + } + this._selection_updating = true + this.model.trigger_event(new SelectionEvent(indices, includes, flush)) + this._selection_updating = false + return + } + if (e.ctrlKey || e.metaKey) { indices = [...this.model.source.selected.indices] } else if (e.shiftKey && selected.indices.length) { @@ -1124,11 +1146,11 @@ export class DataTabulatorView extends HTMLBoxView { 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)) + this.model.trigger_event(new SelectionEvent(selected_indices, true, false)) } if (deselected_indices.length > 0) { this._selection_updating = true - this.model.trigger_event(new SelectionEvent(deselected_indices, selected=false)) + this.model.trigger_event(new SelectionEvent(deselected_indices, false, false)) } } else { const indices: number[] = data.map((row: any) => row._index) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 5892e04529..0604b6f0a6 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -2,6 +2,8 @@ import datetime as dt +from contextlib import contextmanager + import numpy as np import pandas as pd import param @@ -3279,17 +3281,19 @@ def test_tabulator_update_hidden_columns(page): ), page) -class Test_CheckboxSelection_RemotePagination: +class Test_RemotePagination: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup_widget(self, page): self.widget = Tabulator( value=pd.DataFrame(np.arange(20) + 100), disabled=True, pagination="remote", page_size=10, - selectable="checkbox", + selectable=self.selectable, header_filters=True, ) + serve_component(page, self.widget) def check_selected(self, page, expected, ui_count=None): if ui_count is None: @@ -3298,11 +3302,26 @@ def check_selected(self, page, expected, ui_count=None): 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"]') + @contextmanager + def hold_down_ctrl(self, page): + key = get_ctrl_modifier() + page.keyboard.down(key) + yield + page.keyboard.up(key) + + @contextmanager + def hold_down_shift(self, page): + key = "Shift" + page.keyboard.down(key) + yield + page.keyboard.up(key) + + def get_rows(self, page): + return page.locator('.tabulator-row[role="row"]') def goto_page(self, page, page_number): page.locator(f'button.tabulator-page[data-page="{page_number}"]').click() + page.wait_for_timeout(100) def click_sorting(self, page): page.locator('div.tabulator-col-title').get_by_text("index").click() @@ -3313,8 +3332,210 @@ def set_filtering(self, page, number): number_input.fill(str(number)) number_input.press("Enter") + +class Test_RemotePagination_Selection(Test_RemotePagination): + selectable = True + + def test_one_item_first_page(self, page): + rows = self.get_rows(page) + + rows.nth(0).click() + self.check_selected(page, [0]) + + with self.hold_down_ctrl(page): + rows.nth(0).click() + self.check_selected(page, []) + + def test_one_item_first_page_and_then_another(self, page): + rows = self.get_rows(page) + + rows.nth(0).click() + self.check_selected(page, [0]) + + rows.nth(1).click() + self.check_selected(page, [1]) + + def test_two_items_first_page(self, page): + rows = self.get_rows(page) + + rows.nth(0).click() + self.check_selected(page, [0]) + + with self.hold_down_ctrl(page): + rows.nth(1).click() + self.check_selected(page, [0, 1]) + + def test_one_item_first_page_goto_second_page(self, page): + rows = self.get_rows(page) + + rows.nth(0).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): + self.widget.selection = [0, 10] + self.check_selected(page, [0, 10], 1) + + self.goto_page(page, 2) + self.check_selected(page, [0, 10], 1) + + def test_one_item_both_pages(self, page): + rows = self.get_rows(page) + rows.nth(0).click() + self.check_selected(page, [0], 1) + + self.goto_page(page, 2) + rows = self.get_rows(page) + with self.hold_down_ctrl(page): + rows.nth(0).click() + self.check_selected(page, [0, 10], 1) + + def test_one_item_and_then_second_page(self, page): + rows = self.get_rows(page) + rows.nth(0).click() + self.check_selected(page, [0], 1) + + self.goto_page(page, 2) + rows = self.get_rows(page) + rows.nth(0).click() + self.check_selected(page, [10], 1) + + @pytest.mark.parametrize("selection", (0, 10), ids=["page1", "page2"]) + def test_sorting(self, page, selection): + self.widget.selection = [selection] + 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)) + + @pytest.mark.parametrize("selection", (0, 10), ids=["page1", "page2"]) + def test_filtering(self, page, selection): + self.widget.selection = [selection] + 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_shift_select_page_1(self, page): + rows = self.get_rows(page) + with self.hold_down_shift(page): + rows.nth(0).click() + rows.nth(2).click() + self.check_selected(page, [0, 1, 2]) + + self.goto_page(page, 2) + self.check_selected(page, [0, 1, 2], 0) + + self.goto_page(page, 1) + self.check_selected(page, [0, 1, 2]) + + def test_shift_select_page_2(self, page): + self.check_selected(page, []) + + self.goto_page(page, 2) + rows = self.get_rows(page) + with self.hold_down_shift(page): + rows.nth(0).click() + rows.nth(2).click() + self.check_selected(page, [10, 11, 12]) + + self.goto_page(page, 1) + self.check_selected(page, [10, 11, 12], 0) + + def test_shift_select_both_pages(self, page): + rows = self.get_rows(page) + with self.hold_down_shift(page): + rows.nth(0).click() + rows.nth(2).click() + self.check_selected(page, [0, 1, 2]) + + self.goto_page(page, 2) + rows = self.get_rows(page) + with self.hold_down_shift(page): + rows.nth(0).click() + rows.nth(2).click() + self.check_selected(page, [0, 1, 2, 10, 11, 12], 3) + + self.goto_page(page, 1) + self.check_selected(page, [0, 1, 2, 10, 11, 12], 3) + + +class Test_RemotePagination_NumberSelection(Test_RemotePagination): + selectable = 2 + + def test_selectable_integer_page_1(self, page): + rows = self.get_rows(page) + with self.hold_down_ctrl(page): + rows.nth(0).click() + rows.nth(1).click() + self.check_selected(page, [0, 1]) + + with self.hold_down_ctrl(page): + rows.nth(2).click() + self.check_selected(page, [1, 2]) + + self.goto_page(page, 2) + self.check_selected(page, [1, 2], 0) + + self.goto_page(page, 1) + self.check_selected(page, [1, 2]) + + def test_selectable_integer_page_2(self, page): + self.goto_page(page, 2) + rows = self.get_rows(page) + with self.hold_down_ctrl(page): + rows.nth(0).click() + rows.nth(1).click() + self.check_selected(page, [10, 11]) + + with self.hold_down_ctrl(page): + rows.nth(2).click() + self.check_selected(page, [11, 12]) + + self.goto_page(page, 1) + self.check_selected(page, [11, 12], 0) + + def test_selectable_integer_both_pages(self, page): + rows = self.get_rows(page) + with self.hold_down_ctrl(page): + rows.nth(0).click() + rows.nth(1).click() + self.check_selected(page, [0, 1]) + + self.goto_page(page, 2) + rows = self.get_rows(page) + with self.hold_down_ctrl(page): + rows.nth(0).click() + self.check_selected(page, [1, 10], 1) + + self.goto_page(page, 1) + self.check_selected(page, [1, 10], 1) + + +class Test_RemotePagination_CheckboxSelection(Test_RemotePagination): + selectable="checkbox" + + def get_checkboxes(self, page): + return page.locator('input[type="checkbox"]') + def test_full_firstpage(self, page): - serve_component(page, self.widget) checkboxes = self.get_checkboxes(page) # Select all items on page @@ -3326,7 +3547,6 @@ def test_full_firstpage(self, page): 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() @@ -3336,7 +3556,6 @@ def test_one_item_first_page(self, page): 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() @@ -3349,8 +3568,6 @@ def test_one_item_first_page_goto_second_page(self, page): 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) @@ -3360,7 +3577,6 @@ def test_one_item_both_pages_python(self, page): @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 @@ -3376,7 +3592,6 @@ def test_sorting(self, page, selection): 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 @@ -3397,7 +3612,6 @@ def test_sorting_all(self, page): @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) @@ -3407,7 +3621,6 @@ def test_filtering(self, page, selection): 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 diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index b578be0279..843217a010 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1537,11 +1537,7 @@ 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 - ): + elif self.pagination == 'remote': self._processed = None recompute = not all( e.name in ('page', 'page_size', 'pagination') for e in events @@ -1615,8 +1611,8 @@ def _update_selection(self, indices: List[int] | SelectionEvent): ilocs = [] else: # SelectionEvent selected = indices.selected + ilocs = [] if indices.flush else self.selection.copy() indices = indices.indices - ilocs = self.selection nrows = self.page_size start = (self.page-1)*nrows @@ -1629,9 +1625,12 @@ def _update_selection(self, indices: List[int] | SelectionEvent): continue if selected: ilocs.append(iloc) - else: + elif iloc in ilocs: ilocs.remove(iloc) - self.selection = list(dict.fromkeys(ilocs)) + ilocs = list(dict.fromkeys(ilocs)) + if isinstance(self.selectable, int) and not isinstance(self.selectable, bool): + ilocs = ilocs[len(ilocs) - self.selectable:] + self.selection = ilocs def _get_properties(self, doc: Document) -> Dict[str, Any]: properties = super()._get_properties(doc)