diff --git a/pywebio/html/css/app.css b/pywebio/html/css/app.css
index 8a16ee77..aea61ea5 100644
--- a/pywebio/html/css/app.css
+++ b/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;
}
\ No newline at end of file
diff --git a/pywebio/output.py b/pywebio/output.py
index dd016e40..fc0ec528 100644
--- a/pywebio/output.py
+++ b/pywebio/output.py
@@ -42,7 +42,7 @@
| +---------------------------+------------------------------------------------------------+
| | `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 @@
| +---------------------------+------------------------------------------------------------+
| | `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 @@
.. 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 @@
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 @@
'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 `_ 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 `_ in dict.
+ :param grid_args: ag-grid grid options.
+ Visit `ag-grid doc - grid options `_ for more information.
+ :param str enterprise_key: `ag-grid enterprise `_ 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
diff --git a/pywebio/platform/tpl/index.html b/pywebio/platform/tpl/index.html
index 613e1582..91345598 100644
--- a/pywebio/platform/tpl/index.html
+++ b/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",
},
});
diff --git a/webiojs/src/models/datatable.ts b/webiojs/src/models/datatable.ts
new file mode 100644
index 00000000..8af98e87
--- /dev/null
+++ b/webiojs/src/models/datatable.ts
@@ -0,0 +1,379 @@
+import {pushData} from "../session";
+
+const tpl = `
`
+
+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('');
+ } else {
+ let btn = $(`${label}
`);
+ 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;
+ }
+};
\ No newline at end of file
diff --git a/webiojs/src/models/output.ts b/webiojs/src/models/output.ts
index 2461ee6e..d0867c1d 100644
--- a/webiojs/src/models/output.ts
+++ b/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 } = {};