Browse Source

add datatable widget

wangweimin 2 years ago
parent
commit
ee61b0ed38

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

@@ -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;
 }

+ 263 - 4
pywebio/output.py

@@ -42,7 +42,7 @@ Functions list
 |                    +---------------------------+------------------------------------------------------------+
 |                    | `put_link`                | Output link                                                |
 |                    +---------------------------+------------------------------------------------------------+
-|                    | `put_progressbar`          | Output a progress bar                                       |
+|                    | `put_progressbar`         | Output a progress bar                                      |
 |                    +---------------------------+------------------------------------------------------------+
 |                    | `put_loading`:sup:`†`     | Output loading prompt                                      |
 |                    +---------------------------+------------------------------------------------------------+
@@ -50,6 +50,11 @@ Functions list
 |                    +---------------------------+------------------------------------------------------------+
 |                    | `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`           |                                                            |
 |                    +---------------------------+------------------------------------------------------------+
@@ -186,6 +191,10 @@ index equal ``position``:
 .. 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
@@ -208,14 +217,23 @@ Layout and Style
 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
 
@@ -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',
            '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
@@ -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

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

@@ -78,6 +78,8 @@
     require.config({
         paths: {
             '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 {t} from "../i18n";
 import {AfterCurrentOutputWidgetShow} from "../handlers/output";
+import {Datatable} from "./datatable";
 
 export interface Widget {
     handle_type: string;
@@ -264,7 +265,7 @@ let CustomWidget = {
 };
 
 let all_widgets: Widget[] = [Text, Markdown, Html, Buttons, File, Table, CustomWidget, TabsWidget, PinWidget,
-    ScopeWidget, ScrollableWidget];
+    ScopeWidget, ScrollableWidget, Datatable];
 
 
 let type2widget: { [i: string]: Widget } = {};