From 01f0fba37014977b66b9f7b059872631397ad7be Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 8 Aug 2024 23:21:36 +0200 Subject: [PATCH] Correctly map Tabulator expanded indexes when paginated, filtered and sorted (#7103) --- panel/models/tabulator.ts | 21 +++-- panel/tests/widgets/test_tables.py | 94 +++++++++++++++++++++- panel/widgets/tables.py | 124 +++++++++++++++++------------ 3 files changed, 179 insertions(+), 60 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 563dbf4221..17736da80b 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -365,7 +365,7 @@ export class DataTabulatorView extends HTMLBoxView { for (const row of this.tabulator.rowManager.getRows()) { if (row.cells.length > 0) { const index = row.data._index - const icon = this.model.expanded.indexOf(index) < 0 ? "►" : "▼" + const icon = this.model.expanded.includes(index) ? "▼" : "►" row.cells[1].element.innerText = icon } } @@ -741,10 +741,17 @@ export class DataTabulatorView extends HTMLBoxView { const new_children = await this.build_child_views() resolve(new_children) }).then((new_children) => { - for (const r of this.model.expanded) { - const row = this.tabulator.getRow(r) + const rows = this.tabulator.getRows() + const lookup = new Map() + for (const row of rows) { const index = row._row?.data._index - if (this.model.children.get(index) == null) { + if (index != null) { + lookup.set(index, row) + } + } + for (const index of this.model.expanded) { + const row = lookup.get(index) + if (!this.model.children.has(index)) { continue } const model = this.model.children.get(index) @@ -798,10 +805,10 @@ export class DataTabulatorView extends HTMLBoxView { _update_expand(cell: any): void { const index = cell._cell.row.data._index const expanded = [...this.model.expanded] - const exp_index = expanded.indexOf(index) - if (exp_index < 0) { + if (!expanded.includes(index)) { expanded.push(index) } else { + const exp_index = expanded.indexOf(index) const removed = expanded.splice(exp_index, 1)[0] const model = this.model.children.get(removed) if (model != null) { @@ -812,7 +819,7 @@ export class DataTabulatorView extends HTMLBoxView { } } this.model.expanded = expanded - if (expanded.indexOf(index) < 0) { + if (!expanded.includes(index)) { return } let ready = true diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index b716522f21..b0d60f6933 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -339,6 +339,99 @@ def test_tabulator_expanded_content(document, comm): assert row2.text == "<pre>2.0</pre>" +def test_tabulator_remote_paginated_expanded_content(document, comm): + df = makeMixedDataFrame() + + table = Tabulator( + df, expanded=[0, 4], row_content=lambda r: r.A, pagination='remote', page_size=3 + ) + + model = table.get_root(document, comm) + + assert len(model.children) == 1 + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>0.0</pre>" + + table.page = 2 + + assert len(model.children) == 1 + assert 1 in model.children + row1 = model.children[1] + assert row1.text == "<pre>4.0</pre>" + + +def test_tabulator_remote_sorted_paginated_expanded_content(document, comm): + df = makeMixedDataFrame() + + table = Tabulator( + df, expanded=[0, 1], row_content=lambda r: r.A, pagination='remote', page_size=2, + sorters = [{'field': 'A', 'sorter': 'number', 'dir': 'desc'}], page=3 + ) + + model = table.get_root(document, comm) + + assert len(model.children) == 1 + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>0.0</pre>" + + table.page = 2 + + assert len(model.children) == 1 + assert 1 in model.children + row1 = model.children[1] + assert row1.text == "<pre>1.0</pre>" + + table.expanded = [0, 1, 2] + + assert len(model.children) == 2 + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>2.0</pre>" + + +@pytest.mark.parametrize('pagination', ['local', 'remote', None]) +def test_tabulator_filtered_expanded_content(document, comm, pagination): + df = makeMixedDataFrame() + + table = Tabulator( + df, + expanded=[0, 1, 2, 3], + filters=[{'field': 'B', 'sorter': 'number', 'type': '=', 'value': '1.0'}], + pagination=pagination, + row_content=lambda r: r.A, + ) + + model = table.get_root(document, comm) + + assert len(model.children) == 2 + + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>1.0</pre>" + + assert 1 in model.children + row1 = model.children[1] + assert row1.text == "<pre>3.0</pre>" + + model.expanded = [0] + assert table.expanded == [1] + + table.filters = [{'field': 'B', 'sorter': 'number', 'type': '=', 'value': '0'}] + + assert not model.expanded + assert table.expanded == [1] + + table.expanded = [0, 1] + + assert len(model.children) == 1 + + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>0.0</pre>" + + def test_tabulator_index_column(document, comm): df = pd.DataFrame({ 'int': [1, 2, 3], @@ -912,7 +1005,6 @@ def test_tabulator_empty_table(document, comm): assert table.value.shape == value_df.shape - def test_tabulator_sorters_unnamed_index(document, comm): df = pd.DataFrame(np.random.rand(10, 4)) assert df.columns.dtype == np.int64 diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index d9f2ac63f9..554a7fe397 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1237,6 +1237,7 @@ def __init__(self, value=None, **params): self.style = None self._computed_styler = None self._child_panels = {} + self._indexed_children = {} self._explicit_pagination = 'pagination' in params self._on_edit_callbacks = [] self._on_click_callbacks = {} @@ -1302,6 +1303,11 @@ def _cleanup(self, root: Model | None = None) -> None: p._cleanup(root) super()._cleanup(root) + def _process_events(self, events: dict[str, Any]) -> None: + if 'expanded' in events: + self._update_expanded(events.pop('expanded')) + return super()._process_events(events) + def _process_event(self, event) -> None: if event.event_name == 'selection-change': if self.pagination == 'remote': @@ -1498,27 +1504,50 @@ def _update_style(self, recompute=True): for ref, (m, _) in self._models.items(): self._apply_update([], msg, m, ref) - def _get_children(self, old={}): + def _get_children(self): if self.row_content is None or self.value is None: - return {} + return {}, [], [] from ..pane import panel df = self._processed if self.pagination == 'remote': nrows = self.page_size or self.initial_page_size start = (self.page-1)*nrows df = df.iloc[start:(start+nrows)] - children = {} - for i in (range(len(df)) if self.embed_content else self.expanded): - if i in old: - children[i] = old[i] - else: - children[i] = panel(self.row_content(df.iloc[i])) - return children - - def _get_model_children(self, panels, doc, root, parent, comm=None): + indexed_children, children = {}, {} + expanded = [] + if self.embed_content: + for i in range(len(df)): + expanded.append(i) + idx = df.index[i] + if idx in self._indexed_children: + child = self._indexed_children[idx] + else: + child = panel(self.row_content(df.iloc[i])) + indexed_children[idx] = children[i] = child + else: + for i in self.expanded: + idx = self.value.index[i] + if idx in self._indexed_children: + child = self._indexed_children[idx] + else: + child = panel(self.row_content(self.value.iloc[i])) + try: + loc = df.index.get_loc(idx) + except KeyError: + continue + expanded.append(loc) + indexed_children[idx] = children[loc] = child + removed = [ + child for idx, child in self._indexed_children.items() + if idx not in indexed_children + ] + self._indexed_children = indexed_children + return children, removed, expanded + + def _get_model_children(self, doc, root, parent, comm=None): ref = root.ref['id'] models = {} - for i, p in panels.items(): + for i, p in self._child_panels.items(): if ref in p._models: model = p._models[ref][0] else: @@ -1528,35 +1557,20 @@ def _get_model_children(self, panels, doc, root, parent, comm=None): return models def _update_children(self, *events): - cleanup, reuse = set(), set() - page_events = ('page', 'page_size', 'pagination') - old_panels = self._child_panels for event in events: - if event.name == 'expanded' and len(events) == 1: - if self.embed_content: - cleanup = set() - reuse = set(old_panels) - else: - cleanup = set(event.old) - set(event.new) - reuse = set(event.old) & set(event.new) - elif ( - (event.name == 'value' and self._indexes_changed(event.old, event.new)) or - (event.name in page_events and not self._updating) or - (self.pagination == 'remote' and event.name == 'sorters') - ): + if event.name == 'value' and self._indexes_changed(event.old, event.new): self.expanded = [] + self._indexed_children.clear() return - self._child_panels = child_panels = self._get_children( - {i: old_panels[i] for i in reuse} - ) + elif event.name == 'row_content': + self._indexed_children.clear() + self._child_panels, removed, expanded = self._get_children() for ref, (m, _) in self._models.items(): root, doc, comm = state._views[ref][1:] - for idx in cleanup: - old_panels[idx]._cleanup(root) - children = self._get_model_children( - child_panels, doc, root, m, comm - ) - msg = {'children': children} + for child_panel in removed: + child_panel._cleanup(root) + children = self._get_model_children(doc, root, m, comm) + msg = {'expanded': expanded, 'children': children} self._apply_update([], msg, m, ref) @updating @@ -1689,32 +1703,39 @@ 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] | SelectionEvent): - if isinstance(indices, list): - selected = True - ilocs = [] - else: # SelectionEvent - selected = indices.selected - ilocs = [] if indices.flush else self.selection.copy() - indices = indices.indices - + def _map_indexes(self, indexes, existing=[], add=True): if self.pagination == 'remote': nrows = self.page_size or self.initial_page_size start = (self.page-1)*nrows else: start = 0 - index = self._processed.iloc[[start+ind for ind in indices]].index + ilocs = list(existing) + index = self._processed.iloc[[start+ind for ind in indexes]].index for v in index.values: try: iloc = self.value.index.get_loc(v) self._validate_iloc(v, iloc) except KeyError: continue - if selected: + if add: ilocs.append(iloc) elif iloc in ilocs: ilocs.remove(iloc) - ilocs = list(dict.fromkeys(ilocs)) + return list(dict.fromkeys(ilocs)) + + def _update_expanded(self, expanded): + self.expanded = self._map_indexes(expanded) + + def _update_selection(self, indices: list[int] | SelectionEvent): + if isinstance(indices, list): + selected = True + ilocs = [] + else: + selected = indices.selected + ilocs = [] if indices.flush else self.selection.copy() + indices = indices.indices + + ilocs = self._map_indexes(indices, ilocs, add=selected) if isinstance(self.selectable, int) and not isinstance(self.selectable, bool): ilocs = ilocs[len(ilocs) - self.selectable:] self.selection = ilocs @@ -1765,10 +1786,9 @@ def _get_model( ) model = super()._get_model(doc, root, parent, comm) root = root or model - self._child_panels = child_panels = self._get_children() - model.children = self._get_model_children( - child_panels, doc, root, parent, comm - ) + self._child_panels, removed, expanded = self._get_children() + model.expanded = expanded + model.children = self._get_model_children(doc, root, parent, comm) self._link_props(model, ['page', 'sorters', 'expanded', 'filters', 'page_size'], doc, root, comm) self._register_events('cell-click', 'table-edit', 'selection-change', model=model, doc=doc, comm=comm) return model