diff --git a/.idea/misc.xml b/.idea/misc.xml index 574ac89..c223352 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/.idea/py-tabulator.iml b/.idea/py-tabulator.iml index c0c85dc..351d4dd 100644 --- a/.idea/py-tabulator.iml +++ b/.idea/py-tabulator.iml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml index a2f3433..c7e1a41 100644 --- a/.idea/watcherTasks.xml +++ b/.idea/watcherTasks.xml @@ -2,7 +2,6 @@ - diff --git a/docs/examples/getting_started/app.py b/docs/examples/getting_started/app.py index fa1695e..f6725ec 100644 --- a/docs/examples/getting_started/app.py +++ b/docs/examples/getting_started/app.py @@ -1,6 +1,8 @@ import pandas as pd from pytabulator.shiny_bindings import output_tabulator, render_tabulator from pytabulator.tabulator import Tabulator +from pytabulator.editors import NumberEditor, StarEditor, ProgressEditor +from pytabulator.formatters import StarFormatter from shiny import App, render, ui app_ui = ui.page_fluid( @@ -15,7 +17,30 @@ def tabulator(): df = pd.read_csv( "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv" ) - return Tabulator(df, table_options={"height": 311}) + """ + return Tabulator(df, options={"height": 311}).set_column_formatter( + "Pclass", "star", {"stars": 3}, hozAlign="center" + ) + """ + return ( + Tabulator(df) + .set_options(height=311) + # .set_column_formatter_star("Pclass", 3) + .set_column_formatter("Pclass", StarFormatter(stars=3), hoz_align="center") + .set_column_formatter_tick_cross("Survived", hoz_align="center") + # .set_column_editor("Fare", "number", dict(min=0, max=10)) + .set_column_editor_number("Fare", min_=0, max_=5) + .set_column_title("Pclass", "PassengerClass") + .set_column_editor(["Name", "Sex"], "input", hoz_align="center") + .set_column_editor("PassengerId", NumberEditor(min=0, max=1000, step=1)) + .set_column_editor("Pclass", StarEditor()) + .set_column_formatter("Fare", "progress") + .set_column_editor( + "Fare", + ProgressEditor(min=0, max=100, element_attributes=dict(title="Hey ho")), + hoz_align="left", + ) + ) @render.code async def txt(): diff --git a/docs/examples/getting_started/shiny_express_all.py b/docs/examples/getting_started/shiny_express_all.py index 74409b7..ad96325 100644 --- a/docs/examples/getting_started/shiny_express_all.py +++ b/docs/examples/getting_started/shiny_express_all.py @@ -77,7 +77,7 @@ def selected_rows(): @render_tabulator def tabulator(): - return Tabulator(df, table_options).options( + return Tabulator(df, table_options).set_options( editTriggerEvent="dblclick" ) # .options(selectableRows=True) @@ -136,4 +136,4 @@ async def trigger_get_data(): @reactive.Effect @reactive.event(input.tabulator_data) def tabulator_data(): - print(input.tabulator_data()[0]) + print(input.tabulator_data()) diff --git a/docs/examples/getting_started/shiny_express_all_new_style.py b/docs/examples/getting_started/shiny_express_all_new_style.py new file mode 100644 index 0000000..bd4332d --- /dev/null +++ b/docs/examples/getting_started/shiny_express_all_new_style.py @@ -0,0 +1,147 @@ +from random import randrange + +import pandas as pd +from pytabulator import TableOptions, Tabulator, TabulatorContext, render_tabulator +from pytabulator.utils import create_columns +from pytabulator.formatters import ProgressFormatter, TickCrossFormatter +from pytabulator.editors import ListEditor, InputEditor, ProgressEditor +from shiny import reactive, render +from shiny.express import input, ui + +# Fetch data +# +df = pd.read_csv( + "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv" +)[["PassengerId", "Name", "Pclass", "Sex", "Age", "Fare", "Survived"]] + +# Setup +# +table_options = TableOptions( + columns=create_columns( + df, + default_filter=True, + default_editor=True, + updates={ + "Pclass": { + "formatter": "star", + "formatterParams": {"stars": 3}, + "hozAlign": "center", + }, + # "Survived": {"formatter": "tickCross"}, + # "Fare": {"formatter": "progress", "hozAlign": "left"}, + }, + ), + height=413, + pagination=True, + pagination_add_row="table", + layout="fitColumns", + index="PassengerId", + add_row_pos="top", + selectable_rows=True, + history=True, +) + +# Shiny Express App +# +with ui.div(style="padding-top: 0px;"): + ui.input_action_button("trigger_download", "Download") + ui.input_action_button("add_row", "Add row") + ui.input_action_button("delete_selected_rows", "Delete selected rows") + ui.input_action_button("undo", "Undo") + ui.input_action_button("redo", "Redo") + ui.input_action_button("trigger_get_data", "Submit data") + +ui.div( + ui.input_text("name", "Click on 'Add row' to add the Person to the table."), + style="padding-top: 20px;", +) +ui.div("Click on a row to print the name of the person.", style="padding: 10px;"), + + +@render.code +async def txt(): + print(input.tabulator_row_clicked()) + return input.tabulator_row_clicked()["Name"] + + +ui.div( + "Select multiple rows to print the names of the selected persons.", + style="padding: 10px;", +), + + +@render.code +def selected_rows(): + data = input.tabulator_rows_selected() + output = [item["Name"] for item in data] + return "\n".join(output) + + +@render_tabulator +def tabulator(): + return ( + Tabulator(df, table_options) + .set_options(editTriggerEvent="dblclick") + .set_column_formatter("Fare", ProgressFormatter(), hoz_align="left") + .set_column_formatter("Survived", TickCrossFormatter(), hoz_align="center") + .set_column_editor("Sex", ListEditor()) + .set_column_editor("Name", InputEditor()) + .set_column_editor("Fare", ProgressEditor(), hoz_align="left") + ) + + +@reactive.Effect +@reactive.event(input.trigger_download) +async def trigger_download(): + print("download triggered") + async with TabulatorContext("tabulator") as table: + table.trigger_download("csv") + + +@reactive.Effect +@reactive.event(input.add_row) +async def add_row(): + async with TabulatorContext("tabulator") as table: + table.add_row( + { + "Name": input.name() or "Hans", + "Age": randrange(55), + "Survived": randrange(2), + "PassengerId": randrange(10000, 20000, 1), + "SibSp": randrange(9), + } + ) + + +@reactive.Effect +@reactive.event(input.delete_selected_rows) +async def delete_selected_rows(): + async with TabulatorContext("tabulator") as table: + table.delete_selected_rows() + + +@reactive.Effect +@reactive.event(input.undo) +async def undo(): + async with TabulatorContext("tabulator") as table: + table.undo() + + +@reactive.Effect +@reactive.event(input.redo) +async def redo(): + async with TabulatorContext("tabulator") as table: + table.redo() + + +@reactive.Effect +@reactive.event(input.trigger_get_data) +async def trigger_get_data(): + async with TabulatorContext("tabulator") as table: + table.trigger_get_data() + + +@reactive.Effect +@reactive.event(input.tabulator_data) +def tabulator_data(): + print(input.tabulator_data()) diff --git a/get-py-bindings.sh b/get-py-bindings.sh new file mode 100755 index 0000000..e3da277 --- /dev/null +++ b/get-py-bindings.sh @@ -0,0 +1,4 @@ +#!/bin/sh +branch=${1:-dev} +# curl -O https://raw.githubusercontent.com/eodaGmbH/tabulator-bindings/${branch}/r-bindings/rtabulator.js +curl -o pytabulator/srcjs/pytabulator.js https://raw.githubusercontent.com/eodaGmbH/tabulator-bindings/refs/heads/feature/typescript/py-bindings/pytabulator.js diff --git a/pytabulator/__init__.py b/pytabulator/__init__.py index 4b66192..233257d 100644 --- a/pytabulator/__init__.py +++ b/pytabulator/__init__.py @@ -1,3 +1,4 @@ +""" from importlib.metadata import PackageNotFoundError, version try: @@ -12,7 +13,8 @@ from ._table_options_dc import TableOptionsDC as TableOptions # print("dataclass") - +""" +from .tabulator_options import TabulatorOptions as TableOptions # from ._table_options_pydantic import TableOptionsPydantic as TableOptions from .shiny_bindings import output_tabulator, render_data_frame, render_tabulator from .tabulator import Tabulator diff --git a/pytabulator/_abstracts.py b/pytabulator/_abstracts.py new file mode 100644 index 0000000..8ef7454 --- /dev/null +++ b/pytabulator/_abstracts.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + +from ._utils import as_camel_dict_recursive + + +class MyBaseModel(BaseModel): + def to_dict(self) -> dict: + return as_camel_dict_recursive(self.model_dump(exclude_none=True)) diff --git a/pytabulator/_utils.py b/pytabulator/_utils.py index 6cd9876..1e102ca 100644 --- a/pytabulator/_utils.py +++ b/pytabulator/_utils.py @@ -15,6 +15,20 @@ def set_theme(stylesheet): def snake_to_camel_case(snake_str: str) -> str: return snake_str[0].lower() + snake_str.title()[1:].replace("_", "") - # return "".join( - # [item if not i else item.title() for i, item in enumerate(snake_str.split("_"))] - # ) + +def as_camel_dict(snake_dict: dict) -> dict: + return {snake_to_camel_case(k): v for (k, v) in snake_dict.items() if v is not None} + + +def as_camel_dict_recursive(snake_dict: dict) -> dict: + camel_case_dict = {} + for k, v in snake_dict.items(): + if v is not None: + camel_key = snake_to_camel_case(k) if "_" in k else k + + if isinstance(v, dict): + camel_case_dict[camel_key] = as_camel_dict_recursive(v) + else: + camel_case_dict[camel_key] = v + + return camel_case_dict diff --git a/pytabulator/data.py b/pytabulator/data.py new file mode 100644 index 0000000..40ecbd2 --- /dev/null +++ b/pytabulator/data.py @@ -0,0 +1,4 @@ +from pandas import read_csv + +def titanic(): + pass diff --git a/pytabulator/editors.py b/pytabulator/editors.py new file mode 100644 index 0000000..593b10a --- /dev/null +++ b/pytabulator/editors.py @@ -0,0 +1,89 @@ +from enum import Enum +from typing import Literal, Optional + +from ._abstracts import MyBaseModel + + +class Editors(Enum): + INPUT = "input" + TEXTAREA = "textarea" + NUMBER = "number" + RANGE = "range" + TICK_CROSS = "tickCross" + STAR = "star" + PROGRESS = "progress" + LIST = "list" + + +class Editor(MyBaseModel): + _name: str = "" + + @property + def name(self) -> str: + return self._name + + +class InputEditor(Editor): + _name: str = Editors.INPUT.value + + search: Optional[bool] = None + mask: Optional[str] = None + select_contents: Optional[bool] = None + element_attributes: Optional[dict] = None + + +class TextareaEditor(Editor): + _name: str = Editors.TEXTAREA.value + + mask: Optional[str] = None + select_contents: Optional[bool] = None + vertical_navigation: Literal["hybrid", "editor", "table"] = None + shift_enter_submit: Optional[bool] = None + + +class NumberEditor(Editor): + _name: str = Editors.NUMBER.value + + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + element_attributes: Optional[dict] = None + mask: Optional[str] = None + select_contents: Optional[bool] = None + vertical_navigation: Literal["editor", "table"] = None + + +class RangeEditor(Editor): + _name: str = Editors.RANGE.value + + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + element_attributes: Optional[dict] = None + + +class TickCrossEditor(Editor): + _name: str = Editors.TICK_CROSS.value + + true_value: Optional[str] = None + false_value: Optional[str] = None + element_attributes: Optional[dict] = None + + +class StarEditor(Editor): + _name: str = Editors.STAR.value + + +class ProgressEditor(Editor): + _name: str = Editors.PROGRESS.value + + min: Optional[float] = None + max: Optional[float] = None + element_attributes: Optional[dict] = None + + +class ListEditor(Editor): + _name: str = Editors.LIST.value + + values: Optional[list] = None + values_lookup: Optional[bool] = True diff --git a/pytabulator/formatters.py b/pytabulator/formatters.py new file mode 100644 index 0000000..eab2774 --- /dev/null +++ b/pytabulator/formatters.py @@ -0,0 +1,41 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +from ._utils import as_camel_dict_recursive + + +class Formatters(Enum): + STAR = "star" + PROGRESS = "progress" + TICK_CROSS = "tickCross" + + +class Formatter(BaseModel): + def to_dict(self) -> dict: + return as_camel_dict_recursive(self.model_dump(exclude_none=True)) + + @property + def name(self) -> str: + return "" + + +class StarFormatter(Formatter): + stars: Optional[int] = None + + @property + def name(self) -> str: + return Formatters.STAR.value + + +class ProgressFormatter(Formatter): + @property + def name(self) -> str: + return Formatters.PROGRESS.value + + +class TickCrossFormatter(Formatter): + @property + def name(self) -> str: + return Formatters.TICK_CROSS.value diff --git a/pytabulator/shiny_bindings.py b/pytabulator/shiny_bindings.py index c6b212a..07fc315 100644 --- a/pytabulator/shiny_bindings.py +++ b/pytabulator/shiny_bindings.py @@ -10,7 +10,8 @@ from ._types import TableOptions from ._utils import df_to_dict -from .tabulator import Tabulator, jsonifiable_table_options +# from .tabulator import Tabulator, jsonifiable_table_options +from .tabulator import Tabulator # from . import TableOptions @@ -32,10 +33,10 @@ def tabulator_dep() -> HTMLDependency: tabulator_bindings_dep = HTMLDependency( - "tabulator-bindings", + "pytabulator", "0.1.0", source={"package": "pytabulator", "subdir": "srcjs"}, - script={"src": "tabulator-bindings.js", "type": "module"}, + script={"src": "pytabulator.js", "type": "module"}, all_files=False, ) @@ -95,5 +96,7 @@ async def render(self) -> Jsonifiable: # return {"values": value.values.tolist(), "columns": value.columns.tolist()} # TODO: convert with js data = df_to_dict(df) - data["options"] = jsonifiable_table_options(self.table_options) + + # TODO: Fix this, func was removed + # data["options"] = jsonifiable_table_options(self.table_options) return data diff --git a/pytabulator/srcjs/pytabulator.js b/pytabulator/srcjs/pytabulator.js new file mode 100644 index 0000000..0078270 --- /dev/null +++ b/pytabulator/srcjs/pytabulator.js @@ -0,0 +1,129 @@ +"use strict"; +(() => { + // built/utils.js + function convertToDataFrame(data) { + const res = {}; + if (data.length === 0) { + return res; + } + const keys = Object.keys(data[0]); + keys.forEach((key) => res[key] = data.map((item) => item[key])); + return res; + } + + // built/events.js + function addEventListeners(tabulatorWidget) { + const table = tabulatorWidget.getTable(); + const elementId = tabulatorWidget.getElementId(); + const bindingLang = tabulatorWidget.getBindingLang(); + console.log("binding lang", bindingLang); + table.on("rowClick", function(e, row) { + const inputName = `${elementId}_row_clicked`; + console.log(inputName, row.getData()); + Shiny.onInputChange(inputName, row.getData()); + }); + table.on("rowClick", (e, row) => { + const inputName = bindingLang === "r" ? `${elementId}_data:rtabulator.data` : `${elementId}_data`; + const data = table.getSelectedRows().map((row2) => row2.getData()); + console.log(inputName, data); + Shiny.onInputChange(inputName, { data: convertToDataFrame(data) }); + }); + table.on("cellEdited", function(cell) { + const inputName = `${elementId}_cell_edited`; + console.log(inputName, cell.getData()); + Shiny.onInputChange(inputName, cell.getData()); + }); + table.on("dataFiltered", function(filters, rows) { + const inputName = bindingLang === "r" ? `${elementId}_data:rtabulator.data` : `${elementId}_data`; + const data = rows.map((row) => row.getData()); + console.log(inputName, data); + Shiny.onInputChange(inputName, { data: convertToDataFrame(data) }); + }); + } + + // built/widget.js + function run_calls(tabulatorWidget, calls) { + const table = tabulatorWidget.getTable(); + const elementId = tabulatorWidget.getElementId(); + const bindingLang = tabulatorWidget.getBindingLang(); + console.log("binding lang", bindingLang); + calls.forEach(([method_name, options]) => { + if (method_name === "getData") { + const inputName = bindingLang === "r" ? `${elementId}_data:rtabulator.data` : `${elementId}_data`; + console.log("custom call", inputName); + Shiny.setInputValue(inputName, { data: convertToDataFrame(table.getData()) }, { priority: "event" }); + return; + } + if (method_name === "deleteSelectedRows") { + console.log("custom call"); + const rows = table.getSelectedRows(); + rows.forEach((row) => { + console.log(row.getIndex()); + table.deleteRow(row.getIndex()); + }); + return; + } + if (method_name === "getSheetData") { + const inputName = bindingLang === "r" ? `${elementId}_sheet_data:rtabulator.sheet_data` : `${elementId}_sheet_data`; + console.log("custom call", inputName); + Shiny.setInputValue(inputName, { data: table.getSheetData() }, { priority: "event" }); + return; + } + console.log(method_name, options); + table[method_name](...options); + }); + } + var TabulatorWidget = class { + constructor(container, data, options, bindingOptions) { + options.data = data; + this._container = container; + this._bindingOptions = bindingOptions; + console.log("columns", options.columns); + if (data !== null && options.columns == null) { + options.autoColumns = true; + } + if (options.spreadsheet && options.spreadsheetData == null) { + options.spreadsheetData = []; + } + this._table = new Tabulator(this._container, options); + if (typeof Shiny === "object") { + addEventListeners(this); + this._addShinyMessageHandler(); + } + } + _addShinyMessageHandler() { + const messageHandlerName = `tabulator-${this._container.id}`; + Shiny.addCustomMessageHandler(messageHandlerName, (payload) => { + console.log(payload); + run_calls(this, payload.calls); + }); + } + getTable() { + return this._table; + } + getElementId() { + return this._container.id; + } + getBindingLang() { + return this._bindingOptions.lang; + } + }; + + // built/index-py.js + var TabulatorOutputBinding = class extends Shiny.OutputBinding { + find(scope) { + return scope.find(".shiny-tabulator-output"); + } + renderValue(el, payload) { + console.log("payload", payload); + const widget = new TabulatorWidget(el, payload.data, payload.options, payload.bindingOptions); + const table = widget.getTable(); + table.on("tableBuilt", function() { + if (payload.options.columnUpdates != null) { + console.log("column updates", payload.options.columnUpdates); + } + }); + } + }; + Shiny.outputBindings.register(new TabulatorOutputBinding(), "shiny-tabulator-output"); +})(); diff --git a/pytabulator/srcjs/tabulator-bindings.js b/pytabulator/srcjs/tabulator-bindings.js deleted file mode 100644 index 81b0324..0000000 --- a/pytabulator/srcjs/tabulator-bindings.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{function c(s,e){s.on("rowClick",function(n,t){let o=`${e.id}_row_clicked`;console.log(o,t.getData()),Shiny.onInputChange(o,t.getData())}),s.on("rowClick",(n,t)=>{let o=`${e.id}_rows_selected`,a=s.getSelectedRows().map(i=>i.getData());console.log(o,a),Shiny.onInputChange(o,a)}),s.on("cellEdited",function(n){let t=`${e.id}_row_edited`;console.log(t,n.getData()),Shiny.onInputChange(t,n.getData())}),s.on("dataFiltered",function(n,t){let o=`${e.id}_data_filtered`,a=t.map(i=>i.getData());console.log(o,a),Shiny.onInputChange(o,a)})}function r(s,e,n){n.forEach(([t,o])=>{if(t==="getData"){console.log("custom call"),Shiny.onInputChange(`${s.id}_data`,e.getData());return}if(t==="deleteSelectedRows"){console.log("custom call"),e.getSelectedRows().forEach(i=>{console.log(i.getIndex()),e.deleteRow(i.getIndex())});return}console.log(t,o),e[t](...o)})}var l=class{constructor(e,n,t){t.data=n,this._container=e,console.log("columns",t.columns),t.columns==null&&(t.autoColumns=!0),this._table=new Tabulator(this._container,t),typeof Shiny=="object"&&(c(this._table,this._container),this._addShinyMessageHandler())}_addShinyMessageHandler(){let e=`tabulator-${this._container.id}`;Shiny.addCustomMessageHandler(e,n=>{console.log(n),r(this._container,this._table,n.calls)})}getTable(){return this._table}};var u=class extends Shiny.OutputBinding{find(e){return e.find(".shiny-tabulator-output")}renderValue(e,n){console.log("payload",n),new l(e,n.data,n.options).getTable().on("tableBuilt",function(){n.options.columnUpdates!=null&&console.log("column updates",n.options.columnUpdates)})}};Shiny.outputBindings.register(new u,"shiny-tabulator-output");})(); diff --git a/pytabulator/tabulator.py b/pytabulator/tabulator.py index 5245f7e..83e3a8f 100644 --- a/pytabulator/tabulator.py +++ b/pytabulator/tabulator.py @@ -1,19 +1,19 @@ from __future__ import annotations -from pandas import DataFrame - -from ._types import TableOptions -from ._utils import df_to_dict +from typing import Any +from pandas import DataFrame -# TODO: Move somewhere else!? -def jsonifiable_table_options( - table_options: TableOptions | dict, -) -> dict: - if isinstance(table_options, TableOptions): - return table_options.to_dict() +try: + from typing_extensions import Self +except ImportError: + from typing import Self - return table_options +from ._utils import as_camel_dict_recursive, df_to_dict +from .editors import Editor +from .formatters import Formatter +from .tabulator_options import TabulatorOptions +from .utils import create_columns class Tabulator(object): @@ -21,24 +21,150 @@ class Tabulator(object): Args: df (DataFrame): A data frame. - table_options (TableOptions): Table options. + options (TabulatorOptions): Setup options. """ def __init__( self, df: DataFrame, - table_options: TableOptions | dict = {}, + options: TabulatorOptions | dict = TabulatorOptions(), ) -> None: self.df = df - # self.table_options = table_options - self._table_options = jsonifiable_table_options(table_options) + self._options = ( + options + if isinstance(options, TabulatorOptions) + else TabulatorOptions(**options) + ) + if not self._options.columns: + self._options.columns = create_columns(self.df) + + @property + def columns(self) -> list[dict]: + return self._options.columns + + def _find_column(self, col_name: str) -> tuple: + for i, col in enumerate(self.columns): + if col["field"] == col_name: + return i, col + + return None, None + + # Update single column + def _update_column(self, col_name: str, **kwargs: Any) -> Self: + i, col = self._find_column(col_name) + if col is not None: + self._options.columns[i] = col | as_camel_dict_recursive(kwargs) + + return self + + # ----- Column generics ----- + def update_column(self, col_name: str | list, **kwargs: Any) -> Self: + col_names = [col_name] if isinstance(col_name, str) else col_name + for col_name in col_names: + self._update_column(col_name, **kwargs) + + return self + + def update_column2(self, col_name, formatter: dict | Formatter = None, editor: dict | Editor = None, **kwargs: Any) -> Self: + if formatter is not None: + self.set_column_formatter(col_name, formatter) + + if editor is not None: + self.set_column_editor(col_name, editor) + + self._update_column(col_name, **kwargs) - def options(self, **kwargs) -> Tabulator: - self._table_options.update(kwargs) + return self + + def set_column_formatter( + self, + col_name: str | list, + formatter: str | Formatter, + formatter_params: dict = None, + **kwargs: Any, + ) -> Self: + if isinstance(formatter, Formatter): + formatter_name = formatter.name + formatter_params = formatter.to_dict() + else: + formatter_name = formatter + + return self.update_column( + col_name, + **dict( + formatter=formatter_name, + formatterParams=formatter_params or dict(), + **kwargs, + ), + ) + + def set_column_editor( + self, + col_name: str | list, + editor: str | Editor, + editor_params: dict = None, + validator: Any = None, + **kwargs: Any, + ) -> Self: + if isinstance(editor, Editor): + editor_name = editor.name + editor_params = editor.to_dict() + else: + editor_name = editor + + return self.update_column( + col_name, + **dict( + editor=editor_name, + editorParams=editor_params or dict(), + validator=validator, + **kwargs, + ), + ) + + # ----- Column formatters ----- + def set_column_formatter_star( + self, col_name: str | list, stars: int, **kwargs + ) -> Self: + formatter_params = dict(stars=stars) + self.set_column_formatter( + col_name, "star", formatter_params, hozAlign="center", **kwargs + ) + return self + + def set_column_formatter_tick_cross(self, col_name: str | list, **kwargs) -> Self: + self.set_column_formatter(col_name, "tickCross", **kwargs) + return self + + # ----- Column editor ----- + def set_column_editor_number( + self, + col_name: str | list, + min_value: float = None, + max_value: float = None, + step: float = None, + validator=None, + **kwargs, + ) -> Self: + editor_params = dict(min=min_value, max=max_value, step=step) + return self.set_column_editor( + col_name, "number", editor_params, validator, **kwargs + ) + + # ----- Column headers ----- + def set_column_title(self, col_name: str, title: str, **kwargs) -> Self: + return self.update_column(col_name, title=title, **kwargs) + + # ----- Misc ----- + def set_options(self, **kwargs) -> Self: + self._options = self._options.model_copy(update=kwargs) return self def to_dict(self) -> dict: - data = df_to_dict(self.df) - # data["options"] = jsonifiable_table_options(self.table_options) - data["options"] = self._table_options - return data + payload = df_to_dict(self.df) + payload["options"] = self._options.to_dict() + payload["bindingOptions"] = dict(lang="python") + return payload + + def to_html(self): + pass diff --git a/pytabulator/tabulator_options.py b/pytabulator/tabulator_options.py new file mode 100644 index 0000000..3005fd3 --- /dev/null +++ b/pytabulator/tabulator_options.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Literal, Union, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +class TabulatorOptions(BaseModel): + """Tabulator options. + + Attributes: + index (str, optional): The index of the table. Defaults to `id`. + header_visible (bool, optional): Whether to display the header of the table. Defaults to `True`. + movable_rows (bool, optional): Whether rows are movable or not. Defaults to `False`. + group_by: Columns to group by. Defaults to `None`. + height (int, optional): Height in px. Defaults to `300`. + pagination (bool, optional): Whether to enable pagination. Defaults to `False`. + pagination_counter (str, optional): Whether to display counted rows in footer. Defaults to `rows`. + pagination_add_row: Where to add rows when pagination is enabled. Defaults to `page`. + selectable_rows: Whether a row is selectable. An integer value sets the maximum number of rows, that can be selected. + If set to `highlight`, rows do not change state when clicked. Defaults to `highlight`. + columns (list, optional): Columns configuration. Defaults to `None`, + which means that the default configuration is used. + layout: The layout of the table. Defaults to `fitColumns`. + add_row_pos: Where to add rows. Defaults to `bottom`. + frozen_rows (int, optional): Number of frozen rows. Defaults to `Ǹone`. + row_height: Fixed height of rows. Defaults to `None`. + history (bool, optional): Whether to enable history. Must be set if `undo` and `redo` is used. Defaults to `False`. + + Note: + See [Tabulator Setup Options](https://tabulator.info/docs/5.5/options) for details. + """ + + index: str = "id" + header_visible: Optional[bool] = Field(True, serialization_alias="headerVisible") + movable_rows: Optional[bool] = Field(False, serialization_alias="movableRows") + group_by: Union[str, list, None] = Field(None, serialization_alias="groupBy") + height: Union[int, str, None] = None + pagination: Optional[bool] = False + pagination_counter: str = Field("rows", serialization_alias="paginationCounter") + pagination_add_row: Literal["page", "table"] = Field( + "page", serialization_alias="paginationAddRow" + ) + selectable_rows: Union[str, bool, int] = Field( + "highlight", serialization_alias="selectableRows" + ) + columns: Optional[list] = None + layout: Literal[ + "fitData", "fitDataFill", "fitDataStretch", "fitDataTable", "fitColumns" + ] = "fitColumns" + add_row_pos: Literal["bottom", "top"] = Field( + "bottom", serialization_alias="addRowPos" + ) + frozen_rows: Optional[int] = Field(None, serialization_alias="frozenRows") + row_height: Optional[int] = Field(None, serialization_alias="rowHeight") + resizable_column_fit: Optional[bool] = Field(False, serialization_alias="resizableColumnFit") + history: Optional[bool] = False + + # New features to be added in the next release + """ + responsiveLayout: str = "hide" + columnDefaults: dict = {"tooltip": True} + """ + + model_config = ConfigDict( + validate_assignment=True, + extra="allow", + # use_enum_values=True + ) + + @field_validator("height") + def validate_height(cls, v): + if isinstance(v, int): + return f"{v}px" + + return v + + def to_dict(self) -> dict: + return self.model_dump(by_alias=True, exclude_none=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..57472cf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest +import pandas as pd + +@pytest.fixture +def persons(): + data = [["Peter", 10, 10.5], ["Hans", 12, 13.7]] + return pd.DataFrame(data, columns=["Name", "Age", "JustANumber"]) diff --git a/tests/test_table.py b/tests/test_table.py index 4a368a1..a975cda 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -1,12 +1,7 @@ import pytest from pandas import DataFrame -from pydantic import BaseModel from pytabulator import Tabulator -from pytabulator._table_options_dc import TableOptionsDC as TableOptionsDC - -# from pytabulator import TableOptions as TableOptionsPydantic -from pytabulator._table_options_pydantic import TableOptionsPydantic - +from pytabulator.tabulator_options import TabulatorOptions @pytest.fixture def df() -> DataFrame: @@ -14,31 +9,15 @@ def df() -> DataFrame: return DataFrame(data, columns=["Name", "Age"]) -def test_table_dc(df: DataFrame) -> None: - # Prepare - table_options = TableOptionsDC(selectable_rows=3) - - # Act - table = Tabulator(df, table_options=table_options) - table_dict = table.to_dict() - print(table_dict) - - # Assert - assert list(table_dict.keys()) == ["schema", "data", "options"] - assert isinstance(table_dict["options"], dict) - # assert hasattr(table.table_options, "__dataclass_fields__") - - def test_table_pydantic(df: DataFrame) -> None: # Prepare - table_options = TableOptionsPydantic(selectable_rows=3) + table_options = TabulatorOptions(selectable_rows=3) # Act - table = Tabulator(df, table_options=table_options) + table = Tabulator(df, options=table_options) table_dict = table.to_dict() print(table_dict) - # assert isinstance(table.table_options, BaseModel) - # print("pydantic", type(table.table_options)) - assert list(table_dict.keys()) == ["schema", "data", "options"] + # Assert + assert list(table_dict.keys()) == ["schema", "data", "options", "bindingOptions"] assert isinstance(table_dict["options"], dict) diff --git a/tests/test_table_options.py b/tests/test_table_options.py index 8f390a9..4dc0ce8 100644 --- a/tests/test_table_options.py +++ b/tests/test_table_options.py @@ -1,9 +1,6 @@ import pytest -# from pytabulator import TableOptions -from pytabulator._table_options_dc import TableOptionsDC -from pytabulator._table_options_dc import TableOptionsDC as TableOptionsDC -from pytabulator._table_options_pydantic import TableOptionsPydantic as TableOptions +from pytabulator.tabulator_options import TabulatorOptions @pytest.fixture @@ -18,17 +15,11 @@ def some_table_options(): def test_table_options(some_table_options): # Prepare - table_options_pydantic = TableOptions(**some_table_options) - print("pydantic", table_options_pydantic) - - table_options_dc = TableOptionsDC(**some_table_options) - print("dc", table_options_dc) + table_options = TabulatorOptions(**some_table_options) # Act - table_options_pydantic_dict = table_options_pydantic.to_dict() - table_options_dc_dict = table_options_dc.to_dict() + table_options_dict = table_options.to_dict() # Assert - assert list(table_options_pydantic_dict.items()).sort( - key=lambda item: item[0] - ) == list(table_options_dc_dict.items()).sort(key=lambda item: item[0]) + print(table_options_dict) + assert table_options_dict["movableRows"] == False diff --git a/tests/test_tabulator_columns.py b/tests/test_tabulator_columns.py new file mode 100644 index 0000000..5cde48a --- /dev/null +++ b/tests/test_tabulator_columns.py @@ -0,0 +1,17 @@ +from pytabulator import Tabulator + + +def test_tabulator_columns(persons): + # print(persons) + table = Tabulator(persons) + + print(table.columns) + + col = table._find_column("Name") + print(col) + + table = table.update_column("Name", editor = True) + print(table.columns) + + table = table.set_column_formatter("Age", "html", hozAlign="center") + print(table.columns)