Skip to content

Commit 1d55fed

Browse files
committed
add datatable widget
1 parent c0c3447 commit 1d55fed

File tree

4 files changed

+686
-4
lines changed

4 files changed

+686
-4
lines changed

pywebio/html/css/app.css

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,78 @@ details[open]>summary {
373373
color: #6c757d;
374374
line-height: 14px;
375375
vertical-align: text-top;
376+
}
377+
378+
/* ag-grid datatable */
379+
.ag-grid-cell-bar, .ag-grid-tools {
380+
border-left: solid 1px #bdc3c7;
381+
border-right: solid 1px #bdc3c7;
382+
border-bottom: solid 1px #bdc3c7;
383+
font-size: 13px;
384+
line-height: 16px;
385+
}
386+
387+
.ag-grid-cell-bar {
388+
display: none;
389+
padding: 4px 12px;
390+
word-break: break-word;
391+
min-height: 24px;
392+
}
393+
394+
.ag-grid-tools {
395+
display: -webkit-flex; /* Safari */
396+
display: flex;
397+
align-items: center;
398+
min-height: 23px;
399+
font-weight: 600;
400+
font-size: 12px;
401+
opacity: 0;
402+
}
403+
404+
.ag-grid-tools > .grid-status {
405+
display: -webkit-flex; /* Safari */
406+
display: flex;
407+
align-items: center;
408+
flex-shrink: 0;; /* don't compress me when there no more space */
409+
margin: 0 12px;
410+
color: rgba(0, 0, 0, 0.38);
411+
min-width: 170px;
412+
}
413+
414+
.ag-grid-tools .select-count {
415+
padding-right: 8px;
416+
}
417+
418+
.ag-grid-tools > .grid-actions {
419+
flex-grow: 1; /* use left space */
420+
display: -webkit-flex; /* Safari */
421+
display: flex;
422+
flex-wrap: wrap;
423+
justify-content: flex-end;
424+
align-items: center;
425+
}
426+
427+
.ag-grid-tools .sep {
428+
background-color: rgba(189, 195, 199, 0.5);
429+
width: 1px;
430+
height: 14px;
431+
}
432+
433+
.ag-grid-tools .act-btn {
434+
font-weight: 600;
435+
font-size: 12px;
436+
box-shadow: none;
437+
color: #0000008a;
438+
cursor: pointer;
439+
padding: 3px 8px;
440+
border: none;
441+
border-radius: 0;
442+
}
443+
444+
.ag-grid-tools .act-btn:hover {
445+
background-color: #f1f3f4;
446+
}
447+
448+
.ag-grid-tools .act-btn:active {
449+
background-color: #dadada;
376450
}

pywebio/output.py

Lines changed: 242 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,19 @@
4242
| +---------------------------+------------------------------------------------------------+
4343
| | `put_link` | Output link |
4444
| +---------------------------+------------------------------------------------------------+
45-
| | `put_progressbar` | Output a progress bar |
45+
| | `put_progressbar` | Output a progress bar |
4646
| +---------------------------+------------------------------------------------------------+
4747
| | `put_loading`:sup:`†` | Output loading prompt |
4848
| +---------------------------+------------------------------------------------------------+
4949
| | `put_code` | Output code block |
5050
| +---------------------------+------------------------------------------------------------+
5151
| | `put_table`:sup:`*` | Output table |
5252
| +---------------------------+------------------------------------------------------------+
53+
| | | `put_datatable` | Output and update data table |
54+
| | | `datatable_update` | |
55+
| | | `datatable_insert` | |
56+
| | | `datatable_remove` | |
57+
| +---------------------------+------------------------------------------------------------+
5358
| | | `put_button` | Output button and bind click event |
5459
| | | `put_buttons` | |
5560
| +---------------------------+------------------------------------------------------------+
@@ -186,6 +191,10 @@
186191
.. autofunction:: put_tabs
187192
.. autofunction:: put_collapse
188193
.. autofunction:: put_scrollable
194+
.. autofunction:: put_datatable
195+
.. autofunction:: datatable_update
196+
.. autofunction:: datatable_insert
197+
.. autofunction:: datatable_remove
189198
.. autofunction:: put_widget
190199
191200
Other Interactions
@@ -208,14 +217,23 @@
208217
import copy
209218
import html
210219
import io
220+
import json
211221
import logging
212222
import string
213223
from base64 import b64encode
214224
from collections.abc import Mapping, Sequence
215225
from functools import wraps
216-
from typing import Any, Callable, Dict, List, Tuple, Union, Sequence as SequenceType
226+
from typing import (
227+
Any, Callable, Dict, List, Tuple, Union, Sequence as SequenceType, Mapping as MappingType
228+
)
229+
230+
try:
231+
from typing import Literal # added in Python 3.8
232+
except ImportError:
233+
pass
217234

218-
from .io_ctrl import output_register_callback, send_msg, Output, safely_destruct_output_when_exp, OutputList, scope2dom
235+
from .io_ctrl import output_register_callback, send_msg, Output, \
236+
safely_destruct_output_when_exp, OutputList, scope2dom
219237
from .session import get_current_session, download
220238
from .utils import random_str, iscoroutinefunction, check_dom_name_value
221239

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

236255

237256
# popup size
@@ -1455,6 +1474,225 @@ def put_scope(name: str, content: Union[Output, List[Output]] = [], scope: str =
14551474
return Output(spec)
14561475

14571476

1477+
class JSFunction:
1478+
def __init__(self, *params_and_body: str):
1479+
if not params_and_body:
1480+
raise ValueError('JSFunction must have at least body')
1481+
self.params = params_and_body[:-1]
1482+
self.body = params_and_body[-1]
1483+
1484+
1485+
def put_datatable(
1486+
records: SequenceType[MappingType],
1487+
actions: SequenceType[Tuple[str, Callable[[Union[str, int, List[Union[str, int]]]], None]]] = None,
1488+
onselect: Callable[[Union[str, int, List[Union[str, int]]]], None] = None,
1489+
multiple_select=False,
1490+
id_field: str = None,
1491+
height: Union[str, int] = 600,
1492+
theme: "Literal['alpine', 'alpine-dark', 'balham', 'balham-dark', 'material']" = 'balham',
1493+
cell_content_bar=True,
1494+
instance_id='',
1495+
column_args: MappingType[Union[str, Tuple], MappingType] = None,
1496+
grid_args: MappingType[str, MappingType] = None,
1497+
enterprise_key='',
1498+
scope: str = None,
1499+
position: int = OutputPosition.BOTTOM
1500+
) -> Output:
1501+
"""
1502+
Output a datatable.
1503+
This widget is powered by the awesome `ag-grid <https://www.ag-grid.com/>`_ library.
1504+
1505+
:param list[dict] records: data of rows, each row is a python ``dict``, which can be nested.
1506+
:param list actions: actions for selected row(s), they will be shown as buttons when row is selected.
1507+
The format of the action item: `(button_label:str, on_click:callable)`.
1508+
The ``on_click`` callback receives the selected raw ID as parameter.
1509+
:param callable onselect: callback when row is selected, receives the selected raw ID as parameter.
1510+
:param bool multiple_select: whether multiple rows can be selected.
1511+
When enabled, the ``on_click`` callback in ``actions`` and the ``onselect`` callback will receive
1512+
ID list of selected raws as parameter.
1513+
:param str/tuple id_field: row ID field, that is, the key of the row dict to uniquely identifies a row.
1514+
If the value is a tuple, it will be used as the nested key path.
1515+
When not provide, the datatable will use the index in ``records`` to assign row ID.
1516+
:param int/str height: widget height. When pass ``int`` type, the unit is pixel,
1517+
when pass ``str`` type, you can specify any valid CSS height value.
1518+
:param str theme: datatable theme.
1519+
Available themes are: 'balham' (default), 'alpine', 'alpine-dark', 'balham-dark', 'material'.
1520+
:param bool cell_content_bar: whether to add a text bar to datatable to show the content of current focused cell.
1521+
:param str instance_id: Assign a unique ID to the datatable, so that you can refer this datatable in
1522+
`datatable_update()`, `datatable_insert()` and `datatable_remove()` functions.
1523+
When provided, the ag-grid ``gridOptions`` object can be accessed with JS global variable ``ag_grid_{instance_id}_promise``.
1524+
:param column_args: column properties.
1525+
Dict type, the key is str or tuple to specify the column field, the value is
1526+
`ag-grid column properties <https://www.ag-grid.com/javascript-data-grid/column-properties/>`_ in dict.
1527+
:param grid_args: ag-grid grid options.
1528+
Visit `ag-grid doc - grid options <https://www.ag-grid.com/javascript-data-grid/grid-options/>`_ for more information.
1529+
:param str enterprise_key: `ag-grid enterprise <https://www.ag-grid.com/javascript-data-grid/licensing/>`_ license key.
1530+
When not provided, will use the ag-grid community version.
1531+
1532+
To pass JS function as value of ``column_args`` or ``grid_args``, you can use ``JSFunction`` object:
1533+
1534+
.. py:function:: JSFunction([param1], [param2], ... , [param n], body)
1535+
1536+
Example::
1537+
1538+
JSFunction("return new Date()")
1539+
JSFunction("a", "b", "return a+b;")
1540+
"""
1541+
actions = actions or []
1542+
column_args = column_args or {}
1543+
grid_args = grid_args or {}
1544+
1545+
if isinstance(height, int):
1546+
height = f"{height}px"
1547+
if isinstance(id_field, str):
1548+
id_field = [id_field]
1549+
1550+
js_func_key = random_str(10)
1551+
1552+
def json_encoder(obj):
1553+
if isinstance(obj, JSFunction):
1554+
return dict(
1555+
__pywebio_js_function__=js_func_key,
1556+
params=obj.params,
1557+
body=obj.body,
1558+
)
1559+
raise TypeError
1560+
1561+
column_args = json.loads(json.dumps(column_args, default=json_encoder))
1562+
grid_args = json.loads(json.dumps(grid_args, default=json_encoder))
1563+
1564+
def callback(data: Dict):
1565+
rows = data['rows'] if multiple_select else data['rows'][0]
1566+
1567+
if "btn" not in data and onselect is not None:
1568+
return onselect(rows)
1569+
1570+
_, cb = actions[data['btn']]
1571+
return cb(rows)
1572+
1573+
callback_id = None
1574+
if actions or onselect:
1575+
callback_id = output_register_callback(callback)
1576+
1577+
action_labels = [a[0] if a else None for a in actions]
1578+
field_args = {k: v for k, v in column_args.items() if isinstance(k, str)}
1579+
path_args = [(k, v) for k, v in column_args.items() if not isinstance(k, str)]
1580+
spec = _get_output_spec(
1581+
'datatable',
1582+
records=records, callback_id=callback_id, actions=action_labels, on_select=onselect is not None,
1583+
id_field=id_field,
1584+
multiple_select=multiple_select, field_args=field_args, path_args=path_args,
1585+
grid_args=grid_args, js_func_key=js_func_key, cell_content_bar=cell_content_bar,
1586+
height=height, theme=theme, enterprise_key=enterprise_key,
1587+
instance_id=instance_id,
1588+
scope=scope, position=position
1589+
)
1590+
return Output(spec)
1591+
1592+
1593+
def datatable_update(
1594+
instance_id: str,
1595+
data: Any,
1596+
row_id: Union[int, str] = None,
1597+
field: Union[str, List[str], Tuple[str]] = None
1598+
):
1599+
"""
1600+
Update the whole data / a row / a cell in datatable.
1601+
1602+
To use `datatable_update()`, you need to specify the ``instance_id`` parameter when calling :py:func:`put_datatable()`.
1603+
1604+
When ``row_id`` and ``field`` is not specified, the whole data of datatable will be updated, in this case,
1605+
the ``data`` parameter should be a list of dict (same as ``records`` in :py:func:`put_datatable()`).
1606+
1607+
To update a row, specify the ``row_id`` parameter and pass the row data in dict to ``data`` parameter.
1608+
See ``id_field`` of :py:func:`put_datatable()` for more info of ``row_id``.
1609+
1610+
To update a cell, specify the ``row_id`` and ``field`` parameters, in this case, the ``data`` parameter should be the cell value.
1611+
The ``field`` can be a tuple to indicate nested key path.
1612+
"""
1613+
from .session import run_js
1614+
1615+
instance_id = f"ag_grid_{instance_id}_promise"
1616+
if row_id is None and field is None: # update whole table
1617+
run_js("""window[instance_id].then((grid) => {
1618+
grid.api.setRowData(data.map((row) => grid.flatten_row(row)))
1619+
});
1620+
""", instance_id=instance_id, data=data)
1621+
1622+
if row_id is not None and field is None: # update whole row
1623+
run_js("""window[instance_id].then((grid) => {
1624+
let row = grid.api.getRowNode(row_id);
1625+
if (row) row.setData(grid.flatten_row(data))
1626+
});
1627+
""", instance_id=instance_id, row_id=row_id, data=data)
1628+
1629+
if row_id is not None and field is not None: # update field
1630+
if not isinstance(field, (list, tuple)):
1631+
field = [field]
1632+
run_js("""window[instance_id].then((grid) => {
1633+
let row = grid.api.getRowNode(row_id);
1634+
if (row)
1635+
row.setDataValue(grid.path2field(path), data) &&
1636+
grid.api.refreshClientSideRowModel();
1637+
});
1638+
""", instance_id=instance_id, row_id=row_id, data=data, path=field)
1639+
1640+
if row_id is None and field is not None:
1641+
raise ValueError("`row_id` is required when provide `field`")
1642+
1643+
1644+
def datatable_insert(instance_id: str, records: List, row_id=None):
1645+
"""
1646+
Insert rows to datatable.
1647+
1648+
:param str instance_id: Datatable instance id
1649+
(i.e., the ``instance_id`` parameter when calling :py:func:`put_datatable()`)
1650+
:param dict/list[dict] records: row record or row record list to insert
1651+
:param str/int row_id: row id to insert before, if not specified, insert to the end
1652+
1653+
Note:
1654+
When use ``id_field=None`` (default) in :py:func:`put_datatable()`, the row id of new inserted rows will
1655+
auto increase from the last max row id.
1656+
"""
1657+
from .session import run_js
1658+
1659+
if not isinstance(records, (list, tuple)):
1660+
records = [records]
1661+
1662+
instance_id = f"ag_grid_{instance_id}_promise"
1663+
run_js("""window[instance_id].then((grid) => {
1664+
let row = grid.api.getRowNode(row_id);
1665+
let idx = row ? row.rowIndex : null;
1666+
grid.api.applyTransaction({
1667+
add: records.map((row) => grid.flatten_row(row)),
1668+
addIndex: idx,
1669+
});
1670+
});""", instance_id=instance_id, records=records, row_id=row_id)
1671+
1672+
1673+
def datatable_remove(instance_id: str, row_ids: List):
1674+
"""
1675+
Remove rows from datatable.
1676+
1677+
:param str instance_id: Datatable instance id
1678+
(i.e., the ``instance_id`` parameter when calling :py:func:`put_datatable()`)
1679+
:param int/str/list row_ids: row id or row id list to remove
1680+
"""
1681+
from .session import run_js
1682+
1683+
instance_id = f"ag_grid_{instance_id}_promise"
1684+
if not isinstance(row_ids, (list, tuple)):
1685+
row_ids = [row_ids]
1686+
run_js("""window[instance_id].then((grid) => {
1687+
let remove_rows = [];
1688+
for (let row_id of row_ids) {
1689+
let row = grid.api.getRowNode(row_id);
1690+
if (row) remove_rows.push(row.data);
1691+
}
1692+
grid.api.applyTransaction({remove: remove_rows});
1693+
});""", instance_id=instance_id, row_ids=row_ids)
1694+
1695+
14581696
@safely_destruct_output_when_exp('contents')
14591697
def output(*contents):
14601698
"""Placeholder of output

pywebio/platform/tpl/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
require.config({
7979
paths: {
8080
'plotly': "https://cdn.plot.ly/plotly-2.12.1.min",
81+
"ag-grid": "https://unpkg.com/ag-grid-community/dist/ag-grid-community.min",
82+
"ag-grid-enterprise": "https://unpkg.com/[email protected]/dist/ag-grid-enterprise.min",
8183
},
8284
});
8385

0 commit comments

Comments
 (0)