|
42 | 42 | | +---------------------------+------------------------------------------------------------+
|
43 | 43 | | | `put_link` | Output link |
|
44 | 44 | | +---------------------------+------------------------------------------------------------+
|
45 |
| -| | `put_progressbar` | Output a progress bar | |
| 45 | +| | `put_progressbar` | Output a progress bar | |
46 | 46 | | +---------------------------+------------------------------------------------------------+
|
47 | 47 | | | `put_loading`:sup:`†` | Output loading prompt |
|
48 | 48 | | +---------------------------+------------------------------------------------------------+
|
49 | 49 | | | `put_code` | Output code block |
|
50 | 50 | | +---------------------------+------------------------------------------------------------+
|
51 | 51 | | | `put_table`:sup:`*` | Output table |
|
52 | 52 | | +---------------------------+------------------------------------------------------------+
|
| 53 | +| | | `put_datatable` | Output and update data table | |
| 54 | +| | | `datatable_update` | | |
| 55 | +| | | `datatable_insert` | | |
| 56 | +| | | `datatable_remove` | | |
| 57 | +| +---------------------------+------------------------------------------------------------+ |
53 | 58 | | | | `put_button` | Output button and bind click event |
|
54 | 59 | | | | `put_buttons` | |
|
55 | 60 | | +---------------------------+------------------------------------------------------------+
|
|
186 | 191 | .. autofunction:: put_tabs
|
187 | 192 | .. autofunction:: put_collapse
|
188 | 193 | .. autofunction:: put_scrollable
|
| 194 | +.. autofunction:: put_datatable |
| 195 | +.. autofunction:: datatable_update |
| 196 | +.. autofunction:: datatable_insert |
| 197 | +.. autofunction:: datatable_remove |
189 | 198 | .. autofunction:: put_widget
|
190 | 199 |
|
191 | 200 | Other Interactions
|
|
208 | 217 | import copy
|
209 | 218 | import html
|
210 | 219 | import io
|
| 220 | +import json |
211 | 221 | import logging
|
212 | 222 | import string
|
213 | 223 | from base64 import b64encode
|
214 | 224 | from collections.abc import Mapping, Sequence
|
215 | 225 | 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 |
217 | 234 |
|
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 |
219 | 237 | from .session import get_current_session, download
|
220 | 238 | from .utils import random_str, iscoroutinefunction, check_dom_name_value
|
221 | 239 |
|
|
231 | 249 | 'put_table', 'put_buttons', 'put_image', 'put_file', 'PopupSize', 'popup', 'put_button',
|
232 | 250 | 'close_popup', 'put_widget', 'put_collapse', 'put_link', 'put_scrollable', 'style', 'put_column',
|
233 | 251 | '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'] |
235 | 254 |
|
236 | 255 |
|
237 | 256 | # popup size
|
@@ -1455,6 +1474,225 @@ def put_scope(name: str, content: Union[Output, List[Output]] = [], scope: str =
|
1455 | 1474 | return Output(spec)
|
1456 | 1475 |
|
1457 | 1476 |
|
| 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 | + |
1458 | 1696 | @safely_destruct_output_when_exp('contents')
|
1459 | 1697 | def output(*contents):
|
1460 | 1698 | """Placeholder of output
|
|
0 commit comments