HomeDashboard/.venv/lib/python3.12/site-packages/nicegui/elements/table.py
2026-01-03 14:54:18 +01:00

462 lines
19 KiB
Python

import importlib.util
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
from typing_extensions import Self
from .. import optional_features
from ..element import Element
from ..events import (
GenericEventArguments,
Handler,
TableSelectionEventArguments,
ValueChangeEventArguments,
handle_event,
)
from ..logging import log
from .mixins.filter_element import FilterElement
if importlib.util.find_spec('pandas'):
optional_features.register('pandas')
if TYPE_CHECKING:
import pandas as pd
if importlib.util.find_spec('polars'):
optional_features.register('polars')
if TYPE_CHECKING:
import polars as pl
class Table(FilterElement, component='table.js'):
def __init__(self,
*,
rows: list[dict],
columns: Optional[list[dict]] = None,
column_defaults: Optional[dict] = None,
row_key: str = 'id',
title: Optional[str] = None,
selection: Literal[None, 'single', 'multiple'] = None,
pagination: Optional[Union[int, dict]] = None,
on_select: Optional[Handler[TableSelectionEventArguments]] = None,
on_pagination_change: Optional[Handler[ValueChangeEventArguments]] = None,
) -> None:
"""Table
A table based on Quasar's `QTable <https://quasar.dev/vue-components/table>`_ component.
Updates can be pushed to the table by updating the ``rows`` or ``columns`` properties.
If ``selection`` is "single" or "multiple", then a ``selected`` property is accessible containing the selected rows.
Note:
Cells in ``rows`` must not contain lists because they can cause the browser to crash.
To display complex data structures, convert them to strings first (e.g., using ``str()`` or custom formatting).
:param rows: list of row objects
:param columns: list of column objects (defaults to the columns of the first row *since version 2.0.0*)
:param column_defaults: optional default column properties, *added in version 2.0.0*
:param row_key: name of the column containing unique data identifying the row (default: "id")
:param title: title of the table
:param selection: selection type ("single" or "multiple"; default: `None`)
:param pagination: a dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
:param on_select: callback which is invoked when the selection changes
:param on_pagination_change: callback which is invoked when the pagination changes
"""
super().__init__()
if columns is None:
first_row = rows[0] if rows else {}
columns = [{'name': key, 'label': str(key).upper(), 'field': key, 'sortable': True} for key in first_row]
self._column_defaults = column_defaults
self._use_columns_from_df = False
self._props['columns'] = self._normalize_columns(columns)
self._props['rows'] = rows
self._props['row-key'] = row_key
self._props['title'] = title
self._props['hide-pagination'] = pagination is None
self._props['pagination'] = pagination if isinstance(pagination, dict) else {'rowsPerPage': pagination or 0}
self._props['selection'] = selection or 'none'
self._props['selected'] = []
self._props['fullscreen'] = False
self._selection_handlers = [on_select] if on_select else []
self._pagination_change_handlers = [on_pagination_change] if on_pagination_change else []
def handle_selection(e: GenericEventArguments) -> None:
if e.args['added']:
if self.selection == 'single':
self.selected.clear()
self.selected.extend(e.args['rows'])
else:
self.selected = [row for row in self.selected if row[self.row_key] not in e.args['keys']]
arguments = TableSelectionEventArguments(sender=self, client=self.client, selection=self.selected)
for handler in self._selection_handlers:
handle_event(handler, arguments)
self.on('selection', handle_selection, ['added', 'rows', 'keys'])
def handle_pagination_change(e: GenericEventArguments) -> None:
previous_value = self.pagination
self.pagination = e.args
arguments = ValueChangeEventArguments(sender=self, client=self.client,
value=self.pagination, previous_value=previous_value)
for handler in self._pagination_change_handlers:
handle_event(handler, arguments)
self.on('update:pagination', handle_pagination_change)
def _to_dict(self) -> dict[str, Any]:
# scan rows for lists and add slot templates if needed
for column in self._props['columns']:
field = column.get('field')
name = column.get('name')
if not field or not name or f'body-cell-{name}' in self.slots:
continue
for row in self._props['rows']:
value = row.get(field)
if isinstance(value, (list, set, tuple)):
log.warning(
f'Found list in column "{name}": {value}.\n'
'Unless there is slot template, table rows must not contain lists or the browser will crash.\n'
'NiceGUI is intervening by adding a slot template to display the list as comma-separated values.'
)
self.add_slot(f'body-cell-{name}', '''
<td class="text-right" :props="props">
{{ Array.isArray(props.value) ? props.value.join(', ') : props.value }}
</td>
''')
break
return super()._to_dict()
def on_select(self, callback: Handler[TableSelectionEventArguments]) -> Self:
"""Add a callback to be invoked when the selection changes."""
self._selection_handlers.append(callback)
return self
def on_pagination_change(self, callback: Handler[ValueChangeEventArguments]) -> Self:
"""Add a callback to be invoked when the pagination changes."""
self._pagination_change_handlers.append(callback)
return self
def _normalize_columns(self, columns: list[dict]) -> list[dict]:
return [{**self._column_defaults, **column} for column in columns] if self._column_defaults else columns
@classmethod
def from_pandas(cls,
df: 'pd.DataFrame', *,
columns: Optional[list[dict]] = None,
column_defaults: Optional[dict] = None,
row_key: str = 'id',
title: Optional[str] = None,
selection: Optional[Literal['single', 'multiple']] = None,
pagination: Optional[Union[int, dict]] = None,
on_select: Optional[Handler[TableSelectionEventArguments]] = None) -> Self:
"""Create a table from a Pandas DataFrame.
Note:
If the DataFrame contains non-serializable columns of type `datetime64[ns]`, `timedelta64[ns]`, `complex128` or `period[M]`,
they will be converted to strings.
To use a different conversion, convert the DataFrame manually before passing it to this method.
See `issue 1698 <https://github.com/zauberzeug/nicegui/issues/1698>`_ for more information.
*Added in version 2.0.0*
:param df: Pandas DataFrame
:param columns: list of column objects (defaults to the columns of the dataframe)
:param column_defaults: optional default column properties
:param row_key: name of the column containing unique data identifying the row (default: "id")
:param title: title of the table
:param selection: selection type ("single" or "multiple"; default: `None`)
:param pagination: a dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
:param on_select: callback which is invoked when the selection changes
:return: table element
"""
rows, columns_from_df = cls._pandas_df_to_rows_and_columns(df)
table = cls(
rows=rows,
columns=columns or columns_from_df,
column_defaults=column_defaults,
row_key=row_key,
title=title,
selection=selection,
pagination=pagination,
on_select=on_select,
)
table._use_columns_from_df = columns is None
return table
@classmethod
def from_polars(cls,
df: 'pl.DataFrame', *,
columns: Optional[list[dict]] = None,
column_defaults: Optional[dict] = None,
row_key: str = 'id',
title: Optional[str] = None,
selection: Optional[Literal['single', 'multiple']] = None,
pagination: Optional[Union[int, dict]] = None,
on_select: Optional[Handler[TableSelectionEventArguments]] = None) -> Self:
"""Create a table from a Polars DataFrame.
Note:
If the DataFrame contains non-UTF-8 datatypes, they will be converted to strings.
To use a different conversion, convert the DataFrame manually before passing it to this method.
*Added in version 2.7.0*
:param df: Polars DataFrame
:param columns: list of column objects (defaults to the columns of the dataframe)
:param column_defaults: optional default column properties
:param row_key: name of the column containing unique data identifying the row (default: "id")
:param title: title of the table
:param selection: selection type ("single" or "multiple"; default: `None`)
:param pagination: a dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
:param on_select: callback which is invoked when the selection changes
:return: table element
"""
rows, columns_from_df = cls._polars_df_to_rows_and_columns(df)
table = cls(
rows=rows,
columns=columns or columns_from_df,
column_defaults=column_defaults,
row_key=row_key,
title=title,
selection=selection,
pagination=pagination,
on_select=on_select,
)
table._use_columns_from_df = columns is None
return table
def update_from_pandas(self,
df: 'pd.DataFrame', *,
clear_selection: bool = True,
columns: Optional[list[dict]] = None,
column_defaults: Optional[dict] = None) -> None:
"""Update the table from a Pandas DataFrame.
See `from_pandas()` for more information about the conversion of non-serializable columns.
If `columns` is not provided and the columns had been inferred from a DataFrame,
the columns will be updated to match the new DataFrame.
:param df: Pandas DataFrame
:param clear_selection: whether to clear the selection (default: True)
:param columns: list of column objects (defaults to the columns of the dataframe)
:param column_defaults: optional default column properties
"""
rows, columns_from_df = self._pandas_df_to_rows_and_columns(df)
self._update_table(rows, columns_from_df, clear_selection, columns, column_defaults)
def update_from_polars(self,
df: 'pl.DataFrame', *,
clear_selection: bool = True,
columns: Optional[list[dict]] = None,
column_defaults: Optional[dict] = None) -> None:
"""Update the table from a Polars DataFrame.
:param df: Polars DataFrame
:param clear_selection: whether to clear the selection (default: True)
:param columns: list of column objects (defaults to the columns of the dataframe)
:param column_defaults: optional default column properties
"""
rows, columns_from_df = self._polars_df_to_rows_and_columns(df)
self._update_table(rows, columns_from_df, clear_selection, columns, column_defaults)
def _update_table(self,
rows: list[dict],
columns_from_df: list[dict],
clear_selection: bool,
columns: Optional[list[dict]],
column_defaults: Optional[dict]) -> None:
"""Helper function to update the table."""
self.rows[:] = rows
if column_defaults is not None:
self._column_defaults = column_defaults
if columns or self._use_columns_from_df:
self.columns[:] = self._normalize_columns(columns or columns_from_df)
if clear_selection:
self.selected.clear()
@staticmethod
def _pandas_df_to_rows_and_columns(df: 'pd.DataFrame') -> tuple[list[dict], list[dict]]:
import pandas as pd # pylint: disable=import-outside-toplevel
def is_special_dtype(dtype):
return (pd.api.types.is_datetime64_any_dtype(dtype) or
pd.api.types.is_timedelta64_dtype(dtype) or
pd.api.types.is_complex_dtype(dtype) or
pd.api.types.is_object_dtype(dtype) or
isinstance(dtype, (pd.PeriodDtype, pd.IntervalDtype)))
special_cols = df.columns[df.dtypes.apply(is_special_dtype)]
if not special_cols.empty:
df = df.copy()
df[special_cols] = df[special_cols].astype(str)
if isinstance(df.columns, pd.MultiIndex):
raise ValueError('MultiIndex columns are not supported. '
'You can convert them to strings using something like '
'`df.columns = ["_".join(col) for col in df.columns.values]`.')
return df.to_dict('records'), [{'name': col, 'label': col, 'field': col} for col in df.columns]
@staticmethod
def _polars_df_to_rows_and_columns(df: 'pl.DataFrame') -> tuple[list[dict], list[dict]]:
return df.to_dicts(), [{'name': col, 'label': col, 'field': col} for col in df.columns]
@property
def rows(self) -> list[dict]:
"""List of rows."""
return self._props['rows']
@rows.setter
def rows(self, value: list[dict]) -> None:
self._props['rows'] = value
@property
def columns(self) -> list[dict]:
"""List of columns."""
return self._props['columns']
@columns.setter
def columns(self, value: list[dict]) -> None:
self._props['columns'] = self._normalize_columns(value)
@property
def column_defaults(self) -> Optional[dict]:
"""Default column properties."""
return self._column_defaults
@column_defaults.setter
def column_defaults(self, value: Optional[dict]) -> None:
self._column_defaults = value
self.columns = self.columns # re-normalize columns
@property
def row_key(self) -> str:
"""Name of the column containing unique data identifying the row."""
return self._props['row-key']
@row_key.setter
def row_key(self, value: str) -> None:
self._props['row-key'] = value
@property
def selected(self) -> list[dict]:
"""List of selected rows."""
return self._props['selected']
@selected.setter
def selected(self, value: list[dict]) -> None:
self._props['selected'] = value
@property
def selection(self) -> Literal[None, 'single', 'multiple']:
"""Selection type.
*Added in version 2.11.0*
"""
return None if self._props['selection'] == 'none' else self._props['selection']
@selection.setter
def selection(self, value: Literal[None, 'single', 'multiple']) -> None:
self._props['selection'] = value or 'none'
def set_selection(self, value: Literal[None, 'single', 'multiple']) -> None:
"""Set the selection type.
*Added in version 2.11.0*
"""
self.selection = value
@property
def pagination(self) -> dict:
"""Pagination object."""
return self._props['pagination']
@pagination.setter
def pagination(self, value: dict) -> None:
self._props['pagination'] = value
@property
def is_fullscreen(self) -> bool:
"""Whether the table is in fullscreen mode."""
return self._props['fullscreen']
@is_fullscreen.setter
def is_fullscreen(self, value: bool) -> None:
"""Set fullscreen mode."""
self._props['fullscreen'] = value
def set_fullscreen(self, value: bool) -> None:
"""Set fullscreen mode."""
self.is_fullscreen = value
def toggle_fullscreen(self) -> None:
"""Toggle fullscreen mode."""
self.is_fullscreen = not self.is_fullscreen
def add_rows(self, rows: list[dict]) -> None:
"""Add rows to the table."""
self.rows.extend(rows)
def add_row(self, row: dict) -> None:
"""Add a single row to the table."""
self.add_rows([row])
def remove_rows(self, rows: list[dict]) -> None:
"""Remove rows from the table."""
keys = [row[self.row_key] for row in rows]
self.rows[:] = [row for row in self.rows if row[self.row_key] not in keys]
self.selected[:] = [row for row in self.selected if row[self.row_key] not in keys]
def remove_row(self, row: dict) -> None:
"""Remove a single row from the table."""
self.remove_rows([row])
def update_rows(self, rows: list[dict], *, clear_selection: bool = True) -> None:
"""Update rows in the table.
:param rows: list of rows to update
:param clear_selection: whether to clear the selection (default: True)
"""
self.rows[:] = rows
if clear_selection:
self.selected.clear()
async def get_filtered_sorted_rows(self, *, timeout: float = 1) -> list[dict]:
"""Asynchronously return the filtered and sorted rows of the table."""
return await self.get_computed_prop('filteredSortedRows', timeout=timeout)
async def get_computed_rows(self, *, timeout: float = 1) -> list[dict]:
"""Asynchronously return the computed rows of the table."""
return await self.get_computed_prop('computedRows', timeout=timeout)
async def get_computed_rows_number(self, *, timeout: float = 1) -> int:
"""Asynchronously return the number of computed rows of the table."""
return await self.get_computed_prop('computedRowsNumber', timeout=timeout)
class row(Element):
def __init__(self) -> None:
"""Row Element
This element is based on Quasar's `QTr <https://quasar.dev/vue-components/table#qtr-api>`_ component.
"""
super().__init__('q-tr')
class header(Element):
def __init__(self) -> None:
"""Header Element
This element is based on Quasar's `QTh <https://quasar.dev/vue-components/table#qth-api>`_ component.
"""
super().__init__('q-th')
class cell(Element):
def __init__(self) -> None:
"""Cell Element
This element is based on Quasar's `QTd <https://quasar.dev/vue-components/table#qtd-api>`_ component.
"""
super().__init__('q-td')