From 1d55fedaf1fb4182d4859f6e0b845d205363265d Mon Sep 17 00:00:00 2001 From: wangweimin Date: Mon, 30 Jan 2023 23:10:21 +0800 Subject: [PATCH] add datatable widget --- pywebio/html/css/app.css | 74 +++++++ pywebio/output.py | 246 ++++++++++++++++++++- pywebio/platform/tpl/index.html | 2 + webiojs/src/models/datatable.ts | 368 ++++++++++++++++++++++++++++++++ 4 files changed, 686 insertions(+), 4 deletions(-) create mode 100644 webiojs/src/models/datatable.ts 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..0529a5fa 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,225 @@ 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;") + """ + 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..a510d797 --- /dev/null +++ b/webiojs/src/models/datatable.ts @@ -0,0 +1,368 @@ +import {pushData} from "../session"; + +const tpl = `
+
+
+
+
+
Selected 0 row
+
+
+
Unselect
+
+
+
+
+
` + +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(); + 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