mesop / grid_table.py
wwwillchen's picture
Commit
45df88a
"""Pure Mesop Table built using CSS Grid.
Functionality:
- Column sorting
- Header styles
- Row styles
- Cell styles
- Cell templating
- Row click
- Expandable rows
- Sticky header
- Filtering (technically not built-in to the grid table component)
TODOs:
- Pagination
- Sticky column
- Control column width
- Column filtering within grid table
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable, Literal
import pandas as pd
import mesop as me
df = pd.DataFrame(
data={
"NA": [pd.NA, pd.NA, pd.NA],
"Index": [3, 2, 1],
"Bools": [True, False, True],
"Ints": [101, 90, -55],
"Floats": [1002.3, 4.5, -1050203.021],
"Date Times": [
pd.Timestamp("20180310"),
pd.Timestamp("20230310"),
datetime(2023, 1, 1, 12, 12, 1),
],
"Strings": ["Hello", "World", "!"],
}
)
SortDirection = Literal["asc", "desc"]
@me.stateclass
class State:
expanded_df_row_index: int | None = None
sort_column: str
sort_direction: SortDirection = "asc"
string_output: str
table_filter: str
theme: str = "light"
def load(e: me.LoadEvent):
me.set_theme_mode("system")
@me.page(
on_load=load,
security_policy=me.SecurityPolicy(
allowed_iframe_parents=["https://google.github.io", "https://huggingface.co."]
),
path="/grid_table",
)
def app():
state = me.state(State)
with me.box(style=me.Style(margin=me.Margin.all(30))):
me.select(
label="Theme",
options=[
me.SelectOption(label="Light", value="light"),
me.SelectOption(label="Dark", value="dark"),
],
on_selection_change=on_theme_changed,
)
# Simple example of filtering a data table. This is implemented separately of the
# grid table component. For simplicity, we only filter against a single column.
me.input(
label="Filter by Strings column",
style=me.Style(width="100%"),
on_blur=on_filter_by_strings,
on_enter=on_filter_by_strings,
)
# Grid Table demonstrating all features.
grid_table(
get_data_frame(),
header_config=GridTableHeader(sticky=True),
on_click=on_table_cell_click,
on_sort=on_table_sort,
row_config=GridTableRow(
columns={
"Bools": GridTableColumn(component=bool_component),
"Date Times": GridTableColumn(component=date_component),
"Floats": GridTableColumn(component=floats_component),
"Ints": GridTableColumn(style=ints_style, sortable=True),
"Strings": GridTableColumn(
component=strings_component, sortable=True
),
},
expander=GridTableExpander(
component=expander,
df_row_index=state.expanded_df_row_index,
),
),
sort_column=state.sort_column,
sort_direction=state.sort_direction,
theme=GridTableTheme(striped=True),
)
# Used for demonstrating "table button" click example.
if state.string_output:
with me.box(
style=me.Style(
background=me.theme_var("surface-container-high"),
color=me.theme_var("on-surface"),
margin=me.Margin(top=20),
padding=me.Padding.all(15),
)
):
me.text(f"You clicked button: {state.string_output}")
@dataclass(kw_only=True)
class GridTableHeader:
"""Configuration for the table header
Attributes:
sticky: Enables sticky headers
style: Overrides default header styles
"""
sticky: bool = False
style: Callable | None = None
@dataclass(kw_only=True)
class GridTableColumn:
"""Configuration for a table column
Attributes:
component: Custom rendering for the table cell
sortable: Whether this column can be sorted or not
style: Custom styling for the table cell
"""
component: Callable | None = None
sortable: bool = False
style: Callable | None = None
@dataclass(kw_only=True)
class GridTableExpander:
"""Configuration for expander table row
Currently only one row can be expanded at a time.
Attributes:
component: Custom rendering for the table row
df_row_index: DataFrame row that is expanded.
style: Custom styling for the expanded row
"""
component: Callable | None = None
df_row_index: int | None = None
style: Callable | None = None
@dataclass(kw_only=True)
class GridTableRow:
"""Configuration for the table's rows.
Attributes:
columns: A map of column name to column specific configuration
expander: Configuration for expanded row
style: Custom styles at the row level.
"""
columns: dict[str, GridTableColumn] = field(default_factory=lambda: {})
expander: GridTableExpander = field(
default_factory=lambda: GridTableExpander()
)
style: Callable | None = None
@dataclass(kw_only=True)
class GridTableCellMeta:
"""Metadata that is passed into style/component/expander callables.
This metadata can be used to display things in custom ways based on the data.
"""
df_row_index: int
df_col_index: int
name: str
row_index: int
value: Any
class GridTableTheme:
"""This default theme utilizes Mesop's built in dark mode to toggle between dark/light themes."""
_HEADER_BG: str = me.theme_var("surface-container-highest")
_CELL_BG: str = me.theme_var("surface-container-low")
_CELL_BG_ALT: str = me.theme_var("surface-container-lowest")
_COLOR: str = me.theme_var("on-surface-variant")
_PADDING: me.Padding = me.Padding.all(10)
_BORDER: me.Border = me.Border.all(
me.BorderSide(width=1, style="solid", color=me.theme_var("outline-variant"))
)
def __init__(self, striped: bool = False):
self.striped = striped
def header(self, sortable: bool = False) -> me.Style:
return me.Style(
background=self._HEADER_BG,
color=self._COLOR,
cursor="pointer" if sortable else "default",
padding=self._PADDING,
border=self._BORDER,
)
def sort_icon(self, current_column: str, sort_column: str) -> me.Style:
return me.Style(
color=me.theme_var("outline")
if sort_column == current_column
else me.theme_var("outline-variant"),
# Hack to make the icon align correctly. Will break if user changes the
# font size with custom styles.
height=16,
)
def cell(self, cell_meta: GridTableCellMeta) -> me.Style:
return me.Style(
background=self._CELL_BG_ALT
if self.striped and cell_meta.row_index % 2
else self._CELL_BG,
color=self._COLOR,
padding=self._PADDING,
border=self._BORDER,
)
def expander(self, df_row_index: int) -> me.Style:
return me.Style(
background=self._CELL_BG,
color=self._COLOR,
padding=self._PADDING,
border=self._BORDER,
)
def get_data_frame():
"""Helper function to get a sorted/filtered version of the main data frame.
One drawback of this approach is that we sort/filter the main data frame with every
refresh, which may not be efficient for larger data frames.
"""
state = me.state(State)
# Sort the data frame if sorting is enabled.
if state.sort_column:
sorted_df = df.sort_values(
by=state.sort_column, ascending=state.sort_direction == "asc"
)
else:
sorted_df = df
# Simple filtering by the Strings column.
if state.table_filter:
return sorted_df[
sorted_df["Strings"].str.lower().str.contains(state.table_filter.lower())
]
else:
return sorted_df
def on_theme_changed(e: me.SelectSelectionChangeEvent):
"""Changes the theme of the grid table"""
state = me.state(State)
state.theme = e.value
me.set_theme_mode(state.theme) # type: ignore
def on_filter_by_strings(e: me.InputBlurEvent | me.InputEnterEvent):
"""Saves the filtering string to be used in `get_data_frame`"""
state = me.state(State)
state.table_filter = e.value
def on_table_cell_click(e: me.ClickEvent):
"""If the table cell is clicked, show the expanded content."""
state = me.state(State)
df_row_index, _ = map(int, e.key.split("-"))
if state.expanded_df_row_index == df_row_index:
state.expanded_df_row_index = None
else:
state.expanded_df_row_index = df_row_index
def on_table_sort(e: me.ClickEvent):
"""Handles the table sort event by saving the sort information to be used in `get_data_frame`"""
state = me.state(State)
column, direction = e.key.split("-")
if state.sort_column == column:
state.sort_direction = "asc" if direction == "desc" else "desc"
else:
state.sort_direction = direction # type: ignore
state.sort_column = column
def expander(df_row_index: int):
"""Rendering logic for expanded row.
Here we just display the row data in two columns as text inputs.
But you can do more advanced things, such as:
- rendering another table inside the table
- fetching data to show drill down data
- add a form for data entry
"""
columns = list(df.columns)
with me.box(style=me.Style(padding=me.Padding.all(15))):
me.text(f"Expanded row: {df_row_index}", type="headline-5")
with me.box(
style=me.Style(
display="grid",
grid_template_columns="repeat(2, 1fr)",
gap=10,
)
):
for index, col in enumerate(df.iloc[df_row_index]):
me.input(
label=columns[index], value=str(col), style=me.Style(width="100%")
)
def on_click_strings(e: me.ClickEvent):
"""Click event for the cell button example."""
state = me.state(State)
state.string_output = e.key
def strings_component(meta: GridTableCellMeta):
"""Example of a cell rendering a button with a click event.
Note that the behavior is slightly buggy if there is also a cell click event. This
event will fire, but so will the cell click event. This is due to
https://github.com/google/mesop/issues/268.
"""
me.button(
meta.value,
key=meta.value,
on_click=on_click_strings,
style=me.Style(
border_radius=3,
background=me.theme_var("primary-container"),
border=me.Border.all(
me.BorderSide(
width=1, style="solid", color=me.theme_var("primary-fixed-dim")
)
),
font_weight="bold",
color=me.theme_var("on-primary-container"),
),
)
def bool_component(meta: GridTableCellMeta):
"""Example of a cell rendering icons based on the cell value."""
if meta.value:
me.icon("check_circle", style=me.Style(color="green"))
else:
me.icon("cancel", style=me.Style(color="red"))
def ints_style(meta: GridTableCellMeta) -> me.Style:
"""Example of a cell style based on the integer value."""
return me.Style(
background="#29a529" if meta.value > 0 else "#db4848",
color="#fff",
padding=me.Padding.all(10),
border=me.Border.all(
me.BorderSide(width=1, style="solid", color="rgba(255, 255, 255, 0.16)")
),
)
def floats_component(meta: GridTableCellMeta):
"""Example of a cell rendering using string formatting."""
me.text(f"${meta.value:,.2f}")
def date_component(meta: GridTableCellMeta):
"""Example of a cell rendering using custom date formatting."""
me.text(meta.value.strftime("%b %d, %Y at %I:%M %p"))
@me.component
def grid_table(
data,
*,
header_config: GridTableHeader | None = None,
on_click: Callable | None = None,
on_sort: Callable | None = None,
row_config: GridTableRow | None = None,
sort_column: str = "",
sort_direction: SortDirection = "asc",
theme: Any
| None = None, # Using Any since Pydantic complains about using a class.
):
"""Grid table component.
Args:
data: Pandas data frame
header_config: Configuration for the table header
on_click: Click event that fires when a cell is clicked
on_sort: Click event that fires when a sortable header column is clicked
row_config: Configuration for the tables's rows
sort_column: Current sort column
sort_direction: Current sort direction
theme: Table theme
"""
with me.box(
style=me.Style(
display="grid",
# This creates a grid with "equal" sized rows based on the columns. We may want to
# override this to allow custom widths.
grid_template_columns=f"repeat({len(data.columns)}, 1fr)",
)
):
_theme: GridTableTheme = GridTableTheme()
if theme:
_theme = theme
if not header_config:
header_config = GridTableHeader()
if not row_config:
row_config = GridTableRow()
col_index_name_map = {}
# Render the table header
for col_index, col in enumerate(data.columns):
col_index_name_map[col_index] = col
sortable_col = row_config.columns.get(col, GridTableColumn()).sortable
with me.box(
# Sort key format: ColumName-SortDirection
key=_make_sort_key(col, sort_column, sort_direction),
style=_make_header_style(
theme=_theme, header_config=header_config, sortable=sortable_col
),
on_click=on_sort if sortable_col else None,
):
with me.box(
style=me.Style(
display="flex",
align_items="center",
)
):
if sortable_col:
# Render sorting icons for sortable columns
#
# If column is sortable and not selected, always render an up arrow that is de-emphasized
# If column is sortable and selected, render the arrow with emphasis
# If the column is newly selected, render the up arrow to sort ascending
# If the column is selected and reselected, render the opposite arrow
me.icon(
"arrow_downward"
if sort_column == col and sort_direction == "desc"
else "arrow_upward",
style=_theme.sort_icon(col, sort_column),
)
me.text(col)
# Render table rows
for row_index, row in enumerate(data.itertuples(name=None)):
for col_index, col in enumerate(row[1:]):
cell_config = row_config.columns.get(
col_index_name_map[col_index], GridTableColumn()
)
cell_meta = GridTableCellMeta(
df_row_index=row[0],
df_col_index=col_index,
name=col_index_name_map[col_index],
row_index=row_index,
value=col,
)
with me.box(
# Store the df row index and df col index for the cell click event so we know
# which cell is clicked.
key=f"{row[0]}-{col_index}",
style=_make_cell_style(
theme=_theme,
cell_meta=cell_meta,
column=cell_config,
row_style=row_config.style,
),
on_click=on_click,
):
if cell_config.component:
# Render custom cell markup
cell_config.component(cell_meta)
else:
me.text(str(col))
# Render the expander if it's enabled and a row has been selected.
if (
row_config.expander.component
and row_config.expander.df_row_index == row[0]
):
with me.box(
style=_make_expander_style(
df_row_index=row[0],
col_span=len(data.columns),
expander_style=row_config.expander.style,
theme=_theme,
)
):
row_config.expander.component(row[0])
def _make_header_style(
*, theme: GridTableTheme, header_config: GridTableHeader, sortable: bool
) -> me.Style:
"""Renders the header style
Precendence of styles:
- Header style override
- Theme default
"""
# Default styles
style = theme.header(sortable)
if header_config.style:
style = header_config.style(sortable)
if header_config.sticky:
style.position = "sticky"
style.top = 0
return style
def _make_sort_key(col: str, sort_column: str, sort_direction: SortDirection):
if col == sort_column:
return f"{sort_column}-{sort_direction}"
return f"{col}-asc"
def _make_cell_style(
*,
theme: GridTableTheme,
cell_meta: GridTableCellMeta,
column: GridTableColumn,
row_style: Callable | None = None,
) -> me.Style:
"""Renders the cell style
Precendence of styles:
- Cell style override
- Row style override
- Theme Default
"""
style = theme.cell(cell_meta)
if column.style:
style = column.style(cell_meta)
elif row_style:
style = row_style(cell_meta)
return style
def _make_expander_style(
*,
theme: GridTableTheme,
df_row_index: int,
col_span: int,
expander_style: Callable | None = None,
) -> me.Style:
"""Renders the expander style
Precendence of styles:
- Cell style override
- Theme default
"""
style = theme.expander(df_row_index)
if expander_style:
style = expander_style(df_row_index)
style.grid_column = f"span {col_span}"
return style