From 77ced7be8be6ab55f446b0af41122692f7a48977 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 29 Oct 2024 11:38:43 +0700 Subject: [PATCH 01/28] aggregators: allow nested dict --- panel/models/tabulator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index a98b5b0f84..bee0e54bdc 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -113,7 +113,7 @@ class DataTabulator(HTMLBox): See http://tabulator.info/ """ - aggregators = Dict(String, String) + aggregators = Dict(String, Any) buttons = Dict(String, String) From ffde6ac296ae4cb672c094f1ec14c900b78d7c69 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 29 Oct 2024 14:08:17 +0700 Subject: [PATCH 02/28] aggregating data in column by agg method --- panel/models/tabulator.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 51867a87b4..e09f7eb553 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -78,7 +78,6 @@ function summarize(grouped: any[], columns: any[], aggregators: string[], depth: if (grouped.length == 0) { return summary } - const agg = aggregators[depth] for (const group of grouped) { const subsummary = summarize(group._children, columns, aggregators, depth+1) for (const col in subsummary) { @@ -90,6 +89,7 @@ function summarize(grouped: any[], columns: any[], aggregators: string[], depth: } for (const column of columns.slice(1)) { const val = group[column.field] + const agg = aggregators[column.field] if (column.field in summary) { const old_val = summary[column.field] if (agg === "min") { @@ -143,9 +143,25 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato subgroup[column.field] = record[column.field] } } - const aggs = [] + const aggs: any = {} + let nested = false for (const index of indexes) { - aggs.push((index in aggregators) ? aggregators[index] : "sum") + if (index in aggregators) { + nested = true + const aggs_dict = aggregators[index] + for (const column of columns) { + if (column.field in aggs_dict) { + aggs[column.field] = aggs_dict[column.field] + } + } + } + } + if (!nested) { + for (const column of columns) { + if (column.field in aggregators) { + aggs[column.field] = aggregators[column.field] + } + } } summarize(grouped, columns, aggs) return grouped From a1f6253c00b67793efbd3919f2df4098a52ff674 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Fri, 1 Nov 2024 20:08:20 +0700 Subject: [PATCH 03/28] removed unused depth --- panel/models/tabulator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index e09f7eb553..0959485742 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -73,13 +73,13 @@ function find_group(key: any, value: string, records: any[]): any { return null } -function summarize(grouped: any[], columns: any[], aggregators: string[], depth: number = 0): any { +function summarize(grouped: any[], columns: any[], aggregators: string[]): any { const summary: any = {} if (grouped.length == 0) { return summary } for (const group of grouped) { - const subsummary = summarize(group._children, columns, aggregators, depth+1) + const subsummary = summarize(group._children, columns, aggregators) for (const col in subsummary) { if (isArray(subsummary[col])) { group[col] = sum(subsummary[col] as number[]) / subsummary[col].length From cb5680b0527555f38be70fd9ffc3a29601eb7e12 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Sun, 3 Nov 2024 22:02:03 +0700 Subject: [PATCH 04/28] validate aggs --- panel/widgets/tables.py | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 6feb6d61e5..88e5367f99 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1272,6 +1272,64 @@ def __init__(self, value=None, **params): self.style._todo = style._todo self.param.selection.callable = self._get_selectable + @param.depends('aggregators', 'value', watch=True, on_init=True) + def _validate_aggregators(self): + + def _validate_aggs( + df: pd.DataFrame, + aggregators: dict, + ) -> list[str]: + import pandas as pd + + aggs_by_types = { + 'number': ['sum', 'mean', 'min', 'max'], + 'datetime': ['min', 'max'], + 'string': ['min', 'max'], + 'boolean': ['sum', 'mean'] # sum and mean will treat True as 1 and False as 0 + } + + for key, value in aggregators.items(): + # Check if value is a dictionary (nested) + if isinstance(value, dict): + # Recursively validate nested dictionary + _validate_aggs(df, value) + else: + # Handle non-nested case (leaf node) + if key not in df.columns: + raise ValueError( + f'Error validating `aggregators`, column {key} not found.' + ) + + # Determine the data type of the column + dtype = df[key].dtype + if pd.api.types.is_numeric_dtype(dtype): + dtype_category = 'number' + elif pd.api.types.is_datetime64_any_dtype(dtype): + dtype_category = 'datetime' + elif pd.api.types.is_bool_dtype(dtype): + dtype_category = 'boolean' + elif pd.api.types.is_string_dtype(dtype): + dtype_category = 'string' + else: + dtype_category = None + + # Validate aggregator method based on data type + if dtype_category: + valid_aggregators = aggs_by_types[dtype_category] + if value not in valid_aggregators: + raise ValueError( + f"Invalid aggregator '{value}' for column '{key}' " + f"of type '{dtype_category}'. Valid options are: {valid_aggregators}." + ) + else: + raise ValueError(f"Unsupported aggregating on column '{key}' of type {dtype}.") + + if self.value is None or self.value.empty: + pass + if not self.aggregators: + pass + _validate_aggs(self.value, self.aggregators) + @param.depends('value', watch=True, on_init=True) def _apply_max_size(self): """ From d0022d6a29953a4031dbd1893640c6c93464ff4e Mon Sep 17 00:00:00 2001 From: thuydotm Date: Sun, 3 Nov 2024 22:06:38 +0700 Subject: [PATCH 05/28] aggregating by dtypes --- panel/models/tabulator.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 0959485742..99d6a64d8b 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -84,25 +84,43 @@ function summarize(grouped: any[], columns: any[], aggregators: string[]): any { if (isArray(subsummary[col])) { group[col] = sum(subsummary[col] as number[]) / subsummary[col].length } else { - group[col] = subsummary[col] + if (col in aggregators) { + group[col] = subsummary[col] + } + else { + group[col] = NaN + } } } for (const column of columns.slice(1)) { const val = group[column.field] const agg = aggregators[column.field] + if (column.field in summary) { - const old_val = summary[column.field] + const old_val = summary[column.field]; + // Apply aggregation based on type if (agg === "min") { - summary[column.field] = Math.min(val, old_val) + if (val instanceof Date && old_val instanceof Date) { + summary[column.field] = new Date(Math.min(val.getTime(), old_val.getTime())); + } else { + summary[column.field] = val < old_val ? val : old_val; + } } else if (agg === "max") { - summary[column.field] = Math.max(val, old_val) + if (val instanceof Date && old_val instanceof Date) { + summary[column.field] = new Date(Math.max(val.getTime(), old_val.getTime())); + } else { + summary[column.field] = val > old_val ? val : old_val; + } } else if (agg === "sum") { - summary[column.field] = val + old_val + if (typeof val === "boolean" && typeof old_val === "boolean") { + summary[column.field] = (val ? 1 : 0) + (old_val ? 1 : 0); + } else if (typeof val === "number" && typeof old_val === "number") { + summary[column.field] = val + old_val; } else if (agg === "mean") { - if (isArray(summary[column.field])) { - summary[column.field].push(val) + if (Array.isArray(summary[column.field])) { + summary[column.field].push(val); } else { - summary[column.field] = [old_val, val] + summary[column.field] = [old_val, val]; } } } else { From bcb21399f1eb66bf13b853cb4cab0a8299f2c4b9 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Mon, 4 Nov 2024 10:31:10 +0700 Subject: [PATCH 06/28] validate nested aggs --- panel/widgets/tables.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 88e5367f99..c28f913eb5 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1291,6 +1291,11 @@ def _validate_aggs( for key, value in aggregators.items(): # Check if value is a dictionary (nested) if isinstance(value, dict): + # check if key is an index of the df + if key not in df.index.names: + raise ValueError( + f'Error validating `aggregators`, {key} is not an index of the dataframe.' + ) # Recursively validate nested dictionary _validate_aggs(df, value) else: From 6216ba60b113b3884237bbe746d22379959ba81c Mon Sep 17 00:00:00 2001 From: thuydotm Date: Mon, 4 Nov 2024 15:53:09 +0700 Subject: [PATCH 07/28] cleanup --- panel/models/tabulator.ts | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 99d6a64d8b..b5697222e2 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -84,12 +84,7 @@ function summarize(grouped: any[], columns: any[], aggregators: string[]): any { if (isArray(subsummary[col])) { group[col] = sum(subsummary[col] as number[]) / subsummary[col].length } else { - if (col in aggregators) { - group[col] = subsummary[col] - } - else { - group[col] = NaN - } + group[col] = subsummary[col] } } for (const column of columns.slice(1)) { @@ -98,23 +93,11 @@ function summarize(grouped: any[], columns: any[], aggregators: string[]): any { if (column.field in summary) { const old_val = summary[column.field]; - // Apply aggregation based on type if (agg === "min") { - if (val instanceof Date && old_val instanceof Date) { - summary[column.field] = new Date(Math.min(val.getTime(), old_val.getTime())); - } else { - summary[column.field] = val < old_val ? val : old_val; - } + summary[column.field] = (val < old_val) ? val : old_val; } else if (agg === "max") { - if (val instanceof Date && old_val instanceof Date) { - summary[column.field] = new Date(Math.max(val.getTime(), old_val.getTime())); - } else { - summary[column.field] = val > old_val ? val : old_val; - } + summary[column.field] = (val > old_val) ? val : old_val; } else if (agg === "sum") { - if (typeof val === "boolean" && typeof old_val === "boolean") { - summary[column.field] = (val ? 1 : 0) + (old_val ? 1 : 0); - } else if (typeof val === "number" && typeof old_val === "number") { summary[column.field] = val + old_val; } else if (agg === "mean") { if (Array.isArray(summary[column.field])) { From 7ee7c1be174b5ac91f303da5c8c124f7498d4903 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 5 Nov 2024 16:05:58 +0700 Subject: [PATCH 08/28] remove validating aggs --- panel/models/tabulator.ts | 12 ++++---- panel/widgets/tables.py | 63 --------------------------------------- 2 files changed, 6 insertions(+), 69 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index b5697222e2..151e5929ed 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -92,18 +92,18 @@ function summarize(grouped: any[], columns: any[], aggregators: string[]): any { const agg = aggregators[column.field] if (column.field in summary) { - const old_val = summary[column.field]; + const old_val = summary[column.field] if (agg === "min") { - summary[column.field] = (val < old_val) ? val : old_val; + summary[column.field] = (val < old_val) ? val : old_val } else if (agg === "max") { - summary[column.field] = (val > old_val) ? val : old_val; + summary[column.field] = (val > old_val) ? val : old_val } else if (agg === "sum") { - summary[column.field] = val + old_val; + summary[column.field] = val + old_val } else if (agg === "mean") { if (Array.isArray(summary[column.field])) { - summary[column.field].push(val); + summary[column.field].push(val) } else { - summary[column.field] = [old_val, val]; + summary[column.field] = [old_val, val] } } } else { diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index c28f913eb5..6feb6d61e5 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -1272,69 +1272,6 @@ def __init__(self, value=None, **params): self.style._todo = style._todo self.param.selection.callable = self._get_selectable - @param.depends('aggregators', 'value', watch=True, on_init=True) - def _validate_aggregators(self): - - def _validate_aggs( - df: pd.DataFrame, - aggregators: dict, - ) -> list[str]: - import pandas as pd - - aggs_by_types = { - 'number': ['sum', 'mean', 'min', 'max'], - 'datetime': ['min', 'max'], - 'string': ['min', 'max'], - 'boolean': ['sum', 'mean'] # sum and mean will treat True as 1 and False as 0 - } - - for key, value in aggregators.items(): - # Check if value is a dictionary (nested) - if isinstance(value, dict): - # check if key is an index of the df - if key not in df.index.names: - raise ValueError( - f'Error validating `aggregators`, {key} is not an index of the dataframe.' - ) - # Recursively validate nested dictionary - _validate_aggs(df, value) - else: - # Handle non-nested case (leaf node) - if key not in df.columns: - raise ValueError( - f'Error validating `aggregators`, column {key} not found.' - ) - - # Determine the data type of the column - dtype = df[key].dtype - if pd.api.types.is_numeric_dtype(dtype): - dtype_category = 'number' - elif pd.api.types.is_datetime64_any_dtype(dtype): - dtype_category = 'datetime' - elif pd.api.types.is_bool_dtype(dtype): - dtype_category = 'boolean' - elif pd.api.types.is_string_dtype(dtype): - dtype_category = 'string' - else: - dtype_category = None - - # Validate aggregator method based on data type - if dtype_category: - valid_aggregators = aggs_by_types[dtype_category] - if value not in valid_aggregators: - raise ValueError( - f"Invalid aggregator '{value}' for column '{key}' " - f"of type '{dtype_category}'. Valid options are: {valid_aggregators}." - ) - else: - raise ValueError(f"Unsupported aggregating on column '{key}' of type {dtype}.") - - if self.value is None or self.value.empty: - pass - if not self.aggregators: - pass - _validate_aggs(self.value, self.aggregators) - @param.depends('value', watch=True, on_init=True) def _apply_max_size(self): """ From f50bd6c5eea4155396346ef09ca0f06ef82bfabf Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 5 Nov 2024 18:46:13 +0700 Subject: [PATCH 09/28] test flat aggs --- panel/tests/ui/widgets/test_tabulator.py | 100 +++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index c7643ed344..b41a74da7a 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4094,3 +4094,103 @@ def test_tabulator_row_content_markup_wrap(page): md = page.locator('.row-content .bk-panel-models-markup-HTML') assert md.bounding_box()['height'] >= 130 + + +@pytest.fixture(scope='session') +def df_agg(): + data = { + "employee_id": range(101, 111), + "name": [ + "Alice", "Bob", "Charlie", "David", "Eve", + "Frank", "Grace", "Heidi", "Ivan", "Judy" + ], + "gender": [ + "Female", "Male", "Male", "Male", "Female", + "Male", "Female", "Female", "Male", "Female" + ], + "salary": [ + 75000.0, 82000.5, np.nan, 64000.0, 91000.0, + 54000.0, 67000.5, 71000.0, 95000.0, 78000.5 + ], + "days_off_used": [ + [2, 1, 3, 0], # Alice's days off in the last 4 months + [1, 0, 2, 2], # Bob's days off + [3, 2, 1, 1], # Charlie's days off + [0, 1, 0, 0], # Diana's days off + [1, 1, 2, 1], # Evan's days off + [4, 3, 3, 4], # Fay's days off + [2, 1, 1, 1], # George's days off + [0, 0, 1, 2], # Hana's days off + [3, 2, 2, 2], # Ivy's days off + [1, 1, 0, 1] # Jack's days off + ], + "active": [ + True, False, True, np.nan, True, + True, False, True, True, False + ], + "department": [ + "HR", "IT", "HR", "Finance", "HR", + "Finance", "IT", "HR", "Finance", "IT" + ], + "date_joined": [ + dt.datetime(2020, 1, 10), # Alice + dt.datetime(2019, 3, 15), # Bob + np.nan, # Charlie + dt.datetime(2021, 5, 20), # David + dt.datetime(2022, 7, 30), # Eve + dt.datetime(2020, 8, 25), # Frank + dt.datetime(2021, 2, 10), # Grace + dt.datetime(2023, 11, 5), # Heidi + dt.datetime(2020, 12, 1), # Ivan + dt.datetime(2021, 4, 22), # Judy + ], + "region": [ + "East", "West", "North", "West", "North", + "North", "West", "North", "North", "South" + ], + } + # Create DataFrame + df = pd.DataFrame(data).astype({ + 'name': 'string', + 'gender': 'string', + 'salary': 'float', + 'active': 'bool', + 'department': 'string', + 'date_joined': 'datetime64[ns]', + }).set_index(["region", "employee_id"]) + return df + + +@pytest.fixture(scope='session') +def nested_aggregators(): + return { + 'region': { + 'name': 'min', + 'gender': 'min', + 'salary': 'sum', + 'department': 'max', + 'date_joined': 'max', + } + } + + +@pytest.fixture(scope='session') +def flat_aggregators(): + return { + 'name': 'min', + 'gender': 'min', + 'salary': 'sum', + 'department': 'max', + 'date_joined': 'max', + } + + +def test_tabulator_flat_aggregators(page, df_agg, flat_aggregators): + widget = Tabulator(df_agg, hierarchical=True, aggregators=flat_aggregators) + serve_component(page, widget) + + +def test_tabulator_flat_aggregators_group_data(page, df_agg, flat_aggregators): + widget = Tabulator(df_agg, hierarchical=True, aggregators=flat_aggregators) + serve_component(page, widget) + expect(page.locator('.tabulator-data-tree-control-expand')).to_have_count(4) From bf05cb71c4b14b960e3f8e5539d8fb271f2c6725 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Wed, 6 Nov 2024 15:35:51 +0700 Subject: [PATCH 10/28] test data grouping --- panel/tests/ui/widgets/test_tabulator.py | 109 +++++++++-------------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index b41a74da7a..c81617d192 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4099,54 +4099,26 @@ def test_tabulator_row_content_markup_wrap(page): @pytest.fixture(scope='session') def df_agg(): data = { - "employee_id": range(101, 111), - "name": [ - "Alice", "Bob", "Charlie", "David", "Eve", - "Frank", "Grace", "Heidi", "Ivan", "Judy" - ], - "gender": [ - "Female", "Male", "Male", "Male", "Female", - "Male", "Female", "Female", "Male", "Female" - ], - "salary": [ - 75000.0, 82000.5, np.nan, 64000.0, 91000.0, - 54000.0, 67000.5, 71000.0, 95000.0, 78000.5 - ], + "employee_id": range(1, 6), + "name": ["Charlie", "Bob", "Alice", "David", "Eve"], + "gender": ["Male", "Male", "Female", "Male", "Female"], + "salary": [75000.0, 82000.5, np.nan, 64000.0, 91000.0], + "region": ["East", "North", "North", "North", "North"], + "active": [True, False, True, np.nan, True], + "department": ["HR", "IT", "HR", "Finance", "HR"], "days_off_used": [ - [2, 1, 3, 0], # Alice's days off in the last 4 months + [3, 2, 1, 1], # Charlie's days off in the last 4 months [1, 0, 2, 2], # Bob's days off - [3, 2, 1, 1], # Charlie's days off + [2, 1, 3, 0], # Alice's days off [0, 1, 0, 0], # Diana's days off - [1, 1, 2, 1], # Evan's days off - [4, 3, 3, 4], # Fay's days off - [2, 1, 1, 1], # George's days off - [0, 0, 1, 2], # Hana's days off - [3, 2, 2, 2], # Ivy's days off [1, 1, 0, 1] # Jack's days off ], - "active": [ - True, False, True, np.nan, True, - True, False, True, True, False - ], - "department": [ - "HR", "IT", "HR", "Finance", "HR", - "Finance", "IT", "HR", "Finance", "IT" - ], "date_joined": [ - dt.datetime(2020, 1, 10), # Alice - dt.datetime(2019, 3, 15), # Bob np.nan, # Charlie + dt.datetime(2019, 3, 15), # Bob + dt.datetime(2020, 1, 10), # Alice dt.datetime(2021, 5, 20), # David dt.datetime(2022, 7, 30), # Eve - dt.datetime(2020, 8, 25), # Frank - dt.datetime(2021, 2, 10), # Grace - dt.datetime(2023, 11, 5), # Heidi - dt.datetime(2020, 12, 1), # Ivan - dt.datetime(2021, 4, 22), # Judy - ], - "region": [ - "East", "West", "North", "West", "North", - "North", "West", "North", "North", "South" ], } # Create DataFrame @@ -4157,40 +4129,41 @@ def df_agg(): 'active': 'bool', 'department': 'string', 'date_joined': 'datetime64[ns]', - }).set_index(["region", "employee_id"]) + }) return df -@pytest.fixture(scope='session') -def nested_aggregators(): - return { - 'region': { - 'name': 'min', - 'gender': 'min', - 'salary': 'sum', - 'department': 'max', - 'date_joined': 'max', - } - } - - -@pytest.fixture(scope='session') -def flat_aggregators(): - return { - 'name': 'min', - 'gender': 'min', - 'salary': 'sum', - 'department': 'max', - 'date_joined': 'max', - } +@pytest.mark.parametrize("aggs", [{'salary': 'mean'}, {'region': {'salary': 'mean'}}]) +def test_tabulator_aggregators(page, df_agg, aggs): + widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True, aggregators=aggs) + serve_component(page, widget) -def test_tabulator_flat_aggregators(page, df_agg, flat_aggregators): - widget = Tabulator(df_agg, hierarchical=True, aggregators=flat_aggregators) +@pytest.mark.parametrize("aggs", [{'salary': 'mean'}, {'region': {'salary': 'mean'}}]) +def test_tabulator_aggregators_data_grouping(page, df_agg, aggs): + widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True, aggregators=aggs) serve_component(page, widget) + expanded_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-collapse') + collapsed_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-expand') + expect(collapsed_groups).to_have_count(2) + expect(expanded_groups).to_have_count(0) + group_east = collapsed_groups.nth(0) + group_north = collapsed_groups.nth(1) -def test_tabulator_flat_aggregators_group_data(page, df_agg, flat_aggregators): - widget = Tabulator(df_agg, hierarchical=True, aggregators=flat_aggregators) - serve_component(page, widget) - expect(page.locator('.tabulator-data-tree-control-expand')).to_have_count(4) + # expand first group and see the data there + group_east.click() + expect(collapsed_groups).to_have_count(1) + expect(expanded_groups).to_have_count(1) + expanded_group_members = page.locator(".tabulator-tree-level-1") + expect(expanded_group_members).to_have_count(1) + expect(expanded_group_members).to_contain_text("Charlie") + + # collapse 1st group and expand 2nd group and see the data there + expanded_groups.click() + group_north.click() + expect(expanded_group_members).to_have_count(4) + expect(expanded_group_members.nth(0)).to_contain_text("Bob") + expect(expanded_group_members.nth(1)).to_contain_text("Alice") + expect(expanded_group_members.nth(2)).to_contain_text("David") + expect(expanded_group_members.nth(3)).to_contain_text("Eve") From 3193de72930e3595ffd0b1c27d12e2a38a2f05bd Mon Sep 17 00:00:00 2001 From: thuydotm Date: Wed, 6 Nov 2024 17:15:13 +0700 Subject: [PATCH 11/28] test aggregated data --- panel/tests/ui/widgets/test_tabulator.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index c81617d192..7fee445155 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4167,3 +4167,20 @@ def test_tabulator_aggregators_data_grouping(page, df_agg, aggs): expect(expanded_group_members.nth(1)).to_contain_text("Alice") expect(expanded_group_members.nth(2)).to_contain_text("David") expect(expanded_group_members.nth(3)).to_contain_text("Eve") + + +def test_tabulator_aggregators_numeric_data_aggregation(page, df_agg): + # TODO: parametrize agg_method and column + agg_method, column = "sum", "salary" + aggs = {column: agg_method} + widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True, aggregators=aggs) + serve_component(page, widget) + + column_titles = page.locator('.tabulator-col-title') + column_index = 3 # salary column is the 4th column displayed in the table (1st is not displayed) + expect(column_titles.nth(column_index + 1)).to_have_text("salary") + rows = page.locator('.tabulator-row') + expect(rows).to_have_count(2) + assert rows.nth(0).inner_text().split("\n")[column_index] == "75,000.0" + # sum with a NaN value + assert rows.nth(1).inner_text().split("\n")[column_index] == "-" From 731211f863e90564ad2e50100fb1899fc704f177 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Wed, 6 Nov 2024 17:17:55 +0700 Subject: [PATCH 12/28] allow flat and nested dict --- panel/models/tabulator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index bee0e54bdc..182bfd3ac8 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -113,7 +113,7 @@ class DataTabulator(HTMLBox): See http://tabulator.info/ """ - aggregators = Dict(String, Any) + aggregators = Dict(String, Either(String, Dict(String, String))) buttons = Dict(String, String) From 2a40bbc648ecb40f46bbf259ee626bb44d15653f Mon Sep 17 00:00:00 2001 From: thuydotm Date: Sun, 10 Nov 2024 21:36:30 +0700 Subject: [PATCH 13/28] agg data --- panel/models/tabulator.ts | 47 ++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 151e5929ed..beccd23ca5 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -73,13 +73,19 @@ function find_group(key: any, value: string, records: any[]): any { return null } -function summarize(grouped: any[], columns: any[], aggregators: string[]): any { +function summarize(grouped: any[], columns: any[], aggregators: any, depth: number = 0): any { const summary: any = {} if (grouped.length == 0) { return summary } + // depth level 0 is leaves, do not aggregate data over this level + let aggs = "" + if (depth > 0) { + aggs = aggregators[depth-1] + } + for (const group of grouped) { - const subsummary = summarize(group._children, columns, aggregators) + const subsummary = summarize(group._children, columns, aggregators, depth+1) for (const col in subsummary) { if (isArray(subsummary[col])) { group[col] = sum(subsummary[col] as number[]) / subsummary[col].length @@ -87,10 +93,19 @@ function summarize(grouped: any[], columns: any[], aggregators: string[]): any { group[col] = subsummary[col] } } + for (const column of columns.slice(1)) { + // if no aggregation method provided for an index level, + // or a specific column of an index level, do not aggregate data + let agg: string = "" + if (typeof aggs === "string") { + agg = aggs + } else { + if (column.field in aggs) { + agg = aggs[column.field] + } + } const val = group[column.field] - const agg = aggregators[column.field] - if (column.field in summary) { const old_val = summary[column.field] if (agg === "min") { @@ -126,7 +141,6 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato grouped.push(group) } let subgroup = group - const groups: any = {} for (const index of indexes.slice(1)) { subgroup = find_group(index_field, record[index], subgroup._children) if (subgroup == null) { @@ -134,7 +148,6 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato subgroup[index_field] = record[index] group._children.push(subgroup) } - groups[index] = group for (const column of columns.slice(1)) { subgroup[column.field] = record[column] } @@ -144,25 +157,9 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato subgroup[column.field] = record[column.field] } } - const aggs: any = {} - let nested = false - for (const index of indexes) { - if (index in aggregators) { - nested = true - const aggs_dict = aggregators[index] - for (const column of columns) { - if (column.field in aggs_dict) { - aggs[column.field] = aggs_dict[column.field] - } - } - } - } - if (!nested) { - for (const column of columns) { - if (column.field in aggregators) { - aggs[column.field] = aggregators[column.field] - } - } + const aggs = [] + for (const index of indexes.slice()) { + aggs.push((index in aggregators) ? aggregators[index] : "sum") } summarize(grouped, columns, aggs) return grouped From cdfaf674e1c6d397381f77817befbdae4a996aa4 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Mon, 11 Nov 2024 21:47:40 +0700 Subject: [PATCH 14/28] tests --- panel/tests/ui/widgets/test_tabulator.py | 147 +++++++++++++++++------ 1 file changed, 109 insertions(+), 38 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 7fee445155..ffffbff722 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4100,19 +4100,10 @@ def test_tabulator_row_content_markup_wrap(page): def df_agg(): data = { "employee_id": range(1, 6), - "name": ["Charlie", "Bob", "Alice", "David", "Eve"], "gender": ["Male", "Male", "Female", "Male", "Female"], - "salary": [75000.0, 82000.5, np.nan, 64000.0, 91000.0], "region": ["East", "North", "North", "North", "North"], - "active": [True, False, True, np.nan, True], - "department": ["HR", "IT", "HR", "Finance", "HR"], - "days_off_used": [ - [3, 2, 1, 1], # Charlie's days off in the last 4 months - [1, 0, 2, 2], # Bob's days off - [2, 1, 3, 0], # Alice's days off - [0, 1, 0, 0], # Diana's days off - [1, 1, 0, 1] # Jack's days off - ], + "name": ["Charlie", "Bob", "Alice", "David", "Eve"], + "salary": [75000.0, 82000.5, np.nan, 64000.0, 91000.0], "date_joined": [ np.nan, # Charlie dt.datetime(2019, 3, 15), # Bob @@ -4120,28 +4111,23 @@ def df_agg(): dt.datetime(2021, 5, 20), # David dt.datetime(2022, 7, 30), # Eve ], + # "active": [True, False, True, np.nan, True], + # "department": ["HR", "IT", "HR", "Finance", "HR"], + # "days_off_used": [ + # [3, 2, 1, 1], # Charlie's days off in the last 4 months + # [1, 0, 2, 2], # Bob's days off + # [2, 1, 3, 0], # Alice's days off + # [0, 1, 0, 0], # David's days off + # [1, 1, 0, 1] # Eve's days off + # ], } # Create DataFrame - df = pd.DataFrame(data).astype({ - 'name': 'string', - 'gender': 'string', - 'salary': 'float', - 'active': 'bool', - 'department': 'string', - 'date_joined': 'datetime64[ns]', - }) + df = pd.DataFrame(data) return df -@pytest.mark.parametrize("aggs", [{'salary': 'mean'}, {'region': {'salary': 'mean'}}]) -def test_tabulator_aggregators(page, df_agg, aggs): - widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True, aggregators=aggs) - serve_component(page, widget) - - -@pytest.mark.parametrize("aggs", [{'salary': 'mean'}, {'region': {'salary': 'mean'}}]) -def test_tabulator_aggregators_data_grouping(page, df_agg, aggs): - widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True, aggregators=aggs) +def test_tabulator_2level_hierarchical_data_grouping(page, df_agg): + widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True) serve_component(page, widget) expanded_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-collapse') @@ -4169,18 +4155,103 @@ def test_tabulator_aggregators_data_grouping(page, df_agg, aggs): expect(expanded_group_members.nth(3)).to_contain_text("Eve") -def test_tabulator_aggregators_numeric_data_aggregation(page, df_agg): - # TODO: parametrize agg_method and column - agg_method, column = "sum", "salary" - aggs = {column: agg_method} - widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True, aggregators=aggs) +def test_tabulator_3level_hierarchical_data_grouping(page, df_agg): + widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True) + serve_component(page, widget) + + expanded_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-collapse') + collapsed_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-expand') + + expect(collapsed_groups).to_have_count(2) + expect(expanded_groups).to_have_count(0) + group_east = collapsed_groups.nth(0) + group_north = collapsed_groups.nth(1) + + # expand first group and see the data there + group_east.click() + expect(collapsed_groups).to_have_count(1) + expect(expanded_groups).to_have_count(1) + collapsed_genders = page.locator(".tabulator-tree-level-1 .tabulator-data-tree-control-expand") + expanded_genders = page.locator(".tabulator-tree-level-1 .tabulator-data-tree-control-collapse") + expect(collapsed_genders).to_have_count(1) + expect(expanded_genders).to_have_count(0) + # TODO: uncomment when showing indexes fixed + # expect(collapsed_genders).to_contain_text("Male") + collapsed_genders.click() + employees = page.locator(".tabulator-tree-level-2") + expect(employees).to_have_count(1) + # TODO: assert employee id + expect(employees).to_contain_text("Charlie") + + # collapse 1st group and expand 2nd group and see the data there + expanded_groups.click() + group_north.click() + expect(collapsed_genders).to_have_count(2) + # note: after clicking 1st gender group, `gender` now has count 1 as we queries for css class + # .tabulator-data-tree-control-expand + collapsed_genders.nth(0).click() + expect(collapsed_genders).to_have_count(1) + expect(expanded_genders).to_have_count(1) + expect(employees).to_have_count(2) + expect(employees.nth(0)).to_contain_text("Bob") + expect(employees.nth(1)).to_contain_text("David") + + collapsed_genders.nth(0).click() + expanded_genders.nth(0).click() + expect(employees).to_have_count(2) + expect(employees.nth(0)).to_contain_text("Alice") + expect(employees.nth(1)).to_contain_text("Eve") + + +@pytest.mark.parametrize("aggs", [{}, {"gender": "sum"}, {"region": "mean", "gender": {"salary": "min"}}]) +def test_tabulator_aggregators(page, df_agg, aggs): + widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True, aggregators=aggs) + serve_component(page, widget) + + +def test_tabulator_aggregators_flat_data_aggregation(page, df_agg): + # TODO: parametrize agg_method, index level and column + aggs = {"region": "min", "gender": "max"} + widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True, aggregators=aggs) serve_component(page, widget) column_titles = page.locator('.tabulator-col-title') - column_index = 3 # salary column is the 4th column displayed in the table (1st is not displayed) - expect(column_titles.nth(column_index + 1)).to_have_text("salary") + col_mapping = {"salary": 3, "date_joined": 4} + expect(column_titles.nth(col_mapping["salary"])).to_have_text("salary") + expect(column_titles.nth(col_mapping["date_joined"])).to_have_text("date_joined") + + expected_results = { + "region": { + "region1": {"salary": "75,000.0", "date_joined": "-"}, + "region2": {"salary": "82,000.5", "date_joined": "2021-05-20 00:00:00"}, + }, + "gender": { + "region1": { + "Male": {"salary": "75,000.0", "date_joined": "-"}, + # "Female": {}, # no female in this region + }, + "region2": { + "Male": {"salary": "82,000.5", "date_joined": "2021-05-20 00:00:00"}, + "Female": {"salary": "-", "date_joined": "2022-07-30 00:00:00"}, + }, + } + } + + # region level rows = page.locator('.tabulator-row') expect(rows).to_have_count(2) - assert rows.nth(0).inner_text().split("\n")[column_index] == "75,000.0" - # sum with a NaN value - assert rows.nth(1).inner_text().split("\n")[column_index] == "-" + agged = { + "region1": rows.nth(0).inner_text().split("\n"), + "region2": rows.nth(1).inner_text().split("\n"), + } + region_agged = { + "region1": { + "salary": agged["region1"][col_mapping["salary"] - 1], + "date_joined": agged["region1"][col_mapping["date_joined"] - 1], + }, + "region2": { + "salary": agged["region2"][col_mapping["salary"] - 1], + "date_joined": agged["region2"][col_mapping["date_joined"] - 1], + }, + } + assert region_agged == expected_results["region"] From f3e8f2d9fa19740186cd2abf1c1e0967ff0f991b Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 12 Nov 2024 00:10:41 +0700 Subject: [PATCH 15/28] more tests --- panel/tests/ui/widgets/test_tabulator.py | 27 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index ffffbff722..107cb4e10a 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4245,13 +4245,26 @@ def test_tabulator_aggregators_flat_data_aggregation(page, df_agg): "region2": rows.nth(1).inner_text().split("\n"), } region_agged = { - "region1": { - "salary": agged["region1"][col_mapping["salary"] - 1], - "date_joined": agged["region1"][col_mapping["date_joined"] - 1], - }, + region: {col: agged[region][col_mapping[col] - 1] for col in col_mapping} for region in agged + } + assert region_agged == expected_results["region"] + + regions = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-expand') + # expand all region groups and see the data there + regions.nth(0).click() + regions.nth(0).click() + rows = page.locator(".tabulator-row.tabulator-tree-level-1") + expect(rows).to_have_count(3) + # gender level + agged = { + "region1": {"Male": rows.nth(0).inner_text().split("\n")}, "region2": { - "salary": agged["region2"][col_mapping["salary"] - 1], - "date_joined": agged["region2"][col_mapping["date_joined"] - 1], + "Male": rows.nth(1).inner_text().split("\n"), + "Female": rows.nth(2).inner_text().split("\n"), }, } - assert region_agged == expected_results["region"] + gender_agged = { + region: { + gender: {col: agged[region][gender][col_mapping[col] - 1] for col in col_mapping} for gender in agged[region]} for region in agged + } + assert gender_agged == expected_results["gender"] From 8983c22f64ec191c637ef6d420a855cd28efaecb Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 12 Nov 2024 00:15:17 +0700 Subject: [PATCH 16/28] parametrize --- panel/tests/ui/widgets/test_tabulator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 107cb4e10a..4e8e78709a 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4209,9 +4209,14 @@ def test_tabulator_aggregators(page, df_agg, aggs): serve_component(page, widget) -def test_tabulator_aggregators_flat_data_aggregation(page, df_agg): +@pytest.mark.parametrize("aggs", [ + {"region": "min", "gender": "max"}, + {"region": "min", "gender": {"salary": "max", "date_joined": "max"}}, + {"region": {"salary": "min", "date_joined": "min"}, "gender": {"salary": "max", "date_joined": "max"}}, + {"region": {"salary": "min", "date_joined": "min"}, "gender": "max"}, +]) +def test_tabulator_aggregators_data_aggregation(page, df_agg, aggs): # TODO: parametrize agg_method, index level and column - aggs = {"region": "min", "gender": "max"} widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True, aggregators=aggs) serve_component(page, widget) From a388d64acbc134017984f5a710273140be88fb60 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 12 Nov 2024 00:20:46 +0700 Subject: [PATCH 17/28] cleanup --- panel/models/tabulator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index beccd23ca5..fba895f7dd 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -73,7 +73,7 @@ function find_group(key: any, value: string, records: any[]): any { return null } -function summarize(grouped: any[], columns: any[], aggregators: any, depth: number = 0): any { +function summarize(grouped: any[], columns: any[], aggregators: any[], depth: number = 0): any { const summary: any = {} if (grouped.length == 0) { return summary @@ -115,7 +115,7 @@ function summarize(grouped: any[], columns: any[], aggregators: any, depth: numb } else if (agg === "sum") { summary[column.field] = val + old_val } else if (agg === "mean") { - if (Array.isArray(summary[column.field])) { + if (isArray(summary[column.field])) { summary[column.field].push(val) } else { summary[column.field] = [old_val, val] @@ -158,7 +158,7 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato } } const aggs = [] - for (const index of indexes.slice()) { + for (const index of indexes) { aggs.push((index in aggregators) ? aggregators[index] : "sum") } summarize(grouped, columns, aggs) From 74b6451741cf5c09804897772f6451c9e536d296 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 12 Nov 2024 01:35:04 +0700 Subject: [PATCH 18/28] docs --- examples/reference/widgets/Tabulator.ipynb | 26 +++++++++++++++++++--- panel/models/tabulator.py | 6 ++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/examples/reference/widgets/Tabulator.ipynb b/examples/reference/widgets/Tabulator.ipynb index 1feb474bf3..61b33b3a48 100644 --- a/examples/reference/widgets/Tabulator.ipynb +++ b/examples/reference/widgets/Tabulator.ipynb @@ -882,7 +882,7 @@ "\n", "The `Tabulator` widget can also render a hierarchical multi-index and aggregate over specific categories. If a DataFrame with a hierarchical multi-index is supplied and the `hierarchical` is enabled the widget will group data by the categories in the order they are defined in. Additionally for each group in the multi-index an aggregator may be provided which will aggregate over the values in that category.\n", "\n", - "For example we may load population data for locations around the world broken down by sex and age-group. If we specify aggregators over the 'AgeGrp' and 'Sex' indexes we can see the aggregated values for each of those groups (note that we do not have to specify an aggregator for the outer index since we specify the aggregators over the subgroups in this case the 'Sex'):" + "For example we may load population data for locations around the world for years 2000 and 2020, broken down by sex and age-group. If we specify aggregators over the 'AgeGrp' and 'Sex' indexes we can see the aggregated values for each of those groups (note that if no aggregators specified to an index level, it will be aggregated by the default method of `sum`):" ] }, { @@ -893,9 +893,29 @@ "source": [ "from bokeh.sampledata.population import data as population_data \n", "\n", - "pop_df = population_data[population_data.Year == 2020].set_index(['Location', 'AgeGrp', 'Sex'])[['Value']]\n", + "pop_df = population_data[population_data.Year.isin([2000, 2020])]\n", "\n", - "pn.widgets.Tabulator(value=pop_df, hierarchical=True, aggregators={'Sex': 'sum', 'AgeGrp': 'sum'}, height=200)" + "pop_df = pop_df.pivot_table(index=[\"Location\", \"AgeGrp\", \"Sex\"], columns=\"Year\", values=\"Value\")[[2000, 2020]].dropna()\n", + "\n", + "pn.widgets.Tabulator(value=pop_df, hierarchical=True, aggregators={'AgeGrp': 'mean'}, height=200)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Separate aggregators for different columns are also supported. You can specify the `aggregators` as a nested dictionary as `{index_name: {column_name: aggregator}}`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "nested_aggs = {'AgeGrp': {2000: \"max\", 2020: \"min\"}, \"Sex\": \"mean\", \"Location\": \"mean\"}\n", + "\n", + "pn.widgets.Tabulator(value=pop_df, hierarchical=True, aggregators=nested_aggs, height=200)" ] }, { diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 182bfd3ac8..780f0e8175 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -4,8 +4,8 @@ See http://tabulator.info/ """ from bokeh.core.properties import ( - Any, Bool, Dict, Either, Enum, Instance, Int, List, Nullable, String, - Tuple, + Any, Bool, Dict, Either, Enum, Float, Instance, Int, List, Nullable, + String, Tuple, ) from bokeh.events import ModelEvent from bokeh.models import ColumnDataSource, LayoutDOM @@ -113,7 +113,7 @@ class DataTabulator(HTMLBox): See http://tabulator.info/ """ - aggregators = Dict(String, Either(String, Dict(String, String))) + aggregators = Dict(Either(String, Int, Float), Either(String, Dict(Either(String, Int, Float), String))) buttons = Dict(String, String) From 68c59163e592cf7691e05b4639670068768462b0 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 12 Nov 2024 23:43:10 +0700 Subject: [PATCH 19/28] support numeric dtype column names for nested aggregators --- panel/models/tabulator.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index fba895f7dd..f7f8b52897 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -159,7 +159,15 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato } const aggs = [] for (const index of indexes) { - aggs.push((index in aggregators) ? aggregators[index] : "sum") + if (index in aggregators) { + if (aggregators[index] instanceof Map) { + aggs.push(Object.fromEntries(aggregators[index])) + } else { + aggs.push(aggregators[index]) + } + } else { + aggs.push("sum") + } } summarize(grouped, columns, aggs) return grouped From 72d87cda7fb5e28ee80f566de92f1935b7221397 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Wed, 13 Nov 2024 21:55:47 +0700 Subject: [PATCH 20/28] tests for numeric column names --- panel/models/tabulator.py | 6 +- panel/tests/ui/widgets/test_tabulator.py | 84 ++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 780f0e8175..5f03b56563 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -4,8 +4,8 @@ See http://tabulator.info/ """ from bokeh.core.properties import ( - Any, Bool, Dict, Either, Enum, Float, Instance, Int, List, Nullable, - String, Tuple, + Any, Bool, Dict, Either, Enum, Instance, Int, List, Nullable, String, + Tuple, ) from bokeh.events import ModelEvent from bokeh.models import ColumnDataSource, LayoutDOM @@ -113,7 +113,7 @@ class DataTabulator(HTMLBox): See http://tabulator.info/ """ - aggregators = Dict(Either(String, Int, Float), Either(String, Dict(Either(String, Int, Float), String))) + aggregators = Dict(Either(String, Int), Either(String, Dict(Either(String, Int), String))) buttons = Dict(String, String) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 4e8e78709a..2cbd2050fe 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4126,7 +4126,14 @@ def df_agg(): return df -def test_tabulator_2level_hierarchical_data_grouping(page, df_agg): +@pytest.fixture(scope='session') +def df_agg_int_column_names(df_agg): + return df_agg.rename(columns={"salary": 1, "date_joined": 2}) + + +@pytest.mark.parametrize("df", ["df_agg", "df_agg_int_column_names"]) +def test_tabulator_2level_hierarchical_data_grouping(page, df, request): + df_agg = request.getfixturevalue(df) widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True) serve_component(page, widget) @@ -4155,7 +4162,9 @@ def test_tabulator_2level_hierarchical_data_grouping(page, df_agg): expect(expanded_group_members.nth(3)).to_contain_text("Eve") -def test_tabulator_3level_hierarchical_data_grouping(page, df_agg): +@pytest.mark.parametrize("df", ["df_agg", "df_agg_int_column_names"]) +def test_tabulator_3level_hierarchical_data_grouping(page, df, request): + df_agg = request.getfixturevalue(df) widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True) serve_component(page, widget) @@ -4222,8 +4231,8 @@ def test_tabulator_aggregators_data_aggregation(page, df_agg, aggs): column_titles = page.locator('.tabulator-col-title') col_mapping = {"salary": 3, "date_joined": 4} - expect(column_titles.nth(col_mapping["salary"])).to_have_text("salary") - expect(column_titles.nth(col_mapping["date_joined"])).to_have_text("date_joined") + for col in col_mapping: + expect(column_titles.nth(col_mapping[col])).to_have_text(col) expected_results = { "region": { @@ -4273,3 +4282,70 @@ def test_tabulator_aggregators_data_aggregation(page, df_agg, aggs): gender: {col: agged[region][gender][col_mapping[col] - 1] for col in col_mapping} for gender in agged[region]} for region in agged } assert gender_agged == expected_results["gender"] + + +@pytest.mark.parametrize("aggs", [ + {"region": "min", "gender": "max"}, + {"region": "min", "gender": {1: "max", 2: "max"}}, + {"region": {1: "min", 2: "min"}, "gender": {1: "max", 2: "max"}}, + {"region": {1: "min", 2: "min"}, "gender": "max"}, +]) +def test_tabulator_aggregators_data_aggregation_numeric_column_names(page, df_agg_int_column_names, aggs): + # TODO: parametrize agg_method, index level and column + df_agg = df_agg_int_column_names + widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True, aggregators=aggs) + serve_component(page, widget) + + column_titles = page.locator('.tabulator-col-title') + col_mapping = {1: 3, 2: 4} + for col in col_mapping: + expect(column_titles.nth(col_mapping[col])).to_have_text(str(col)) + + expected_results = { + "region": { + "region1": {1: "75,000.0", 2: "-"}, + "region2": {1: "82,000.5", 2: "2021-05-20 00:00:00"}, + }, + "gender": { + "region1": { + "Male": {1: "75,000.0", 2: "-"}, + # "Female": {}, # no female in this region + }, + "region2": { + "Male": {1: "82,000.5", 2: "2021-05-20 00:00:00"}, + "Female": {1: "-", 2: "2022-07-30 00:00:00"}, + }, + } + } + + # region level + rows = page.locator('.tabulator-row') + expect(rows).to_have_count(2) + agged = { + "region1": rows.nth(0).inner_text().split("\n"), + "region2": rows.nth(1).inner_text().split("\n"), + } + region_agged = { + region: {col: agged[region][col_mapping[col] - 1] for col in col_mapping} for region in agged + } + assert region_agged == expected_results["region"] + + regions = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-expand') + # expand all region groups and see the data there + regions.nth(0).click() + regions.nth(0).click() + rows = page.locator(".tabulator-row.tabulator-tree-level-1") + expect(rows).to_have_count(3) + # gender level + agged = { + "region1": {"Male": rows.nth(0).inner_text().split("\n")}, + "region2": { + "Male": rows.nth(1).inner_text().split("\n"), + "Female": rows.nth(2).inner_text().split("\n"), + }, + } + gender_agged = { + region: { + gender: {col: agged[region][gender][col_mapping[col] - 1] for col in col_mapping} for gender in agged[region]} for region in agged + } + assert gender_agged == expected_results["gender"] From ddd4de1f8024391d7be0729a4d0bc2826f501ca9 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Thu, 14 Nov 2024 00:28:21 +0700 Subject: [PATCH 21/28] update example nb --- examples/reference/widgets/Tabulator.ipynb | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/reference/widgets/Tabulator.ipynb b/examples/reference/widgets/Tabulator.ipynb index 61b33b3a48..707b9394b4 100644 --- a/examples/reference/widgets/Tabulator.ipynb +++ b/examples/reference/widgets/Tabulator.ipynb @@ -882,7 +882,7 @@ "\n", "The `Tabulator` widget can also render a hierarchical multi-index and aggregate over specific categories. If a DataFrame with a hierarchical multi-index is supplied and the `hierarchical` is enabled the widget will group data by the categories in the order they are defined in. Additionally for each group in the multi-index an aggregator may be provided which will aggregate over the values in that category.\n", "\n", - "For example we may load population data for locations around the world for years 2000 and 2020, broken down by sex and age-group. If we specify aggregators over the 'AgeGrp' and 'Sex' indexes we can see the aggregated values for each of those groups (note that if no aggregators specified to an index level, it will be aggregated by the default method of `sum`):" + "For example we may load the Automobile Mileage dataset for various car models from the 1970s and 1980s around the world, broken down by regions, model years and manufacturers. The dataset includes details on car characteristics and performance metrics, making it ideal for exploring trends in automobile efficiency and performance during this period. If we specify aggregators over the 'origin' (Region) and 'yr' (Model Year) index, we can see the aggregated values for each of those groups (note that if no aggregators specified to an outer index level, it will be aggregated by the default method of `sum`):" ] }, { @@ -891,20 +891,22 @@ "metadata": {}, "outputs": [], "source": [ - "from bokeh.sampledata.population import data as population_data \n", + "from bokeh.sampledata.autompg import autompg_clean as autompg_df\n", "\n", - "pop_df = population_data[population_data.Year.isin([2000, 2020])]\n", + "df = autompg_df.set_index([\"origin\", \"yr\", \"mfr\"])\n", "\n", - "pop_df = pop_df.pivot_table(index=[\"Location\", \"AgeGrp\", \"Sex\"], columns=\"Year\", values=\"Value\")[[2000, 2020]].dropna()\n", - "\n", - "pn.widgets.Tabulator(value=pop_df, hierarchical=True, aggregators={'AgeGrp': 'mean'}, height=200)" + "pn.widgets.Tabulator(value=df, hierarchical=True, aggregators={\"origin\": \"mean\", \"yr\": \"mean\"}, height=200)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Separate aggregators for different columns are also supported. You can specify the `aggregators` as a nested dictionary as `{index_name: {column_name: aggregator}}`" + "Separate aggregators for different columns are also supported. You can specify the `aggregators` as a nested dictionary as `{index_name: {column_name: aggregator}}`\n", + "\n", + "Applied to the Automobile Mileage dataset we just loaded, we can do different aggregations for different columns:\n", + "- mpg: Average fuel economy, a meaningful metric to evaluate fuel efficiency within groups.\n", + "- hp: Maximum horsepower to find the most powerful cars within each subgroup." ] }, { @@ -913,9 +915,9 @@ "metadata": {}, "outputs": [], "source": [ - "nested_aggs = {'AgeGrp': {2000: \"max\", 2020: \"min\"}, \"Sex\": \"mean\", \"Location\": \"mean\"}\n", + "nested_aggs = {\"origin\": {\"mpg\": \"mean\", \"hp\": \"max\"}, \"yr\": {\"mpg\": \"mean\", \"hp\": \"max\"}}\n", "\n", - "pn.widgets.Tabulator(value=pop_df, hierarchical=True, aggregators=nested_aggs, height=200)" + "pn.widgets.Tabulator(value=df[[\"mpg\", \"hp\"]], hierarchical=True, aggregators=nested_aggs, height=200)" ] }, { From d08363e9c33baa86672dd6bf33b3f83016516e65 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Tue, 19 Nov 2024 00:26:15 +0700 Subject: [PATCH 22/28] example fixes --- examples/reference/widgets/Tabulator.ipynb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/reference/widgets/Tabulator.ipynb b/examples/reference/widgets/Tabulator.ipynb index 707b9394b4..3381f8c3b8 100644 --- a/examples/reference/widgets/Tabulator.ipynb +++ b/examples/reference/widgets/Tabulator.ipynb @@ -893,9 +893,9 @@ "source": [ "from bokeh.sampledata.autompg import autompg_clean as autompg_df\n", "\n", - "df = autompg_df.set_index([\"origin\", \"yr\", \"mfr\"])\n", + "autompg_df = autompg_df.set_index([\"origin\", \"yr\", \"mfr\"])\n", "\n", - "pn.widgets.Tabulator(value=df, hierarchical=True, aggregators={\"origin\": \"mean\", \"yr\": \"mean\"}, height=200)" + "pn.widgets.Tabulator(value=autompg_df, hierarchical=True, aggregators={\"origin\": \"mean\", \"yr\": \"mean\"}, height=200)" ] }, { @@ -917,7 +917,7 @@ "source": [ "nested_aggs = {\"origin\": {\"mpg\": \"mean\", \"hp\": \"max\"}, \"yr\": {\"mpg\": \"mean\", \"hp\": \"max\"}}\n", "\n", - "pn.widgets.Tabulator(value=df[[\"mpg\", \"hp\"]], hierarchical=True, aggregators=nested_aggs, height=200)" + "pn.widgets.Tabulator(value=autompg_df[[\"mpg\", \"hp\"]], hierarchical=True, aggregators=nested_aggs, height=200)" ] }, { From 707d7f97352f0e413d3c51238d9ab2a39af69739 Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 26 Nov 2024 10:05:23 +0100 Subject: [PATCH 23/28] minor docs changes --- examples/reference/widgets/Tabulator.ipynb | 24 ++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/examples/reference/widgets/Tabulator.ipynb b/examples/reference/widgets/Tabulator.ipynb index 3381f8c3b8..961494eb70 100644 --- a/examples/reference/widgets/Tabulator.ipynb +++ b/examples/reference/widgets/Tabulator.ipynb @@ -882,7 +882,7 @@ "\n", "The `Tabulator` widget can also render a hierarchical multi-index and aggregate over specific categories. If a DataFrame with a hierarchical multi-index is supplied and the `hierarchical` is enabled the widget will group data by the categories in the order they are defined in. Additionally for each group in the multi-index an aggregator may be provided which will aggregate over the values in that category.\n", "\n", - "For example we may load the Automobile Mileage dataset for various car models from the 1970s and 1980s around the world, broken down by regions, model years and manufacturers. The dataset includes details on car characteristics and performance metrics, making it ideal for exploring trends in automobile efficiency and performance during this period. If we specify aggregators over the 'origin' (Region) and 'yr' (Model Year) index, we can see the aggregated values for each of those groups (note that if no aggregators specified to an outer index level, it will be aggregated by the default method of `sum`):" + "We will use the Automobile Mileage dataset for various car models from the 1970s and 1980s around the world, broken down by regions, model years and manufacturers. The dataset includes details on car characteristics and performance metrics." ] }, { @@ -894,7 +894,22 @@ "from bokeh.sampledata.autompg import autompg_clean as autompg_df\n", "\n", "autompg_df = autompg_df.set_index([\"origin\", \"yr\", \"mfr\"])\n", - "\n", + "autompg_df.head(3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we specify aggregators over the 'origin' (region) and 'yr' (model year) indexes, we can see the aggregated values for each of those groups. Note that if no aggregators are specified to an outer index level, it will be aggregated with the default method of `sum`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ "pn.widgets.Tabulator(value=autompg_df, hierarchical=True, aggregators={\"origin\": \"mean\", \"yr\": \"mean\"}, height=200)" ] }, @@ -904,9 +919,7 @@ "source": [ "Separate aggregators for different columns are also supported. You can specify the `aggregators` as a nested dictionary as `{index_name: {column_name: aggregator}}`\n", "\n", - "Applied to the Automobile Mileage dataset we just loaded, we can do different aggregations for different columns:\n", - "- mpg: Average fuel economy, a meaningful metric to evaluate fuel efficiency within groups.\n", - "- hp: Maximum horsepower to find the most powerful cars within each subgroup." + "Applied to the same dataset, we can aggregate the data in the `mpg` (miles per galon) and `hp` columns differently, with `mean` and `max`, respectively." ] }, { @@ -916,7 +929,6 @@ "outputs": [], "source": [ "nested_aggs = {\"origin\": {\"mpg\": \"mean\", \"hp\": \"max\"}, \"yr\": {\"mpg\": \"mean\", \"hp\": \"max\"}}\n", - "\n", "pn.widgets.Tabulator(value=autompg_df[[\"mpg\", \"hp\"]], hierarchical=True, aggregators=nested_aggs, height=200)" ] }, From b032311a9bcee8e0dbe080dbb0251a2b77164e84 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Thu, 28 Nov 2024 21:27:46 +0700 Subject: [PATCH 24/28] Update panel/models/tabulator.ts Co-authored-by: Maxime Liquet <35924738+maximlt@users.noreply.github.com> --- panel/models/tabulator.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index f7f8b52897..c8c3a34e72 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -100,10 +100,8 @@ function summarize(grouped: any[], columns: any[], aggregators: any[], depth: nu let agg: string = "" if (typeof aggs === "string") { agg = aggs - } else { - if (column.field in aggs) { + } else if (column.field in aggs) { agg = aggs[column.field] - } } const val = group[column.field] if (column.field in summary) { From 8bf2a15081c9eff689d6584ecfd875c6605bb9b3 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Fri, 29 Nov 2024 14:53:01 +0700 Subject: [PATCH 25/28] clean up --- panel/tests/ui/widgets/test_tabulator.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 2cbd2050fe..554cd1f275 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4111,19 +4111,8 @@ def df_agg(): dt.datetime(2021, 5, 20), # David dt.datetime(2022, 7, 30), # Eve ], - # "active": [True, False, True, np.nan, True], - # "department": ["HR", "IT", "HR", "Finance", "HR"], - # "days_off_used": [ - # [3, 2, 1, 1], # Charlie's days off in the last 4 months - # [1, 0, 2, 2], # Bob's days off - # [2, 1, 3, 0], # Alice's days off - # [0, 1, 0, 0], # David's days off - # [1, 1, 0, 1] # Eve's days off - # ], } - # Create DataFrame - df = pd.DataFrame(data) - return df + return pd.DataFrame(data) @pytest.fixture(scope='session') @@ -4212,12 +4201,6 @@ def test_tabulator_3level_hierarchical_data_grouping(page, df, request): expect(employees.nth(1)).to_contain_text("Eve") -@pytest.mark.parametrize("aggs", [{}, {"gender": "sum"}, {"region": "mean", "gender": {"salary": "min"}}]) -def test_tabulator_aggregators(page, df_agg, aggs): - widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True, aggregators=aggs) - serve_component(page, widget) - - @pytest.mark.parametrize("aggs", [ {"region": "min", "gender": "max"}, {"region": "min", "gender": {"salary": "max", "date_joined": "max"}}, From db08f118edbc1f74086b6559f6ebc53e3f4e07b9 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Fri, 29 Nov 2024 15:41:42 +0700 Subject: [PATCH 26/28] eslint --- panel/models/tabulator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index c8c3a34e72..589be7b195 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -101,7 +101,7 @@ function summarize(grouped: any[], columns: any[], aggregators: any[], depth: nu if (typeof aggs === "string") { agg = aggs } else if (column.field in aggs) { - agg = aggs[column.field] + agg = aggs[column.field] } const val = group[column.field] if (column.field in summary) { From fb3a62a0c15f234eb46ebdf0fe49a6d81644e495 Mon Sep 17 00:00:00 2001 From: thuydotm Date: Fri, 29 Nov 2024 18:37:26 +0700 Subject: [PATCH 27/28] refactor, more tests --- panel/models/tabulator.ts | 3 ++- panel/tests/widgets/test_tables.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 589be7b195..5922a5c4d1 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -78,7 +78,7 @@ function summarize(grouped: any[], columns: any[], aggregators: any[], depth: nu if (grouped.length == 0) { return summary } - // depth level 0 is leaves, do not aggregate data over this level + // depth level 0 is the root, finish here let aggs = "" if (depth > 0) { aggs = aggregators[depth-1] @@ -159,6 +159,7 @@ function group_data(records: any[], columns: any[], indexes: string[], aggregato for (const index of indexes) { if (index in aggregators) { if (aggregators[index] instanceof Map) { + // when some column names are numeric, need to convert that from a Map to an Object aggs.push(Object.fromEntries(aggregators[index])) } else { aggs.push(aggregators[index]) diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index 5d93c50773..8b3a91fbd1 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -200,7 +200,8 @@ def test_dataframe_duplicate_column_name(document, comm): table.value = table.value.rename(columns={'a': 'b'}) -def test_hierarchical_index(document, comm): +@pytest.fixture +def df_agg(): df = pd.DataFrame([ ('Germany', 2020, 9, 2.4, 'A'), ('Germany', 2021, 3, 7.3, 'C'), @@ -209,8 +210,11 @@ def test_hierarchical_index(document, comm): ('UK', 2021, 1, 3.9, 'B'), ('UK', 2022, 9, 2.2, 'A') ], columns=['Country', 'Year', 'Int', 'Float', 'Str']).set_index(['Country', 'Year']) + return df - table = DataFrame(value=df, hierarchical=True, + +def test_hierarchical_index(document, comm, df_agg): + table = DataFrame(value=df_agg, hierarchical=True, aggregators={'Year': {'Int': 'sum', 'Float': 'mean'}}) model = table.get_root(document, comm) @@ -2713,3 +2717,8 @@ def test_header_filters_categorial_dtype(): widget = Tabulator(df, header_filters=True) widget.filters = [{'field': 'model', 'type': 'like', 'value': 'A'}] assert widget.current_view.size == 1 + +@pytest.mark.parametrize('aggs', [{}, {'Country': 'sum'}, {'Country': {'Int': 'sum', 'Float': 'mean'}}]) +def test_tabulator_aggregators(document, comm, df_agg, aggs): + tabulator = Tabulator(df_agg, hierarchical=True, aggregators=aggs) + tabulator.get_root(document, comm) From 4324b3b9884c29279a335ed4b7753de37a65b47c Mon Sep 17 00:00:00 2001 From: thuydotm Date: Fri, 29 Nov 2024 18:41:00 +0700 Subject: [PATCH 28/28] remove dup test --- panel/tests/ui/widgets/test_tabulator.py | 33 +----------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 554cd1f275..18f51f6ae6 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -4121,38 +4121,7 @@ def df_agg_int_column_names(df_agg): @pytest.mark.parametrize("df", ["df_agg", "df_agg_int_column_names"]) -def test_tabulator_2level_hierarchical_data_grouping(page, df, request): - df_agg = request.getfixturevalue(df) - widget = Tabulator(df_agg.set_index(["region", "employee_id"]), hierarchical=True) - serve_component(page, widget) - - expanded_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-collapse') - collapsed_groups = page.locator('.tabulator-tree-level-0 .tabulator-data-tree-control-expand') - expect(collapsed_groups).to_have_count(2) - expect(expanded_groups).to_have_count(0) - group_east = collapsed_groups.nth(0) - group_north = collapsed_groups.nth(1) - - # expand first group and see the data there - group_east.click() - expect(collapsed_groups).to_have_count(1) - expect(expanded_groups).to_have_count(1) - expanded_group_members = page.locator(".tabulator-tree-level-1") - expect(expanded_group_members).to_have_count(1) - expect(expanded_group_members).to_contain_text("Charlie") - - # collapse 1st group and expand 2nd group and see the data there - expanded_groups.click() - group_north.click() - expect(expanded_group_members).to_have_count(4) - expect(expanded_group_members.nth(0)).to_contain_text("Bob") - expect(expanded_group_members.nth(1)).to_contain_text("Alice") - expect(expanded_group_members.nth(2)).to_contain_text("David") - expect(expanded_group_members.nth(3)).to_contain_text("Eve") - - -@pytest.mark.parametrize("df", ["df_agg", "df_agg_int_column_names"]) -def test_tabulator_3level_hierarchical_data_grouping(page, df, request): +def test_tabulator_hierarchical_data_grouping(page, df, request): df_agg = request.getfixturevalue(df) widget = Tabulator(df_agg.set_index(["region", "gender", "employee_id"]), hierarchical=True) serve_component(page, widget)