Skip to content

Commit

Permalink
add datatable widget
Browse files Browse the repository at this point in the history
  • Loading branch information
wang0618 committed Jan 31, 2023
1 parent c0c3447 commit ee61b0e
Show file tree
Hide file tree
Showing 5 changed files with 720 additions and 5 deletions.
74 changes: 74 additions & 0 deletions pywebio/html/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,78 @@ details[open]>summary {
color: #6c757d;
line-height: 14px;
vertical-align: text-top;
}

/* ag-grid datatable */
.ag-grid-cell-bar, .ag-grid-tools {
border-left: solid 1px #bdc3c7;
border-right: solid 1px #bdc3c7;
border-bottom: solid 1px #bdc3c7;
font-size: 13px;
line-height: 16px;
}

.ag-grid-cell-bar {
display: none;
padding: 4px 12px;
word-break: break-word;
min-height: 24px;
}

.ag-grid-tools {
display: -webkit-flex; /* Safari */
display: flex;
align-items: center;
min-height: 23px;
font-weight: 600;
font-size: 12px;
opacity: 0;
}

.ag-grid-tools > .grid-status {
display: -webkit-flex; /* Safari */
display: flex;
align-items: center;
flex-shrink: 0;; /* don't compress me when there no more space */
margin: 0 12px;
color: rgba(0, 0, 0, 0.38);
min-width: 170px;
}

.ag-grid-tools .select-count {
padding-right: 8px;
}

.ag-grid-tools > .grid-actions {
flex-grow: 1; /* use left space */
display: -webkit-flex; /* Safari */
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}

.ag-grid-tools .sep {
background-color: rgba(189, 195, 199, 0.5);
width: 1px;
height: 14px;
}

.ag-grid-tools .act-btn {
font-weight: 600;
font-size: 12px;
box-shadow: none;
color: #0000008a;
cursor: pointer;
padding: 3px 8px;
border: none;
border-radius: 0;
}

.ag-grid-tools .act-btn:hover {
background-color: #f1f3f4;
}

.ag-grid-tools .act-btn:active {
background-color: #dadada;
}
267 changes: 263 additions & 4 deletions pywebio/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,19 @@
| +---------------------------+------------------------------------------------------------+
| | `put_link` | Output link |
| +---------------------------+------------------------------------------------------------+
| | `put_progressbar` | Output a progress bar |
| | `put_progressbar` | Output a progress bar |
| +---------------------------+------------------------------------------------------------+
| | `put_loading`:sup:`†` | Output loading prompt |
| +---------------------------+------------------------------------------------------------+
| | `put_code` | Output code block |
| +---------------------------+------------------------------------------------------------+
| | `put_table`:sup:`*` | Output table |
| +---------------------------+------------------------------------------------------------+
| | | `put_datatable` | Output and update data table |
| | | `datatable_update` | |
| | | `datatable_insert` | |
| | | `datatable_remove` | |
| +---------------------------+------------------------------------------------------------+
| | | `put_button` | Output button and bind click event |
| | | `put_buttons` | |
| +---------------------------+------------------------------------------------------------+
Expand Down Expand Up @@ -186,6 +191,10 @@
.. autofunction:: put_tabs
.. autofunction:: put_collapse
.. autofunction:: put_scrollable
.. autofunction:: put_datatable
.. autofunction:: datatable_update
.. autofunction:: datatable_insert
.. autofunction:: datatable_remove
.. autofunction:: put_widget
Other Interactions
Expand All @@ -208,14 +217,23 @@
import copy
import html
import io
import json
import logging
import string
from base64 import b64encode
from collections.abc import Mapping, Sequence
from functools import wraps
from typing import Any, Callable, Dict, List, Tuple, Union, Sequence as SequenceType
from typing import (
Any, Callable, Dict, List, Tuple, Union, Sequence as SequenceType, Mapping as MappingType
)

try:
from typing import Literal # added in Python 3.8
except ImportError:
pass

from .io_ctrl import output_register_callback, send_msg, Output, safely_destruct_output_when_exp, OutputList, scope2dom
from .io_ctrl import output_register_callback, send_msg, Output, \
safely_destruct_output_when_exp, OutputList, scope2dom
from .session import get_current_session, download
from .utils import random_str, iscoroutinefunction, check_dom_name_value

Expand All @@ -231,7 +249,8 @@
'put_table', 'put_buttons', 'put_image', 'put_file', 'PopupSize', 'popup', 'put_button',
'close_popup', 'put_widget', 'put_collapse', 'put_link', 'put_scrollable', 'style', 'put_column',
'put_row', 'put_grid', 'span', 'put_progressbar', 'set_progressbar', 'put_processbar', 'set_processbar',
'put_loading', 'output', 'toast', 'get_scope', 'put_info', 'put_error', 'put_warning', 'put_success']
'put_loading', 'output', 'toast', 'get_scope', 'put_info', 'put_error', 'put_warning', 'put_success',
'put_datatable', 'datatable_update', 'datatable_insert', 'datatable_remove', 'JSFunction']


# popup size
Expand Down Expand Up @@ -1455,6 +1474,246 @@ def put_scope(name: str, content: Union[Output, List[Output]] = [], scope: str =
return Output(spec)


class JSFunction:
def __init__(self, *params_and_body: str):
if not params_and_body:
raise ValueError('JSFunction must have at least body')
self.params = params_and_body[:-1]
self.body = params_and_body[-1]


def put_datatable(
records: SequenceType[MappingType],
actions: SequenceType[Tuple[str, Callable[[Union[str, int, List[Union[str, int]]]], None]]] = None,
onselect: Callable[[Union[str, int, List[Union[str, int]]]], None] = None,
multiple_select=False,
id_field: str = None,
height: Union[str, int] = 600,
theme: "Literal['alpine', 'alpine-dark', 'balham', 'balham-dark', 'material']" = 'balham',
cell_content_bar=True,
instance_id='',
column_args: MappingType[Union[str, Tuple], MappingType] = None,
grid_args: MappingType[str, MappingType] = None,
enterprise_key='',
scope: str = None,
position: int = OutputPosition.BOTTOM
) -> Output:
"""
Output a datatable.
This widget is powered by the awesome `ag-grid <https://www.ag-grid.com/>`_ library.
:param list[dict] records: data of rows, each row is a python ``dict``, which can be nested.
:param list actions: actions for selected row(s), they will be shown as buttons when row is selected.
The format of the action item: `(button_label:str, on_click:callable)`.
The ``on_click`` callback receives the selected raw ID as parameter.
:param callable onselect: callback when row is selected, receives the selected raw ID as parameter.
:param bool multiple_select: whether multiple rows can be selected.
When enabled, the ``on_click`` callback in ``actions`` and the ``onselect`` callback will receive
ID list of selected raws as parameter.
:param str/tuple id_field: row ID field, that is, the key of the row dict to uniquely identifies a row.
If the value is a tuple, it will be used as the nested key path.
When not provide, the datatable will use the index in ``records`` to assign row ID.
:param int/str height: widget height. When pass ``int`` type, the unit is pixel,
when pass ``str`` type, you can specify any valid CSS height value.
:param str theme: datatable theme.
Available themes are: 'balham' (default), 'alpine', 'alpine-dark', 'balham-dark', 'material'.
:param bool cell_content_bar: whether to add a text bar to datatable to show the content of current focused cell.
:param str instance_id: Assign a unique ID to the datatable, so that you can refer this datatable in
`datatable_update()`, `datatable_insert()` and `datatable_remove()` functions.
When provided, the ag-grid ``gridOptions`` object can be accessed with JS global variable ``ag_grid_{instance_id}_promise``.
:param column_args: column properties.
Dict type, the key is str or tuple to specify the column field, the value is
`ag-grid column properties <https://www.ag-grid.com/javascript-data-grid/column-properties/>`_ in dict.
:param grid_args: ag-grid grid options.
Visit `ag-grid doc - grid options <https://www.ag-grid.com/javascript-data-grid/grid-options/>`_ for more information.
:param str enterprise_key: `ag-grid enterprise <https://www.ag-grid.com/javascript-data-grid/licensing/>`_ license key.
When not provided, will use the ag-grid community version.
To pass JS function as value of ``column_args`` or ``grid_args``, you can use ``JSFunction`` object:
.. py:function:: JSFunction([param1], [param2], ... , [param n], body)
Example::
JSFunction("return new Date()")
JSFunction("a", "b", "return a+b;")
Example:
.. exportable-codeblock::
:name: datatable
:summary: `put_datatable()` usage
import urllib.request
import json
with urllib.request.urlopen('https://fakerapi.it/api/v1/persons?_quantity=30') as f:
data = json.load(f)['data']
put_datatable(
data,
actions=[
("Delete", lambda row_id: datatable_remove('persons', row_id))
],
onselect=lambda row_id: toast('Selected row: %s' % row_id),
instance_id='persons'
)
"""
actions = actions or []
column_args = column_args or {}
grid_args = grid_args or {}

if isinstance(height, int):
height = f"{height}px"
if isinstance(id_field, str):
id_field = [id_field]

js_func_key = random_str(10)

def json_encoder(obj):
if isinstance(obj, JSFunction):
return dict(
__pywebio_js_function__=js_func_key,
params=obj.params,
body=obj.body,
)
raise TypeError

column_args = json.loads(json.dumps(column_args, default=json_encoder))
grid_args = json.loads(json.dumps(grid_args, default=json_encoder))

def callback(data: Dict):
rows = data['rows'] if multiple_select else data['rows'][0]

if "btn" not in data and onselect is not None:
return onselect(rows)

_, cb = actions[data['btn']]
return cb(rows)

callback_id = None
if actions or onselect:
callback_id = output_register_callback(callback)

action_labels = [a[0] if a else None for a in actions]
field_args = {k: v for k, v in column_args.items() if isinstance(k, str)}
path_args = [(k, v) for k, v in column_args.items() if not isinstance(k, str)]
spec = _get_output_spec(
'datatable',
records=records, callback_id=callback_id, actions=action_labels, on_select=onselect is not None,
id_field=id_field,
multiple_select=multiple_select, field_args=field_args, path_args=path_args,
grid_args=grid_args, js_func_key=js_func_key, cell_content_bar=cell_content_bar,
height=height, theme=theme, enterprise_key=enterprise_key,
instance_id=instance_id,
scope=scope, position=position
)
return Output(spec)


def datatable_update(
instance_id: str,
data: Any,
row_id: Union[int, str] = None,
field: Union[str, List[str], Tuple[str]] = None
):
"""
Update the whole data / a row / a cell in datatable.
To use `datatable_update()`, you need to specify the ``instance_id`` parameter when calling :py:func:`put_datatable()`.
When ``row_id`` and ``field`` is not specified, the whole data of datatable will be updated, in this case,
the ``data`` parameter should be a list of dict (same as ``records`` in :py:func:`put_datatable()`).
To update a row, specify the ``row_id`` parameter and pass the row data in dict to ``data`` parameter.
See ``id_field`` of :py:func:`put_datatable()` for more info of ``row_id``.
To update a cell, specify the ``row_id`` and ``field`` parameters, in this case, the ``data`` parameter should be the cell value.
The ``field`` can be a tuple to indicate nested key path.
"""
from .session import run_js

instance_id = f"ag_grid_{instance_id}_promise"
if row_id is None and field is None: # update whole table
run_js("""window[instance_id].then((grid) => {
grid.api.setRowData(data.map((row) => grid.flatten_row(row)))
});
""", instance_id=instance_id, data=data)

if row_id is not None and field is None: # update whole row
run_js("""window[instance_id].then((grid) => {
let row = grid.api.getRowNode(row_id);
if (row) row.setData(grid.flatten_row(data))
});
""", instance_id=instance_id, row_id=row_id, data=data)

if row_id is not None and field is not None: # update field
if not isinstance(field, (list, tuple)):
field = [field]
run_js("""window[instance_id].then((grid) => {
let row = grid.api.getRowNode(row_id);
if (row)
row.setDataValue(grid.path2field(path), data) &&
grid.api.refreshClientSideRowModel();
});
""", instance_id=instance_id, row_id=row_id, data=data, path=field)

if row_id is None and field is not None:
raise ValueError("`row_id` is required when provide `field`")


def datatable_insert(instance_id: str, records: List, row_id=None):
"""
Insert rows to datatable.
:param str instance_id: Datatable instance id
(i.e., the ``instance_id`` parameter when calling :py:func:`put_datatable()`)
:param dict/list[dict] records: row record or row record list to insert
:param str/int row_id: row id to insert before, if not specified, insert to the end
Note:
When use ``id_field=None`` (default) in :py:func:`put_datatable()`, the row id of new inserted rows will
auto increase from the last max row id.
"""
from .session import run_js

if not isinstance(records, (list, tuple)):
records = [records]

instance_id = f"ag_grid_{instance_id}_promise"
run_js("""window[instance_id].then((grid) => {
let row = grid.api.getRowNode(row_id);
let idx = row ? row.rowIndex : null;
grid.api.applyTransaction({
add: records.map((row) => grid.flatten_row(row)),
addIndex: idx,
});
});""", instance_id=instance_id, records=records, row_id=row_id)


def datatable_remove(instance_id: str, row_ids: List):
"""
Remove rows from datatable.
:param str instance_id: Datatable instance id
(i.e., the ``instance_id`` parameter when calling :py:func:`put_datatable()`)
:param int/str/list row_ids: row id or row id list to remove
"""
from .session import run_js

instance_id = f"ag_grid_{instance_id}_promise"
if not isinstance(row_ids, (list, tuple)):
row_ids = [row_ids]
run_js("""window[instance_id].then((grid) => {
let remove_rows = [];
for (let row_id of row_ids) {
let row = grid.api.getRowNode(row_id);
if (row) remove_rows.push(row.data);
}
grid.api.applyTransaction({remove: remove_rows});
});""", instance_id=instance_id, row_ids=row_ids)


@safely_destruct_output_when_exp('contents')
def output(*contents):
"""Placeholder of output
Expand Down
Loading

0 comments on commit ee61b0e

Please sign in to comment.