Pārlūkot izejas kodu

add datatable widget

wangweimin 2 gadi atpakaļ
vecāks
revīzija
ee61b0ed38

+ 74 - 0
pywebio/html/css/app.css

@@ -373,4 +373,78 @@ details[open]>summary {
     color: #6c757d;
     color: #6c757d;
     line-height: 14px;
     line-height: 14px;
     vertical-align: text-top;
     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;
 }
 }

+ 263 - 4
pywebio/output.py

@@ -42,7 +42,7 @@ Functions list
 |                    +---------------------------+------------------------------------------------------------+
 |                    +---------------------------+------------------------------------------------------------+
 |                    | `put_link`                | Output link                                                |
 |                    | `put_link`                | Output link                                                |
 |                    +---------------------------+------------------------------------------------------------+
 |                    +---------------------------+------------------------------------------------------------+
-|                    | `put_progressbar`          | Output a progress bar                                       |
+|                    | `put_progressbar`         | Output a progress bar                                      |
 |                    +---------------------------+------------------------------------------------------------+
 |                    +---------------------------+------------------------------------------------------------+
 |                    | `put_loading`:sup:`†`     | Output loading prompt                                      |
 |                    | `put_loading`:sup:`†`     | Output loading prompt                                      |
 |                    +---------------------------+------------------------------------------------------------+
 |                    +---------------------------+------------------------------------------------------------+
@@ -50,6 +50,11 @@ Functions list
 |                    +---------------------------+------------------------------------------------------------+
 |                    +---------------------------+------------------------------------------------------------+
 |                    | `put_table`:sup:`*`       | Output table                                               |
 |                    | `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_button`            | Output button and bind click event                         |
 |                    | | `put_buttons`           |                                                            |
 |                    | | `put_buttons`           |                                                            |
 |                    +---------------------------+------------------------------------------------------------+
 |                    +---------------------------+------------------------------------------------------------+
@@ -186,6 +191,10 @@ index equal ``position``:
 .. autofunction:: put_tabs
 .. autofunction:: put_tabs
 .. autofunction:: put_collapse
 .. autofunction:: put_collapse
 .. autofunction:: put_scrollable
 .. autofunction:: put_scrollable
+.. autofunction:: put_datatable
+.. autofunction:: datatable_update
+.. autofunction:: datatable_insert
+.. autofunction:: datatable_remove
 .. autofunction:: put_widget
 .. autofunction:: put_widget
 
 
 Other Interactions
 Other Interactions
@@ -208,14 +217,23 @@ Layout and Style
 import copy
 import copy
 import html
 import html
 import io
 import io
+import json
 import logging
 import logging
 import string
 import string
 from base64 import b64encode
 from base64 import b64encode
 from collections.abc import Mapping, Sequence
 from collections.abc import Mapping, Sequence
 from functools import wraps
 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 .session import get_current_session, download
 from .utils import random_str, iscoroutinefunction, check_dom_name_value
 from .utils import random_str, iscoroutinefunction, check_dom_name_value
 
 
@@ -231,7 +249,8 @@ __all__ = ['Position', 'OutputPosition', 'remove', 'scroll_to', 'put_tabs', 'put
            'put_table', 'put_buttons', 'put_image', 'put_file', 'PopupSize', 'popup', 'put_button',
            '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',
            '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_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
 # popup size
@@ -1455,6 +1474,246 @@ def put_scope(name: str, content: Union[Output, List[Output]] = [], scope: str =
     return Output(spec)
     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')
 @safely_destruct_output_when_exp('contents')
 def output(*contents):
 def output(*contents):
     """Placeholder of output
     """Placeholder of output

+ 2 - 0
pywebio/platform/tpl/index.html

@@ -78,6 +78,8 @@
     require.config({
     require.config({
         paths: {
         paths: {
             'plotly': "https://cdn.plot.ly/plotly-2.12.1.min",
             'plotly': "https://cdn.plot.ly/plotly-2.12.1.min",
+            "ag-grid": "https://unpkg.com/ag-grid-community/dist/ag-grid-community.min",
+            "ag-grid-enterprise": "https://unpkg.com/ag-grid-enterprise@28.2.0/dist/ag-grid-enterprise.min",
         },
         },
     });
     });
 
 

+ 379 - 0
webiojs/src/models/datatable.ts

@@ -0,0 +1,379 @@
+import {pushData} from "../session";
+
+const tpl = `<div>
+<div class="ag-theme-{{theme}} ag-grid" style="width: 100%; height: {{height}}"></div>
+<div class="ag-grid-cell-bar"></div>
+<div class="ag-grid-tools">
+    <div class="grid-status">
+        <div class="select-count">Selected <span class="ag-grid-row-count">0</span> <span
+                class="ag-grid-row-unit">row</span></div>
+        <div class="grid-unselect" style="display: flex;align-items: center;">
+            <div class="sep"></div>
+            <div class="act-btn">Unselect</div>
+        </div>
+    </div>
+    <div class="grid-actions"></div>
+</div>
+</div>`
+
+function path2field(path: string[]) {
+    return [
+        path.join(''),
+        path.map((p) => p.length).join('_'),
+        path.length
+    ].join('_');
+}
+
+function field2path(field: string) {
+    let parts = field.split('_');
+    let level = parseInt(parts[parts.length - 1]);
+    let path = [];
+    let start = 0;
+    for (let i = 0; i < level; i++) {
+        let len = parseInt(parts[parts.length - 1 - level + i]);
+        path.push(field.substring(start, start + len));
+        start += len;
+    }
+    return path;
+}
+
+function flatten_row_and_extract_column(
+    row: { [field: string]: any },  // origin row
+    current_columns: { [field: string]: any },  // used to receive column struct
+    row_data: { [field: string]: any }, // used to receive flatten row
+    path: string[]
+) {
+    if (!row) return;
+    Object.keys(row).forEach((key: any) => {
+        let val = row[key];
+        path.push(key);
+        if (!(key in current_columns))
+            current_columns[key] = {};
+        if (typeof val == "object") {
+            flatten_row_and_extract_column(val, current_columns[key], row_data, path);
+        } else {
+            row_data[path2field(path)] = val;
+        }
+        path.pop();
+    });
+}
+
+function flatten_row(row: { [field: string]: any }) {
+    let current_columns = {}, row_data = {}, path: string[] = [];
+    flatten_row_and_extract_column(row, current_columns, row_data, path);
+    return row_data;
+}
+
+/*
+* field_args: key -> column_def
+* path_args: [(path, column_def), ...]
+* */
+function row_data_and_column_def(
+    data: any[],
+    field_args: { [field: string]: any },
+    path_args: any[][]
+) {
+    function capitalizeFirstLetter(s: string) {
+        return s.charAt(0).toUpperCase() + s.slice(1);
+    }
+
+
+    function gen_columns_def(
+        current_columns: { [field: string]: any },
+        path: string[],
+        field_args: { [field: string]: any },
+        path_field_args: { [field: string]: any },
+        args_from_parent: { [field: string]: any }
+    ) {
+        let column_def: any[] = [];
+        Object.keys(current_columns).forEach((key) => {
+            let val = current_columns[key];
+            path.push(key);
+            let path_field = path2field(path);
+            if (Object.keys(val).length > 0) {
+                let extra_args = {
+                    ...args_from_parent,
+                    ...(path_field_args[path_field] || {}),
+                };
+                column_def.push({
+                    headerName: capitalizeFirstLetter(key.replace(/_/g, " ")),
+                    children: gen_columns_def(val, path, field_args, path_field_args, extra_args)
+                });
+            } else {
+                let column = {
+                    headerName: capitalizeFirstLetter(key.replace(/_/g, " ")),
+                    field: path_field,
+                    ...args_from_parent,
+                    ...(field_args[key] || {}),
+                    ...(path_field_args[path_field] || {}),
+                };
+                column_def.push(column);
+            }
+            path.pop();
+        })
+        return column_def;
+    }
+
+    let columns = {};
+    let rows = [];
+    for (let row of data) {
+        let row_data = {};
+        flatten_row_and_extract_column(row, columns, row_data, []);
+        rows.push(row_data);
+    }
+    let path_field_args: { [field: string]: any } = {};
+    path_args.map(([path, column_def]) => {
+        path_field_args[path2field(path)] = column_def
+    })
+    let column_defs = gen_columns_def(columns, [], field_args, path_field_args, {});
+    return {
+        rowData: rows,
+        columnDefs: column_defs,
+    }
+}
+
+function parse_js_func(object: any, js_func_key: string) {
+    return JSON.parse(JSON.stringify(object), (key, value) => {
+        if (
+            typeof value === 'object' &&
+            value.__pywebio_js_function__ === js_func_key &&
+            'params' in value && 'body' in value
+        ) {
+            try {
+                return new Function(...value.params, value.body);
+            } catch (e) {
+                console.error("Parse js function error: %s", e);
+                return null;
+            }
+        }
+        return value;
+    })
+}
+
+function safe_run(func_name: string, func: any, ...args: any[]) {
+    try {
+        if (typeof func === 'function')
+            func.bind(this)(...args);
+    } catch (e) {
+        console.error("Error on %s function:\n", func_name, e);
+    }
+}
+
+const gridDefaultOptions = {
+    //https://www.ag-grid.com/javascript-data-grid/row-selection/
+    rowMultiSelectWithClick: true,
+    groupSelectsChildren: true,
+    groupSelectsFiltered: true,
+
+    // https://www.ag-grid.com/javascript-data-grid/selection-overview/
+    enableCellTextSelection: true,
+    ensureDomOrder: true,
+
+    autoGroupColumnDef: {
+        pinned: 'left',//force pinned left. Does not work in columnDef
+    },
+
+    // some enterprise config
+    enableCharts: true,
+    enableRangeSelection: true,
+    // animateRows: true, // have rows animate to new positions when sorted
+    sideBar: {
+        toolPanels: [
+            {
+                id: 'columns',
+                labelDefault: 'Columns',
+                labelKey: 'columns',
+                iconKey: 'columns',
+                toolPanel: 'agColumnsToolPanel',
+                minWidth: 225,
+                width: 290,
+                maxWidth: 400,
+            },
+            {
+                id: 'filters',
+                labelDefault: 'Filters',
+                labelKey: 'filters',
+                iconKey: 'filter',
+                toolPanel: 'agFiltersToolPanel',
+                minWidth: 180,
+                maxWidth: 400,
+                width: 250,
+            },
+        ],
+        position: 'right',
+    },
+};
+
+const gridDefaultColDef = {
+    //https://www.ag-grid.com/javascript-data-grid/row-height/#text-wrapping
+    //wrapText: true,     // <-- HERE
+    //autoHeight: true,   // <-- & HERE
+
+    // suppressMenu: true,
+    wrapHeaderText: true,
+    autoHeaderHeight: true,
+
+    sortable: true,
+    filter: true,
+    // flex: 1,
+    // minWidth: 90,
+    resizable: true,
+
+    // allow every column to be aggregated
+    enableValue: true,
+    // allow every column to be grouped
+    enableRowGroup: true,
+    // allow every column to be pivoted
+    enablePivot: true,
+    // sizeColumnsToFit:true,
+    defaultAggFunc: 'avg',
+}
+
+
+export let Datatable = {
+    handle_type: 'datatable',
+    get_element: function (spec: any): JQuery {
+        let html = Mustache.render(tpl, spec);
+        let elem = $(html);
+
+        spec.field_args = parse_js_func(spec.field_args, spec.js_func_key);
+        spec.path_args = parse_js_func(spec.path_args, spec.js_func_key);
+        spec.grid_args = parse_js_func(spec.grid_args, spec.js_func_key);
+
+        let options = row_data_and_column_def(spec.records, spec.field_args, spec.path_args);
+
+        if (spec.actions.length === 0) {
+            elem.find('.ag-grid-tools').hide();
+        } else {
+            // not show actions at beginning
+            elem.find('.ag-grid-tools .grid-unselect, .ag-grid-tools .grid-actions').hide();
+        }
+
+        let getRowId = undefined;
+        if (spec.id_field) {
+            getRowId = (params: any) => params.data[path2field(spec.id_field)]
+        }
+
+        let grid_resolve: (opts: any) => void = null;
+        let gridPromise = new Promise((resolve, reject) => {
+            grid_resolve = resolve;
+        });
+        if (spec.instance_id)
+            // @ts-ignore
+            window[`ag_grid_${spec.instance_id}_promise`] = gridPromise;
+
+        const gridOptions: any = {
+            ...gridDefaultOptions,
+            ...spec.grid_args,
+
+            path2field, field2path, spec, flatten_row,
+
+            // https://www.ag-grid.com/javascript-data-grid/row-ids/
+            getRowId: getRowId,
+
+            rowData: options.rowData,
+            columnDefs: options.columnDefs,
+
+            //https://www.ag-grid.com/javascript-data-grid/row-selection/
+            rowSelection: (spec.actions.length > 0) && (spec.multiple_select ? 'multiple' : 'single'),
+
+            defaultColDef: {
+                ...gridDefaultColDef,
+                ...(spec.grid_args.defaultColDef || {}),
+            },
+            getSelectedRowIDs: function () {
+                const selectedRows = gridOptions.api.getSelectedNodes();
+                let selected_row_ids = [];
+                for (let r of selectedRows) {
+                    if (!r.group)
+                        selected_row_ids.push(r.id);
+                }
+                if (!spec.id_field)
+                    selected_row_ids = selected_row_ids.map((rid: any) => parseInt(rid));
+                return selected_row_ids;
+            },
+            onGridReady: (param: any) => {
+                grid_resolve(gridOptions);
+
+                gridOptions.columnApi.autoSizeAllColumns();
+                let content_width = 0;
+                gridOptions.columnApi.getColumns().forEach((column:any) => {
+                    if(!column.getColDef().hide)
+                        content_width += column.getActualWidth();
+                });
+                if (content_width < elem.find(".ag-grid")[0].clientWidth) {
+                    // the content is smaller than the grid, so we set columns to adjust in size to fit the grid horizontally
+                    gridOptions.api.sizeColumnsToFit();
+                }
+
+                if (spec.actions.length > 0) {
+                    elem.find('.ag-grid-tools').css('opacity', 1);
+                }
+                elem.find('.grid-unselect .act-btn').on('click', () => gridOptions.api.deselectAll());
+                for (let btn_idx in spec.actions) {
+                    let label = spec.actions[btn_idx];
+                    if (label === null) {
+                        elem.find('.grid-actions').append('<div class="sep"></div>');
+                    } else {
+                        let btn = $(`<div class="act-btn">${label}</div>`);
+                        btn.on('click', () => {
+                            pushData({
+                                btn: parseInt(btn_idx),
+                                rows: gridOptions.getSelectedRowIDs()
+                            }, spec.callback_id)
+                        });
+                        elem.find('.grid-actions').append(btn);
+                    }
+                }
+
+                safe_run('agGrid.onGridReady()', spec.grid_args.onGridReady, param);
+            },
+            onCellFocused: (params: any) => {
+                var row = gridOptions.api.getDisplayedRowAtIndex(params.rowIndex);
+                var cellValue = gridOptions.api.getValue(params.column, row)
+                if (cellValue === undefined)
+                    cellValue = ''
+                document.querySelector('.ag-grid-cell-bar').innerHTML = cellValue;
+
+                if (spec.cell_content_bar) {
+                    let bar = elem.find('.ag-grid-cell-bar');
+                    bar.show();
+                }
+
+                safe_run('agGrid.onCellFocused()', spec.grid_args.onCellFocused, params);
+            },
+
+            onSelectionChanged: (param: any) => {
+                const selectedRows = gridOptions.getSelectedRowIDs();
+                if (spec.on_select && selectedRows.length > 0) {
+                    pushData({
+                        rows: selectedRows
+                    }, spec.callback_id)
+                }
+
+                elem.find(".ag-grid-row-count").text(selectedRows.length);
+                elem.find(".ag-grid-row-unit").text(selectedRows.length > 1 ? 'rows' : 'row');
+                if (selectedRows.length === 0) {
+                    elem.find('.ag-grid-tools .grid-unselect, .ag-grid-tools .grid-actions').hide();
+                }
+                if (selectedRows.length >= 1) {
+                    elem.find('.ag-grid-tools .grid-unselect, .ag-grid-tools .grid-actions').fadeIn(200);
+                }
+
+                safe_run('agGrid.onSelectionChanged()', spec.grid_args.onSelectionChanged, param);
+            }
+        };
+
+        let ag_version = spec.enterprise_key ? 'ag-grid-enterprise' : 'ag-grid';
+        // @ts-ignore
+        requirejs([ag_version], function (agGrid) {
+            new agGrid.Grid(elem.find(".ag-grid")[0], gridOptions);
+            if (spec.instance_id) {
+                // @ts-ignore
+                window[`ag_grid_${spec.instance_id}`] = gridOptions;
+            }
+        });
+
+        return elem;
+    }
+};

+ 2 - 1
webiojs/src/models/output.ts

@@ -4,6 +4,7 @@ import {pushData} from "../session";
 import {PinWidget} from "./pin";
 import {PinWidget} from "./pin";
 import {t} from "../i18n";
 import {t} from "../i18n";
 import {AfterCurrentOutputWidgetShow} from "../handlers/output";
 import {AfterCurrentOutputWidgetShow} from "../handlers/output";
+import {Datatable} from "./datatable";
 
 
 export interface Widget {
 export interface Widget {
     handle_type: string;
     handle_type: string;
@@ -264,7 +265,7 @@ let CustomWidget = {
 };
 };
 
 
 let all_widgets: Widget[] = [Text, Markdown, Html, Buttons, File, Table, CustomWidget, TabsWidget, PinWidget,
 let all_widgets: Widget[] = [Text, Markdown, Html, Buttons, File, Table, CustomWidget, TabsWidget, PinWidget,
-    ScopeWidget, ScrollableWidget];
+    ScopeWidget, ScrollableWidget, Datatable];
 
 
 
 
 let type2widget: { [i: string]: Widget } = {};
 let type2widget: { [i: string]: Widget } = {};