holoviz/panel · #8837

perf: Render Tabulator without the bokeh-tables bundle

philippjfr · merged Oct 11, 202617 files · 529 + / 95 −
CHANGELOG.md10 + / 0 −
@@ -1,5 +1,15 @@ # Releases +## Version 1.10.0++### ✨ Enhancements++* Render `Tabulator` without Bokeh's `bokeh-tables` bundle, which is now only loaded when a page uses a `DataTable` ([#8837](https://github.com/holoviz/panel/pull/8837))++### ⚙️ Compatibility++* A `DataFrame` widget or HoloViews `Table` that is added to a served app after the page has loaded requires `pn.extension('bokeh-tables')` ([#8837](https://github.com/holoviz/panel/pull/8837))+ ## Version 1.9.4  Panel 1.9.4 is a patch release focused on rendering robustness for ESM/React components, several `Tabulator` and `Pipeline` fixes, OAuth hardening, and stylesheet/loading reliability improvements. It also adds support for movable `Tabulator` columns. Many thanks to @TheoMathurin, @rimsha152007-dotcom, @ebarkhordar, @SimonHeybrock, @ahuang11, @Kkkakania, @EltonChang1, @hoxbro, @MarcSkovMadsen, @Coderambling, @SuMayaBee, @youdie006, @kyo5uke, @ghostiee-11 and @philippjfr for contributing to this release.
doc/about/releases.md10 + / 0 −
@@ -1,5 +1,15 @@ # Releases +## Version 1.10.0++### ✨ Enhancements++* Render `Tabulator` without Bokeh's `bokeh-tables` bundle, which is now only loaded when a page uses a `DataTable` ([#8837](https://github.com/holoviz/panel/pull/8837))++### ⚙️ Compatibility++* A `DataFrame` widget or HoloViews `Table` that is added to a served app after the page has loaded requires `pn.extension('bokeh-tables')` ([#8837](https://github.com/holoviz/panel/pull/8837))+ ## Version 1.9.4  Panel 1.9.4 is a patch release focused on rendering robustness for ESM/React components, several `Tabulator` and `Pipeline` fixes, OAuth hardening, and stylesheet/loading reliability improvements. It also adds support for movable `Tabulator` columns. Many thanks to @TheoMathurin, @rimsha152007-dotcom, @ebarkhordar, @SimonHeybrock, @ahuang11, @Kkkakania, @EltonChang1, @hoxbro, @MarcSkovMadsen, @Coderambling, @SuMayaBee, @youdie006, @kyo5uke, @ghostiee-11 and @philippjfr for contributing to this release.
doc/upgrade.md14 + / 0 −
@@ -105,6 +105,20 @@ The choices are `tornado` (the default, unchanged), `fastapi` and `asgi`, the la  `--plugins` is supported on `--server fastapi`, but since the endpoints are no longer Tornado request handlers the plugin module has to declare a FastAPI `APIRouter` in a `ROUTER` variable rather than a list of `ROUTES`. A module may declare both and then be served on either implementation. See [Add custom endpoints](how_to/server/endpoints) for the details. +### `Tabulator` and the `bokeh-tables` bundle++`Tabulator` no longer uses Bokeh's `DataTable` models, so Panel only loads Bokeh's `bokeh-tables` bundle (about 500 KB) when a page needs it, i.e. when a `pn.widgets.DataFrame`, a HoloViews `Table` or a Bokeh `DataTable` is part of the initial layout of a served app or saved file. Notebooks always load it. If such a component is only added after the page has loaded, e.g. in a callback, request the bundle up front:++```python+pn.extension('bokeh-tables')+```++Bokeh `CellFormatter` and `CellEditor` instances passed to the `formatters` and `editors` of a `Tabulator` keep working, but they are now converted to plain specs and rendered by Panel itself:++- `PercentEditor` and `TimeEditor` use Tabulator's own text input.+- The `columns` of the underlying `DataTabulator` model are `TabulatorColumn` models holding the formatter and editor specs, rather than Bokeh `TableColumn` models. Code that inspects the model, e.g. in tests, needs to be updated.+- Cell formatters and editors defined in a custom Bokeh extension are still rendered by their own views, and the bundle is loaded automatically when such an extension is used.+ ### Deprecations  #### REST providers and `pn.state.publish`
examples/reference/widgets/DataFrame.ipynb3 + / 1 −
@@ -18,7 +18,7 @@     "# the deprecation warning under every example.\n",     "warnings.simplefilter('ignore', PanelDeprecationWarning)\n",     "\n",-    "pn.extension()"+    "pn.extension('bokeh-tables')"    ]   },   {@@ -31,6 +31,8 @@     "\n",     "The ``DataFrame`` widget allows displaying and editing a pandas DataFrame. Note that editing is not possible for multi-indexed DataFrames, in which case you will need to reduce the DataFrame to a single index.\n",     "\n",+    "The `DataFrame` widget is built on Bokeh's `DataTable`, which lives in a separate `bokeh-tables` bundle that Panel only loads when a page needs it. In a notebook or when the widget is part of the initial layout of a served app this happens automatically, but if the widget is only added later on, e.g. in a callback, load the bundle up front with `pn.extension('bokeh-tables')`.\n",+    "\n",     "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.md).\n",     "\n",     "#### Parameters:\n",
panel/config.py1 + / 0 −
@@ -843,6 +843,7 @@ class panel_extension(_pyviz_extension):      _imports: t.ClassVar[dict[str, str]] = {         'ace': 'panel.models.ace',+        'bokeh-tables': 'bokeh.models.widgets.tables',         'codeeditor': 'panel.models.ace',         'deckgl': 'panel.models.deckgl',         'echarts': 'panel.models.echarts',
panel/io/resources.py27 + / 1 −
@@ -25,7 +25,8 @@ from bokeh.core.property.descriptors import UnsetValueError from bokeh.embed.bundle import (     CSS_RESOURCES as BkCSS_RESOURCES, URL, Bundle as BkBundle, _any,-    _bundle_extensions, _use_mathjax, bundle_models, extension_dirs,+    _bundle_extensions, _query_extensions, _use_mathjax, _use_tables,+    bundle_models, extension_dirs, ) from bokeh.model import Model from bokeh.models import ImportedStyleSheet@@ -541,6 +542,22 @@ def _panel_use_mathjax(roots) -> bool:     return _any(roots, lambda obj: isinstance(obj, PanelHTML))  +def _panel_use_tables(roots) -> bool:+    """+    Whether any model reachable from roots requires the bokeh-tables bundle.++    Tabulator renders without it, but Bokeh's DataTable and DataCube need it+    and so do extensions defining custom cell editors or formatters.+    """+    from bokeh.models.widgets.tables import CellEditor, CellFormatter+    models = set()+    for root in roots:+        models |= root.references()+    return _use_tables(models) or _query_extensions(+        models, lambda cls: issubclass(cls, (CellEditor, CellFormatter))+    )++ def bundle_resources(     roots,     resources: BkResources,@@ -571,10 +588,19 @@ def bundle_resources(     else:         use_mathjax = 'mathjax' in ext._loaded_extensions +    # Notebooks load resources before anything is rendered, so we cannot+    # know whether a table will be displayed later and always load it+    use_tables = (+        notebook or roots is None or 'bokeh-tables' in ext._loaded_extensions or+        _panel_use_tables(roots)+    )+     if js_resources:         js_resources = js_resources.clone()         if not use_mathjax and "bokeh-mathjax" in js_resources.components:             js_resources.components.remove("bokeh-mathjax")+        if not use_tables and "bokeh-tables" in js_resources.components:+            js_resources.components.remove("bokeh-tables")         if reloading:             js_resources.components.clear() 
panel/models/cell_formatters.tsadded179 + / 0 −
@@ -0,0 +1,179 @@+// Port of the Bokeh CellFormatter models (bokehjs/src/lib/models/widgets/tables/cell_formatters.ts)+// operating on plain specs, so that Tabulator does not depend on the bokeh-tables bundle.+import {div, i} from "@bokehjs/core/dom"+import {color2css, rgba2css} from "@bokehjs/core/util/color"+import {to_fixed} from "@bokehjs/core/util/string"+import {datetime} from "@bokehjs/core/util/templating"+import {isNumber, isString} from "@bokehjs/core/util/types"+import {NumeralTickFormatter} from "@bokehjs/models/formatters/numeral_tick_formatter"+import {ColorMapper} from "@bokehjs/models/mappers/color_mapper"+import {_} from "underscore.template"++export type CellFormatterSpec = {type: string, [key: string]: any}++export type CellFormatterFn = (value: any, data: {[key: string]: any}) => string | HTMLElement++type Formatted = {value: any, data: {[key: string]: any}}++function resolve(spec: any, data: {[key: string]: any}): any {+  if (spec == null) {+    return null+  } else if ("value" in spec) {+    return spec.value+  } else if ("field" in spec) {+    return data[spec.field]+  }+  return null+}++function resolve_color(spec: any, data: {[key: string]: any}): string | null {+  if (spec == null) {+    return null+  } else if ("value" in spec) {+    return spec.value == null ? null : color2css(spec.value)+  } else if ("field" in spec) {+    const value = data[spec.field]+    if (spec.transform instanceof ColorMapper) {+      const [r, g, b, a] = spec.transform.rgba_mapper.v_compute([value])+      return rgba2css([r, g, b, a])+    }+    return color2css(value)+  }+  return null+}++function string_format(spec: CellFormatterSpec, {value, data}: Formatted): HTMLElement {+  if (Number.isNaN(value)) {+    value = spec.nan_format+  } else if (value == null) {+    value = spec.null_format+  }++  const text = div(value == null ? "" : `${value}`)++  switch (resolve(spec.font_style, data)) {+    case "italic":+      text.style.fontStyle = "italic"+      break+    case "bold":+      text.style.fontWeight = "bold"+      break+    case "bold italic":+      text.style.fontStyle = "italic"+      text.style.fontWeight = "bold"+      break+  }++  const text_align = resolve(spec.text_align, data)+  if (text_align != null) {+    text.style.textAlign = text_align+  }+  const color = resolve_color(spec.text_color, data)+  if (color != null) {+    text.style.color = color+  }+  const background_color = resolve_color(spec.background_color, data)+  if (background_color != null) {+    text.style.backgroundColor = background_color+  }+  return text+}++function scientific_value(spec: CellFormatterSpec, value: any): any {+  const need_sci = Math.abs(value) <= 10.0**spec.power_limit_low || Math.abs(value) >= 10.0**spec.power_limit_high+  // toExponential does not handle precision values < 0 correctly+  const precision = Math.max(spec.precision, 1)+  if (Number.isNaN(value)) {+    return spec.nan_format+  } else if (value == null) {+    return spec.null_format+  } else if (value == 0) {+    return to_fixed(value, 1)+  } else if (need_sci) {+    return value.toExponential(precision)+  }+  return to_fixed(value, precision)+}++function date_format(format: string): string {+  // using definitions provided here: https://api.jqueryui.com/datepicker/+  // except not implementing TICKS+  switch (format) {+    case "ATOM":+    case "W3C":+    case "RFC-3339":+    case "ISO-8601":+      return "%Y-%m-%d"+    case "COOKIE":+      return "%a, %d %b %Y"+    case "RFC-850":+      return "%A, %d-%b-%y"+    case "RFC-1123":+    case "RFC-2822":+      return "%a, %e %b %Y"+    case "RSS":+    case "RFC-822":+    case "RFC-1036":+      return "%a, %e %b %y"+    case "TIMESTAMP":+      return "@"+    default:+      return format+  }+}++function date_value(spec: CellFormatterSpec, value: any): any {+  function parse(date: string) {+    /* Parse bare dates as UTC. */+    const has_tz = /Z$|[+-]\d\d((:?)\d\d)?$/.test(date) // ISO 8601 TZ designation or offset+    const iso_date = has_tz ? date : `${date}Z`+    return new Date(iso_date).getTime()+  }++  let epoch: any+  if (value == null || isNumber(value)) {+    epoch = value+  } else if (isString(value)) {+    const num = Number(value)+    epoch = isNaN(num) ? parse(value) : num+  } else if (value instanceof Date) {+    epoch = value.valueOf()+  } else {+    epoch = Number(value)+  }++  const NaT = -9223372036854776.0+  if (Number.isNaN(epoch) || epoch == NaT) {+    return spec.nan_format+  } else if (value == null) {+    return spec.null_format+  }+  return datetime(epoch, date_format(spec.format))+}++export function cell_formatter(spec: CellFormatterSpec): CellFormatterFn | null {+  switch (spec.type) {+    case "StringFormatter":+      return (value, data) => string_format(spec, {value, data})+    case "ScientificFormatter":+      return (value, data) => string_format(spec, {value: scientific_value(spec, value), data})+    case "NumberFormatter": {+      const numeral = new NumeralTickFormatter({format: spec.format, language: spec.language, rounding: spec.rounding})+      return (value, data) => {+        if (!Number.isNaN(value) && value != null) {+          value = numeral.doFormat([value], {loc: 0})[0]+        }+        return string_format(spec, {value, data})+      }+    }+    case "DateFormatter":+      return (value, data) => string_format(spec, {value: date_value(spec, value), data})+    case "BooleanFormatter":+      return (value) => value ? i({class: spec.icon}).outerHTML : ""+    case "HTMLTemplateFormatter": {+      const template = _.template(spec.template)+      return (value, data) => value == null ? "" : template({...data, value})+    }+  }+  return null+}
panel/models/declaration.d.ts5 + / 0 −
@@ -1,2 +1,7 @@ declare module "json-formatter-js"; declare module "@luma.gl/constants";+declare module "underscore.template" {+  export const _: {+    template(template: string): (context: {[key: string]: unknown}) => string+  }+}
panel/models/index.ts1 + / 1 −
@@ -12,7 +12,7 @@ export {Column} from "./column" export {CommManager} from "./comm_manager" export {CustomSelect} from "./customselect" export {CustomMultiSelect} from "./multiselect"-export {DataTabulator} from "./tabulator"+export {DataTabulator, TabulatorColumn} from "./tabulator" export {DatetimePicker} from "./datetime_picker" export {DatetimeSlider} from "./datetime_slider" export {DeckGLPlot} from "./deckgl"
panel/models/tabulator.py30 + / 2 −
@@ -8,8 +8,8 @@     Tuple, ) from bokeh.events import ModelEvent+from bokeh.model import Model from bokeh.models import ColumnDataSource, LayoutDOM-from bokeh.models.widgets.tables import TableColumn  from ..config import config from ..io.resources import bundled_files@@ -107,6 +107,34 @@ def __repr__(self):     CSS_URLS.append(f'{THEME_URL}{_theme_file}')  +class TabulatorColumn(Model):+    """+    Column definition for the DataTabulator model. Unlike Bokeh's+    TableColumn the formatter and editor are plain specs, so the+    bokeh-tables bundle is not required to render a Tabulator.+    """++    field = String(help="""+    The name of the field mapping to a column in the data source.+    """)++    title = Nullable(String, help="""+    The title of this column.+    """)++    width = Int(default=300, help="""+    The width of the column, zero means it is determined automatically.+    """)++    formatter = Nullable(Any, help="""+    Spec of a Bokeh CellFormatter or a custom CellFormatter model.+    """)++    editor = Nullable(Any, help="""+    Spec of a Bokeh CellEditor or a custom CellEditor model.+    """)++ class DataTabulator(HTMLBox):     """A Bokeh Model that enables easy use of Tabulator tables     See http://tabulator.info/@@ -118,7 +146,7 @@ class DataTabulator(HTMLBox):      configuration = Dict(String, Any) -    columns = List(Instance(TableColumn), help="""+    columns = List(Instance(TabulatorColumn), help="""     The list of child column widgets.     """) 
panel/models/tabulator.ts103 + / 52 −
@@ -7,13 +7,14 @@ import type {StyleSheetLike} from "@bokehjs/core/dom" import {div} from "@bokehjs/core/dom" import {Enum} from "@bokehjs/core/kinds" import type * as p from "@bokehjs/core/properties"+import {Model} from "@bokehjs/model" import type {LayoutDOM} from "@bokehjs/models/layouts/layout_dom" import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"-import {TableColumn} from "@bokehjs/models/widgets/tables" import type {Attrs} from "@bokehjs/core/types"  import {debounce} from "debounce" +import {cell_formatter} from "./cell_formatters" import {comm_settings} from "./comm_manager" import {transform_cds_to_records} from "./data" import {HTMLBox, HTMLBoxView} from "./layout"@@ -328,6 +329,45 @@ const datetimeEditor = function(cell: any, onRendered: any, success: any, cancel   return input } +function is_spec(obj: any): boolean {+  return !(obj instanceof Model)+}++// Maps Bokeh CellEditor specs onto native Tabulator editors+function apply_editor_spec(tab_column: any, editor: any): void {+  switch (editor.type) {+    case "StringEditor":+      if (editor.completions.length > 0) {+        tab_column.editor = "list"+        tab_column.editorParams = {values: editor.completions, autocomplete: true, listOnEmpty: true}+      } else {+        tab_column.editor = "input"+      }+      break+    case "TextEditor":+      tab_column.editor = "textarea"+      break+    case "IntEditor":+    case "NumberEditor":+      tab_column.editor = "number"+      tab_column.editorParams = {step: editor.step}+      tab_column.validator = editor.type === "IntEditor" ? "integer" : "numeric"+      break+    case "CheckboxEditor":+      tab_column.editor = "tickCross"+      break+    case "DateEditor":+      tab_column.editor = dateEditor+      break+    case "SelectEditor":+      tab_column.editor = "list"+      tab_column.editorParams = {values: editor.options}+      break+    default:+      tab_column.editor = "input"+  }+}+ const nestedEditor = function(cell: any, editorParams: any) {   //cell - the cell component for the editable cell @@ -1185,26 +1225,7 @@ export class DataTabulatorView extends HTMLBoxView {         tab_column.width = column.width       }       if (tab_column.formatter == null && column.formatter != null) {-        const formatter: any = column.formatter-        const ftype = formatter.type-        if (ftype === "BooleanFormatter") {-          tab_column.formatter = "tickCross"-        } else {-          tab_column.formatter = (cell: any) => {-            const row = cell.getRow()-            const formatted = column.formatter.doFormat(cell.getRow(), cell, cell.getValue(), null, row.getData())-            if (column.formatter.type === "HTMLTemplateFormatter") {-              return formatted-            }-            const node = div()-            node.innerHTML = formatted-            const child = node.children[0]-            if (child.innerHTML === "function(){return c.convert(arguments)}") { // If the formatter fails-              return ""-            }-            return child-          }-        }+        tab_column.formatter = this._get_formatter(column.formatter)       }       if (tab_column.sorter == "timestamp") {         tab_column.sorter = timestampSorter@@ -1213,7 +1234,7 @@ export class DataTabulatorView extends HTMLBoxView {         tab_column.sorter = "string"       }       const editor: any = column.editor-      const ctype = editor.type+      const has_editor = editor != null && (is_spec(editor) ? editor.type !== "CellEditor" : editor.default_view != null)       if (tab_column.editor != null) {         if (tab_column.editor === "date") {           tab_column.editor = dateEditor@@ -1225,43 +1246,21 @@ export class DataTabulatorView extends HTMLBoxView {           }           tab_column.editor = "list"         }-      } else if (ctype === "StringEditor") {-        if (editor.completions.length > 0) {-          tab_column.editor = "list"-          tab_column.editorParams = {values: editor.completions, autocomplete: true, listOnEmpty: true}-        } else {-          tab_column.editor = "input"-        }-      } else if (ctype === "TextEditor") {-        tab_column.editor = "textarea"-      } else if (ctype === "IntEditor" || ctype === "NumberEditor") {-        tab_column.editor = "number"-        tab_column.editorParams = {step: editor.step}-        if (ctype === "IntEditor") {-          tab_column.validator = "integer"-        } else {-          tab_column.validator = "numeric"-        }-      } else if (ctype === "CheckboxEditor") {-        tab_column.editor = "tickCross"-      } else if (ctype === "DateEditor") {-        tab_column.editor = dateEditor-      } else if (ctype === "SelectEditor") {-        tab_column.editor = "list"-        tab_column.editorParams = {values: editor.options}-      } else if (editor != null && editor.default_view != null) {+      } else if (has_editor && !is_spec(editor)) {         tab_column.editor = (cell: any, onRendered: any, success: any, cancel: any) => {           this.renderEditor(column, cell, onRendered, success, cancel)         }+      } else if (has_editor) {+        apply_editor_spec(tab_column, editor)       }       tab_column.visible = (tab_column.visible != false && !this.model.hidden_columns.includes(column.field))       const originalEditable = tab_column.editable       if (isFunction(originalEditable)) {-        tab_column.editable = (cell: any) => (this.model.editable && (editor.default_view != null) && originalEditable(cell))+        tab_column.editable = (cell: any) => (this.model.editable && has_editor && originalEditable(cell))       } else if (isBoolean(originalEditable)) {-        tab_column.editable = () => (this.model.editable && (editor.default_view != null) && originalEditable)+        tab_column.editable = () => (this.model.editable && has_editor && originalEditable)       } else {-        tab_column.editable = () => (this.model.editable && (editor.default_view != null))+        tab_column.editable = () => (this.model.editable && has_editor)       }       if (tab_column.headerFilter) {         if (isBoolean(tab_column.headerFilter) && isString(tab_column.editor)) {@@ -1306,6 +1305,25 @@ export class DataTabulatorView extends HTMLBoxView {     return columns   } +  _get_formatter(formatter: any): any {+    if (!is_spec(formatter)) {+      // Custom Bokeh CellFormatter model compiled as an extension+      return (cell: any) => {+        const row = cell.getRow()+        const node = div()+        node.innerHTML = formatter.doFormat(row, cell, cell.getValue(), null, row.getData())+        return node.children[0]+      }+    } else if (formatter.type === "BooleanFormatter") {+      return "tickCross"+    }+    const format = cell_formatter(formatter)+    if (format == null) {+      return undefined+    }+    return (cell: any) => format(cell.getValue(), cell.getRow().getData())+  }+   renderEditor(column: any, cell: any, onRendered: any, success: any, cancel: any): any {     const editor = column.editor     const view = new editor.default_view({column, model: editor, parent: this, container: cell._cell.element})@@ -1714,13 +1732,46 @@ export class DataTabulatorView extends HTMLBoxView {  export const TableLayout = Enum("fit_data", "fit_data_fill", "fit_data_stretch", "fit_data_table", "fit_columns") +export namespace TabulatorColumn {+  export type Attrs = p.AttrsOf<Props>+  export type Props = Model.Props & {+    field: p.Property<string>+    title: p.Property<string | null>+    width: p.Property<number>+    formatter: p.Property<any>+    editor: p.Property<any>+  }+}++export interface TabulatorColumn extends TabulatorColumn.Attrs {}++export class TabulatorColumn extends Model {+  declare properties: TabulatorColumn.Props++  constructor(attrs?: Partial<TabulatorColumn.Attrs>) {+    super(attrs)+  }++  static override __module__ = "panel.models.tabulator"++  static {+    this.define<TabulatorColumn.Props>(({Any, Float, Nullable, Str}) => ({+      field:     [ Str              ],+      title:     [ Nullable(Str), null ],+      width:     [ Float,          300 ],+      formatter: [ Nullable(Any), null ],+      editor:    [ Nullable(Any), null ],+    }))+  }+}+ export namespace DataTabulator {   export type Attrs = p.AttrsOf<Props>   export type Props = HTMLBox.Props & {     aggregators: p.Property<any>     buttons: p.Property<any>     children: p.Property<Map<number, LayoutDOM>>-    columns: p.Property<TableColumn[]>+    columns: p.Property<TabulatorColumn[]>     configuration: p.Property<any>     download: p.Property<boolean>     editable: p.Property<boolean>@@ -1769,7 +1820,7 @@ export class DataTabulator extends HTMLBox {       buttons:        [ Any,                     {} ],       children:       [ Any,              new Map() ],       configuration:  [ Any,                     {} ],-      columns:        [ List(Ref(TableColumn)), [] ],+      columns:        [ List(Ref(TabulatorColumn)), [] ],       download:       [ Bool,              false ],       editable:       [ Bool,               true ],       embed_content:  [ Bool,               false ],
panel/package-lock.json2 + / 1 −
@@ -16,7 +16,8 @@         "htm": "^3.1.1",         "json-formatter-js": "^2.2.1",         "preact": "^10.26.6",-        "sucrase": "^3.35.0"+        "sucrase": "^3.35.0",+        "underscore.template": "^0.1.7"       },       "devDependencies": {         "@stylistic/eslint-plugin": "^5.9.0",
panel/package.json2 + / 1 −
@@ -15,7 +15,8 @@     "htm": "^3.1.1",     "json-formatter-js": "^2.2.1",     "preact": "^10.26.6",-    "sucrase": "^3.35.0"+    "sucrase": "^3.35.0",+    "underscore.template": "^0.1.7"   },   "devDependencies": {     "@stylistic/eslint-plugin": "^5.9.0",
panel/tests/io/test_resources.py32 + / 2 −
@@ -13,8 +13,9 @@ from panel.custom import JSComponent from panel.io.resources import (     CDN_DIST, DIST_DIR, JS_RESOURCES, JS_VERSION, PANEL_DIR, Resources,-    component_resource_path, module_tags, resolve_custom_path,-    resolve_resource_cdn, resolve_stylesheet, set_resource_mode,+    bundle_resources, component_resource_path, module_tags,+    resolve_custom_path, resolve_resource_cdn, resolve_stylesheet,+    set_resource_mode, ) from panel.io.state import set_curdoc, state from panel.models.tabulator import TABULATOR_VERSION@@ -114,6 +115,35 @@ def test_resources_cdn():     ]  +def _uses_bokeh_tables(bundle):+    return any('bokeh-tables' in str(url) for url in bundle.js_files)++def test_bundle_resources_tabulator_skips_bokeh_tables(document, comm, monkeypatch):+    pd = pytest.importorskip('pandas')+    from panel.widgets import Tabulator+    monkeypatch.setattr(extension, '_loaded_extensions', [])+    model = Tabulator(pd.DataFrame({'a': [1, 2]})).get_root(document, comm)+    assert not _uses_bokeh_tables(bundle_resources([model], Resources(mode='cdn')))++def test_bundle_resources_nested_datatable_loads_bokeh_tables(document, comm, monkeypatch):+    from bokeh.models import DataTable++    from panel.layout import Column+    from panel.pane import Bokeh+    monkeypatch.setattr(extension, '_loaded_extensions', [])+    model = Column(Bokeh(DataTable())).get_root(document, comm)+    assert _uses_bokeh_tables(bundle_resources([model], Resources(mode='cdn')))++def test_bundle_resources_tables_extension_loads_bokeh_tables(document, comm, monkeypatch):+    monkeypatch.setattr(extension, '_loaded_extensions', ['bokeh-tables'])+    model = Button().get_root(document, comm)+    assert _uses_bokeh_tables(bundle_resources([model], Resources(mode='cdn')))++def test_bundle_resources_notebook_loads_bokeh_tables(monkeypatch):+    monkeypatch.setattr(extension, '_loaded_extensions', [])+    assert _uses_bokeh_tables(bundle_resources(None, Resources(mode='cdn')))++ def test_notebook_resources_respect_jupyterhub_base_url():     resource = f'{CDN_DIST}bundled/datatabulator/tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'     with edit_readonly(state):
panel/tests/ui/widgets/test_tabulator.py26 + / 0 −
@@ -12,12 +12,14 @@  pytest.importorskip("playwright") +from bokeh.models import LinearColorMapper from bokeh.models.widgets.tables import (     BooleanFormatter, CheckboxEditor, DateEditor, DateFormatter,     HTMLTemplateFormatter, IntEditor, NumberEditor, NumberFormatter,     ScientificFormatter, SelectEditor, StringEditor, StringFormatter,     TextEditor, )+from bokeh.transform import transform from playwright.sync_api import Error, expect  from panel import extension@@ -489,6 +491,30 @@ def test_tabulator_formatters_bokeh_string(page, df_mixed):     expect(page.locator('text="A"')).to_have_attribute("style", style)  +def test_tabulator_formatters_bokeh_string_color_mapper(page, df_mixed):+    mapper = LinearColorMapper(palette=['#ff0000', '#0000ff'], low=1, high=4)+    widget = Tabulator(+        df_mixed,+        formatters={+            'str': StringFormatter(background_color=transform('int', mapper)),+        },+    )++    serve_component(page, widget)++    expect(page.locator('text="A"')).to_have_css("background-color", "rgb(255, 0, 0)")+    expect(page.locator('text="D"')).to_have_css("background-color", "rgb(0, 0, 255)")+++def test_tabulator_renders_without_bokeh_tables(page, df_mixed):+    widget = Tabulator(df_mixed, formatters={'float': NumberFormatter(format='0.000')})++    serve_component(page, widget)++    expect(page.locator('text="3.140"')).to_have_count(1)+    assert not page.evaluate("Bokeh.Models._known_models.has('DataTable')")++ def test_tabulator_formatters_bokeh_html_multiple_columns(page, df_mixed):     htmlfmt = HTMLTemplateFormatter(         template='<p class="html-format"><%= str %> <%= bool %></p>'
panel/tests/widgets/test_tables.py42 + / 28 −
@@ -10,12 +10,13 @@ import param import pytest +from bokeh.models import LinearColorMapper from bokeh.models.widgets.tables import (-    AvgAggregator, CellEditor, CheckboxEditor, DataCube, DateEditor,-    DateFormatter, HTMLTemplateFormatter, IntEditor, MinAggregator,-    NumberEditor, NumberFormatter, SelectEditor, StringEditor, StringFormatter,-    SumAggregator,+    AvgAggregator, CellEditor, DataCube, DateFormatter, HTMLTemplateFormatter,+    IntEditor, MinAggregator, NumberEditor, NumberFormatter, SelectEditor,+    StringFormatter, SumAggregator, )+from bokeh.transform import transform from packaging.version import Version  from panel.depends import bind@@ -476,8 +477,8 @@ def test_tabulator_multi_index_columns(document, comm):      assert model.columns[2].field == 'A_one_Y'     mformatter = model.columns[2].formatter-    assert isinstance(mformatter, NumberFormatter)-    assert mformatter.format == '0.0000'+    assert mformatter['type'] == 'NumberFormatter'+    assert mformatter['format'] == '0.0000'      for field in ("index", "A_one_X", "A_one_Y", "A_two_X", "B_two_Y", "B_three_X", "B_three_Y"):         assert field in model.source.data@@ -1097,12 +1098,12 @@ def test_tabulator_editors_default(document, comm):     })     table = Tabulator(df)     model = table.get_root(document, comm)-    assert isinstance(model.columns[1].editor, IntEditor)-    assert isinstance(model.columns[2].editor, NumberEditor)-    assert isinstance(model.columns[3].editor, StringEditor)-    assert isinstance(model.columns[4].editor, DateEditor)-    assert isinstance(model.columns[5].editor, DateEditor)-    assert isinstance(model.columns[6].editor, CheckboxEditor)+    assert model.columns[1].editor == {'type': 'IntEditor', 'step': 1}+    assert model.columns[2].editor == {'type': 'NumberEditor', 'step': 0.01}+    assert model.columns[3].editor == {'type': 'StringEditor', 'completions': []}+    assert model.columns[4].editor == {'type': 'DateEditor'}+    assert model.columns[5].editor == {'type': 'DateEditor'}+    assert model.columns[6].editor == {'type': 'CheckboxEditor'}   def test_tabulator_formatters_default(document, comm):@@ -1116,18 +1117,20 @@ def test_tabulator_formatters_default(document, comm):     table = Tabulator(df)     model = table.get_root(document, comm)     mformatter = model.columns[1].formatter-    assert isinstance(mformatter, NumberFormatter)+    assert mformatter['type'] == 'NumberFormatter'+    assert mformatter['text_align'] == {'value': 'right'}     mformatter = model.columns[2].formatter-    assert isinstance(mformatter, NumberFormatter)-    assert mformatter.format == '0,0.0[00000]'+    assert mformatter['type'] == 'NumberFormatter'+    assert mformatter['format'] == '0,0.0[00000]'     mformatter = model.columns[3].formatter-    assert isinstance(mformatter, StringFormatter)+    assert mformatter['type'] == 'StringFormatter'+    assert mformatter['null_format'] == ''     mformatter = model.columns[4].formatter-    assert isinstance(mformatter, DateFormatter)-    assert mformatter.format == '%Y-%m-%d'+    assert mformatter['type'] == 'DateFormatter'+    assert mformatter['format'] == '%Y-%m-%d'     mformatter = model.columns[5].formatter-    assert isinstance(mformatter, DateFormatter)-    assert mformatter.format == '%Y-%m-%d %H:%M:%S'+    assert mformatter['type'] == 'DateFormatter'+    assert mformatter['format'] == '%Y-%m-%d %H:%M:%S'   def test_tabulator_config_formatter_string(document, comm):@@ -3099,26 +3102,37 @@ def test_tabulator_editor_property_change(dataframe, document, comm):     model = table.get_root(document, comm)      model_editor = model.columns[-1].editor-    assert isinstance(model_editor, SelectEditor) is not editor-    assert isinstance(model_editor, SelectEditor)-    assert model_editor.options == editor.options+    assert model_editor == {'type': 'SelectEditor', 'options': ['A', 'B', 'C']}      editor.options = ['D', 'E']     model_editor = model.columns[-1].editor-    assert model_editor.options == editor.options+    assert model_editor == {'type': 'SelectEditor', 'options': ['D', 'E']}  def test_tabulator_formatter_update(dataframe, document, comm):     formatter = NumberFormatter(format='0.0000')     table = Tabulator(dataframe, formatters={'float': formatter})     model = table.get_root(document, comm)     model_formatter = model.columns[2].formatter-    assert model_formatter is not formatter-    assert isinstance(model_formatter, NumberFormatter)-    assert model_formatter.format == formatter.format+    assert model_formatter['type'] == 'NumberFormatter'+    assert model_formatter['format'] == '0.0000'      formatter.format = '0.0'     model_formatter = model.columns[2].formatter-    assert model_formatter.format == formatter.format+    assert model_formatter['format'] == '0.0'++def test_tabulator_formatter_field_spec(dataframe, document, comm):+    mapper = LinearColorMapper(palette=['red', 'blue'], low=0, high=10)+    formatter = StringFormatter(text_color=transform('int', mapper), font_style='bold')+    table = Tabulator(dataframe, formatters={'str': formatter})+    model = table.get_root(document, comm)+    model_formatter = model.columns[-1].formatter+    assert model_formatter['text_color'] == {'field': 'int', 'transform': mapper}+    assert model_formatter['font_style'] == {'value': 'bold'}++def test_tabulator_non_editable_index_spec(dataframe, document, comm):+    table = Tabulator(dataframe)+    model = table.get_root(document, comm)+    assert model.columns[0].editor == {'type': 'CellEditor'}  def test_tabulator_sortable_update(dataframe, document, comm):     table = Tabulator(dataframe, sortable={'int': False})
panel/widgets/tables.py42 + / 6 −
@@ -13,6 +13,7 @@ import numpy as np import param +from bokeh.core.property.vectorization import Field, Value from bokeh.core.serialization import Serializer from bokeh.model import Model from bokeh.models import ColumnDataSource, ImportedStyleSheet@@ -47,7 +48,7 @@     from pyviz_comms import Comm      from ..models.tabulator import (-        CellClickEvent, SelectionEvent, TableEditEvent,+        CellClickEvent, SelectionEvent, TableEditEvent, TabulatorColumn,     )     from ..reactive import TDataColumn @@ -94,6 +95,29 @@ def _get_value_from_keys(d:dict, key1, key2, default=None):         return d[key2]     return default +def _cell_model_spec(model: CellEditor | CellFormatter | None) -> dict[str, t.Any] | Model | None:+    """+    Converts a builtin Bokeh CellEditor or CellFormatter into a plain+    spec so it can be rendered without loading the bokeh-tables bundle.+    Subclasses defined in custom extensions are passed through as is.+    """+    if model is None or type(model).__module__ != CellEditor.__module__:+        return model+    spec: dict[str, t.Any] = {'type': type(model).__name__}+    for name, value in model.properties_with_values(include_defaults=True).items():+        if name in Model.properties():+            continue+        if isinstance(value, Value):+            value = {'value': value.value}+        elif isinstance(value, Field):+            field_spec: dict[str, t.Any] = {'field': value.field}+            if isinstance(value.transform, Model):+                field_spec['transform'] = value.transform+            value = field_spec+        spec[name] = value+    return spec++ @contextmanager def _stringdtype_error(df: pd.DataFrame, column: str, array):     import pandas as pd@@ -1986,6 +2010,16 @@ def _update_selection(self, indices: list[int] | SelectionEvent):             ilocs = ilocs[len(ilocs) - self.selectable:]         self.selection = ilocs  # type: ignore +    def _get_columns(self) -> list[TabulatorColumn]:  # type: ignore[override]+        from ..models.tabulator import TabulatorColumn+        return [+            TabulatorColumn(+                field=column.field, title=column.title, width=column.width,+                formatter=_cell_model_spec(column.formatter),+                editor=_cell_model_spec(column.editor),+            ) for column in super()._get_columns()+        ]+     def _get_properties(self, doc: Document | None = None) -> dict[str, t.Any]:         properties = super()._get_properties(doc)         properties['configuration'] = self._get_configuration(properties['columns'])@@ -2040,7 +2074,7 @@ def _get_model(         self._register_events('cell-click', 'table-edit', 'selection-change', model=model, doc=doc, comm=comm)         return model -    def _get_filter_spec(self, column: TableColumn) -> FilterSpec:+    def _get_filter_spec(self, column: TabulatorColumn) -> FilterSpec:         fspec: FilterSpec = {}         if not self.header_filters or (isinstance(self.header_filters, dict) and                                        column.field not in self.header_filters):@@ -2060,7 +2094,9 @@ def _get_filter_spec(self, column: TableColumn) -> FilterSpec:                     fspec['headerFilter'] = False                 else:                     fspec['headerFilter'] = True-            elif isinstance(column.editor, DateEditor):+            elif isinstance(column.editor, DateEditor) or (+                isinstance(column.editor, dict) and column.editor['type'] == 'DateEditor'+            ):                 # Datetime filtering currently broken with Tabulator 5.4.3                 # Initial (empty) value of filter is passed to luxon.js                 # and causes error@@ -2108,7 +2144,7 @@ def _get_filter_spec(self, column: TableColumn) -> FilterSpec:             fspec['headerFilterPlaceholder'] = filter_placeholder         return fspec -    def _config_columns(self, column_objs: list[TableColumn]) -> Sequence[ColumnSpec | GroupSpec]:+    def _config_columns(self, column_objs: list[TabulatorColumn]) -> Sequence[ColumnSpec | GroupSpec]:         column_objs = list(column_objs)         groups: dict[str, GroupSpec] = {}         columns: Sequence[ColumnSpec | GroupSpec] = []@@ -2248,7 +2284,7 @@ def _config_columns(self, column_objs: list[TableColumn]) -> Sequence[ColumnSpec                     if new:                         children = last['columns']                 children.append(col_dict)-                column.title = self.titles.get(index, index[-1])+                column.update(title=self.titles.get(index, index[-1]))             elif matching_groups:                 group = matching_groups[0]                 if group in groups:@@ -2264,7 +2300,7 @@ def _config_columns(self, column_objs: list[TableColumn]) -> Sequence[ColumnSpec                 columns.append(col_dict)         return columns -    def _get_configuration(self, columns: list[TableColumn]) -> dict[str, t.Any]:+    def _get_configuration(self, columns: list[TabulatorColumn]) -> dict[str, t.Any]:         """         Returns the Tabulator configuration.         """