holoviz/panel · #8837
perf: Render Tabulator without the bokeh-tables bundle
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. """