diff --git a/README_Chinese.md b/README_Chinese.md index cc35ffdbbbb..e7eda0038ea 100644 --- a/README_Chinese.md +++ b/README_Chinese.md @@ -8,17 +8,25 @@

用户手册 · Discord 社区 · - 示例 + 示例 · + 展示廊 · + YouTube

English - | 简体中文 + | 简体中文 | + 日本語 + | + Español

- + +discord +Pepy Total Downloads +Conda Downloads

@@ -26,23 +34,23 @@ **为什么选择 marimo** -- 🚀 **功能齐全:** 替代 `jupyter`、`streamlit`、`jupytext`、`ipywidgets`、`papermill` 等更多工具 -- ⚡️ **响应式**: 运行一个单元格,marimo 会响应式地[运行所有依赖单元格](https://docs.marimo.io/guides/reactivity.html) 或 将它们标记为陈旧 -- 🖐️ **交互性:** [绑定滑块、表格、图表等 UI 元素](https://docs.marimo.io/guides/interactivity.html) 到 Python——无需回调 -- 🔬 **可复现:** [无隐藏状态](https://docs.marimo.io/guides/reactivity.html#no-hidden-state),确定性执行 -- 🏃‍♂️ **可执行:** [作为 Python 脚本执行](https://docs.marimo.io/guides/scripts.html),通过命令行调整参数 -- 🛜 **可分享**: [部署为交互式 Web 应用](https://docs.marimo.io/guides/apps.html) 或 [幻灯片](https://docs.marimo.io/guides/apps.html#slides-layout),[通过 WASM 在浏览器中运行](https://docs.marimo.io/guides/wasm.html) -- 🛢️ **为数据设计**: 使用 [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) 查询数据框和数据库,过滤和搜索 [数据框](https://docs.marimo.io/guides/working_with_data/dataframes.html) -- 🐍 **支持 Git:** 笔记本以 `.py` 文件格式存储 -- ⌨️ **现代编辑器**: GitHub Copilot、AI 助手、vim 快捷键、变量浏览器,和 [更多功能](https://docs.marimo.io/guides/editor_features/index.html) +- 🚀 **功能齐全**:替代 `jupyter`、`streamlit`、`jupytext`、`ipywidgets`、`papermill` 等更多工具 +- ⚡️ **响应式**:运行一个单元格,marimo会响应式地[运行所有依赖单元格](https://docs.marimo.io/guides/reactivity.html)或将它们标记为过时 +- 🖐️ **交互性**:[绑定滑块、表格、图表等UI元素](https://docs.marimo.io/guides/interactivity.html)到Python代码——无需回调函数 +- 🔬 **可复现**:[无隐藏状态](https://docs.marimo.io/guides/reactivity.html#no-hidden-state),确定性执行,[内置包管理](https://docs.marimo.io/guides/editor_features/package_management.html) +- 🏃 **可执行**:[作为Python脚本执行](https://docs.marimo.io/guides/scripts.html),通过命令行参数进行配置 +- 🛜 **可分享**:[部署为交互式Web应用](https://docs.marimo.io/guides/apps.html)或[幻灯片](https://docs.marimo.io/guides/apps.html#slides-layout),[通过WASM在浏览器中运行](https://docs.marimo.io/guides/wasm.html) +- 🛢️ **为数据设计**:使用[SQL](https://docs.marimo.io/guides/working_with_data/sql.html)查询数据框和数据库,过滤和搜索[数据框](https://docs.marimo.io/guides/working_with_data/dataframes.html) +- 🐍 **支持Git版本控制**:笔记本以`.py`文件格式存储 +- ⌨️ **现代编辑器**:[GitHub Copilot](https://docs.marimo.io/guides/editor_features/ai_completion.html#github-copilot)、[AI助手](https://docs.marimo.io/guides/editor_features/ai_completion.html#using-ollama)、vim快捷键、变量浏览器和[更多功能](https://docs.marimo.io/guides/editor_features/index.html) ```python pip install marimo && marimo tutorial intro ``` -_在浏览器中运行[在线体验平台](https://marimo.app/l/c7h6pz)!_ +_在我们的[在线体验平台](https://marimo.app/l/c7h6pz)试用marimo,完全在浏览器中运行!_ -_跳转到[快速起步](#快速起步),了解命令行工具。_ +_跳转到[快速入门](#快速入门)了解我们的命令行工具。_ ## 响应式编程环境 @@ -55,23 +63,29 @@ Marimo 确保了您的代码、输出和程序的状态始的一致性,解决 -**与计算成本高昂的笔记兼容** marimo 允许你将运行时配置为 “懒惰”模式,将受影响的单元标记为过时单元,而不是自动运行它们。这样既能保证程序状态,又能防止意外执行昂贵的单元。 +**兼容计算密集型笔记本**。marimo允许您[将运行时配置为延迟模式](https://docs.marimo.io/guides/configuration/runtime_configuration.html),将受影响的单元格标记为过时而不是自动运行它们。这既能保证程序状态的完整性,又能防止意外执行计算密集型单元格。 -**同步的 UI 元素** 与滑块、下拉菜单和数据框转换器等 UI 元素交互,使用这些元素的单元格会自动以最新值重新运行。 +**同步的UI元素**。与[UI元素](https://docs.marimo.io/guides/interactivity.html)如[滑块](https://docs.marimo.io/api/inputs/slider.html#slider)、[下拉菜单](https://docs.marimo.io/api/inputs/dropdown.html)、[数据框转换器](https://docs.marimo.io/api/inputs/dataframe.html)和[聊天界面](https://docs.marimo.io/api/inputs/chat.html)交互时,使用它们的单元格会自动以最新值重新运行。 -**高效运行** 通过静态分析代码,marimo 只运行需要运行的单元。 +**交互式数据框**。[分页浏览、搜索、过滤和排序](https://docs.marimo.io/guides/working_with_data/dataframes.html)数百万行数据,极速运行,无需编写代码。 -**动态的 Markdown 与 SQL** 使用 Markdown 编写 Python 代码的输出动态进行更新的文档。同时,使用内置 [SQL](https://docs.marimo.io/guides/working_with_data/sql.html) 引擎,可创建依赖于 Python 值的 SQL 查询,并针对数据框、数据库、CSV、Google Sheets 或其他任何内容执行查询,SQL 引擎会将结果返回为 Python 数据框。 + + +**高效运行时**。marimo通过静态分析代码,只运行需要运行的单元格。 + +**动态Markdown和SQL**。使用Markdown创建依赖Python数据的动态文档。或者构建依赖Python值的[SQL](https://docs.marimo.io/guides/working_with_data/sql.html)查询,并针对数据框、数据库、CSV、Google Sheets或其他数据源执行,使用我们内置的SQL引擎将结果作为Python数据框返回。 -即使笔记本(notebook)使用了 markdown 或 SQL,它仍然是纯 Python 程序。 +即使使用了Markdown或SQL,您的笔记本仍然是纯Python代码。 + +**确定性执行顺序**。笔记本按照基于变量引用而非单元格页面位置的确定性顺序执行。您可以根据想要讲述的故事组织笔记本。 -**确定性的执行顺序** 笔记本的执行顺序是确定的,基于变量引用,而不是单元格在页面上的位置。根据你顺序逻辑来组织笔记本。 +**内置包管理**。marimo内置支持所有主要的包管理器,允许您[在导入时安装包](https://docs.marimo.io/guides/editor_features/package_management.html)。marimo甚至可以[序列化包依赖](https://docs.marimo.io/guides/package_reproducibility.html)到笔记本文件中,并在隔离的venv沙箱中自动安装它们。 -**易用且强大** Marimo 集成了包括 GitHub Copilot、Ruff 代码格式化、HTML 导出、快速代码补全、[VSCode 扩展](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo)、交互式数据框查看器等非常有用的功能。 +**功能齐全**。marimo集成了GitHub Copilot、AI助手、Ruff代码格式化、HTML导出、快速代码补全、[VS Code扩展](https://marketplace.visualstudio.com/items?itemName=marimo-team.vscode-marimo)、交互式数据框查看器和[更多](https://docs.marimo.io/guides/editor_features/index.html)便捷功能。 ## 快速起步 @@ -82,11 +96,11 @@ pip install marimo # or conda install -c conda-forge marimo marimo tutorial intro ``` -**或者在 Gitpod 运行** +要安装包含额外依赖项的版本(启用SQL单元格、AI补全等功能),运行: -单击此链接以在 Gitpod 工作区中打开存储库: - -[https://gitpod.io/#https://github.com/marimo-team/marimo](https://gitpod.io/#https://github.com/marimo-team/marimo) +```bash +pip install marimo[recommended] +``` **创建新的笔记本** @@ -96,7 +110,7 @@ marimo tutorial intro marimo edit ``` -**运行应用** 将笔记本作为网络应用程序运行,隐藏 Python 代码,且不可编辑: +**运行应用** 将笔记本作为Web应用运行,隐藏并锁定Python代码: ```bash marimo run your_notebook.py @@ -136,8 +150,8 @@ Marimo 很容易上手,为高级用户提供了很大的空间。 例如,这 -参阅我们的 [用户手册](https://docs.marimo.io), -在 `examples/` 文件夹下, 以及我们的[精选示例](https://marimo.io/@public)。 +查看我们的[文档](https://docs.marimo.io)、 +[使用示例](https://docs.marimo.io/examples/)和[展示廊](https://marimo.io/gallery)了解更多。 @@ -167,13 +181,13 @@ Marimo 很容易上手,为高级用户提供了很大的空间。 例如,这 教程 @@ -205,20 +219,21 @@ Marimo 很容易上手,为高级用户提供了很大的空间。 例如,这 我们感谢所有人的贡献! 这是为所有人设计的工具,我们真挚的欢迎任何人的任何意见! 请参阅[CONTRIBUTING.md](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md) 获取更多信息,了解如何参与到这个项目中来。 -> 看到这里,如果你有任何的想法或者问题,欢迎加入我们的 [Discord](https://marimo.io/discord?ref=readme)! +> 有问题?请[在Discord上联系我们](https://marimo.io/discord?ref=readme)。 ## 社区 我们也正在建设 marimo 社区,来和我们一起玩吧! -- 🌟 [给我们的项目点一颗星星](https://github.com/marimo-team/marimo) -- 💬 [在 Discord 上与我们交流](https://marimo.io/discord?ref=readme) -- 📧 [订阅我们的最新动态](https://marimo.io/newsletter) -- ☁️ [加入我们的云服务器候补名单](https://marimo.io/cloud) -- ✏️ [在 github 上开始一个讨论话题](https://github.com/marimo-team/marimo/discussions) -- 🐦 [在推特上关注我们](https://twitter.com/marimo_io) -- 🎥 [在 YouTube 上关注我们](https://www.youtube.com/@marimo-team) -- 🕴️ [在领英上关注我们](https://www.linkedin.com/company/marimo-io) +- 🌟 [在GitHub上为我们点赞](https://github.com/marimo-team/marimo) +- 💬 [在Discord上与我们交流](https://marimo.io/discord?ref=readme) +- 📧 [订阅我们的通讯](https://marimo.io/newsletter) +- ☁️ [加入我们的云服务候补名单](https://marimo.io/cloud) +- ✏️ [在GitHub上开始讨论](https://github.com/marimo-team/marimo/discussions) +- 🦋 [在Bluesky上关注我们](https://bsky.app/profile/marimo.io) +- 🐦 [在Twitter上关注我们](https://twitter.com/marimo_io) +- 🎥 [在YouTube上订阅](https://www.youtube.com/@marimo-team) +- 🕴️ [在LinkedIn上关注我们](https://www.linkedin.com/company/marimo-io) ## 愿景 ✨ diff --git a/docs/blocks.py b/docs/blocks.py index d89ff19158e..9445ddd6085 100644 --- a/docs/blocks.py +++ b/docs/blocks.py @@ -4,7 +4,12 @@ import urllib.parse from pymdownx.blocks import BlocksExtension # type: ignore -from pymdownx.blocks.block import Block, type_boolean, type_string, type_string_in # type: ignore +from pymdownx.blocks.block import ( + Block, + type_boolean, + type_string, + type_string_in, +) # type: ignore class BaseMarimoBlock(Block): @@ -72,7 +77,6 @@ def on_end(self, block: etree.Element) -> None: code=create_marimo_app_code(code=code, app_width=app_width), mode=mode, show_chrome=show_chrome, - ) self._create_iframe(block, url) @@ -99,7 +103,9 @@ def on_end(self, block: etree.Element) -> None: mode: str = cast(str, self.options["mode"]) show_chrome: bool = cast(bool, self.options["show-chrome"]) - url = create_marimo_app_url(code=code, mode=mode, show_chrome=show_chrome) + url = create_marimo_app_url( + code=code, mode=mode, show_chrome=show_chrome + ) self._create_iframe(block, url) # Add source code section if enabled @@ -139,7 +145,8 @@ def create_marimo_app_code( f'app = marimo.App(width="{app_width}")', "", ] - ) + "\n".join( + ) + mo_cell = "\n".join( [ "", "@app.cell", @@ -148,12 +155,18 @@ def create_marimo_app_code( " return", ] ) - return header + code + + mo_at_bottom = "with app.setup:" in code + if mo_at_bottom: + return header + code + mo_cell + return header + mo_cell + code -def create_marimo_app_url(code: str, mode: str = "edit", show_chrome: bool = False) -> str: +def create_marimo_app_url( + code: str, mode: str = "edit", show_chrome: bool = False +) -> str: encoded_code = uri_encode_component(code) - return f'https://marimo.app/?code={encoded_code}&embed=true&mode={mode}&show-chrome={"true" if show_chrome else "false"}' + return f"https://marimo.app/?code={encoded_code}&embed=true&mode={mode}&show-chrome={'true' if show_chrome else 'false'}" class MarimoBlocksExtension(BlocksExtension): diff --git a/docs/guides/deploying/authentication.md b/docs/guides/deploying/authentication.md index f689b4697ca..b2249a92bbf 100644 --- a/docs/guides/deploying/authentication.md +++ b/docs/guides/deploying/authentication.md @@ -68,4 +68,4 @@ if __name__ == "__main__": uvicorn.run(app, host="localhost", port=8000) ``` -or for a full example on implementing OAuth2 with FastAPI, see the [FastAPI OAuth2 example](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/). +For for a full example on implementing OAuth2 with FastAPI, see the [FastAPI OAuth2 example](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/). diff --git a/docs/guides/package_reproducibility.md b/docs/guides/package_reproducibility.md index 16d5095cd79..c7138c7a615 100644 --- a/docs/guides/package_reproducibility.md +++ b/docs/guides/package_reproducibility.md @@ -119,6 +119,38 @@ When developing a local package, you can install it in editable mode using the ` This is particularly useful when you want to test changes to your package without reinstalling it. The package will be installed in "editable" mode, meaning changes to the source code will be reflected immediately in your notebook. +### Specifying alternative package indexes + +When you need to use packages from a custom PyPI server or alternative index, you can specify these in your script metadata using the `[[tool.uv.index]]` section. This is useful for private packages or when you want to use packages from a specific source. + +```python +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas==", +# "private-package==", +# ] +# +# [[tool.uv.index]] +# name = "custom-index" +# url = "https://custom-pypi-server.example.com/simple/" +# explicit = true +# +# [tool.uv.sources] +# private-package = { index = "custom-index" } +# /// +``` + +In this example: + +- `[[tool.uv.index]]` defines a custom package index +- `name` is an identifier for the index +- `url` points to your custom PyPI server +- `explicit = true` means this index will only be used for packages explicitly associated with it +- `[tool.uv.sources]` specifies which packages should come from which indexes + +This approach ensures that specific packages are always fetched from your designated custom index, while other packages continue to be fetched from the default PyPI repository. + ## Configuration Running marimo in a sandbox environment uses `uv` to create an isolated virtual diff --git a/examples/third_party/pyiceberg/data_catalog.py b/examples/third_party/pyiceberg/data_catalog.py new file mode 100644 index 00000000000..53e3210bb6b --- /dev/null +++ b/examples/third_party/pyiceberg/data_catalog.py @@ -0,0 +1,139 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "polars==1.27.1", +# "pyarrow==19.0.1", +# "pyiceberg==0.9.0", +# ] +# /// + +import marimo + +__generated_with = "0.13.0" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import os + return mo, os + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + # PyIceberg REST Catalog + + This notebook shows you how to connect to an Apache Iceberg data catalog over REST. + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + /// details | Create a new catalog with Cloudflare + + 1. Create a Cloudflare account + 2. Go to + + + /// + """ + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""## Connect to a data catalog """) + return + + +@app.cell(hide_code=True) +def _(mo, os): + warehouse_input = mo.ui.text( + label="Warehouse", value=os.environ.get("DATA_CATALOG_WAREHOUSE", "") + ) + token_input = mo.ui.text( + label="Token", + value=os.environ.get("DATA_CATALOG_TOKEN", ""), + kind="password", + ) + catalog_input = mo.ui.text( + label="Catalog URI", value=os.environ.get("DATA_CATALOG_URI", "") + ) + mo.vstack([warehouse_input, token_input, catalog_input]) + return catalog_input, token_input, warehouse_input + + +@app.cell(hide_code=True) +def _(catalog_input, mo, token_input, warehouse_input): + mo.stop(not warehouse_input.value, mo.md("Missing Warehouse")) + mo.stop(not token_input.value, mo.md("Missing Token")) + mo.stop(not catalog_input.value, mo.md("Missing Catalog UI")) + + WAREHOUSE = warehouse_input.value + TOKEN = token_input.value + CATALOG_URI = catalog_input.value + return CATALOG_URI, TOKEN, WAREHOUSE + + +@app.cell +def _(CATALOG_URI, TOKEN, WAREHOUSE): + import pyarrow as pa + from pyiceberg.catalog.rest import RestCatalog + from pyiceberg.exceptions import NamespaceAlreadyExistsError + + # Connect to R2 Data Catalog + catalog = RestCatalog( + name="my_catalog", + warehouse=WAREHOUSE, + uri=CATALOG_URI, + token=TOKEN, + ) + + # Create default namespace + catalog.create_namespace_if_not_exists("default") + + # Create simple PyArrow table + df = pa.table( + { + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + } + ) + + # Create an Iceberg table + test_table = ("default", "my_table") + table = catalog.create_table_if_not_exists( + test_table, + schema=df.schema, + ) + return df, table + + +@app.cell +def _(mo): + add_button = mo.ui.run_button(label="Add data") + clear_button = mo.ui.run_button(label="Clear data") + mo.hstack([add_button, clear_button]) + return add_button, clear_button + + +@app.cell +def _(add_button, clear_button, df, table): + if add_button.value: + table.append(df) + if clear_button.value: + table.delete() + table.to_polars().collect() + return + + +if __name__ == "__main__": + app.run() diff --git a/frontend/package.json b/frontend/package.json index f4d04e4059a..99c8e1b2c7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -135,6 +135,7 @@ "react-virtuoso": "^4.12.6", "reactflow": "^11.11.4", "rpc-anywhere": "^1.7.0", + "sql-formatter": "^15.6.0", "string-dedent": "^3.0.1", "swiper": "^11.2.6", "tailwind-merge": "^2.6.0", @@ -206,8 +207,8 @@ "@types/timestring": "^6.0.5", "@typescript-eslint/eslint-plugin": "^7.15.0", "@typescript-eslint/parser": "^7.15.0", - "@vitejs/plugin-react": "^4.3.4", - "@vitejs/plugin-react-swc": "^3.8.1", + "@vitejs/plugin-react": "^4.4.1", + "@vitejs/plugin-react-swc": "^3.9.0", "autoprefixer": "^10.4.21", "babel-plugin-react-compiler": "19.0.0-beta-e552027-20250112", "blob-polyfill": "^7.0.20220408", @@ -235,10 +236,10 @@ "stylelint-config-standard": "^36.0.1", "tailwindcss": "^3.4.17", "turbo": "^2.4.4", - "typescript": "^5.8.2", - "vite": "^6.2.2", + "typescript": "^5.8.3", + "vite": "^6.3.2", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.9" + "vitest": "^3.1.1" }, "packageManager": "pnpm@9.15.9", "pnpm": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9a064ae22b4..07efb6cdafd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -381,6 +381,9 @@ importers: rpc-anywhere: specifier: ^1.7.0 version: 1.7.0 + sql-formatter: + specifier: ^15.6.0 + version: 15.6.0 string-dedent: specifier: ^3.0.1 version: 3.0.1 @@ -435,19 +438,19 @@ importers: devDependencies: '@babel/plugin-proposal-class-properties': specifier: ^7.18.6 - version: 7.18.6(@babel/core@7.26.9) + version: 7.18.6(@babel/core@7.26.10) '@babel/plugin-proposal-decorators': specifier: ^7.25.9 - version: 7.25.9(@babel/core@7.26.9) + version: 7.25.9(@babel/core@7.26.10) '@babel/preset-typescript': specifier: ^7.25.9 - version: 7.25.9(@babel/core@7.26.9) + version: 7.25.9(@babel/core@7.26.10) '@biomejs/biome': specifier: 1.9.4 version: 1.9.4 '@codecov/vite-plugin': specifier: ^1.9.0 - version: 1.9.0(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + version: 1.9.0(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) '@csstools/postcss-light-dark-function': specifier: ^2.0.7 version: 2.0.7(postcss@8.5.3) @@ -471,10 +474,10 @@ importers: version: 8.6.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10) '@storybook/react': specifier: ^8.6.9 - version: 8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.2) + version: 8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.3) '@storybook/react-vite': specifier: ^8.6.9 - version: 8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.2)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + version: 8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.3)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) '@swc-jotai/react-refresh': specifier: ^0.3.0 version: 0.3.0 @@ -510,16 +513,16 @@ importers: version: 6.0.5 '@typescript-eslint/eslint-plugin': specifier: ^7.15.0 - version: 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2) + version: 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^7.15.0 - version: 7.15.0(eslint@8.57.0)(typescript@5.8.2) + version: 7.15.0(eslint@8.57.0)(typescript@5.8.3) '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.3.4(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + specifier: ^4.4.1 + version: 4.4.1(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) '@vitejs/plugin-react-swc': - specifier: ^3.8.1 - version: 3.8.1(@swc/helpers@0.5.1)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + specifier: ^3.9.0 + version: 3.9.0(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.3) @@ -555,13 +558,13 @@ importers: version: 1.3.0(eslint@8.57.0) eslint-plugin-storybook: specifier: ^0.10.1 - version: 0.10.1(eslint@8.57.0)(typescript@5.8.2) + version: 0.10.1(eslint@8.57.0)(typescript@5.8.3) eslint-plugin-unicorn: specifier: ^54.0.0 version: 54.0.0(eslint@8.57.0) eslint-plugin-vitest: specifier: ^0.4.1 - version: 0.4.1(@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2)(vitest@3.0.9(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + version: 0.4.1(@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3)(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) jsdom: specifier: ^24.1.3 version: 24.1.3 @@ -591,10 +594,10 @@ importers: version: 8.6.10 stylelint: specifier: ^16.17.0 - version: 16.17.0(typescript@5.8.2) + version: 16.17.0(typescript@5.8.3) stylelint-config-standard: specifier: ^36.0.1 - version: 36.0.1(stylelint@16.17.0(typescript@5.8.2)) + version: 36.0.1(stylelint@16.17.0(typescript@5.8.3)) tailwindcss: specifier: ^3.4.17 version: 3.4.17 @@ -602,17 +605,17 @@ importers: specifier: ^2.4.4 version: 2.4.4 typescript: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.8.3 + version: 5.8.3 vite: - specifier: ^6.2.2 - version: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + specifier: ^6.3.2 + version: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.2)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + version: 5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) vitest: - specifier: ^3.0.9 - version: 3.0.9(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + specifier: ^3.1.1 + version: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) packages: @@ -689,12 +692,12 @@ packages: resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} engines: {node: '>=6.9.0'} - '@babel/core@7.26.9': - resolution: {integrity: sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==} + '@babel/core@7.26.10': + resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==} engines: {node: '>=6.9.0'} - '@babel/generator@7.26.9': - resolution: {integrity: sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==} + '@babel/generator@7.27.0': + resolution: {integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.25.9': @@ -759,12 +762,12 @@ packages: resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.26.9': - resolution: {integrity: sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==} + '@babel/helpers@7.27.0': + resolution: {integrity: sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.26.9': - resolution: {integrity: sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==} + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} engines: {node: '>=6.0.0'} hasBin: true @@ -840,16 +843,16 @@ packages: resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} - '@babel/template@7.26.9': - resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} + '@babel/template@7.27.0': + resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.26.9': - resolution: {integrity: sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==} + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} engines: {node: '>=6.9.0'} - '@babel/types@7.26.9': - resolution: {integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==} + '@babel/types@7.27.0': + resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} engines: {node: '>=6.9.0'} '@biomejs/biome@1.9.4': @@ -3293,71 +3296,71 @@ packages: '@swc-jotai/react-refresh@0.3.0': resolution: {integrity: sha512-WIWesycqFWqFRlfMa/NYON7AX6zTtSwK7z+nVRgdlk2r5iIv2/BDTeRgg3on+YvYlKR7IipioRVswyPOTq/ZKA==} - '@swc/core-darwin-arm64@1.11.11': - resolution: {integrity: sha512-vJcjGVDB8cZH7zyOkC0AfpFYI/7GHKG0NSsH3tpuKrmoAXJyCYspKPGid7FT53EAlWreN7+Pew+bukYf5j+Fmg==} + '@swc/core-darwin-arm64@1.11.21': + resolution: {integrity: sha512-v6gjw9YFWvKulCw3ZA1dY+LGMafYzJksm1mD4UZFZ9b36CyHFowYVYug1ajYRIRqEvvfIhHUNV660zTLoVFR8g==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.11.11': - resolution: {integrity: sha512-/N4dGdqEYvD48mCF3QBSycAbbQd3yoZ2YHSzYesQf8usNc2YpIhYqEH3sql02UsxTjEFOJSf1bxZABDdhbSl6A==} + '@swc/core-darwin-x64@1.11.21': + resolution: {integrity: sha512-CUiTiqKlzskwswrx9Ve5NhNoab30L1/ScOfQwr1duvNlFvarC8fvQSgdtpw2Zh3MfnfNPpyLZnYg7ah4kbT9JQ==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.11.11': - resolution: {integrity: sha512-hsBhKK+wVXdN3x9MrL5GW0yT8o9GxteE5zHAI2HJjRQel3HtW7m5Nvwaq+q8rwMf4YQRd8ydbvwl4iUOZx7i2Q==} + '@swc/core-linux-arm-gnueabihf@1.11.21': + resolution: {integrity: sha512-YyBTAFM/QPqt1PscD8hDmCLnqPGKmUZpqeE25HXY8OLjl2MUs8+O4KjwPZZ+OGxpdTbwuWFyMoxjcLy80JODvg==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.11.11': - resolution: {integrity: sha512-YOCdxsqbnn/HMPCNM6nrXUpSndLXMUssGTtzT7ffXqr7WuzRg2e170FVDVQFIkb08E7Ku5uOnnUVAChAJQbMOQ==} + '@swc/core-linux-arm64-gnu@1.11.21': + resolution: {integrity: sha512-DQD+ooJmwpNsh4acrftdkuwl5LNxxg8U4+C/RJNDd7m5FP9Wo4c0URi5U0a9Vk/6sQNh9aSGcYChDpqCDWEcBw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.11.11': - resolution: {integrity: sha512-nR2tfdQRRzwqR2XYw9NnBk9Fdvff/b8IiJzDL28gRR2QiJWLaE8LsRovtWrzCOYq6o5Uu9cJ3WbabWthLo4jLw==} + '@swc/core-linux-arm64-musl@1.11.21': + resolution: {integrity: sha512-y1L49+snt1a1gLTYPY641slqy55QotPdtRK9Y6jMi4JBQyZwxC8swWYlQWb+MyILwxA614fi62SCNZNznB3XSA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.11.11': - resolution: {integrity: sha512-b4gBp5HA9xNWNC5gsYbdzGBJWx4vKSGybGMGOVWWuF+ynx10+0sA/o4XJGuNHm8TEDuNh9YLKf6QkIO8+GPJ1g==} + '@swc/core-linux-x64-gnu@1.11.21': + resolution: {integrity: sha512-NesdBXv4CvVEaFUlqKj+GA4jJMNUzK2NtKOrUNEtTbXaVyNiXjFCSaDajMTedEB0jTAd9ybB0aBvwhgkJUWkWA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.11.11': - resolution: {integrity: sha512-dEvqmQVswjNvMBwXNb8q5uSvhWrJLdttBSef3s6UC5oDSwOr00t3RQPzyS3n5qmGJ8UMTdPRmsopxmqaODISdg==} + '@swc/core-linux-x64-musl@1.11.21': + resolution: {integrity: sha512-qFV60pwpKVOdmX67wqQzgtSrUGWX9Cibnp1CXyqZ9Mmt8UyYGvmGu7p6PMbTyX7vdpVUvWVRf8DzrW2//wmVHg==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.11.11': - resolution: {integrity: sha512-aZNZznem9WRnw2FbTqVpnclvl8Q2apOBW2B316gZK+qxbe+ktjOUnYaMhdCG3+BYggyIBDOnaJeQrXbKIMmNdw==} + '@swc/core-win32-arm64-msvc@1.11.21': + resolution: {integrity: sha512-DJJe9k6gXR/15ZZVLv1SKhXkFst8lYCeZRNHH99SlBodvu4slhh/MKQ6YCixINRhCwliHrpXPym8/5fOq8b7Ig==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.11.11': - resolution: {integrity: sha512-DjeJn/IfjgOddmJ8IBbWuDK53Fqw7UvOz7kyI/728CSdDYC3LXigzj3ZYs4VvyeOt+ZcQZUB2HA27edOifomGw==} + '@swc/core-win32-ia32-msvc@1.11.21': + resolution: {integrity: sha512-TqEXuy6wedId7bMwLIr9byds+mKsaXVHctTN88R1UIBPwJA92Pdk0uxDgip0pEFzHB/ugU27g6d8cwUH3h2eIw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.11.11': - resolution: {integrity: sha512-Gp/SLoeMtsU4n0uRoKDOlGrRC6wCfifq7bqLwSlAG8u8MyJYJCcwjg7ggm0rhLdC2vbiZ+lLVl3kkETp+JUvKg==} + '@swc/core-win32-x64-msvc@1.11.21': + resolution: {integrity: sha512-BT9BNNbMxdpUM1PPAkYtviaV0A8QcXttjs2MDtOeSqqvSJaPtyM+Fof2/+xSwQDmDEFzbGCcn75M5+xy3lGqpA==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.11.11': - resolution: {integrity: sha512-pCVY2Wn6dV/labNvssk9b3Owi4WOYsapcbWm90XkIj4xH/56Z6gzja9fsU+4MdPuEfC2Smw835nZHcdCFGyX6A==} + '@swc/core@1.11.21': + resolution: {integrity: sha512-/Y3BJLcwd40pExmdar8MH2UGGvCBrqNN7hauOMckrEX2Ivcbv3IMhrbGX4od1dnF880Ed8y/E9aStZCIQi0EGw==} engines: {node: '>=10'} peerDependencies: - '@swc/helpers': '*' + '@swc/helpers': '>=0.5.17' peerDependenciesMeta: '@swc/helpers': optional: true @@ -3368,8 +3371,8 @@ packages: '@swc/helpers@0.5.1': resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==} - '@swc/types@0.1.19': - resolution: {integrity: sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==} + '@swc/types@0.1.21': + resolution: {integrity: sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==} '@tailwindcss/typography@0.5.16': resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} @@ -3854,13 +3857,13 @@ packages: '@codemirror/state': ^6 '@codemirror/view': ^6 - '@vitejs/plugin-react-swc@3.8.1': - resolution: {integrity: sha512-aEUPCckHDcFyxpwFm0AIkbtv6PpUp3xTb9wYGFjtABynXjCYKkWoxX0AOK9NT9XCrdk6mBBUOeHQS+RKdcNO1A==} + '@vitejs/plugin-react-swc@3.9.0': + resolution: {integrity: sha512-jYFUSXhwMCYsh/aQTgSGLIN3Foz5wMbH9ahb0Zva//UzwZYbMiZd7oT3AU9jHT9DLswYDswsRwPU9jVF3yA48Q==} peerDependencies: vite: ^4 || ^5 || ^6 - '@vitejs/plugin-react@4.3.4': - resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} + '@vitejs/plugin-react@4.4.1': + resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 @@ -3868,11 +3871,11 @@ packages: '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} - '@vitest/expect@3.0.9': - resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} + '@vitest/expect@3.1.2': + resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} - '@vitest/mocker@3.0.9': - resolution: {integrity: sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==} + '@vitest/mocker@3.1.2': + resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 @@ -3888,20 +3891,20 @@ packages: '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - '@vitest/pretty-format@3.0.9': - resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} + '@vitest/pretty-format@3.1.2': + resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} - '@vitest/runner@3.0.9': - resolution: {integrity: sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==} + '@vitest/runner@3.1.2': + resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} - '@vitest/snapshot@3.0.9': - resolution: {integrity: sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==} + '@vitest/snapshot@3.1.2': + resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} - '@vitest/spy@3.0.9': - resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} + '@vitest/spy@3.1.2': + resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} @@ -3909,8 +3912,8 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - '@vitest/utils@3.0.9': - resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} + '@vitest/utils@3.1.2': + resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5060,6 +5063,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -5399,8 +5405,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - expect-type@1.1.0: - resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} ext@1.7.0: @@ -5455,6 +5461,14 @@ packages: fastq@1.13.0: resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + file-entry-cache@10.0.7: resolution: {integrity: sha512-txsf5fu3anp2ff3+gOJJzRImtrtm/oa9tYLN0iTuINZ++EyVR/nRrg2fKYwvG/pXDofcrvvb0scEbX3NyW/COw==} @@ -6685,6 +6699,9 @@ packages: mlly@1.7.2: resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==} + moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + mouse-change@1.4.0: resolution: {integrity: sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==} @@ -6727,6 +6744,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + needle@2.9.1: resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==} engines: {node: '>= 4.4.x'} @@ -7018,6 +7039,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -7415,6 +7440,13 @@ packages: raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -7546,8 +7578,8 @@ packages: react-property@2.0.2: resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} - react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} react-remove-scroll-bar@2.3.8: @@ -7785,6 +7817,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7961,6 +7997,10 @@ packages: spdx-license-ids@3.0.12: resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} + sql-formatter@15.6.0: + resolution: {integrity: sha512-PFJoCMXx42aoKaSIDhqLBJ/vKHBbwMlFPaW9YFlI99V+VWq2sV31xi3JH/StCovlZ1X5jpqwZMl9vq+X0s6bFw==} + hasBin: true + sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} engines: {node: '>=0.10.0'} @@ -7978,8 +8018,8 @@ packages: static-module@2.2.5: resolution: {integrity: sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ==} - std-env@3.8.0: - resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} stop-iteration-iterator@1.0.0: resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} @@ -8286,6 +8326,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + tinypool@1.0.2: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8492,8 +8536,8 @@ packages: engines: {node: '>=4.2.0'} hasBin: true - typescript@5.8.2: - resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -8761,8 +8805,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-node@3.0.9: - resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==} + vite-node@3.1.2: + resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -8774,8 +8818,8 @@ packages: vite: optional: true - vite@6.2.6: - resolution: {integrity: sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==} + vite@6.3.2: + resolution: {integrity: sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -8814,16 +8858,16 @@ packages: yaml: optional: true - vitest@3.0.9: - resolution: {integrity: sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==} + vitest@3.1.2: + resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.9 - '@vitest/ui': 3.0.9 + '@vitest/browser': 3.1.2 + '@vitest/ui': 3.1.2 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -9246,18 +9290,18 @@ snapshots: '@babel/compat-data@7.26.8': {} - '@babel/core@7.26.9': + '@babel/core@7.26.10': dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.9 + '@babel/generator': 7.27.0 '@babel/helper-compilation-targets': 7.26.5 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.9) - '@babel/helpers': 7.26.9 - '@babel/parser': 7.26.9 - '@babel/template': 7.26.9 - '@babel/traverse': 7.26.9 - '@babel/types': 7.26.9 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) + '@babel/helpers': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 convert-source-map: 2.0.0 debug: 4.4.0 gensync: 1.0.0-beta.2 @@ -9266,17 +9310,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.26.9': + '@babel/generator@7.27.0': dependencies: - '@babel/parser': 7.26.9 - '@babel/types': 7.26.9 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.25.9': dependencies: - '@babel/types': 7.26.9 + '@babel/types': 7.27.0 '@babel/helper-compilation-targets@7.26.5': dependencies: @@ -9286,68 +9330,68 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.26.9)': + '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.9) + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.10) '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.26.9 + '@babel/traverse': 7.27.0 semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-member-expression-to-functions@7.25.9': dependencies: - '@babel/traverse': 7.26.9 - '@babel/types': 7.26.9 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.26.9 - '@babel/types': 7.26.9 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.9)': + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.26.9 + '@babel/traverse': 7.27.0 transitivePeerDependencies: - supports-color '@babel/helper-optimise-call-expression@7.25.9': dependencies: - '@babel/types': 7.26.9 + '@babel/types': 7.27.0 '@babel/helper-plugin-utils@7.26.5': {} - '@babel/helper-replace-supers@7.25.9(@babel/core@7.26.9)': + '@babel/helper-replace-supers@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.26.9 + '@babel/traverse': 7.27.0 transitivePeerDependencies: - supports-color '@babel/helper-simple-access@7.25.9': dependencies: - '@babel/traverse': 7.26.9 - '@babel/types': 7.26.9 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.9': dependencies: - '@babel/traverse': 7.26.9 - '@babel/types': 7.26.9 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 transitivePeerDependencies: - supports-color @@ -9357,93 +9401,93 @@ snapshots: '@babel/helper-validator-option@7.25.9': {} - '@babel/helpers@7.26.9': + '@babel/helpers@7.27.0': dependencies: - '@babel/template': 7.26.9 - '@babel/types': 7.26.9 + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 - '@babel/parser@7.26.9': + '@babel/parser@7.27.0': dependencies: - '@babel/types': 7.26.9 + '@babel/types': 7.27.0 - '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.26.9)': + '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.9) + '@babel/core': 7.26.10 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.10) '@babel/helper-plugin-utils': 7.26.5 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.9) + '@babel/core': 7.26.10 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.10) '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.26.9) + '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.26.10) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.26.9)': + '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.9) + '@babel/core': 7.26.10 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.10) '@babel/helper-plugin-utils': 7.26.5 transitivePeerDependencies: - supports-color - '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.9) + '@babel/core': 7.26.10 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) '@babel/helper-plugin-utils': 7.26.5 '@babel/helper-simple-access': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.26.9)': + '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.9) + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.10) '@babel/helper-plugin-utils': 7.26.5 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.9) + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.10) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.25.9(@babel/core@7.26.9)': + '@babel/preset-typescript@7.25.9(@babel/core@7.26.10)': dependencies: - '@babel/core': 7.26.9 + '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 '@babel/helper-validator-option': 7.25.9 - '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.9) - '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.9) - '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.9) + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.10) + '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.10) + '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.10) transitivePeerDependencies: - supports-color @@ -9451,25 +9495,25 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@babel/template@7.26.9': + '@babel/template@7.27.0': dependencies: '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.9 - '@babel/types': 7.26.9 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 - '@babel/traverse@7.26.9': + '@babel/traverse@7.27.0': dependencies: '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.9 - '@babel/parser': 7.26.9 - '@babel/template': 7.26.9 - '@babel/types': 7.26.9 + '@babel/generator': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/types@7.26.9': + '@babel/types@7.27.0': dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 @@ -9543,11 +9587,11 @@ snapshots: unplugin: 1.16.1 zod: 3.24.2 - '@codecov/vite-plugin@1.9.0(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@codecov/vite-plugin@1.9.0(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@codecov/bundler-plugin-core': 1.9.0 unplugin: 1.16.1 - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) '@codemirror/autocomplete@6.18.6': dependencies: @@ -10178,14 +10222,14 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.8.2)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.8.3)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: glob: 10.4.5 magic-string: 0.27.0 - react-docgen-typescript: 2.2.2(typescript@5.8.2) - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + react-docgen-typescript: 2.2.2(typescript@5.8.3) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -12554,13 +12598,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.6.10(storybook@8.6.10)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@storybook/builder-vite@8.6.10(storybook@8.6.10)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@storybook/csf-plugin': 8.6.10(storybook@8.6.10) browser-assert: 1.2.1 storybook: 8.6.10 ts-dedent: 2.2.0 - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) '@storybook/components@8.6.10(storybook@8.6.10)': dependencies: @@ -12621,12 +12665,12 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.10 - '@storybook/react-vite@8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.2)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@storybook/react-vite@8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.3)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.8.2)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.8.3)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) '@rollup/pluginutils': 5.0.2 - '@storybook/builder-vite': 8.6.10(storybook@8.6.10)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) - '@storybook/react': 8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.2) + '@storybook/builder-vite': 8.6.10(storybook@8.6.10)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + '@storybook/react': 8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.3) find-up: 5.0.0 magic-string: 0.30.17 react: 18.3.1 @@ -12635,7 +12679,7 @@ snapshots: resolve: 1.22.8 storybook: 8.6.10 tsconfig-paths: 4.2.0 - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) optionalDependencies: '@storybook/test': 8.6.10(storybook@8.6.10) transitivePeerDependencies: @@ -12643,7 +12687,7 @@ snapshots: - supports-color - typescript - '@storybook/react@8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.2)': + '@storybook/react@8.6.10(@storybook/test@8.6.10(storybook@8.6.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.10)(typescript@5.8.3)': dependencies: '@storybook/components': 8.6.10(storybook@8.6.10) '@storybook/global': 5.0.0 @@ -12656,7 +12700,7 @@ snapshots: storybook: 8.6.10 optionalDependencies: '@storybook/test': 8.6.10(storybook@8.6.10) - typescript: 5.8.2 + typescript: 5.8.3 '@storybook/test@8.6.10(storybook@8.6.10)': dependencies: @@ -12675,52 +12719,51 @@ snapshots: '@swc-jotai/react-refresh@0.3.0': {} - '@swc/core-darwin-arm64@1.11.11': + '@swc/core-darwin-arm64@1.11.21': optional: true - '@swc/core-darwin-x64@1.11.11': + '@swc/core-darwin-x64@1.11.21': optional: true - '@swc/core-linux-arm-gnueabihf@1.11.11': + '@swc/core-linux-arm-gnueabihf@1.11.21': optional: true - '@swc/core-linux-arm64-gnu@1.11.11': + '@swc/core-linux-arm64-gnu@1.11.21': optional: true - '@swc/core-linux-arm64-musl@1.11.11': + '@swc/core-linux-arm64-musl@1.11.21': optional: true - '@swc/core-linux-x64-gnu@1.11.11': + '@swc/core-linux-x64-gnu@1.11.21': optional: true - '@swc/core-linux-x64-musl@1.11.11': + '@swc/core-linux-x64-musl@1.11.21': optional: true - '@swc/core-win32-arm64-msvc@1.11.11': + '@swc/core-win32-arm64-msvc@1.11.21': optional: true - '@swc/core-win32-ia32-msvc@1.11.11': + '@swc/core-win32-ia32-msvc@1.11.21': optional: true - '@swc/core-win32-x64-msvc@1.11.11': + '@swc/core-win32-x64-msvc@1.11.21': optional: true - '@swc/core@1.11.11(@swc/helpers@0.5.1)': + '@swc/core@1.11.21': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.19 + '@swc/types': 0.1.21 optionalDependencies: - '@swc/core-darwin-arm64': 1.11.11 - '@swc/core-darwin-x64': 1.11.11 - '@swc/core-linux-arm-gnueabihf': 1.11.11 - '@swc/core-linux-arm64-gnu': 1.11.11 - '@swc/core-linux-arm64-musl': 1.11.11 - '@swc/core-linux-x64-gnu': 1.11.11 - '@swc/core-linux-x64-musl': 1.11.11 - '@swc/core-win32-arm64-msvc': 1.11.11 - '@swc/core-win32-ia32-msvc': 1.11.11 - '@swc/core-win32-x64-msvc': 1.11.11 - '@swc/helpers': 0.5.1 + '@swc/core-darwin-arm64': 1.11.21 + '@swc/core-darwin-x64': 1.11.21 + '@swc/core-linux-arm-gnueabihf': 1.11.21 + '@swc/core-linux-arm64-gnu': 1.11.21 + '@swc/core-linux-arm64-musl': 1.11.21 + '@swc/core-linux-x64-gnu': 1.11.21 + '@swc/core-linux-x64-musl': 1.11.21 + '@swc/core-win32-arm64-msvc': 1.11.21 + '@swc/core-win32-ia32-msvc': 1.11.21 + '@swc/core-win32-x64-msvc': 1.11.21 '@swc/counter@0.1.3': {} @@ -12728,7 +12771,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/types@0.1.19': + '@swc/types@0.1.21': dependencies: '@swc/counter': 0.1.3 @@ -12854,24 +12897,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.26.9 - '@babel/types': 7.26.9 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.26.9 + '@babel/types': 7.27.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.26.9 - '@babel/types': 7.26.9 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.26.9 + '@babel/types': 7.27.0 '@types/clone@0.1.30': {} @@ -13133,34 +13176,34 @@ snapshots: '@types/webextension-polyfill@0.10.7': {} - '@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2)': + '@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.15.0(eslint@8.57.0)(typescript@5.8.2) + '@typescript-eslint/parser': 7.15.0(eslint@8.57.0)(typescript@5.8.3) '@typescript-eslint/scope-manager': 7.15.0 - '@typescript-eslint/type-utils': 7.15.0(eslint@8.57.0)(typescript@5.8.2) - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.8.2) + '@typescript-eslint/type-utils': 7.15.0(eslint@8.57.0)(typescript@5.8.3) + '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 7.15.0 eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.8.2) + ts-api-utils: 1.3.0(typescript@5.8.3) optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.2)': + '@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 7.15.0 '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 7.15.0 debug: 4.4.0 eslint: 8.57.0 optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -13174,15 +13217,15 @@ snapshots: '@typescript-eslint/types': 8.11.0 '@typescript-eslint/visitor-keys': 8.11.0 - '@typescript-eslint/type-utils@7.15.0(eslint@8.57.0)(typescript@5.8.2)': + '@typescript-eslint/type-utils@7.15.0(eslint@8.57.0)(typescript@5.8.3)': dependencies: - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.8.2) - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.8.3) + '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.8.3) debug: 4.4.0 eslint: 8.57.0 - ts-api-utils: 1.3.0(typescript@5.8.2) + ts-api-utils: 1.3.0(typescript@5.8.3) optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -13190,7 +13233,7 @@ snapshots: '@typescript-eslint/types@8.11.0': {} - '@typescript-eslint/typescript-estree@7.15.0(typescript@5.8.2)': + '@typescript-eslint/typescript-estree@7.15.0(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 7.15.0 '@typescript-eslint/visitor-keys': 7.15.0 @@ -13199,13 +13242,13 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.4 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.8.2) + ts-api-utils: 1.3.0(typescript@5.8.3) optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.11.0(typescript@5.8.2)': + '@typescript-eslint/typescript-estree@8.11.0(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.11.0 '@typescript-eslint/visitor-keys': 8.11.0 @@ -13214,29 +13257,29 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.4 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.8.2) + ts-api-utils: 1.3.0(typescript@5.8.3) optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.15.0(eslint@8.57.0)(typescript@5.8.2)': + '@typescript-eslint/utils@7.15.0(eslint@8.57.0)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) '@typescript-eslint/scope-manager': 7.15.0 '@typescript-eslint/types': 7.15.0 - '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 7.15.0(typescript@5.8.3) eslint: 8.57.0 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.11.0(eslint@8.57.0)(typescript@5.8.2)': + '@typescript-eslint/utils@8.11.0(eslint@8.57.0)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) '@typescript-eslint/scope-manager': 8.11.0 '@typescript-eslint/types': 8.11.0 - '@typescript-eslint/typescript-estree': 8.11.0(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 8.11.0(typescript@5.8.3) eslint: 8.57.0 transitivePeerDependencies: - supports-color @@ -13336,21 +13379,21 @@ snapshots: '@connectrpc/connect': 1.4.0(@bufbuild/protobuf@1.10.0) '@connectrpc/connect-web': 1.4.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.4.0(@bufbuild/protobuf@1.10.0)) - '@vitejs/plugin-react-swc@3.8.1(@swc/helpers@0.5.1)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@vitejs/plugin-react-swc@3.9.0(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: - '@swc/core': 1.11.11(@swc/helpers@0.5.1) - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + '@swc/core': 1.11.21 + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@4.3.4(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@vitejs/plugin-react@4.4.1(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: - '@babel/core': 7.26.9 - '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.9) - '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.9) + '@babel/core': 7.26.10 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10) '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + react-refresh: 0.17.0 + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -13361,20 +13404,20 @@ snapshots: chai: 5.2.0 tinyrainbow: 1.2.0 - '@vitest/expect@3.0.9': + '@vitest/expect@3.1.2': dependencies: - '@vitest/spy': 3.0.9 - '@vitest/utils': 3.0.9 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.9(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': + '@vitest/mocker@3.1.2(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: - '@vitest/spy': 3.0.9 + '@vitest/spy': 3.1.2 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) '@vitest/pretty-format@2.0.5': dependencies: @@ -13384,18 +13427,18 @@ snapshots: dependencies: tinyrainbow: 1.2.0 - '@vitest/pretty-format@3.0.9': + '@vitest/pretty-format@3.1.2': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.0.9': + '@vitest/runner@3.1.2': dependencies: - '@vitest/utils': 3.0.9 + '@vitest/utils': 3.1.2 pathe: 2.0.3 - '@vitest/snapshot@3.0.9': + '@vitest/snapshot@3.1.2': dependencies: - '@vitest/pretty-format': 3.0.9 + '@vitest/pretty-format': 3.1.2 magic-string: 0.30.17 pathe: 2.0.3 @@ -13403,7 +13446,7 @@ snapshots: dependencies: tinyspy: 3.0.2 - '@vitest/spy@3.0.9': + '@vitest/spy@3.1.2': dependencies: tinyspy: 3.0.2 @@ -13420,9 +13463,9 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 - '@vitest/utils@3.0.9': + '@vitest/utils@3.1.2': dependencies: - '@vitest/pretty-format': 3.0.9 + '@vitest/pretty-format': 3.1.2 loupe: 3.1.3 tinyrainbow: 2.0.0 @@ -13785,7 +13828,7 @@ snapshots: babel-plugin-react-compiler@19.0.0-beta-e552027-20250112: dependencies: - '@babel/types': 7.26.9 + '@babel/types': 7.27.0 bail@2.0.2: {} @@ -14166,14 +14209,14 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - cosmiconfig@9.0.0(typescript@5.8.2): + cosmiconfig@9.0.0(typescript@5.8.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 country-regex@1.1.0: {} @@ -14702,6 +14745,8 @@ snapshots: dependencies: path-type: 4.0.0 + discontinuous-range@1.0.0: {} + dlv@1.1.3: {} dnd-core@14.0.1: @@ -15045,9 +15090,9 @@ snapshots: eslint-plugin-react-compiler@19.0.0-beta-8a03594-20241020(eslint@8.57.0): dependencies: - '@babel/core': 7.26.9 - '@babel/parser': 7.26.9 - '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.26.9) + '@babel/core': 7.26.10 + '@babel/parser': 7.27.0 + '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.26.10) eslint: 8.57.0 hermes-parser: 0.20.1 zod: 3.24.2 @@ -15086,10 +15131,10 @@ snapshots: eslint: 8.57.0 globals: 13.20.0 - eslint-plugin-storybook@0.10.1(eslint@8.57.0)(typescript@5.8.2): + eslint-plugin-storybook@0.10.1(eslint@8.57.0)(typescript@5.8.3): dependencies: '@storybook/csf': 0.1.12 - '@typescript-eslint/utils': 8.11.0(eslint@8.57.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.11.0(eslint@8.57.0)(typescript@5.8.3) eslint: 8.57.0 ts-dedent: 2.2.0 transitivePeerDependencies: @@ -15118,13 +15163,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-vitest@0.4.1(@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2)(vitest@3.0.9(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)): + eslint-plugin-vitest@0.4.1(@typescript-eslint/eslint-plugin@7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3)(vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)): dependencies: - '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.8.2) + '@typescript-eslint/utils': 7.15.0(eslint@8.57.0)(typescript@5.8.3) eslint: 8.57.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.2))(eslint@8.57.0)(typescript@5.8.2) - vitest: 3.0.9(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + '@typescript-eslint/eslint-plugin': 7.15.0(@typescript-eslint/parser@7.15.0(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3) + vitest: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript @@ -15228,7 +15273,7 @@ snapshots: events@3.3.0: {} - expect-type@1.1.0: {} + expect-type@1.2.1: {} ext@1.7.0: dependencies: @@ -15277,6 +15322,10 @@ snapshots: dependencies: reusify: 1.0.4 + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + file-entry-cache@10.0.7: dependencies: flat-cache: 6.1.7 @@ -16755,6 +16804,8 @@ snapshots: pkg-types: 1.2.1 ufo: 1.5.4 + moo@0.5.2: {} + mouse-change@1.4.0: dependencies: mouse-event: 1.0.5 @@ -16794,6 +16845,13 @@ snapshots: natural-compare@1.4.0: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.2 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + needle@2.9.1: dependencies: debug: 3.2.7 @@ -17085,6 +17143,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.2: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -17503,6 +17563,13 @@ snapshots: dependencies: performance-now: 2.1.0 + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -17638,15 +17705,15 @@ snapshots: '@types/node': 20.17.30 '@types/react': 18.3.20 - react-docgen-typescript@2.2.2(typescript@5.8.2): + react-docgen-typescript@2.2.2(typescript@5.8.3): dependencies: - typescript: 5.8.2 + typescript: 5.8.3 react-docgen@7.0.1: dependencies: - '@babel/core': 7.26.9 - '@babel/traverse': 7.26.9 - '@babel/types': 7.26.9 + '@babel/core': 7.26.10 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 '@types/doctrine': 0.0.9 @@ -17736,7 +17803,7 @@ snapshots: react-property@2.0.2: {} - react-refresh@0.14.2: {} + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@18.3.20)(react@18.3.1): dependencies: @@ -18107,6 +18174,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.1.15: {} + reusify@1.0.4: {} right-now@1.0.0: {} @@ -18305,6 +18374,11 @@ snapshots: spdx-license-ids@3.0.12: {} + sql-formatter@15.6.0: + dependencies: + argparse: 2.0.1 + nearley: 2.20.1 + sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -18342,7 +18416,7 @@ snapshots: static-eval: 2.1.0 through2: 2.0.5 - std-env@3.8.0: {} + std-env@3.9.0: {} stop-iteration-iterator@1.0.0: dependencies: @@ -18500,16 +18574,16 @@ snapshots: postcss: 8.5.3 postcss-selector-parser: 6.1.2 - stylelint-config-recommended@14.0.1(stylelint@16.17.0(typescript@5.8.2)): + stylelint-config-recommended@14.0.1(stylelint@16.17.0(typescript@5.8.3)): dependencies: - stylelint: 16.17.0(typescript@5.8.2) + stylelint: 16.17.0(typescript@5.8.3) - stylelint-config-standard@36.0.1(stylelint@16.17.0(typescript@5.8.2)): + stylelint-config-standard@36.0.1(stylelint@16.17.0(typescript@5.8.3)): dependencies: - stylelint: 16.17.0(typescript@5.8.2) - stylelint-config-recommended: 14.0.1(stylelint@16.17.0(typescript@5.8.2)) + stylelint: 16.17.0(typescript@5.8.3) + stylelint-config-recommended: 14.0.1(stylelint@16.17.0(typescript@5.8.3)) - stylelint@16.17.0(typescript@5.8.2): + stylelint@16.17.0(typescript@5.8.3): dependencies: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 @@ -18518,7 +18592,7 @@ snapshots: '@dual-bundle/import-meta-resolve': 4.1.0 balanced-match: 2.0.0 colord: 2.9.3 - cosmiconfig: 9.0.0(typescript@5.8.2) + cosmiconfig: 9.0.0(typescript@5.8.3) css-functions-list: 3.2.3 css-tree: 3.1.0 debug: 4.4.0 @@ -18746,6 +18820,11 @@ snapshots: tinyexec@0.3.2: {} + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + tinypool@1.0.2: {} tinyqueue@2.0.3: {} @@ -18805,17 +18884,17 @@ snapshots: trough@2.2.0: {} - ts-api-utils@1.3.0(typescript@5.8.2): + ts-api-utils@1.3.0(typescript@5.8.3): dependencies: - typescript: 5.8.2 + typescript: 5.8.3 ts-dedent@2.2.0: {} ts-interface-checker@0.1.13: {} - tsconfck@3.1.5(typescript@5.8.2): + tsconfck@3.1.5(typescript@5.8.3): optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 tsconfig-paths@4.2.0: dependencies: @@ -18932,7 +19011,7 @@ snapshots: typescript@2.1.6: {} - typescript@5.8.2: {} + typescript@5.8.3: {} ufo@1.5.4: {} @@ -19406,13 +19485,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@3.0.9(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0): + vite-node@3.1.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -19427,22 +19506,25 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)): dependencies: debug: 4.4.0 globrex: 0.1.2 - tsconfck: 3.1.5(typescript@5.8.2) + tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript - vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0): + vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0): dependencies: esbuild: 0.25.2 + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 postcss: 8.5.3 rollup: 4.39.0 + tinyglobby: 0.2.13 optionalDependencies: '@types/node': 20.17.30 fsevents: 2.3.3 @@ -19451,27 +19533,28 @@ snapshots: terser: 5.39.0 yaml: 2.7.0 - vitest@3.0.9(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0): + vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.30)(jiti@1.21.7)(jsdom@24.1.3)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0): dependencies: - '@vitest/expect': 3.0.9 - '@vitest/mocker': 3.0.9(vite@6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) - '@vitest/pretty-format': 3.0.9 - '@vitest/runner': 3.0.9 - '@vitest/snapshot': 3.0.9 - '@vitest/spy': 3.0.9 - '@vitest/utils': 3.0.9 + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0)) + '@vitest/pretty-format': 3.1.2 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 chai: 5.2.0 debug: 4.4.0 - expect-type: 1.1.0 + expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 - std-env: 3.8.0 + std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 + tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.2.6(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) - vite-node: 3.0.9(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite: 6.3.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) + vite-node: 3.1.2(@types/node@20.17.30)(jiti@1.21.7)(less@4.2.0)(terser@5.39.0)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 diff --git a/frontend/src/components/app-config/app-config-button.tsx b/frontend/src/components/app-config/app-config-button.tsx index a2b4ea37e59..377959ea030 100644 --- a/frontend/src/components/app-config/app-config-button.tsx +++ b/frontend/src/components/app-config/app-config-button.tsx @@ -55,7 +55,7 @@ export const ConfigButton: React.FC = ({ showAppConfig = true }) => { {button} { const [config, setConfig] = useAppConfig(); @@ -55,232 +56,244 @@ export const AppConfigForm: React.FC = () => {
- Application Config + Notebook Settings - Settings applied to this notebook + Configure how your notebook or application looks and behaves.
- ( - - Width - - field.onChange(e.target.value)} - value={field.value} - disabled={field.disabled} - className="inline-flex mr-2" + +
+ + ( + - {getAppWidths().map((option) => ( - - ))} - - - - - )} - /> - ( -
- - App title - - { - field.onChange(e.target.value); - if (AppTitleSchema.safeParse(e.target.value).success) { - document.title = e.target.value; - } - }} - /> - - - - - The application title is put in the title tag in the HTML code - and typically displayed in the title bar of the browser window. - -
- )} - /> - ( -
- - Custom CSS - - { - field.onChange(e.target.value); - if (AppTitleSchema.safeParse(e.target.value).success) { - document.title = e.target.value; - } - }} - /> - - - - - A filepath to a custom css file to be injected into the - notebook. - -
- )} - /> - ( -
- - HTML Head - - { - field.onChange(e.target.value); - }} - /> - - - - - A filepath to an HTML file to be injected into the{" "} - {""} section of the - notebook. Use this to add analytics, custom fonts, meta tags, or - external scripts. - -
- )} - /> - ( -
- - SQL Output Type - - { - field.onChange(e.target.value); - toast({ - title: "Kernel Restart Required", - description: - "This change requires a kernel restart to take effect.", - }); - }} - value={field.value} - disabled={field.disabled} - className="inline-flex mr-2" - > - - - - - - - - - - - The Python type returned by a SQL cell. For best performance - with large datasets, we recommend using{" "} - native. See the{" "} - - SQL guide - {" "} - for more information. - -
- )} - /> - ( -
-
- Auto-download -
- - -
-
- { - field.onChange(arrayToggle(field.value, "html")); + Width + + field.onChange(e.target.value)} + value={field.value} + disabled={field.disabled} + className="inline-flex mr-2" + > + {getAppWidths().map((option) => ( + + ))} + + + + + )} + /> + ( +
+ + App title + + { + field.onChange(e.target.value); + if ( + AppTitleSchema.safeParse(e.target.value).success + ) { + document.title = e.target.value; + } }} /> - HTML -
-
- { - field.onChange(arrayToggle(field.value, "ipynb")); + + + + + The application title is put in the title tag in the HTML + code and typically displayed in the title bar of the browser + window. + +
+ )} + /> + + + + ( +
+ + Custom CSS + + { + field.onChange(e.target.value); + if ( + AppTitleSchema.safeParse(e.target.value).success + ) { + document.title = e.target.value; + } }} /> - IPYNB -
- {/* Disable markdown until we save outputs in the exported markdown */} - {/*
- { - field.onChange(arrayToggle(field.value, "markdown")); + + + + + A filepath to a custom css file to be injected into the + notebook. + +
+ )} + /> + ( +
+ + HTML Head + + { + field.onChange(e.target.value); }} /> - + + A filepath to an HTML file to be injected into the{" "} + {""} section of the + notebook. Use this to add analytics, custom fonts, meta + tags, or external scripts. + +
+ )} + /> +
+ + + ( +
+ + SQL Output Type + + { + field.onChange(e.target.value); + toast({ + title: "Kernel Restart Required", + description: + "This change requires a kernel restart to take effect.", + }); + }} + value={field.value} + disabled={field.disabled} + className="inline-flex mr-2" > - Markdown - -
*/} -
- - - - - When enabled, marimo will periodically save this notebook in - your selected formats (HTML, IPYNB) to a folder named{" "} - __marimo__ next to your notebook - file. - -
- )} - /> + + + + + + +
+ +
+ + The Python type returned by a SQL cell. For best performance + with large datasets, we recommend using{" "} + native. See the{" "} + + SQL guide + {" "} + for more information. + +
+ )} + /> +
+ + + ( +
+ + +
+
+ { + field.onChange(arrayToggle(field.value, "html")); + }} + /> + HTML +
+
+ { + field.onChange(arrayToggle(field.value, "ipynb")); + }} + /> + IPYNB +
+
+
+ +
+ + When enabled, marimo will periodically save this notebook in + your selected formats (HTML, IPYNB) to a folder named{" "} + __marimo__ next to your + notebook file. + +
+ )} + /> +
+
); }; + +const SettingSection = ({ + title, + children, +}: { title: string; children: React.ReactNode }) => { + return ( +
+

{title}

+ {children} +
+ ); +}; diff --git a/frontend/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap b/frontend/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap index afaa4de6d97..97a479ef273 100644 --- a/frontend/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap +++ b/frontend/src/components/data-table/__tests__/__snapshots__/chart-spec-model.test.ts.snap @@ -317,10 +317,21 @@ exports[`ColumnChartSpecModel > snapshot > url data 1`] = ` }, "tooltip": [ { - "bin": true, - "field": "date", + "bin": { + "binned": true, + }, + "field": "bin_maxbins_10_date", + "format": "%Y-%m-%d", + "title": "date (start)", + "type": "temporal", + }, + { + "bin": { + "binned": true, + }, + "field": "bin_maxbins_10_date_end", "format": "%Y-%m-%d", - "title": "date", + "title": "date (end)", "type": "temporal", }, { diff --git a/frontend/src/components/data-table/chart-spec-model.tsx b/frontend/src/components/data-table/chart-spec-model.tsx index a2d2da167cb..05738a3ba8a 100644 --- a/frontend/src/components/data-table/chart-spec-model.tsx +++ b/frontend/src/components/data-table/chart-spec-model.tsx @@ -109,7 +109,14 @@ export class ColumnChartSpecModel { switch (type) { case "date": case "datetime": - case "time": + case "time": { + const format = + type === "date" + ? "%Y-%m-%d" + : type === "time" + ? "%H:%M:%S" + : "%Y-%m-%dT%H:%M:%S"; + return { ...base, // Two layers: one with the visible bars, and one with invisible bars @@ -159,16 +166,19 @@ export class ColumnChartSpecModel { y: { aggregate: "max", type: "quantitative", axis: null }, tooltip: [ { - field: column, + // Can also use column, but this is more explicit + field: `bin_maxbins_10_${column}`, type: "temporal", - format: - type === "date" - ? "%Y-%m-%d" - : type === "time" - ? "%H:%M:%S" - : "%Y-%m-%dT%H:%M:%S", - bin: true, - title: column, + format: format, + bin: { binned: true }, + title: `${column} (start)`, + }, + { + field: `bin_maxbins_10_${column}_end`, + type: "temporal", + format: format, + bin: { binned: true }, + title: `${column} (end)`, }, { aggregate: "count", @@ -189,6 +199,7 @@ export class ColumnChartSpecModel { }, ], }; + } case "integer": case "number": { // Create a histogram spec that properly handles null values diff --git a/frontend/src/components/data-table/chart-transforms/__tests__/__snapshots__/test-chart-spec.test.ts.snap b/frontend/src/components/data-table/chart-transforms/__tests__/__snapshots__/spec-snapshot.test.ts.snap similarity index 56% rename from frontend/src/components/data-table/chart-transforms/__tests__/__snapshots__/test-chart-spec.test.ts.snap rename to frontend/src/components/data-table/chart-transforms/__tests__/__snapshots__/spec-snapshot.test.ts.snap index 9d991e535d8..e2c2f2e2de6 100644 --- a/frontend/src/components/data-table/chart-transforms/__tests__/__snapshots__/test-chart-spec.test.ts.snap +++ b/frontend/src/components/data-table/chart-transforms/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -39,29 +39,13 @@ exports[`createVegaSpec > Bar Chart > should create a horizontal bar chart 1`] = ], }, "encoding": { - "color": { - "field": undefined, - "scale": { - "scheme": undefined, - }, - "type": "nominal", - }, - "tooltip": undefined, "x": { - "bin": undefined, "field": "value", - "scale": undefined, - "stack": undefined, "title": "value", - "type": "quantitative", - }, - "xOffset": { - "field": undefined, + "type": "nominal", }, "y": { - "bin": undefined, "field": "category", - "stack": undefined, "title": "category", "type": "nominal", }, @@ -116,94 +100,20 @@ exports[`createVegaSpec > Bar Chart > should create a stacked bar chart with gro "encoding": { "color": { "field": "group", - "scale": { - "scheme": undefined, - }, + "scale": {}, "type": "nominal", }, - "tooltip": undefined, "x": { - "bin": undefined, "field": "category", - "stack": undefined, "title": "category", "type": "nominal", }, - "xOffset": undefined, "y": { - "bin": undefined, "field": "value", - "scale": undefined, "stack": true, "title": "value", - "type": "quantitative", - }, - }, - "height": 300, - "mark": { - "type": "bar", - }, - "title": "Test Chart", - "width": 400, -} -`; - -exports[`createVegaSpec > Edge cases > should handle NONE_GROUP_BY for groupByColumn 1`] = ` -{ - "$schema": "https://vega.github.io/schema/vega-lite/v5.json", - "background": "white", - "data": { - "values": [ - { - "category": "A", - "group": "Group 1", - "value": 10, - }, - { - "category": "B", - "group": "Group 1", - "value": 20, - }, - { - "category": "C", - "group": "Group 1", - "value": 15, - }, - { - "category": "A", - "group": "Group 2", - "value": 5, - }, - { - "category": "B", - "group": "Group 2", - "value": 10, - }, - { - "category": "C", - "group": "Group 2", - "value": 25, - }, - ], - }, - "encoding": { - "tooltip": undefined, - "x": { - "bin": undefined, - "field": "category", - "stack": undefined, - "title": "category", "type": "nominal", }, - "xOffset": undefined, - "y": { - "bin": undefined, - "field": "value", - "scale": undefined, - "stack": undefined, - "title": "value", - "type": "quantitative", - }, }, "height": 300, "mark": { @@ -214,7 +124,7 @@ exports[`createVegaSpec > Edge cases > should handle NONE_GROUP_BY for groupByCo } `; -exports[`createVegaSpec > Edge cases > should handle missing xColumn field 1`] = ` +exports[`createVegaSpec > Edge cases > should handle NONE_GROUP_BY for groupByColumn 1`] = ` { "$schema": "https://vega.github.io/schema/vega-lite/v5.json", "background": "white", @@ -254,106 +164,21 @@ exports[`createVegaSpec > Edge cases > should handle missing xColumn field 1`] = }, "encoding": { "color": { - "field": undefined, - "scale": { - "scheme": undefined, - }, + "field": "None", + "scale": {}, "type": "nominal", }, - "tooltip": undefined, "x": { - "bin": undefined, - "field": undefined, - "stack": undefined, - "title": undefined, + "field": "category", + "title": "category", "type": "nominal", }, - "xOffset": { - "field": undefined, - }, "y": { - "bin": undefined, "field": "value", - "scale": undefined, - "stack": undefined, + "stack": true, "title": "value", - "type": "quantitative", - }, - }, - "height": 300, - "mark": { - "type": "bar", - }, - "title": "Test Chart", - "width": 400, -} -`; - -exports[`createVegaSpec > Edge cases > should handle missing yColumn field 1`] = ` -{ - "$schema": "https://vega.github.io/schema/vega-lite/v5.json", - "background": "white", - "data": { - "values": [ - { - "category": "A", - "group": "Group 1", - "value": 10, - }, - { - "category": "B", - "group": "Group 1", - "value": 20, - }, - { - "category": "C", - "group": "Group 1", - "value": 15, - }, - { - "category": "A", - "group": "Group 2", - "value": 5, - }, - { - "category": "B", - "group": "Group 2", - "value": 10, - }, - { - "category": "C", - "group": "Group 2", - "value": 25, - }, - ], - }, - "encoding": { - "color": { - "field": undefined, - "scale": { - "scheme": undefined, - }, "type": "nominal", }, - "tooltip": undefined, - "x": { - "bin": undefined, - "field": "category", - "stack": undefined, - "title": "category", - "type": "nominal", - }, - "xOffset": { - "field": undefined, - }, - "y": { - "bin": undefined, - "field": undefined, - "scale": undefined, - "stack": undefined, - "title": undefined, - "type": "quantitative", - }, }, "height": 300, "mark": { @@ -364,6 +189,10 @@ exports[`createVegaSpec > Edge cases > should handle missing yColumn field 1`] = } `; +exports[`createVegaSpec > Edge cases > should handle missing xColumn field 1`] = `"X-axis column is required"`; + +exports[`createVegaSpec > Edge cases > should handle missing yColumn field 1`] = `"Y-axis column is required"`; + exports[`createVegaSpec > Line Chart > should create a basic line chart spec 1`] = ` { "$schema": "https://vega.github.io/schema/vega-lite/v5.json", @@ -403,31 +232,15 @@ exports[`createVegaSpec > Line Chart > should create a basic line chart spec 1`] ], }, "encoding": { - "color": { - "field": undefined, - "scale": { - "scheme": undefined, - }, - "type": "nominal", - }, - "tooltip": undefined, "x": { - "bin": undefined, "field": "category", - "stack": undefined, "title": "category", "type": "nominal", }, - "xOffset": { - "field": undefined, - }, "y": { - "bin": undefined, "field": "value", - "scale": undefined, - "stack": undefined, "title": "value", - "type": "quantitative", + "type": "nominal", }, }, "height": 300, @@ -439,84 +252,7 @@ exports[`createVegaSpec > Line Chart > should create a basic line chart spec 1`] } `; -exports[`createVegaSpec > Pie Chart > should create a pie chart with tooltips 1`] = ` -{ - "$schema": "https://vega.github.io/schema/vega-lite/v5.json", - "background": "white", - "data": { - "values": [ - { - "category": "A", - "group": "Group 1", - "value": 10, - }, - { - "category": "B", - "group": "Group 1", - "value": 20, - }, - { - "category": "C", - "group": "Group 1", - "value": 15, - }, - { - "category": "A", - "group": "Group 2", - "value": 5, - }, - { - "category": "B", - "group": "Group 2", - "value": 10, - }, - { - "category": "C", - "group": "Group 2", - "value": 25, - }, - ], - }, - "encoding": { - "color": { - "bin": undefined, - "field": "value", - "scale": { - "scheme": undefined, - }, - "stack": undefined, - "title": "value", - "type": "quantitative", - }, - "theta": { - "bin": undefined, - "field": "category", - "stack": undefined, - "title": "category", - "type": "nominal", - }, - "tooltip": [ - { - "aggregate": undefined, - "field": "category", - "format": undefined, - }, - { - "aggregate": undefined, - "field": "value", - "format": ".2f", - }, - ], - "xOffset": undefined, - }, - "height": 300, - "mark": { - "type": "arc", - }, - "title": "Test Chart", - "width": 400, -} -`; +exports[`createVegaSpec > Pie Chart > should create a pie chart with tooltips 1`] = `"Color by column is required"`; exports[`createVegaSpec > Scatter Chart > should create a scatter chart with grouping 1`] = ` { @@ -559,29 +295,18 @@ exports[`createVegaSpec > Scatter Chart > should create a scatter chart with gro "encoding": { "color": { "field": "group", - "scale": { - "scheme": undefined, - }, + "scale": {}, "type": "nominal", }, - "tooltip": undefined, "x": { - "bin": undefined, "field": "category", - "stack": undefined, "title": "category", "type": "nominal", }, - "xOffset": { - "field": "group", - }, "y": { - "bin": undefined, "field": "value", - "scale": undefined, - "stack": undefined, "title": "value", - "type": "quantitative", + "type": "nominal", }, }, "height": 300, @@ -632,31 +357,15 @@ exports[`createVegaSpec > Theme variations > should create a chart with dark the ], }, "encoding": { - "color": { - "field": undefined, - "scale": { - "scheme": undefined, - }, - "type": "nominal", - }, - "tooltip": undefined, "x": { - "bin": undefined, "field": "category", - "stack": undefined, "title": "category", "type": "nominal", }, - "xOffset": { - "field": undefined, - }, "y": { - "bin": undefined, "field": "value", - "scale": undefined, - "stack": undefined, "title": "value", - "type": "quantitative", + "type": "nominal", }, }, "height": 300, @@ -707,31 +416,16 @@ exports[`createVegaSpec > should create a bar chart with binning 1`] = ` ], }, "encoding": { - "color": { - "field": undefined, - "scale": { - "scheme": undefined, - }, - "type": "nominal", - }, - "tooltip": undefined, "x": { "bin": true, "field": "category", - "stack": undefined, "title": "category", "type": "nominal", }, - "xOffset": { - "field": undefined, - }, "y": { - "bin": undefined, "field": "value", - "scale": undefined, - "stack": undefined, "title": "value", - "type": "quantitative", + "type": "nominal", }, }, "height": 300, diff --git a/frontend/src/components/data-table/chart-transforms/__tests__/test-chart-spec.test.ts b/frontend/src/components/data-table/chart-transforms/__tests__/spec-snapshot.test.ts similarity index 75% rename from frontend/src/components/data-table/chart-transforms/__tests__/test-chart-spec.test.ts rename to frontend/src/components/data-table/chart-transforms/__tests__/spec-snapshot.test.ts index 759bb07860a..1fe5054bcdc 100644 --- a/frontend/src/components/data-table/chart-transforms/__tests__/test-chart-spec.test.ts +++ b/frontend/src/components/data-table/chart-transforms/__tests__/spec-snapshot.test.ts @@ -2,14 +2,10 @@ import { describe, it, expect } from "vitest"; import { createVegaSpec } from "../chart-spec"; -import { ChartType } from "../storage"; -import { - DEFAULT_AGGREGATION, - DEFAULT_BIN_VALUE, - NONE_GROUP_BY, -} from "../chart-schemas"; +import { DEFAULT_BIN_VALUE, NONE_GROUP_BY } from "../chart-schemas"; import type { z } from "zod"; -import type { ChartSchema } from "../chart-schemas"; +import type { ChartSchema, ChartSchemaType } from "../chart-schemas"; +import { NONE_AGGREGATION, ChartType } from "../types"; describe("createVegaSpec", () => { // Sample data for testing @@ -37,14 +33,14 @@ describe("createVegaSpec", () => { yColumn: { field: "value", type: "number" as const, - agg: DEFAULT_AGGREGATION as "default", + aggregate: NONE_AGGREGATION, }, }, }); describe("Bar Chart", () => { it("should create a horizontal bar chart", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, @@ -61,15 +57,15 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); it("should create a stacked bar chart with grouping", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, - groupByColumn: { + colorByColumn: { field: "group", type: "string" as const, }, @@ -86,12 +82,12 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); }); it("should create a bar chart with binning", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), xAxis: { bin: { binned: true, step: DEFAULT_BIN_VALUE } }, }; @@ -105,12 +101,12 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); describe("Line Chart", () => { it("should create a basic line chart spec", () => { - const formValues = createBasicFormValues(); + const formValues: ChartSchemaType = createBasicFormValues(); const spec = createVegaSpec( ChartType.LINE, sampleData, @@ -120,13 +116,13 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); }); describe("Pie Chart", () => { it("should create a pie chart with tooltips", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, @@ -146,17 +142,17 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); }); describe("Scatter Chart", () => { it("should create a scatter chart with grouping", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, - groupByColumn: { + colorByColumn: { field: "group", type: "string" as const, }, @@ -172,13 +168,13 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); }); describe("Theme variations", () => { it("should create a chart with dark theme", () => { - const formValues = createBasicFormValues(); + const formValues: ChartSchemaType = createBasicFormValues(); const spec = createVegaSpec( ChartType.BAR, sampleData, @@ -188,13 +184,13 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); }); describe("Edge cases", () => { it("should handle missing xColumn field", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, @@ -214,18 +210,18 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); it("should handle missing yColumn field", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, yColumn: { field: undefined, type: "number" as const, - agg: DEFAULT_AGGREGATION as "default", + aggregate: NONE_AGGREGATION, }, }, }; @@ -239,15 +235,15 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); it("should handle NONE_GROUP_BY for groupByColumn", () => { - const formValues = { + const formValues: ChartSchemaType = { ...createBasicFormValues(), general: { ...createBasicFormValues().general, - groupByColumn: { + colorByColumn: { field: NONE_GROUP_BY, type: "string" as const, }, @@ -264,7 +260,20 @@ describe("createVegaSpec", () => { height, ); - expect(spec).toMatchSnapshot(); + expect(removeUndefined(spec)).toMatchSnapshot(); }); }); }); + +function removeUndefined(obj: T): T { + if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) { + const result = {} as T; + for (const key in obj) { + if (obj[key] !== undefined) { + result[key] = removeUndefined(obj[key]); + } + } + return result; + } + return obj; +} diff --git a/frontend/src/components/data-table/chart-transforms/__tests__/spec.test.ts b/frontend/src/components/data-table/chart-transforms/__tests__/spec.test.ts new file mode 100644 index 00000000000..97a3cbb8ed2 --- /dev/null +++ b/frontend/src/components/data-table/chart-transforms/__tests__/spec.test.ts @@ -0,0 +1,139 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import { describe, expect, it } from "vitest"; +import { getAxisEncoding } from "../chart-spec"; +import { ChartType } from "../types"; +import { COUNT_FIELD } from "../constants"; +import { NONE_AGGREGATION } from "../types"; + +describe("getAxisEncoding", () => { + it("should return correct encoding for COUNT_FIELD", () => { + const result = getAxisEncoding( + { + field: COUNT_FIELD, + selectedDataType: "number", + aggregate: "sum", + timeUnit: undefined, + }, + { binned: true, step: 10 }, + COUNT_FIELD, + true, + ChartType.BAR, + ); + + expect(result).toEqual({ + aggregate: "count", + type: "quantitative", + bin: { binned: true, step: 10 }, + title: undefined, + stack: true, + }); + }); + + it("should return correct encoding for numeric field with aggregation", () => { + const result = getAxisEncoding( + { + field: "price", + selectedDataType: "number", + aggregate: "mean", + timeUnit: undefined, + }, + undefined, + "Average Price", + false, + ChartType.BAR, + ); + + expect(result).toEqual({ + field: "price", + type: "quantitative", + bin: undefined, + title: "Average Price", + stack: false, + aggregate: "mean", + }); + }); + + it("should return correct encoding for temporal field with timeUnit", () => { + const result = getAxisEncoding( + { + field: "date", + selectedDataType: "temporal", + aggregate: NONE_AGGREGATION, + timeUnit: "yearmonth", + }, + undefined, + "Date", + undefined, + ChartType.LINE, + ); + + expect(result).toEqual({ + field: "date", + type: "temporal", + bin: undefined, + title: "Date", + stack: undefined, + aggregate: undefined, + timeUnit: "yearmonth", + }); + }); + + it("should return correct encoding for categorical field", () => { + const result = getAxisEncoding( + { + field: "category", + selectedDataType: "string", + aggregate: NONE_AGGREGATION, + timeUnit: undefined, + }, + undefined, + "Category", + true, + ChartType.BAR, + ); + + expect(result).toEqual({ + field: "category", + type: "nominal", + bin: undefined, + title: "Category", + stack: true, + aggregate: undefined, + }); + }); + + it("should handle undefined bin values", () => { + const result = getAxisEncoding( + { + field: "value", + selectedDataType: "number", + aggregate: "sum", + timeUnit: undefined, + }, + undefined, + "Value", + false, + ChartType.BAR, + ); + + expect((result as { bin?: unknown }).bin).toBeUndefined(); + }); + + it("should handle undefined label", () => { + const result = getAxisEncoding( + { + field: "value", + selectedDataType: "number", + aggregate: "sum", + timeUnit: undefined, + }, + undefined, + undefined, + false, + ChartType.BAR, + ); + + expect((result as { title?: string }).title).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/data-table/chart-transforms/__tests__/test-storage.test.ts b/frontend/src/components/data-table/chart-transforms/__tests__/storage.test.ts similarity index 97% rename from frontend/src/components/data-table/chart-transforms/__tests__/test-storage.test.ts rename to frontend/src/components/data-table/chart-transforms/__tests__/storage.test.ts index 373a4314bdd..231e28aeef0 100644 --- a/frontend/src/components/data-table/chart-transforms/__tests__/test-storage.test.ts +++ b/frontend/src/components/data-table/chart-transforms/__tests__/storage.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { getDefaultStore } from "jotai"; -import { tabsStorageAtom, ChartType, KEY } from "../storage"; +import { tabsStorageAtom, KEY } from "../storage"; import { ChartSchema } from "../chart-schemas"; import type { CellId } from "@/core/cells/ids"; import type { TabName } from "../storage"; +import { ChartType } from "../types"; // Mock localStorage const mockLocalStorage = { diff --git a/frontend/src/components/data-table/chart-transforms/chart-components.tsx b/frontend/src/components/data-table/chart-transforms/chart-components.tsx new file mode 100644 index 00000000000..9d268680513 --- /dev/null +++ b/frontend/src/components/data-table/chart-transforms/chart-components.tsx @@ -0,0 +1,265 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import type { LucideProps } from "lucide-react"; +import { cn } from "@/utils/cn"; +import { + ArrowDownWideNarrowIcon, + ArrowUpWideNarrowIcon, + ChevronDown, + Loader2, +} from "lucide-react"; +import { capitalize } from "lodash-es"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { + Select, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; +import { CHART_TYPE_ICON, COUNT_FIELD } from "./constants"; +import { ErrorBanner } from "@/plugins/impl/common/error-banner"; +import { buttonVariants } from "@/components/ui/button"; +import { type UseFormReturn, useWatch } from "react-hook-form"; +import type { z } from "zod"; +import type { ChartSchema } from "./chart-schemas"; +import { FieldValidators, TypeConverters } from "./chart-spec"; +import { + ColumnSelector, + AggregationSelect, + DataTypeSelect, + TimeUnitSelect, + SelectField, + BooleanField, + type Field, +} from "./form-components"; +import { CHART_TYPES, type ChartType, SORT_TYPES } from "./types"; + +export const IconWithText: React.FC<{ + Icon: React.ForwardRefExoticComponent< + Omit & React.RefAttributes + >; + text: string; +}> = ({ Icon, text }) => { + return ( +
+ + {text} +
+ ); +}; + +export const Title: React.FC<{ text: string }> = ({ text }) => { + return {text}; +}; + +export const TabContainer: React.FC<{ + className?: string; + children: React.ReactNode; +}> = ({ children, className }) => { + return
{children}
; +}; + +export const ChartLoadingState: React.FC = () => ( +
+ + Loading chart... +
+); + +export const ChartErrorState: React.FC<{ error: Error }> = ({ error }) => ( +
+ +
+); + +export const ChartTypeSelect: React.FC<{ + value: ChartType; + onValueChange: (value: ChartType) => void; +}> = ({ value, onValueChange }) => { + return ( + + ); +}; + +const ChartSelectItem: React.FC<{ chartType: ChartType }> = ({ chartType }) => { + const Icon = CHART_TYPE_ICON[chartType]; + return ( + +
+ + {capitalize(chartType)} +
+
+ ); +}; + +export const XAxis: React.FC<{ + form: UseFormReturn>; + fields: Field[]; +}> = ({ form, fields }) => { + const formValues = useWatch({ control: form.control }); + const xColumn = formValues.general?.xColumn; + const xColumnExists = FieldValidators.exists(xColumn?.field); + + const inferredXDataType = xColumn?.type + ? TypeConverters.toSelectableDataType(xColumn.type) + : "string"; + + const selectedXDataType = xColumn?.selectedDataType || inferredXDataType; + const isXCountField = xColumn?.field === COUNT_FIELD; + + const shouldShowXAggregation = + xColumnExists && selectedXDataType !== "temporal" && !isXCountField; + + const shouldShowXTimeUnit = + xColumnExists && selectedXDataType === "temporal" && !isXCountField; + + return ( + <> + + <div className="flex flex-row gap-2 justify-between"> + <ColumnSelector + form={form} + name="general.xColumn.field" + columns={fields} + /> + {shouldShowXAggregation && ( + <AggregationSelect form={form} name="general.xColumn.aggregate" /> + )} + </div> + {xColumnExists && !isXCountField && ( + <DataTypeSelect + form={form} + formFieldLabel="Data Type" + name="general.xColumn.selectedDataType" + defaultValue={inferredXDataType} + /> + )} + {shouldShowXTimeUnit && ( + <TimeUnitSelect + form={form} + name="general.xColumn.timeUnit" + formFieldLabel="Time Resolution" + /> + )} + {xColumnExists && !isXCountField && ( + <SelectField + form={form} + name="general.xColumn.sort" + formFieldLabel="Sort" + options={SORT_TYPES.map((type) => ({ + display: ( + <IconWithText + Icon={ + type === "ascending" + ? ArrowUpWideNarrowIcon + : ArrowDownWideNarrowIcon + } + text={capitalize(type)} + /> + ), + value: type, + }))} + defaultValue={formValues.general?.xColumn?.sort ?? "ascending"} + /> + )} + </> + ); +}; + +export const YAxis: React.FC<{ + form: UseFormReturn<z.infer<typeof ChartSchema>>; + fields: Field[]; +}> = ({ form, fields }) => { + const formValues = useWatch({ control: form.control }); + const yColumn = formValues.general?.yColumn; + const yColumnExists = FieldValidators.exists(yColumn?.field); + + const inferredYDataType = yColumn?.type + ? TypeConverters.toSelectableDataType(yColumn.type) + : "string"; + + const selectedYDataType = yColumn?.selectedDataType || inferredYDataType; + const isYCountField = yColumn?.field === COUNT_FIELD; + + const shouldShowYAggregation = + yColumnExists && selectedYDataType !== "temporal" && !isYCountField; + + const shouldShowYTimeUnit = + yColumnExists && selectedYDataType === "temporal" && !isYCountField; + + return ( + <> + <Title text="Y-Axis" /> + <div className="flex flex-row gap-2 justify-between"> + <ColumnSelector + form={form} + name="general.yColumn.field" + columns={fields} + /> + {shouldShowYAggregation && ( + <AggregationSelect form={form} name="general.yColumn.aggregate" /> + )} + </div> + + {yColumnExists && !isYCountField && ( + <DataTypeSelect + form={form} + formFieldLabel="Data Type" + name="general.yColumn.selectedDataType" + defaultValue={inferredYDataType} + /> + )} + {shouldShowYTimeUnit && ( + <TimeUnitSelect + form={form} + name="general.yColumn.timeUnit" + formFieldLabel="Time Resolution" + /> + )} + </> + ); +}; + +export const ColorByAxis: React.FC<{ + form: UseFormReturn<z.infer<typeof ChartSchema>>; + fields: Field[]; +}> = ({ form, fields }) => { + return ( + <> + <BooleanField + form={form} + name="general.horizontal" + formFieldLabel="Horizontal chart" + /> + <Title text="Color by" /> + <div className="flex flex-row justify-between"> + <ColumnSelector + form={form} + name="general.colorByColumn.field" + columns={fields} + /> + <AggregationSelect form={form} name="general.colorByColumn.aggregate" /> + </div> + </> + ); +}; diff --git a/frontend/src/components/data-table/chart-transforms/chart-schemas.ts b/frontend/src/components/data-table/chart-transforms/chart-schemas.ts index 1dd5768b28b..f0641c3419f 100644 --- a/frontend/src/components/data-table/chart-transforms/chart-schemas.ts +++ b/frontend/src/components/data-table/chart-transforms/chart-schemas.ts @@ -1,42 +1,49 @@ /* Copyright 2024 Marimo. All rights reserved. */ +/** + * Zod schema validation for marimo chart configuration. + */ + import { DATA_TYPES } from "@/core/kernel/messages"; -import { AGGREGATION_FNS } from "@/plugins/impl/data-frames/types"; import { z } from "zod"; +import { + AGGREGATION_FNS, + NONE_AGGREGATION, + SELECTABLE_DATA_TYPES, + SORT_TYPES, + TIME_UNITS, +} from "./types"; +import { DEFAULT_COLOR_SCHEME, EMPTY_VALUE } from "./constants"; -export const DEFAULT_AGGREGATION = "default"; export const DEFAULT_BIN_VALUE = 0; export const NONE_GROUP_BY = "None"; -export const DEFAULT_COLOR_SCHEME = "default"; export const BinSchema = z.object({ binned: z.boolean().optional(), step: z.number().optional(), + maxbins: z.number().optional(), }); +export const AxisSchema = z + .object({ + field: z.string().optional(), + type: z.enum([...DATA_TYPES, EMPTY_VALUE]).optional(), + selectedDataType: z + .enum([...SELECTABLE_DATA_TYPES, EMPTY_VALUE]) + .optional(), + aggregate: z.enum(AGGREGATION_FNS).default(NONE_AGGREGATION).optional(), + sort: z.enum(SORT_TYPES).default("ascending").optional(), + timeUnit: z.enum(TIME_UNITS).optional(), + }) + .optional(); + export const ChartSchema = z.object({ general: z.object({ title: z.string().optional(), - xColumn: z.object({ - field: z.string().optional(), - type: z.enum(DATA_TYPES).optional(), - }), - yColumn: z.object({ - field: z.string().optional(), - type: z.enum(DATA_TYPES).optional(), - agg: z - .enum([...AGGREGATION_FNS, DEFAULT_AGGREGATION]) - .default(DEFAULT_AGGREGATION) - .optional(), - }), + xColumn: AxisSchema, + yColumn: AxisSchema, + colorByColumn: AxisSchema, horizontal: z.boolean().optional(), - groupByColumn: z - .object({ - field: z.string().default(NONE_GROUP_BY).optional(), - type: z.enum(DATA_TYPES).optional(), - binned: z.boolean().optional(), - }) - .optional(), stacking: z.boolean().optional(), tooltips: z .array( @@ -50,12 +57,14 @@ export const ChartSchema = z.object({ xAxis: z .object({ label: z.string().optional(), + width: z.number().optional(), bin: BinSchema.optional(), }) .optional(), yAxis: z .object({ label: z.string().optional(), + height: z.number().optional(), bin: BinSchema.optional(), }) .optional(), @@ -66,9 +75,11 @@ export const ChartSchema = z.object({ domain: z.array(z.string()).optional(), }) .optional(), + style: z + .object({ + innerRadius: z.number().optional(), + }) + .optional(), }); -// if groupBy col is nominal, -// color can be a domain with range - -// else probably should be a scale +export type ChartSchemaType = z.infer<typeof ChartSchema>; diff --git a/frontend/src/components/data-table/chart-transforms/chart-spec.tsx b/frontend/src/components/data-table/chart-transforms/chart-spec.tsx index fb8e5f146c0..f21f99b8ac5 100644 --- a/frontend/src/components/data-table/chart-transforms/chart-spec.tsx +++ b/frontend/src/components/data-table/chart-transforms/chart-spec.tsx @@ -1,17 +1,19 @@ /* Copyright 2024 Marimo. All rights reserved. */ - import type { TopLevelSpec } from "vega-lite"; -import { ChartType } from "./storage"; import type { ResolvedTheme } from "@/theme/useTheme"; import type { DataType } from "@/core/kernel/messages"; import { type BinSchema, - DEFAULT_AGGREGATION, DEFAULT_BIN_VALUE, - NONE_GROUP_BY, type ChartSchema, - DEFAULT_COLOR_SCHEME, + type AxisSchema, } from "./chart-schemas"; +import { + ChartType, + NONE_AGGREGATION, + type SelectableDataType, + type TimeUnitTooltip, +} from "./types"; import type { z } from "zod"; import type { Mark } from "@/plugins/impl/vega/types"; import { logNever } from "@/utils/assertNever"; @@ -24,6 +26,15 @@ import type { StringFieldDef, } from "vega-lite/build/src/channeldef"; import type { ColorScheme } from "vega"; +import type { TypedString } from "@/utils/typed"; +import { COUNT_FIELD, DEFAULT_COLOR_SCHEME, EMPTY_VALUE } from "./constants"; +import type { Tooltip } from "./form-components"; + +/** + * Convert marimo chart configuration to Vega-Lite specification. + */ + +export type ErrorMessage = TypedString<"ErrorMessage">; export function createVegaSpec( chartType: ChartType, @@ -32,219 +43,408 @@ export function createVegaSpec( theme: ResolvedTheme, width: number | "container", height: number, -): TopLevelSpec | null { - let xAxisLabel = formValues.general.xColumn?.field; - let yAxisLabel = formValues.general.yColumn?.field; - - if ( - formValues.general.yColumn?.agg && - formValues.general.yColumn.agg !== DEFAULT_AGGREGATION - ) { - yAxisLabel = `${formValues.general.yColumn.agg.toUpperCase()}(${yAxisLabel})`; - } +): TopLevelSpec | ErrorMessage { + const { xColumn, yColumn, colorByColumn, horizontal, stacking, title } = + formValues.general; - if (formValues.xAxis?.label && formValues.xAxis.label.trim() !== "") { - xAxisLabel = formValues.xAxis.label; + if (chartType === ChartType.PIE) { + return getPieChartSpec(data, formValues, theme, width, height); } - if (formValues.yAxis?.label && formValues.yAxis.label.trim() !== "") { - yAxisLabel = formValues.yAxis.label; + // Validate required fields + if (!FieldValidators.exists(xColumn?.field)) { + return "X-axis column is required" as ErrorMessage; + } + if (!FieldValidators.exists(yColumn?.field)) { + return "Y-axis column is required" as ErrorMessage; } - const xEncodingKey = chartType === ChartType.PIE ? "theta" : "x"; - const yEncodingKey = chartType === ChartType.PIE ? "color" : "y"; - - const groupByFieldExists = - formValues.general.groupByColumn?.field !== NONE_GROUP_BY; - - const shouldApplyStackingToX = - groupByFieldExists && formValues.general.horizontal; - const shouldApplyStackingToY = - groupByFieldExists && !formValues.general.horizontal; + // Get axis labels + const xAxisLabel = FieldValidators.getLabel( + xColumn.field, + formValues.xAxis?.label, + ); + const yAxisLabel = FieldValidators.getLabel( + FieldValidators.getAggregatedLabel(yColumn.field, yColumn.aggregate), + formValues.yAxis?.label, + ); - const xEncoding: PositionDef<string> | PolarDef<string> = { - field: formValues.general.xColumn?.field, - type: convertDataTypeToVegaType( - formValues.general.xColumn?.type ?? "unknown", - ), - bin: getBin(formValues.xAxis?.bin), - title: xAxisLabel, - stack: shouldApplyStackingToX ? formValues.general.stacking : undefined, - }; + // Determine encoding keys based on chart type + const xEncodingKey = "x"; + const yEncodingKey = "y"; - const colorInScale = getColorInScale(formValues); + // Create encodings + const xEncoding = getAxisEncoding( + xColumn, + formValues.xAxis?.bin, + xAxisLabel, + colorByColumn?.field && horizontal ? stacking : undefined, + chartType, + ); - const yEncoding: PositionDef<string> | PolarDef<string> = { - field: formValues.general.yColumn?.field, - type: convertDataTypeToVegaType( - formValues.general.yColumn?.type ?? "unknown", - ), - bin: getBin(formValues.yAxis?.bin), - title: yAxisLabel, - // If color encoding is used as y, we can define the scheme here - scale: - colorInScale && yEncodingKey === "color" - ? { ...colorInScale } - : undefined, - stack: shouldApplyStackingToY ? formValues.general.stacking : undefined, - }; + const yEncoding = getAxisEncoding( + yColumn, + formValues.yAxis?.bin, + yAxisLabel, + colorByColumn?.field && !horizontal ? stacking : undefined, + chartType, + ); - const schema: TopLevelSpec = { - $schema: "https://vega.github.io/schema/vega-lite/v5.json", - background: theme === "dark" ? "dark" : "white", - title: formValues.general.title, - data: { - values: data, - }, - height: height, - width: width, - mark: { - type: convertChartTypeToMark(chartType), - }, + // Create the final spec + return { + ...getBaseSpec(data, formValues, theme, width, height, title), + mark: { type: TypeConverters.toMark(chartType) }, encoding: { - [xEncodingKey]: formValues.general.horizontal ? yEncoding : xEncoding, - [yEncodingKey]: formValues.general.horizontal ? xEncoding : yEncoding, - xOffset: getOffset(chartType, formValues), - ...getColor(chartType, formValues), - tooltip: getTooltips(formValues), + [xEncodingKey]: horizontal ? yEncoding : xEncoding, + [yEncodingKey]: horizontal ? xEncoding : yEncoding, + xOffset: EncodingUtils.getOffset(chartType, formValues), + ...ColorUtils.getColor(chartType, formValues), + tooltip: EncodingUtils.getTooltips(formValues), }, }; - return schema; } -// color can be used for grouping -// it can also conflict with the color (y) encoding for pie charts, so we return undefined -function getColor( +export function getAxisEncoding( + column: NonNullable<z.infer<typeof AxisSchema>>, + binValues: z.infer<typeof BinSchema> | undefined, + label: string | undefined, + stack: boolean | undefined, chartType: ChartType, - formValues: z.infer<typeof ChartSchema>, -) { - if ( - chartType === ChartType.PIE || - formValues.general.groupByColumn?.field === NONE_GROUP_BY - ) { - return undefined; +): PositionDef<string> { + if (column.field === COUNT_FIELD) { + return { + aggregate: "count", + type: "quantitative", + bin: EncodingUtils.getBin(binValues), + title: label === COUNT_FIELD ? undefined : label, + stack: stack, + }; } - const colorDef: ColorDef<string> = { - field: formValues.general.groupByColumn?.field, - type: convertDataTypeToVegaType( - formValues.general.groupByColumn?.type ?? "unknown", - ), - scale: { - ...getColorInScale(formValues), - }, - }; - return { - color: colorDef, + field: column.field, + type: TypeConverters.toVegaType(column.selectedDataType || "unknown"), + bin: EncodingUtils.getBin(binValues, chartType), + title: label, + stack: stack, + aggregate: + column.aggregate === NONE_AGGREGATION ? undefined : column.aggregate, + timeUnit: + column.selectedDataType === "temporal" ? column.timeUnit : undefined, }; } -function getColorInScale(formValues: z.infer<typeof ChartSchema>) { - const colorRange = formValues.color?.range; - if (colorRange && colorRange.length > 0) { - return { - range: colorRange, - }; +function getPieChartSpec( + data: object[], + formValues: z.infer<typeof ChartSchema>, + theme: ResolvedTheme, + width: number | "container", + height: number, +) { + const { yColumn, colorByColumn, title } = formValues.general; + + if (!FieldValidators.exists(colorByColumn?.field)) { + return "Color by column is required" as ErrorMessage; } - const scheme = formValues.color?.scheme; - if (scheme === DEFAULT_COLOR_SCHEME) { - return undefined; + if (!FieldValidators.exists(yColumn?.field)) { + return "Size by column is required" as ErrorMessage; } + + const colorFieldLabel = FieldValidators.getLabel( + colorByColumn.field, + formValues.xAxis?.label, + ); + + const thetaFieldLabel = FieldValidators.getLabel( + yColumn.field, + formValues.xAxis?.label, + ); + + const thetaEncoding: PolarDef<string> = getAxisEncoding( + yColumn, + formValues.xAxis?.bin, + thetaFieldLabel, + undefined, + ChartType.PIE, + ); + + const colorEncoding: ColorDef<string> = { + field: colorByColumn.field, + type: TypeConverters.toVegaType( + colorByColumn.selectedDataType || "unknown", + ), + scale: EncodingUtils.getColorInScale(formValues), + title: colorFieldLabel, + }; + return { - scheme: scheme as ColorScheme, + ...getBaseSpec(data, formValues, theme, width, height, title), + mark: { + type: TypeConverters.toMark(ChartType.PIE), + innerRadius: formValues.style?.innerRadius, + }, + encoding: { + theta: thetaEncoding, + color: colorEncoding, + tooltip: EncodingUtils.getTooltips(formValues), + }, }; } -function getOffset( - chartType: ChartType, +function getBaseSpec( + data: object[], formValues: z.infer<typeof ChartSchema>, -): OffsetDef<string> | undefined { - if ( - formValues.general.stacking || - formValues.general.groupByColumn?.field === NONE_GROUP_BY || - chartType === ChartType.PIE - ) { - return undefined; - } + theme: ResolvedTheme, + width: number | "container", + height: number, + title?: string, +) { return { - field: - formValues.general.groupByColumn?.field === NONE_GROUP_BY - ? undefined - : formValues.general.groupByColumn?.field, + $schema: "https://vega.github.io/schema/vega-lite/v5.json", + background: theme === "dark" ? "dark" : "white", + title: title, + data: { values: data }, + height: formValues.yAxis?.height ?? height, + width: formValues.xAxis?.width ?? width, }; } -function getBin(binValues?: z.infer<typeof BinSchema>) { - if (binValues?.binned) { - if (binValues.step === DEFAULT_BIN_VALUE) { - return true; +// Type conversion utilities +export const TypeConverters = { + toVegaType(dataType: DataType | SelectableDataType): Type | undefined { + switch (dataType) { + case "number": + case "integer": + return "quantitative"; + case "string": + case "boolean": + case "unknown": + return "nominal"; + case "date": + case "datetime": + case "time": + case "temporal": + return "temporal"; + default: + logNever(dataType); + return undefined; } + }, - return { - binned: true, - step: binValues.step, - }; - } -} + toSelectableDataType(type: DataType): SelectableDataType { + switch (type) { + case "number": + case "integer": + return "number"; + case "string": + case "boolean": + case "unknown": + return "string"; + case "date": + case "datetime": + case "time": + return "temporal"; + default: + logNever(type); + return "string"; + } + }, -function getTooltips(formValues: z.infer<typeof ChartSchema>) { - return formValues.general.tooltips?.map( - (tooltip): StringFieldDef<string> => ({ - field: tooltip.field, - aggregate: (() => { - if (tooltip.field !== formValues.general.yColumn?.field) { - return undefined; - } - return formValues.general.yColumn?.agg === DEFAULT_AGGREGATION - ? undefined - : formValues.general.yColumn?.agg; - })(), - format: getTooltipFormat(tooltip.type), - }), - ); -} + toMark(chartType: ChartType): Mark { + switch (chartType) { + case ChartType.PIE: + return "arc"; + case ChartType.SCATTER: + return "point"; + case ChartType.HEATMAP: + return "rect"; + default: + return chartType; + } + }, +}; -function getTooltipFormat(dataType: DataType): string | undefined { - switch (dataType) { - case "integer": - return ",d"; - case "number": - return ".2f"; - default: +// Field validation utilities +export const FieldValidators = { + exists(field: string | undefined): field is string { + return field !== undefined && field.trim() !== EMPTY_VALUE; + }, + + getLabel(field: string, label?: string): string { + return label?.trim() || field; + }, + + getAggregatedLabel(field: string, agg?: string): string { + if (!agg || agg === NONE_AGGREGATION) { + return field; + } + return `${agg.toUpperCase()}(${field})`; + }, +}; + +// Encoding utilities +const EncodingUtils = { + getBin(binValues?: z.infer<typeof BinSchema>, chartType?: ChartType) { + if (chartType === ChartType.HEATMAP) { + return { maxbins: binValues?.maxbins }; + } + + if (!binValues?.binned) { return undefined; - } -} + } -function convertDataTypeToVegaType(dataType: DataType): Type { - switch (dataType) { - case "number": - case "integer": - return "quantitative"; - case "string": - return "nominal"; - case "boolean": - return "nominal"; - case "date": - case "datetime": - case "time": - return "temporal"; - case "unknown": - return "nominal"; - default: - logNever(dataType); - return "nominal"; - } -} + return binValues.step === DEFAULT_BIN_VALUE + ? true + : { binned: true, step: binValues.step }; + }, -function convertChartTypeToMark(chartType: ChartType): Mark { - switch (chartType) { - case ChartType.PIE: - return "arc"; - case ChartType.SCATTER: - return "point"; - default: - return chartType; - } -} + getColorInScale(formValues: z.infer<typeof ChartSchema>) { + const colorRange = formValues.color?.range; + if (colorRange?.length) { + return { range: colorRange }; + } + + const scheme = formValues.color?.scheme; + return scheme === DEFAULT_COLOR_SCHEME + ? undefined + : { scheme: scheme as ColorScheme }; + }, + + getOffset( + chartType: ChartType, + formValues: z.infer<typeof ChartSchema>, + ): OffsetDef<string> | undefined { + // Offset only applies to bar charts, to unstack them + if ( + formValues.general.stacking || + !FieldValidators.exists(formValues.general.colorByColumn?.field) || + chartType !== ChartType.BAR + ) { + return undefined; + } + return { field: formValues.general.colorByColumn?.field }; + }, + + getTooltipAggregate( + field: string, + yColumn?: z.infer<typeof AxisSchema>, + ): "count" | "sum" | "mean" | "median" | "min" | "max" | undefined { + if (field !== yColumn?.field) { + return undefined; + } + return yColumn.aggregate === NONE_AGGREGATION + ? undefined + : (yColumn.aggregate as + | "count" + | "sum" + | "mean" + | "median" + | "min" + | "max"); + }, + + getTooltipFormat(dataType: DataType): string | undefined { + switch (dataType) { + case "integer": + return ",.0f"; // Use comma grouping and no decimals + case "number": + return ",.2f"; // Use comma grouping and 2 decimal places + default: + return undefined; + } + }, + + getTooltipTimeUnit( + tooltip: Tooltip, + formValues: z.infer<typeof ChartSchema>, + ): TimeUnitTooltip | undefined { + const xColumn = formValues.general.xColumn; + const yColumn = formValues.general.yColumn; + const colorByColumn = formValues.general.colorByColumn; + const columns = [xColumn, yColumn, colorByColumn]; + + // Check if tooltip field matches any temporal column with timeUnit + const matchingColumn = columns.find( + (col) => + tooltip.field === col?.field && + col?.selectedDataType === "temporal" && + col?.timeUnit, + ); + + if (matchingColumn?.timeUnit) { + return matchingColumn.timeUnit; + } + + switch (tooltip.type) { + case "datetime": + return "yearmonthdatehoursminutesseconds"; + case "date": + return "yearmonthdate"; + case "time": + return "hoursminutesseconds"; + default: + return undefined; + } + }, + + getTooltips(formValues: z.infer<typeof ChartSchema>) { + if (!formValues.general.tooltips) { + return undefined; + } + + return formValues.general.tooltips.map( + (tooltip): StringFieldDef<string> => { + const timeUnit = this.getTooltipTimeUnit(tooltip, formValues); + return { + field: tooltip.field, + aggregate: this.getTooltipAggregate( + tooltip.field, + formValues.general.yColumn, + ), + format: this.getTooltipFormat(tooltip.type), + timeUnit: timeUnit, + title: timeUnit ? tooltip.field : undefined, + }; + }, + ); + }, +}; + +// Color encoding utilities +const ColorUtils = { + getColor( + chartType: ChartType, + formValues: z.infer<typeof ChartSchema>, + ): { color?: ColorDef<string> } | undefined { + if ( + chartType === ChartType.PIE || + !FieldValidators.exists(formValues.general.colorByColumn?.field) + ) { + return undefined; + } + + const colorByColumn = formValues.general.colorByColumn; + if (colorByColumn.field === COUNT_FIELD) { + return { + color: { + aggregate: "count", + type: "quantitative", + }, + }; + } + + const aggregate = formValues.general.colorByColumn.aggregate; + + return { + color: { + field: colorByColumn.field, + type: TypeConverters.toVegaType( + colorByColumn.selectedDataType || "unknown", + ), + scale: EncodingUtils.getColorInScale(formValues), + aggregate: aggregate === NONE_AGGREGATION ? undefined : aggregate, + }, + }; + }, +}; diff --git a/frontend/src/components/data-table/chart-transforms/chart-transforms.tsx b/frontend/src/components/data-table/chart-transforms/chart-transforms.tsx index 96e6630301d..83c3e981733 100644 --- a/frontend/src/components/data-table/chart-transforms/chart-transforms.tsx +++ b/frontend/src/components/data-table/chart-transforms/chart-transforms.tsx @@ -3,76 +3,67 @@ import React, { useState, useMemo, useRef, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { - ChartBarIcon, - InfoIcon, - Loader2, - SquareFunctionIcon, TableIcon, XIcon, + InfoIcon, + DatabaseIcon, + PaintRollerIcon, } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Tabs, TabsTrigger, TabsList, TabsContent } from "@/components/ui/tabs"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "../../ui/select"; import type { z } from "zod"; -import { useForm, type UseFormReturn } from "react-hook-form"; +import { useForm, useWatch, type UseFormReturn } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - ChartSchema, - DEFAULT_AGGREGATION, - DEFAULT_COLOR_SCHEME, -} from "./chart-schemas"; -import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { ChartSchema } from "./chart-schemas"; +import { Form } from "@/components/ui/form"; import { getDefaults } from "@/components/forms/form-utils"; import { useAtom } from "jotai"; import { type CellId, HTMLCellId } from "@/core/cells/ids"; import { capitalize } from "lodash-es"; -import { - type TabName, - tabsStorageAtom, - ChartType, - tabNumberAtom, - CHART_TYPES, -} from "./storage"; +import { type TabName, tabsStorageAtom, tabNumberAtom } from "./storage"; import type { FieldTypesWithExternalType } from "../types"; import { useAsyncData } from "@/hooks/useAsyncData"; import { vegaLoadData } from "@/plugins/impl/vega/loader"; import type { GetDataUrl } from "@/plugins/impl/DataTablePlugin"; -import { AGGREGATION_FNS } from "@/plugins/impl/data-frames/types"; import { + AggregationSelect, BooleanField, ColorArrayField, ColumnSelector, + type Field, InputField, NumberField, + DataTypeSelect, SelectField, + SliderField, + TooltipSelect, } from "./form-components"; -import { - AGGREGATION_TYPE_ICON, - CHART_TYPE_ICON, - COLOR_SCHEMES, -} from "./constants"; -import { Multiselect } from "@/plugins/impl/MultiselectPlugin"; +import { COLOR_SCHEMES, DEFAULT_COLOR_SCHEME } from "./constants"; import { useDebouncedCallback } from "@/hooks/useDebounce"; -import { cn } from "@/utils/cn"; import { inferFieldTypes } from "../columns"; import { LazyChart } from "./lazy-chart"; +import { FieldValidators, TypeConverters } from "./chart-spec"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + TabContainer, + Title, + ChartLoadingState, + ChartErrorState, + ChartTypeSelect, + YAxis, + ColorByAxis, + XAxis, +} from "./chart-components"; +import { ChartType } from "./types"; const NEW_TAB_NAME = "Chart" as TabName; const NEW_CHART_TYPE = "line" as ChartType; const DEFAULT_TAB_NAME = "table" as TabName; +const CHART_HEIGHT = 300; export interface TablePanelProps { dataTable: JSX.Element; @@ -104,24 +95,9 @@ export const TablePanel: React.FC<TablePanelProps> = ({ // If the element is in the light DOM, we can find it directly // Otherwise, we need to traverse up through shadow DOM boundaries - let cellElement = HTMLCellId.findElement(containerRef.current); - - if (!cellElement) { - const root = containerRef.current.getRootNode(); - let element: Element | null = containerRef.current; - - while (element && element !== root) { - cellElement = HTMLCellId.findElement(element); - if (cellElement) { - break; - } - element = - element.getRootNode() instanceof ShadowRoot - ? (element.getRootNode() as ShadowRoot).host - : element.parentElement; - } - } - + const cellElement = HTMLCellId.findElementThroughShadowDOMs( + containerRef.current, + ); if (cellElement) { setCellId(HTMLCellId.parse(cellElement.id)); } @@ -231,19 +207,14 @@ export const TablePanel: React.FC<TablePanelProps> = ({ /> </TabsTrigger> ))} - <DropdownMenu> - <DropdownMenuTrigger asChild={true}> - <Button variant="text" size="icon"> - + - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent> - <DropdownMenuItem onClick={handleAddTab}> - <ChartBarIcon className="w-3 h-3 mr-2" /> - Add chart - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + <Button + variant="text" + size="icon" + onClick={handleAddTab} + title="Add chart" + > + + + </Button> </TabsList> <TabsContent className="mt-1 overflow-hidden" value={DEFAULT_TAB_NAME}> @@ -293,7 +264,7 @@ export const ChartPanel: React.FC<{ resolver: zodResolver(ChartSchema), }); - const [chartTypeSelected, setChartTypeSelected] = + const [selectedChartType, setSelectedChartType] = useState<ChartType>(chartType); const { data, loading, error } = useAsyncData(async () => { @@ -330,89 +301,57 @@ export const ChartPanel: React.FC<{ // Prevent unnecessary re-renders of the chart const memoizedChart = useMemo(() => { if (loading) { - return ( - <div className="flex items-center justify-center h-full w-full"> - <Loader2 className="w-10 h-10 animate-spin" strokeWidth={1} /> - </div> - ); + return <ChartLoadingState />; } if (error) { - return ( - <div className="flex items-center justify-center h-full w-full"> - Error: "" - </div> - ); + return <ChartErrorState error={error} />; } return ( - <Chart - chartType={chartTypeSelected} + <LazyChart + chartType={selectedChartType} formValues={memoizedFormValues} data={data} + width="container" + height={CHART_HEIGHT} /> ); - }, [loading, error, memoizedFormValues, data, chartTypeSelected]); + }, [loading, error, memoizedFormValues, data, selectedChartType]); return ( - <div className="flex flex-row gap-6 p-3 h-full rounded-md border overflow-auto"> - <div className="flex flex-col gap-3"> - <Select - value={chartTypeSelected} + <div className="flex flex-row gap-2 h-full rounded-md border pr-2"> + <div className="flex flex-col gap-2 w-[300px] overflow-auto px-2 py-3 scrollbar-thin"> + <ChartTypeSelect + value={selectedChartType} onValueChange={(value) => { - setChartTypeSelected(value as ChartType); - saveChartType(value as ChartType); + setSelectedChartType(value); + saveChartType(value); }} - > - <div className="flex flex-col gap-1"> - <span className="text-sm">Visualization Type</span> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {CHART_TYPES.map((chartType) => ( - <ChartSelectItem key={chartType} chartType={chartType} /> - ))} - </SelectContent> - </div> - </Select> - - <ChartForm + /> + + <ChartFormContainer form={form} saveChart={saveChart} fieldTypes={fieldTypes} - chartType={chartTypeSelected} + chartType={selectedChartType} /> </div> - {memoizedChart} + <div className="flex-1">{memoizedChart}</div> </div> ); }; -const ChartSelectItem: React.FC<{ chartType: ChartType }> = ({ chartType }) => { - const Icon = CHART_TYPE_ICON[chartType]; - return ( - <SelectItem value={chartType} className="gap-2"> - <div className="flex items-center"> - <Icon className="w-4 h-4 mr-2" /> - {capitalize(chartType)} - </div> - </SelectItem> - ); -}; - -interface ChartFormProps { - form: UseFormReturn<z.infer<typeof ChartSchema>>; - chartType: ChartType; - saveChart: (formValues: z.infer<typeof ChartSchema>) => void; - fieldTypes?: FieldTypesWithExternalType | null; -} - -const ChartForm = ({ +const ChartFormContainer = ({ form, saveChart, fieldTypes, chartType, -}: ChartFormProps) => { - const fields = fieldTypes?.map((field) => { +}: { + form: UseFormReturn<z.infer<typeof ChartSchema>>; + chartType: ChartType; + saveChart: (formValues: z.infer<typeof ChartSchema>) => void; + fieldTypes?: FieldTypesWithExternalType | null; +}) => { + const fields: Field[] | undefined = fieldTypes?.map((field) => { return { name: field[0], type: field[1][0], @@ -424,190 +363,49 @@ const ChartForm = ({ saveChart(values); }, 300); + let ChartForm = CommonChartForm; + + if (chartType === ChartType.PIE) { + ChartForm = PieChartForm; + } else if (chartType === ChartType.HEATMAP) { + ChartForm = HeatmapChartForm; + } + return ( <Form {...form}> <form onSubmit={(e) => e.preventDefault()} onChange={debouncedSave}> - <Tabs defaultValue="general"> - <TabsList> - <TabsTrigger value="general">General</TabsTrigger> - {chartType !== ChartType.PIE && ( - <> - <TabsTrigger value="x-axis">X-Axis</TabsTrigger> - <TabsTrigger value="y-axis">Y-Axis</TabsTrigger> - </> - )} - <TabsTrigger value="color">Color</TabsTrigger> + <Tabs defaultValue="data"> + <TabsList className="w-full"> + <TabsTrigger value="data" className="w-1/2 h-6"> + <DatabaseIcon className="w-4 h-4 mr-2" /> + Data + </TabsTrigger> + <TabsTrigger value="style" className="w-1/2 h-6"> + <PaintRollerIcon className="w-4 h-4 mr-2" /> + Style + </TabsTrigger> </TabsList> - <TabsContent value="general"> + + <TabsContent value="data"> + <hr className="my-2" /> <TabContainer> - <BooleanField - form={form} - name="general.horizontal" - formFieldLabel="Horizontal chart" - /> - <ColumnSelector + <ChartForm form={form} - name="general.xColumn.field" - formFieldLabel={ - chartType === ChartType.PIE ? "Theta" : "X column" - } - columns={fields || []} - /> - <div className="flex flex-row gap-2"> - <ColumnSelector - form={form} - name="general.yColumn.field" - formFieldLabel={ - chartType === ChartType.PIE ? "Color" : "Y column" - } - columns={fields || []} - /> - <FormField - control={form.control} - name="general.yColumn.agg" - render={({ field }) => ( - <FormItem className="self-end w-24"> - <FormControl> - <Select - {...field} - value={field.value ?? DEFAULT_AGGREGATION} - onValueChange={field.onChange} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - <SelectLabel>Aggregation</SelectLabel> - <SelectItem value={DEFAULT_AGGREGATION}> - <div className="flex items-center"> - <SquareFunctionIcon className="w-3 h-3 mr-2" /> - {capitalize(DEFAULT_AGGREGATION)} - </div> - </SelectItem> - {AGGREGATION_FNS.map((agg) => { - const Icon = AGGREGATION_TYPE_ICON[agg]; - return ( - <SelectItem key={agg} value={agg}> - <div className="flex items-center"> - <Icon className="w-3 h-3 mr-2" /> - {capitalize(agg)} - </div> - </SelectItem> - ); - })} - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - </FormItem> - )} - /> - </div> - - {chartType !== ChartType.PIE && ( - <div className="flex flex-row gap-2"> - <ColumnSelector - form={form} - name="general.groupByColumn.field" - formFieldLabel="Group by (color)" - columns={fields ?? []} - includeNoneOption={true} - /> - <div - className={cn( - "flex flex-col self-end gap-1 items-end", - chartType === ChartType.BAR && "mt-1.5", - )} - > - <BooleanField - form={form} - name="general.groupByColumn.binned" - formFieldLabel="Binned" - /> - <BooleanField - form={form} - name="general.stacking" - formFieldLabel="Stacked" - /> - </div> - </div> - )} - - <hr /> - - <InputField - form={form} - formFieldLabel="Plot title" - name="general.title" - /> - <FormField - control={form.control} - name="general.tooltips" - render={({ field }) => ( - <FormItem> - <FormControl> - <Multiselect - options={fields?.map((field) => field.name) ?? []} - value={field.value?.map((item) => item.field) ?? []} - setValue={(values) => { - const selectedValues = - typeof values === "function" ? values([]) : values; - - // find the field types and form objects - const tooltipObjects = selectedValues.map( - (fieldName) => { - const fieldType = fields?.find( - (f) => f.name === fieldName, - )?.type; - - return { - field: fieldName, - type: fieldType ?? "string", - }; - }, - ); - - field.onChange(tooltipObjects); - // Multiselect doesn't trigger onChange, so we need to save the form manually - debouncedSave(); - }} - label="Tooltips" - fullWidth={false} - /> - </FormControl> - </FormItem> - )} + fields={fields ?? []} + saveForm={debouncedSave} + chartType={chartType} /> </TabContainer> </TabsContent> - {chartType !== ChartType.PIE && ( - <> - <AxisTabContent axis="x" form={form} /> - <AxisTabContent axis="y" form={form} /> - </> - )} - <TabsContent value="color"> + + <TabsContent value="style"> + <hr className="my-2" /> <TabContainer> - <SelectField - form={form} - name="color.scheme" - formFieldLabel="Color scheme" - defaultValue={DEFAULT_COLOR_SCHEME} - options={COLOR_SCHEMES.map((scheme) => ({ - label: capitalize(scheme), - value: scheme, - }))} - /> - <ColorArrayField + <StyleForm form={form} - name="color.range" - formFieldLabel="Color range" + fields={fields ?? []} + saveForm={debouncedSave} /> - <p className="text-xs"> - <InfoIcon className="w-2.5 h-2.5 inline mb-1 mr-1" /> - If you are using color range, color scheme will be ignored. - </p> </TabContainer> </TabsContent> </Tabs> @@ -616,60 +414,251 @@ const ChartForm = ({ ); }; -interface AxisTabContentProps { - axis: "x" | "y"; +const CommonChartForm: React.FC<{ form: UseFormReturn<z.infer<typeof ChartSchema>>; -} + fields: Field[]; + saveForm: () => void; + chartType: ChartType; +}> = ({ form, fields, saveForm, chartType }) => { + const formValues = useWatch({ control: form.control }); + const yColumn = formValues.general?.yColumn; + const groupByColumn = formValues.general?.colorByColumn; -const AxisTabContent: React.FC<AxisTabContentProps> = ({ axis, form }) => { - const axisName = axis === "x" ? "X" : "Y"; + const yColumnExists = FieldValidators.exists(yColumn?.field); + const showStacking = FieldValidators.exists(groupByColumn?.field); return ( - <TabsContent value={`${axis}-axis`}> - <TabContainer className="gap-1"> - <InputField + <> + <XAxis form={form} fields={fields} /> + <YAxis form={form} fields={fields} /> + + {yColumnExists && ( + <> + <ColorByAxis form={form} fields={fields} /> + {showStacking && ( + <div className="flex flex-row gap-2"> + <BooleanField + form={form} + name="general.stacking" + formFieldLabel="Stacked" + /> + </div> + )} + </> + )} + + <hr className="my-2" /> + <TooltipSelect + form={form} + name="general.tooltips" + fields={fields} + saveFunction={saveForm} + formFieldLabel="Tooltips" + /> + </> + ); +}; + +const HeatmapChartForm: React.FC<{ + form: UseFormReturn<z.infer<typeof ChartSchema>>; + fields: Field[]; + saveForm: () => void; + chartType: ChartType; +}> = ({ form, fields, saveForm, chartType }) => { + const formValues = useWatch({ control: form.control }); + const xColumnExists = FieldValidators.exists( + formValues.general?.xColumn?.field, + ); + const yColumnExists = FieldValidators.exists( + formValues.general?.yColumn?.field, + ); + + return ( + <> + <XAxis form={form} fields={fields} /> + {xColumnExists && ( + <NumberField form={form} - name={`${axis}Axis.label`} - formFieldLabel={`${axisName}-axis Label`} + name="xAxis.bin.maxbins" + formFieldLabel="Number of boxes (max)" + className="justify-between" /> - <div className="flex flex-row gap-2 w-full"> - <BooleanField - form={form} - name={`${axis}Axis.bin.binned`} - formFieldLabel="Binned" - /> - <NumberField - form={form} - name={`${axis}Axis.bin.step`} - formFieldLabel="Bin step" - step={0.05} - className="w-32" - /> - </div> - </TabContainer> - </TabsContent> + )} + <YAxis form={form} fields={fields} /> + {yColumnExists && ( + <NumberField + form={form} + name="yAxis.bin.maxbins" + formFieldLabel="Number of boxes (max)" + className="justify-between" + /> + )} + <ColorByAxis form={form} fields={fields} /> + </> ); }; -const Chart: React.FC<{ +const PieChartForm: React.FC<{ + form: UseFormReturn<z.infer<typeof ChartSchema>>; + fields: Field[]; + saveForm: () => void; chartType: ChartType; - formValues: z.infer<typeof ChartSchema>; - data?: object[]; -}> = ({ chartType, formValues, data }) => { +}> = ({ form, fields, saveForm, chartType }) => { + const formValues = useWatch({ control: form.control }); + const colorByColumn = formValues.general?.colorByColumn; + + const inferredColorByDataType = colorByColumn?.type + ? TypeConverters.toSelectableDataType(colorByColumn.type) + : "string"; + return ( - <LazyChart - chartType={chartType} - formValues={formValues} - data={data} - width="container" - height={300} - /> + <> + <Title text="Color by" /> + <ColumnSelector + form={form} + name="general.colorByColumn.field" + columns={fields} + includeCountField={false} + /> + {FieldValidators.exists(colorByColumn?.field) && ( + <DataTypeSelect + form={form} + name="general.colorByColumn.selectedDataType" + formFieldLabel="Data Type" + defaultValue={inferredColorByDataType} + /> + )} + + <Title text="Size by" /> + <div className="flex flex-row justify-between"> + <ColumnSelector + form={form} + name="general.yColumn.field" + columns={fields} + /> + <AggregationSelect form={form} name="general.yColumn.aggregate" /> + </div> + + <hr /> + <Title text="General" /> + <TooltipSelect + form={form} + name="general.tooltips" + fields={fields} + saveFunction={saveForm} + formFieldLabel="Tooltips" + /> + <NumberField + form={form} + name="style.innerRadius" + formFieldLabel="Donut size" + className="w-32" + /> + </> ); }; -const TabContainer: React.FC<{ - className?: string; - children: React.ReactNode; -}> = ({ children, className }) => { - return <div className={cn("flex flex-col gap-3", className)}>{children}</div>; +const StyleForm: React.FC<{ + form: UseFormReturn<z.infer<typeof ChartSchema>>; + fields: Field[]; + saveForm: () => void; +}> = ({ form }) => { + const renderBinFields = (axis: "x" | "y") => { + return ( + <div className="flex flex-row gap-2 w-full"> + <BooleanField + form={form} + name={`${axis}Axis.bin.binned`} + formFieldLabel="Binned" + /> + <NumberField + form={form} + name={`${axis}Axis.bin.step`} + formFieldLabel="Bin step" + step={0.05} + className="w-32" + /> + </div> + ); + }; + + return ( + <Accordion type="multiple"> + <AccordionItem value="general" className="border-none"> + <AccordionTrigger className="pt-0 pb-2"> + <Title text="General" /> + </AccordionTrigger> + <AccordionContent wrapperClassName="pb-2"> + <InputField + form={form} + formFieldLabel="Plot title" + name="general.title" + /> + </AccordionContent> + </AccordionItem> + + <AccordionItem value="xAxis" className="border-none"> + <AccordionTrigger className="py-2"> + <Title text="X-Axis" /> + </AccordionTrigger> + <AccordionContent wrapperClassName="pb-2 flex flex-col gap-2"> + <InputField form={form} formFieldLabel="Label" name="xAxis.label" /> + <SliderField + form={form} + name="xAxis.width" + formFieldLabel="Width" + value={400} + start={200} + stop={800} + /> + {renderBinFields("x")} + </AccordionContent> + </AccordionItem> + + <AccordionItem value="yAxis" className="border-none"> + <AccordionTrigger className="py-2"> + <Title text="Y-Axis" /> + </AccordionTrigger> + <AccordionContent wrapperClassName="pb-2 flex flex-col gap-2"> + <InputField form={form} formFieldLabel="Label" name="yAxis.label" /> + <SliderField + form={form} + name="yAxis.height" + formFieldLabel="Height" + value={300} + start={150} + stop={600} + /> + {renderBinFields("y")} + </AccordionContent> + </AccordionItem> + + <AccordionItem value="color" className="border-none"> + <AccordionTrigger className="py-2"> + <Title text="Color" /> + </AccordionTrigger> + <AccordionContent wrapperClassName="pb-2 flex flex-col gap-2"> + <SelectField + form={form} + name="color.scheme" + formFieldLabel="Color scheme" + defaultValue={DEFAULT_COLOR_SCHEME} + options={COLOR_SCHEMES.map((scheme) => ({ + display: capitalize(scheme), + value: scheme, + }))} + /> + <ColorArrayField + form={form} + name="color.range" + formFieldLabel="Color range" + /> + <p className="text-xs"> + <InfoIcon className="w-2.5 h-2.5 inline mb-1 mr-1" /> + If you are using color range, color scheme will be ignored. + </p> + </AccordionContent> + </AccordionItem> + </Accordion> + ); }; diff --git a/frontend/src/components/data-table/chart-transforms/constants.ts b/frontend/src/components/data-table/chart-transforms/constants.ts index 74fee6ee0d8..0deb8474d35 100644 --- a/frontend/src/components/data-table/chart-transforms/constants.ts +++ b/frontend/src/components/data-table/chart-transforms/constants.ts @@ -5,34 +5,56 @@ import { BarChartIcon, PieChartIcon, SigmaIcon, - CircleSlash2, - MinusIcon, - PlusIcon, - BinaryIcon, + HashIcon, + BaselineIcon, + AlignCenterVerticalIcon, + ArrowDownToLineIcon, + ArrowUpToLineIcon, ChartScatterIcon, + SquareFunctionIcon, + TableIcon, + AreaChartIcon, } from "lucide-react"; -import type { ChartType } from "./storage"; -import type { AGGREGATION_FNS } from "@/plugins/impl/data-frames/types"; import type { ColorScheme } from "vega"; -import { DEFAULT_COLOR_SCHEME } from "./chart-schemas"; +import type { + AggregationFn, + ChartType, + SelectableDataType, + TimeUnit, +} from "./types"; + +export const COUNT_FIELD = "__count__"; +export const DEFAULT_COLOR_SCHEME = "default"; export const CHART_TYPE_ICON: Record<ChartType, React.ElementType> = { line: LineChartIcon, bar: BarChartIcon, pie: PieChartIcon, scatter: ChartScatterIcon, + heatmap: TableIcon, + area: AreaChartIcon, }; -export const AGGREGATION_TYPE_ICON: Record< - (typeof AGGREGATION_FNS)[number], - React.ElementType -> = { - count: BinaryIcon, +export const AGGREGATION_TYPE_ICON: Record<AggregationFn, React.ElementType> = { + none: SquareFunctionIcon, + count: HashIcon, sum: SigmaIcon, - mean: CircleSlash2, - median: CircleSlash2, - min: MinusIcon, - max: PlusIcon, + mean: BaselineIcon, + median: AlignCenterVerticalIcon, + min: ArrowDownToLineIcon, + max: ArrowUpToLineIcon, + distinct: HashIcon, +}; + +export const AGGREGATION_TYPE_DESCRIPTIONS: Record<AggregationFn, string> = { + none: "No aggregation", + count: "Count of records", + sum: "Sum of values", + mean: "Mean of values", + median: "Median of values", + min: "Minimum value", + max: "Maximum value", + distinct: "Distinct values", }; export const COLOR_SCHEMES: Array<ColorScheme | typeof DEFAULT_COLOR_SCHEME> = [ @@ -87,3 +109,32 @@ export const COLOR_SCHEMES: Array<ColorScheme | typeof DEFAULT_COLOR_SCHEME> = [ "rainbow", "sinebow", ] as const; + +export const SCALE_TYPE_DESCRIPTIONS: Record<SelectableDataType, string> = { + number: "Continuous numerical scale", + string: "Discrete categorical scale (inputs treated as strings)", + temporal: "Continuous temporal scale", +}; + +// Set a field to this to reflect that it is not set +export const EMPTY_VALUE = ""; + +export const TIME_UNIT_DESCRIPTIONS: Record< + TimeUnit, + [title: string, description: string] +> = { + year: ["Year", "2025"], + quarter: ["Quarter", "Q1 2025"], + month: ["Month", "Jan 2025"], + week: ["Week", "Jan 01, 2025"], + day: ["Day", "Jan 01, 2025"], + hours: ["Hour", "Jan 01, 2025 12:00"], + minutes: ["Minute", "Jan 01, 2025 12:34"], + seconds: ["Second", "Jan 01, 2025 12:34:56"], + milliseconds: ["Millisecond", "Jan 01, 2025 12:34:56.789"], + date: ["Date", "Jan 01, 2025"], + dayofyear: ["Day of Year", "Day 1 of 2025"], + yearmonth: ["Year Month", "Jan 2025"], + yearmonthdate: ["Year Month Date", "Jan 01, 2025"], + monthdate: ["Month Date", "Jan 01"], +}; diff --git a/frontend/src/components/data-table/chart-transforms/form-components.tsx b/frontend/src/components/data-table/chart-transforms/form-components.tsx index 678ae737dd4..457cdc3bd79 100644 --- a/frontend/src/components/data-table/chart-transforms/form-components.tsx +++ b/frontend/src/components/data-table/chart-transforms/form-components.tsx @@ -1,89 +1,178 @@ /* Copyright 2024 Marimo. All rights reserved. */ +import React from "react"; +import { capitalize } from "lodash-es"; +import { XIcon, PlusIcon, SquareFunctionIcon } from "lucide-react"; +import type { UseFormReturn, Path, PathValue } from "react-hook-form"; +import type { z } from "zod"; + +import type { DataType } from "@/core/kernel/messages"; +import type { NumberFieldProps } from "@/components/ui/number-field"; +import type { ChartSchema } from "./chart-schemas"; + import { FormField, FormItem, FormLabel, FormControl, } from "@/components/ui/form"; -import type { UseFormReturn, Path, PathValue } from "react-hook-form"; -import type { DataType } from "@/core/kernel/messages"; import { Select, SelectContent, SelectGroup, SelectItem, + SelectLabel, + SelectSeparator, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DATA_TYPE_ICON } from "@/components/datasets/icons"; import { DebouncedInput, DebouncedNumberInput } from "@/components/ui/input"; -import { SquareFunctionIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/utils/cn"; -import { DEFAULT_BIN_VALUE, NONE_GROUP_BY } from "./chart-schemas"; -import type { NumberFieldProps } from "@/components/ui/number-field"; import { Button } from "@/components/ui/button"; -import { XIcon, PlusIcon } from "lucide-react"; -import React from "react"; +import { cn } from "@/utils/cn"; +import { Multiselect } from "@/plugins/impl/MultiselectPlugin"; + +import { DEFAULT_BIN_VALUE } from "./chart-schemas"; +import { + AGGREGATION_FNS, + COMBINED_TIME_UNITS, + NONE_AGGREGATION, + SELECTABLE_DATA_TYPES, + SINGLE_TIME_UNITS, + type TimeUnit, +} from "./types"; +import { + AGGREGATION_TYPE_DESCRIPTIONS, + AGGREGATION_TYPE_ICON, + COUNT_FIELD, + EMPTY_VALUE, + SCALE_TYPE_DESCRIPTIONS, + TIME_UNIT_DESCRIPTIONS, +} from "./constants"; +import { TypeConverters } from "./chart-spec"; +import { IconWithText } from "./chart-components"; +import { Slider } from "@/components/ui/slider"; + +const CLEAR_VALUE = "__clear__"; + +export interface Field { + name: string; + type: DataType; +} + +export interface Tooltip { + field: string; + type: string; +} + +interface BaseFormFieldProps<T extends object> { + form: UseFormReturn<T>; + name: Path<T>; + formFieldLabel: string; + className?: string; +} export const ColumnSelector = <T extends object>({ form, name, - formFieldLabel, columns, - includeNoneOption = false, + onValueChange, + includeCountField = true, }: { form: UseFormReturn<T>; name: Path<T>; - formFieldLabel: string; columns: Array<{ name: string; type: DataType }>; - includeNoneOption?: boolean; + onValueChange?: (fieldName: string, type: DataType | undefined) => void; + includeCountField?: boolean; }) => { + type AnyPath = Path<T>; + type AnyPathValue = PathValue<T, Path<T>>; + const ANY_VALUE = EMPTY_VALUE as AnyPathValue; + const pathType = name.replace(".field", ".type") as AnyPath; + const pathSelectedDataType = name.replace( + ".field", + ".selectedDataType", + ) as AnyPath; + + const clear = () => { + form.setValue(name, ANY_VALUE); + form.setValue(pathType, ANY_VALUE); + form.setValue(pathSelectedDataType, ANY_VALUE); + onValueChange?.(EMPTY_VALUE, undefined); + }; + return ( <FormField control={form.control} name={name} render={({ field }) => ( <FormItem> - <FormLabel>{formFieldLabel}</FormLabel> <FormControl> <Select {...field} onValueChange={(value) => { - if (value === NONE_GROUP_BY) { - form.setValue(name, value as PathValue<T, Path<T>>); + // Handle clear + if (value === CLEAR_VALUE) { + clear(); + return; + } + + // Handle count + if (value === COUNT_FIELD) { + form.setValue(name, value as AnyPathValue); + form.setValue(pathType, ANY_VALUE); + form.setValue(pathSelectedDataType, ANY_VALUE); + onValueChange?.(name, "number"); return; } + // Handle column selection const column = columns.find((column) => column.name === value); if (column) { - form.setValue(name, value as PathValue<T, Path<T>>); - const typeFieldName = name.replace( - ".field", - ".type", - ) as Path<T>; + form.setValue(name, value as AnyPathValue); + form.setValue(pathType, column.type as AnyPathValue); form.setValue( - typeFieldName, - column.type as PathValue<T, Path<T>>, + pathSelectedDataType, + TypeConverters.toSelectableDataType( + column.type, + ) as AnyPathValue, ); + onValueChange?.(name, column.type); } }} - value={field.value ?? ""} + value={field.value ?? EMPTY_VALUE} > - <SelectTrigger className="w-40"> - <SelectValue /> + <SelectTrigger + className="w-40 truncate" + onClear={field.value ? clear : undefined} + > + <SelectValue placeholder="Select column" /> </SelectTrigger> <SelectContent> - {includeNoneOption && ( - <SelectItem value={NONE_GROUP_BY}> - <div className="flex items-center"> - <SquareFunctionIcon className="w-3 h-3 mr-2" /> - None + {field.value && ( + <SelectItem value={CLEAR_VALUE}> + <div className="flex items-center truncate"> + <XIcon className="w-3 h-3 mr-2" /> + Clear </div> </SelectItem> )} + {includeCountField && ( + <> + <SelectItem key={COUNT_FIELD} value={COUNT_FIELD}> + <div className="flex items-center truncate"> + <SquareFunctionIcon className="w-3 h-3 mr-2" /> + Count of records + </div> + </SelectItem> + <SelectSeparator /> + </> + )} {columns.map((column) => { + if (column.name.trim() === EMPTY_VALUE) { + return null; + } const DataTypeIcon = DATA_TYPE_ICON[column.type]; return ( <SelectItem key={column.name} value={column.name}> @@ -109,75 +198,66 @@ export const SelectField = <T extends object>({ formFieldLabel, options, defaultValue, -}: { - form: UseFormReturn<T>; - name: Path<T>; - formFieldLabel: string; - options: Array<{ label: string; value: string }>; +}: BaseFormFieldProps<T> & { + options: Array<{ display: React.ReactNode; value: string }>; defaultValue: string; -}) => { - return ( - <FormField - control={form.control} - name={name} - render={({ field }) => ( - <FormItem> - <FormLabel>{formFieldLabel}</FormLabel> - <FormControl> - <Select - {...field} - onValueChange={field.onChange} - value={field.value ?? defaultValue} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {options.map((option) => ( +}) => ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between"> + <FormLabel>{formFieldLabel}</FormLabel> + <FormControl> + <Select + {...field} + onValueChange={field.onChange} + value={field.value ?? defaultValue} + > + <SelectTrigger className="truncate"> + <SelectValue placeholder="Select an option" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {options + .filter((option) => option.value !== EMPTY_VALUE) + .map((option) => ( <SelectItem key={option.value} value={option.value}> - {option.label} + {option.display} </SelectItem> ))} - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - </FormItem> - )} - /> - ); -}; + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + </FormItem> + )} + /> +); export const InputField = <T extends object>({ form, name, formFieldLabel, -}: { - form: UseFormReturn<T>; - name: Path<T>; - formFieldLabel: string; -}) => { - return ( - <FormField - control={form.control} - name={name} - render={({ field }) => ( - <FormItem> - <FormLabel>{formFieldLabel}</FormLabel> - <FormControl> - <DebouncedInput - {...field} - value={field.value ?? ""} - onValueChange={field.onChange} - className="w-48 text-xs" - /> - </FormControl> - </FormItem> - )} - /> - ); -}; +}: BaseFormFieldProps<T>) => ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem className="flex flex-row gap-2 items-center"> + <FormLabel>{formFieldLabel}</FormLabel> + <FormControl> + <DebouncedInput + {...field} + value={field.value ?? EMPTY_VALUE} + onValueChange={field.onChange} + className="text-xs h-5" + /> + </FormControl> + </FormItem> + )} + /> +); export const NumberField = <T extends object>({ form, @@ -185,57 +265,108 @@ export const NumberField = <T extends object>({ formFieldLabel, className, ...props -}: { - form: UseFormReturn<T>; - name: Path<T>; - formFieldLabel: string; - className?: string; -} & Omit<NumberFieldProps, "value" | "onValueChange">) => { - return ( - <FormField - control={form.control} - name={name} - render={({ field }) => ( - <FormItem className={cn("flex flex-row items-center gap-2", className)}> - <FormLabel className="whitespace-nowrap">{formFieldLabel}</FormLabel> - <FormControl> - <DebouncedNumberInput - {...field} - value={field.value ?? DEFAULT_BIN_VALUE} - onValueChange={field.onChange} - aria-label={formFieldLabel} - {...props} - /> - </FormControl> - </FormItem> - )} - /> - ); -}; +}: BaseFormFieldProps<T> & + Omit<NumberFieldProps, "value" | "onValueChange">) => ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem className={cn("flex flex-row items-center gap-2", className)}> + <FormLabel className="whitespace-nowrap">{formFieldLabel}</FormLabel> + <FormControl> + <DebouncedNumberInput + {...field} + value={field.value ?? DEFAULT_BIN_VALUE} + onValueChange={field.onChange} + aria-label={formFieldLabel} + {...props} + className="w-16" + /> + </FormControl> + </FormItem> + )} + /> +); export const BooleanField = <T extends object>({ form, name, formFieldLabel, className, -}: { - form: UseFormReturn<T>; - name: Path<T>; - formFieldLabel: string; - className?: string; -}) => { +}: BaseFormFieldProps<T>) => ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem className={cn("flex flex-row items-center gap-2", className)}> + <FormLabel>{formFieldLabel}</FormLabel> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + className="w-4 h-4" + /> + </FormControl> + </FormItem> + )} + /> +); + +interface SliderFieldProps<T extends object> extends BaseFormFieldProps<T> { + value: number; + start: number; + stop: number; + step?: number; +} + +export const SliderField = <T extends object>({ + form, + name, + formFieldLabel, + value, + start, + stop, + step, + className, + ...props +}: SliderFieldProps<T>) => { + const [internalValue, setInternalValue] = React.useState(value); + + // Update internal value on prop change + React.useEffect(() => { + setInternalValue(value); + }, [value]); + return ( <FormField control={form.control} name={name} render={({ field }) => ( - <FormItem className={cn("flex flex-row items-center gap-1", className)}> + <FormItem + className={cn("flex flex-row items-center gap-2 w-1/2", className)} + > <FormLabel>{formFieldLabel}</FormLabel> <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - className="w-4 h-4" + <Slider + {...field} + {...props} + id={name} + className="relative flex items-center select-none" + value={[internalValue]} + min={start} + max={stop} + step={step} + // Triggered on slider drag + onValueChange={([nextValue]) => { + setInternalValue(nextValue); + field.onChange(nextValue); + }} + // Triggered on mouse up + onValueCommit={([nextValue]) => { + field.onChange(nextValue); + form.setValue(name, nextValue as PathValue<T, Path<T>>); + }} + valueMap={(value) => value} /> </FormControl> </FormItem> @@ -249,12 +380,7 @@ export const ColorArrayField = <T extends object>({ name, formFieldLabel, className, -}: { - form: UseFormReturn<T>; - name: Path<T>; - formFieldLabel: string; - className?: string; -}) => { +}: BaseFormFieldProps<T>) => { const formValue = form.watch(name); const [colors, setColors] = React.useState<string[]>(formValue ?? []); @@ -321,3 +447,244 @@ export const ColorArrayField = <T extends object>({ /> ); }; + +export const TimeUnitSelect = <T extends object>({ + form, + name, + formFieldLabel, +}: BaseFormFieldProps<T>) => { + const clear = () => { + form.setValue(name, EMPTY_VALUE as PathValue<T, Path<T>>); + }; + + const renderTimeUnit = (unit: TimeUnit) => { + const [label, description] = TIME_UNIT_DESCRIPTIONS[unit]; + return ( + <SelectItem + key={unit} + value={unit} + className="flex flex-row" + subtitle={ + <span className="text-xs text-muted-foreground ml-auto"> + {description} + </span> + } + > + {label} + </SelectItem> + ); + }; + return ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between w-full"> + <FormLabel>{formFieldLabel}</FormLabel> + <FormControl> + <Select + {...field} + onValueChange={(value) => { + if (value === CLEAR_VALUE) { + clear(); + } else { + field.onChange(value); + } + }} + value={field.value} + > + <SelectTrigger onClear={field.value ? clear : undefined}> + <SelectValue placeholder="Select unit" /> + </SelectTrigger> + <SelectContent className="w-72"> + {field.value && ( + <> + <SelectItem value={CLEAR_VALUE}> + <div className="flex items-center truncate"> + <XIcon className="w-3 h-3 mr-2" /> + Clear + </div> + </SelectItem> + <SelectSeparator /> + </> + )} + <SelectGroup> + {COMBINED_TIME_UNITS.map(renderTimeUnit)} + <SelectSeparator /> + {SINGLE_TIME_UNITS.map(renderTimeUnit)} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + </FormItem> + )} + /> + ); +}; + +export const DataTypeSelect = <T extends object>({ + form, + name, + formFieldLabel, + defaultValue, + onValueChange, +}: BaseFormFieldProps<T> & { + defaultValue: string; + onValueChange?: (value: string) => void; +}) => { + const [isOpen, setIsOpen] = React.useState(false); + + return ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between w-full"> + <FormLabel>{formFieldLabel}</FormLabel> + <FormControl> + <Select + {...field} + onValueChange={(value) => { + field.onChange(value); + onValueChange?.(value); + }} + value={field.value ?? defaultValue} + open={isOpen} + onOpenChange={setIsOpen} + > + <SelectTrigger> + <SelectValue placeholder="Select an option" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {SELECTABLE_DATA_TYPES.map((type) => { + const Icon = DATA_TYPE_ICON[type]; + return ( + <SelectItem + key={type} + value={type} + className="flex flex-col items-start justify-center" + subtitle={ + isOpen && ( + <span className="text-xs text-muted-foreground"> + {SCALE_TYPE_DESCRIPTIONS[type]} + </span> + ) + } + > + <IconWithText Icon={Icon} text={capitalize(type)} /> + </SelectItem> + ); + })} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + </FormItem> + )} + /> + ); +}; + +export const AggregationSelect = <T extends object>({ + form, + name, +}: { form: UseFormReturn<T>; name: Path<T> }) => ( + <FormField + control={form.control} + name={name} + render={({ field }) => ( + <FormItem> + <FormControl> + <Select + {...field} + value={field.value ?? NONE_AGGREGATION} + onValueChange={field.onChange} + > + <SelectTrigger variant="ghost"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectLabel>Aggregation</SelectLabel> + {AGGREGATION_FNS.map((agg) => { + const Icon = AGGREGATION_TYPE_ICON[agg]; + return ( + <SelectItem + key={agg} + value={agg} + className="flex flex-col items-start justify-center" + subtitle={ + <span className="text-xs text-muted-foreground pr-10"> + {AGGREGATION_TYPE_DESCRIPTIONS[agg]} + </span> + } + > + <div className="flex items-center"> + <Icon className="w-3 h-3 mr-2" /> + {capitalize(agg)} + </div> + </SelectItem> + ); + })} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + </FormItem> + )} + /> +); + +export const TooltipSelect = <T extends z.infer<typeof ChartSchema>>({ + form, + name, + formFieldLabel, + fields, + saveFunction, +}: { + form: UseFormReturn<T>; + formFieldLabel?: string; + name: Path<T>; + fields: Field[]; + saveFunction: () => void; +}) => ( + <FormField + control={form.control} + name={name} + render={({ field }) => { + const tooltips = field.value as Tooltip[] | undefined; + return ( + <FormItem className="flex flex-row gap-2 items-center"> + {formFieldLabel && <FormLabel>{formFieldLabel}</FormLabel>} + <FormControl> + <Multiselect + options={fields?.map((field) => field.name) ?? []} + value={tooltips?.map((t) => t.field) ?? []} + setValue={(values) => { + const selectedValues = + typeof values === "function" ? values([]) : values; + + const tooltipObjects = selectedValues.map((fieldName) => { + const fieldType = fields?.find( + (f) => f.name === fieldName, + )?.type; + + return { + field: fieldName, + type: fieldType ?? "string", + }; + }); + + field.onChange(tooltipObjects); + saveFunction(); + }} + label={null} + fullWidth={false} + /> + </FormControl> + </FormItem> + ); + }} + /> +); diff --git a/frontend/src/components/data-table/chart-transforms/lazy-chart.tsx b/frontend/src/components/data-table/chart-transforms/lazy-chart.tsx index 2ac8a4c0459..b5eb24a7ea7 100644 --- a/frontend/src/components/data-table/chart-transforms/lazy-chart.tsx +++ b/frontend/src/components/data-table/chart-transforms/lazy-chart.tsx @@ -1,42 +1,19 @@ /* Copyright 2024 Marimo. All rights reserved. */ import React from "react"; -import type { ChartType } from "./storage"; +import type { ChartType } from "./types"; import { useTheme } from "@/theme/useTheme"; import type { z } from "zod"; import type { ChartSchema } from "./chart-schemas"; import type { TopLevelSpec } from "vega-lite"; -import type { ResolvedTheme } from "@/theme/useTheme"; +import type { ErrorMessage } from "./chart-spec"; +import { ChartPieIcon } from "lucide-react"; +import { createVegaSpec } from "./chart-spec"; const LazyVega = React.lazy(() => import("react-vega").then((m) => ({ default: m.Vega })), ); - -const LazyChartSpec = React.lazy(() => - import("./chart-spec").then((m) => ({ - default: (props: { - chartType: ChartType; - data: object[]; - formValues: z.infer<typeof ChartSchema>; - theme: ResolvedTheme; - width: number | "container"; - height: number; - children: (spec: TopLevelSpec | null) => React.ReactNode; - }) => { - const spec = m.createVegaSpec( - props.chartType, - props.data, - props.formValues, - props.theme, - props.width, - props.height, - ); - return props.children(spec); - }, - })), -); - export const LazyChart: React.FC<{ chartType: ChartType; formValues: z.infer<typeof ChartSchema>; @@ -50,33 +27,55 @@ export const LazyChart: React.FC<{ return <div>No data</div>; } - return ( - <div className="h-full m-auto rounded-md mt-4 w-full"> - <React.Suspense fallback={<div>Loading chart...</div>}> - <LazyChartSpec - chartType={chartType} - data={data} - formValues={formValues} - theme={theme} - width={width} - height={height} - > - {(vegaSpec) => { - if (!vegaSpec) { - return <div>This configuration is not supported</div>; - } + const specOrMessage = createVegaSpec( + chartType, + data, + formValues, + theme, + width, + height, + ); + + const renderChart = (specOrMessage: TopLevelSpec | ErrorMessage) => { + if (typeof specOrMessage === "string") { + return <ChartEmptyState>{specOrMessage}</ChartEmptyState>; + } - return ( - <React.Suspense fallback={<div>Loading Vega...</div>}> - <LazyVega - spec={vegaSpec} - theme={theme === "dark" ? "dark" : undefined} - /> - </React.Suspense> - ); + return ( + <React.Suspense fallback={<LoadingChart />}> + <LazyVega + spec={specOrMessage} + theme={theme === "dark" ? "dark" : undefined} + height={height} + actions={{ + export: true, + source: false, + compiled: false, + editor: true, }} - </LazyChartSpec> + /> </React.Suspense> + ); + }; + + return ( + <div className="h-full m-auto rounded-md mt-4 w-full"> + {renderChart(specOrMessage)} + </div> + ); +}; + +const LoadingChart = () => { + return <ChartEmptyState>Loading chart...</ChartEmptyState>; +}; + +const ChartEmptyState = ({ children }: { children: React.ReactNode }) => { + return ( + <div className="h-full flex flex-col items-center justify-center gap-4"> + <ChartPieIcon className="w-10 h-10 text-muted-foreground" /> + <span className="text-md font-semibold text-muted-foreground"> + {children} + </span> </div> ); }; diff --git a/frontend/src/components/data-table/chart-transforms/storage.ts b/frontend/src/components/data-table/chart-transforms/storage.ts index 258fcd577eb..952b4771f53 100644 --- a/frontend/src/components/data-table/chart-transforms/storage.ts +++ b/frontend/src/components/data-table/chart-transforms/storage.ts @@ -3,28 +3,35 @@ import type { CellId } from "@/core/cells/ids"; import type { TypedString } from "@/utils/typed"; import { atomWithStorage } from "jotai/utils"; -import type { z } from "zod"; +import { z } from "zod"; import { atom } from "jotai"; -import type { ChartSchema } from "./chart-schemas"; +import { ChartSchema } from "./chart-schemas"; import { Logger } from "@/utils/Logger"; -export type TabName = TypedString<"TabName">; -export const KEY = "marimo:charts:v1"; +import type { ChartType } from "./types"; -export enum ChartType { - LINE = "line", - BAR = "bar", - PIE = "pie", - SCATTER = "scatter", -} -export const CHART_TYPES = Object.values(ChartType); +export type TabName = TypedString<"TabName">; +export const KEY = "marimo:charts:v2"; interface TabStorage { tabName: TabName; // unique within cell chartType: ChartType; config: z.infer<typeof ChartSchema>; } + type TabStorageMap = Map<CellId, TabStorage[]>; +const TabStorageSchema = z.object({ + tabName: z.string().transform((name) => name as TabName), + chartType: z.string().transform((type) => type as ChartType), + config: ChartSchema, +}); +const TabStorageEntriesSchema = z.array( + z.tuple([ + z.string().transform((name) => name as CellId), + z.array(TabStorageSchema), + ]), +); + // Custom storage adapter to ensure objects are serialized as maps const mapStorage = { getItem: (key: string): TabStorageMap => { @@ -33,8 +40,12 @@ const mapStorage = { if (!value) { return new Map(); } - const parsed = JSON.parse(value); - return new Map(parsed as Array<[CellId, TabStorage[]]>); + const parsedResult = TabStorageEntriesSchema.safeParse(JSON.parse(value)); + if (!parsedResult.success) { + Logger.warn("Error parsing chart storage", parsedResult.error); + return new Map(); + } + return new Map(parsedResult.data); } catch (error) { Logger.warn("Error getting chart storage", error); return new Map(); diff --git a/frontend/src/components/data-table/chart-transforms/types.ts b/frontend/src/components/data-table/chart-transforms/types.ts new file mode 100644 index 00000000000..170d1714606 --- /dev/null +++ b/frontend/src/components/data-table/chart-transforms/types.ts @@ -0,0 +1,82 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +/** + * Similar to VegaLite's ScaleType, https://vega.github.io/vega-lite/docs/scale.html#type + */ +export const SELECTABLE_DATA_TYPES = ["number", "string", "temporal"] as const; +export type SelectableDataType = (typeof SELECTABLE_DATA_TYPES)[number]; + +/** + * Similar to VegaLite's TimeUnit, https://vega.github.io/vega-lite/docs/timeunit.html + */ +export const SINGLE_TIME_UNITS = [ + // Individual units + "year", // Gregorian calendar years. + "quarter", // Three-month intervals, starting in one of January, April, July, and October. + "month", // Calendar months (January, February, etc.). + "date", // Calendar day of the month (January 1, January 2, etc.). + "week", // Sunday-based weeks. Days before the first Sunday of the year are considered to be in week 0, the first Sunday of the year is the start of week 1, the second Sunday week 2, etc.. + "day", // Day of the week (Sunday, Monday, etc.). + "dayofyear", // Day of the year (1, 2, …, 365, etc.). + "hours", // Hours of the day (12:00am, 1:00am, etc.). + "minutes", // Minutes in an hour (12:00, 12:01, etc.). + "seconds", // Seconds in a minute (12:00:00, 12:00:01, etc.). + "milliseconds", // Milliseconds in a second. +] as const; +// Common combinations of the above +export const COMBINED_TIME_UNITS = [ + "yearmonth", + "yearmonthdate", + "monthdate", +] as const; +export const TIME_UNITS = [ + ...SINGLE_TIME_UNITS, + ...COMBINED_TIME_UNITS, +] as const; +export type TimeUnit = (typeof TIME_UNITS)[number]; + +// Time units that are not selectable options but are used for tooltips +export const TIME_UNIT_TOOLTIPS = [ + ...TIME_UNITS, + "yearmonthdatehoursminutesseconds", + "yearmonthdate", + "hoursminutesseconds", +] as const; +export type TimeUnitTooltip = (typeof TIME_UNIT_TOOLTIPS)[number]; + +/** + * Similar to VegaLite's SortOrder, https://vega.github.io/vega-lite/docs/sort.html#order + */ +export const SORT_TYPES = ["ascending", "descending"] as const; +export type SortType = (typeof SORT_TYPES)[number]; + +export const NONE_AGGREGATION = "none"; + +/** + * Subset of VegaLite's AggregateOp, https://vega.github.io/vega-lite/docs/aggregate.html#op + */ +export const AGGREGATION_FNS = [ + NONE_AGGREGATION, + "count", + "sum", + "mean", + "median", + "min", + "max", + "distinct", +] as const; +export type AggregationFn = (typeof AGGREGATION_FNS)[number]; + +/** + * Subset of VegaLite's MarkType, https://vega.github.io/vega-lite/docs/mark.html#types + */ +export const ChartType = { + LINE: "line", + BAR: "bar", + PIE: "pie", + SCATTER: "scatter", + HEATMAP: "heatmap", + AREA: "area", +} as const; +export type ChartType = (typeof ChartType)[keyof typeof ChartType]; +export const CHART_TYPES = Object.values(ChartType); diff --git a/frontend/src/components/data-table/column-header.tsx b/frontend/src/components/data-table/column-header.tsx index 42afa0994f9..3960084b392 100644 --- a/frontend/src/components/data-table/column-header.tsx +++ b/frontend/src/components/data-table/column-header.tsx @@ -2,7 +2,7 @@ "use no memo"; import type { Column } from "@tanstack/react-table"; -import { FilterIcon, FilterX, MinusIcon, SearchIcon } from "lucide-react"; +import { FilterIcon, MinusIcon, SearchIcon, XIcon } from "lucide-react"; import { cn } from "@/utils/cn"; import { @@ -17,7 +17,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "../ui/button"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { NumberField } from "../ui/number-field"; import { Input } from "../ui/input"; import { type ColumnFilterForType, Filter } from "./filters"; @@ -28,21 +28,45 @@ import { renderCopyColumn, renderDataType, renderFormatOptions, - renderSortIcon, + renderSortFilterIcon, renderSorts, + FilterButtons, + ClearFilterMenuItem, + renderFilterByValues, } from "./header-items"; +import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin"; +import { useAsyncData } from "@/hooks/useAsyncData"; +import { ErrorBanner } from "@/plugins/impl/common/error-banner"; +import { Spinner } from "../icons/spinner"; +import { PopoverClose } from "../ui/popover"; +import { Logger } from "@/utils/Logger"; +import { Checkbox } from "../ui/checkbox"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "../ui/command"; +import { DraggablePopover } from "../ui/draggable-popover"; + +const TOP_K_ROWS = 30; interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> { column: Column<TData, TValue>; header: React.ReactNode; + calculateTopKRows?: CalculateTopKRows; } export const DataTableColumnHeader = <TData, TValue>({ column, header, className, + calculateTopKRows, }: DataTableColumnHeaderProps<TData, TValue>) => { + const [isFilterValueOpen, setIsFilterValueOpen] = useState(false); + // No header if (!header) { return null; @@ -53,38 +77,53 @@ export const DataTableColumnHeader = <TData, TValue>({ return <div className={cn(className)}>{header}</div>; } + const hasFilter = column.getFilterValue() !== undefined; + const hideIcon = !column.getIsSorted() && !hasFilter; + return ( - <DropdownMenu modal={false}> - <DropdownMenuTrigger asChild={true}> - <div - className={cn( - "group flex items-center my-1 space-between w-full select-none gap-2 border hover:border-border border-transparent hover:bg-[var(--slate-3)] data-[state=open]:bg-[var(--slate-3)] data-[state=open]:border-border rounded px-1 -mx-1", - className, - )} - data-testid="data-table-sort-button" - > - <span className="flex-1">{header}</span> - <span + <> + <DropdownMenu modal={false}> + <DropdownMenuTrigger asChild={true}> + <div className={cn( - "h-5 py-1 px-1", - !column.getIsSorted() && - "invisible group-hover:visible data-[state=open]:visible", + "group flex items-center my-1 space-between w-full select-none gap-2 border hover:border-border border-transparent hover:bg-[var(--slate-3)] data-[state=open]:bg-[var(--slate-3)] data-[state=open]:border-border rounded px-1 -mx-1", + className, )} + data-testid="data-table-sort-button" > - {renderSortIcon(column)} - </span> - </div> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {renderDataType(column)} - {renderSorts(column)} - {renderCopyColumn(column)} - {renderColumnPinning(column)} - {renderColumnWrapping(column)} - {renderFormatOptions(column)} - <DropdownMenuItemFilter column={column} /> - </DropdownMenuContent> - </DropdownMenu> + <span className="flex-1">{header}</span> + <span + className={cn( + "h-5 py-1 px-1", + hideIcon && + "invisible group-hover:visible data-[state=open]:visible", + )} + > + {renderSortFilterIcon(column)} + </span> + </div> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {renderDataType(column)} + {renderSorts(column)} + {renderCopyColumn(column)} + {renderColumnPinning(column)} + {renderColumnWrapping(column)} + {renderFormatOptions(column)} + <DropdownMenuSeparator /> + {renderMenuItemFilter(column)} + {renderFilterByValues(column, setIsFilterValueOpen)} + {hasFilter && <ClearFilterMenuItem column={column} />} + </DropdownMenuContent> + </DropdownMenu> + {isFilterValueOpen && ( + <PopoverFilterByValues + setIsFilterValueOpen={setIsFilterValueOpen} + calculateTopKRows={calculateTopKRows} + column={column} + /> + )} + </> ); }; @@ -113,11 +152,9 @@ export const DataTableColumnHeaderWithSummary = <TData, TValue>({ ); }; -export const DropdownMenuItemFilter = <TData, TValue>({ - column, -}: React.PropsWithChildren<{ - column: Column<TData, TValue>; -}>) => { +export function renderMenuItemFilter<TData, TValue>( + column: Column<TData, TValue>, +) { const canFilter = column.getCanFilter(); if (!canFilter) { return null; @@ -128,8 +165,6 @@ export const DropdownMenuItemFilter = <TData, TValue>({ return null; } - const hasFilter = column.getFilterValue() !== undefined; - const filterMenuItem = ( <DropdownMenuSubTrigger> <FilterIcon className="mo-dropdown-icon" /> @@ -137,70 +172,51 @@ export const DropdownMenuItemFilter = <TData, TValue>({ </DropdownMenuSubTrigger> ); - const clearFilterMenuItem = ( - <DropdownMenuItem onClick={() => column.setFilterValue(undefined)}> - <FilterX className="mo-dropdown-icon" /> - Clear filter - </DropdownMenuItem> - ); - if (filterType === "boolean") { return ( - <> - <DropdownMenuSeparator /> - <DropdownMenuSub> - {filterMenuItem} - <DropdownMenuPortal> - <DropdownMenuSubContent> - <DropdownMenuItem - onClick={() => column.setFilterValue(Filter.boolean(true))} - > - True - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => column.setFilterValue(Filter.boolean(false))} - > - False - </DropdownMenuItem> - </DropdownMenuSubContent> - </DropdownMenuPortal> - </DropdownMenuSub> - {hasFilter && clearFilterMenuItem} - </> + <DropdownMenuSub> + {filterMenuItem} + <DropdownMenuPortal> + <DropdownMenuSubContent> + <DropdownMenuItem + onClick={() => column.setFilterValue(Filter.boolean(true))} + > + True + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => column.setFilterValue(Filter.boolean(false))} + > + False + </DropdownMenuItem> + </DropdownMenuSubContent> + </DropdownMenuPortal> + </DropdownMenuSub> ); } if (filterType === "text") { return ( - <> - <DropdownMenuSeparator /> - <DropdownMenuSub> - {filterMenuItem} - <DropdownMenuPortal> - <DropdownMenuSubContent> - <TextFilter column={column} /> - </DropdownMenuSubContent> - </DropdownMenuPortal> - </DropdownMenuSub> - {hasFilter && clearFilterMenuItem} - </> + <DropdownMenuSub> + {filterMenuItem} + <DropdownMenuPortal> + <DropdownMenuSubContent> + <TextFilter column={column} /> + </DropdownMenuSubContent> + </DropdownMenuPortal> + </DropdownMenuSub> ); } if (filterType === "number") { return ( - <> - <DropdownMenuSeparator /> - <DropdownMenuSub> - {filterMenuItem} - <DropdownMenuPortal> - <DropdownMenuSubContent> - <NumberRangeFilter column={column} /> - </DropdownMenuSubContent> - </DropdownMenuPortal> - </DropdownMenuSub> - {hasFilter && clearFilterMenuItem} - </> + <DropdownMenuSub> + {filterMenuItem} + <DropdownMenuPortal> + <DropdownMenuSubContent> + <NumberRangeFilter column={column} /> + </DropdownMenuSubContent> + </DropdownMenuPortal> + </DropdownMenuSub> ); } @@ -226,7 +242,7 @@ export const DropdownMenuItemFilter = <TData, TValue>({ logNever(filterType); return null; -}; +} const NumberRangeFilter = <TData, TValue>({ column, @@ -259,6 +275,7 @@ const NumberRangeFilter = <TData, TValue>({ ref={minRef} value={min} onChange={(value) => setMin(value)} + aria-label="min" placeholder="min" onKeyDown={(e) => { if (e.key === "Enter") { @@ -275,6 +292,7 @@ const NumberRangeFilter = <TData, TValue>({ ref={maxRef} value={max} onChange={(value) => setMax(value)} + aria-label="max" onKeyDown={(e) => { if (e.key === "Enter") { handleApply({ max: Number.parseFloat(e.currentTarget.value) }); @@ -287,24 +305,15 @@ const NumberRangeFilter = <TData, TValue>({ className="shadow-none! border-border hover:shadow-none!" /> </div> - <div className="flex gap-2 px-2 justify-between"> - <Button variant="link" size="sm" onClick={() => handleApply()}> - Apply - </Button> - <Button - variant="linkDestructive" - size="sm" - disabled={!hasFilter} - className="" - onClick={() => { - setMin(undefined); - setMax(undefined); - column.setFilterValue(undefined); - }} - > - Clear - </Button> - </div> + <FilterButtons + onApply={handleApply} + onClear={() => { + setMin(undefined); + setMax(undefined); + column.setFilterValue(undefined); + }} + clearButtonDisabled={!hasFilter} + /> </div> ); }; @@ -343,23 +352,182 @@ const TextFilter = <TData, TValue>({ }} className="shadow-none! border-border hover:shadow-none!" /> - <div className="flex gap-2 px-2 justify-between"> - <Button variant="link" size="sm" onClick={() => handleApply()}> - Apply - </Button> + <FilterButtons + onApply={handleApply} + onClear={() => { + setValue(""); + column.setFilterValue(undefined); + }} + clearButtonDisabled={!hasFilter} + /> + </div> + ); +}; + +const PopoverFilterByValues = <TData, TValue>({ + setIsFilterValueOpen, + calculateTopKRows, + column, +}: { + setIsFilterValueOpen: (open: boolean) => void; + calculateTopKRows?: CalculateTopKRows; + column: Column<TData, TValue>; +}) => { + const [chosenValues, setChosenValues] = useState<Set<unknown>>(new Set()); + const [query, setQuery] = useState<string>(""); + + const { data, loading, error } = useAsyncData(async () => { + if (!calculateTopKRows) { + return null; + } + const res = await calculateTopKRows({ column: column.id, k: TOP_K_ROWS }); + return res.data; + }, []); + + const filteredData = useMemo(() => { + if (!data) { + return []; + } + + try { + return data.filter(([value, count]) => { + // Check if value exists and can be converted to string + // Keep null values for filtering + return value === undefined + ? false + : String(value).toLowerCase().includes(query.toLowerCase()); + }); + } catch (error_) { + Logger.error("Error filtering data", error_); + return []; + } + }, [data, query]); + + let dataTable: React.ReactNode; + + if (loading) { + dataTable = <Spinner size="medium" className="mx-auto mt-12 mb-10" />; + } + + if (error) { + dataTable = <ErrorBanner error={error} className="my-10 mx-4" />; + } + + const handleToggle = (value: unknown) => { + setChosenValues((prev) => { + const checked = prev.has(value); + const newSet = new Set(prev); + if (checked) { + newSet.delete(value); + } else { + newSet.add(value); + } + return newSet; + }); + }; + + const handleToggleAll = (checked: boolean) => { + if (!data) { + return; + } + if (checked) { + setChosenValues(new Set(filteredData.map(([value]) => value))); + } else { + setChosenValues(new Set()); + } + }; + + const handleApply = () => { + if (chosenValues.size === 0) { + column.setFilterValue(undefined); + return; + } + column.setFilterValue(Filter.select([...chosenValues])); + }; + + if (data) { + const allChecked = chosenValues.size === filteredData.length; + + dataTable = ( + <> + <Command className="text-sm outline-none" shouldFilter={false}> + <CommandInput + placeholder="Search" + autoFocus={true} + onValueChange={(value) => setQuery(value.trim())} + /> + <CommandEmpty>No results found.</CommandEmpty> + <CommandList className="border-b"> + {filteredData.length > 0 && ( + <CommandItem + value="__select-all__" + className="border-b rounded-none px-3" + onSelect={() => handleToggleAll(!allChecked)} + > + <Checkbox + checked={chosenValues.size === filteredData.length} + aria-label="Select all" + className="mr-3 h-3.5 w-3.5" + /> + <span className="font-bold flex-1">{column.id}</span> + <span className="font-bold">Count</span> + </CommandItem> + )} + {filteredData.map(([value, count], rowIndex) => { + const isSelected = chosenValues.has(value); + return ( + <CommandItem + key={rowIndex} + value={String(value)} + className="[&:not(:last-child)]:border-b rounded-none px-3" + onSelect={() => handleToggle(value)} + > + <Checkbox + checked={isSelected} + aria-label="Select row" + className="mr-3 h-3.5 w-3.5" + /> + <span className="flex-1 overflow-hidden max-h-20 line-clamp-3"> + {String(value)} + </span> + <span className="ml-3">{count}</span> + </CommandItem> + ); + })} + </CommandList> + {filteredData.length === TOP_K_ROWS && ( + <span className="text-xs text-muted-foreground mt-1.5 text-center"> + Only showing the top {TOP_K_ROWS} values + </span> + )} + </Command> + <FilterButtons + onApply={handleApply} + onClear={() => { + setChosenValues(new Set()); + }} + clearButtonDisabled={chosenValues.size === 0} + /> + </> + ); + } + + return ( + <DraggablePopover + open={true} + onOpenChange={(open) => !open && setIsFilterValueOpen(false)} + className="w-80 p-0" + > + <PopoverClose className="absolute top-2 right-2"> <Button - variant="linkDestructive" + variant="link" size="sm" - disabled={!hasFilter} - className="" - onClick={() => { - setValue(""); - column.setFilterValue(undefined); - }} + onClick={() => setIsFilterValueOpen(false)} > - Clear + <XIcon className="h-4 w-4" /> </Button> - </div> - </div> + </PopoverClose> + <div className="flex flex-col gap-1.5 py-2">{dataTable}</div> + </DraggablePopover> ); }; diff --git a/frontend/src/components/data-table/columns.tsx b/frontend/src/components/data-table/columns.tsx index fbb8d040987..448b1157114 100644 --- a/frontend/src/components/data-table/columns.tsx +++ b/frontend/src/components/data-table/columns.tsx @@ -2,10 +2,7 @@ "use no memo"; import type { ColumnDef } from "@tanstack/react-table"; -import { - DataTableColumnHeader, - DataTableColumnHeaderWithSummary, -} from "./column-header"; +import { DataTableColumnHeader } from "./column-header"; import { Checkbox } from "../ui/checkbox"; import { getMimeValues, MimeCell } from "./mime-cell"; import type { DataType } from "@/core/kernel/messages"; @@ -30,6 +27,7 @@ import { EmotionCacheProvider } from "../editor/output/EmotionCacheProvider"; import { PopoverClose } from "@radix-ui/react-popover"; import { Button } from "../ui/button"; import type { ColumnChartSpecModel } from "./chart-spec-model"; +import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin"; // Artificial limit to display long strings const MAX_STRING_LENGTH = 50; @@ -102,6 +100,7 @@ export function generateColumns<T>({ textJustifyColumns, wrappedColumns, showDataTypes, + calculateTopKRows, }: { rowHeaders: string[]; selection: DataTableSelection; @@ -110,6 +109,7 @@ export function generateColumns<T>({ textJustifyColumns?: Record<string, "left" | "center" | "right">; wrappedColumns?: string[]; showDataTypes?: boolean; + calculateTopKRows?: CalculateTopKRows; }): Array<ColumnDef<T>> { const rowHeadersSet = new Set(rowHeaders); @@ -179,20 +179,24 @@ export function generateColumns<T>({ </div> ); + const dataTableColumnHeader = ( + <DataTableColumnHeader + header={headerWithType} + column={column} + calculateTopKRows={calculateTopKRows} + /> + ); + // Row headers have no summaries if (rowHeadersSet.has(key)) { - return ( - <DataTableColumnHeader header={headerWithType} column={column} /> - ); + return dataTableColumnHeader; } return ( - <DataTableColumnHeaderWithSummary - key={key} - header={headerWithType} - column={column} - summary={<TableColumnSummary columnId={key} />} - /> + <div className="flex flex-col h-full pt-0.5 pb-3 justify-between items-start"> + {dataTableColumnHeader} + <TableColumnSummary columnId={key} /> + </div> ); }, diff --git a/frontend/src/components/data-table/filter-pills.tsx b/frontend/src/components/data-table/filter-pills.tsx index 82330a656cf..80ab6b91c51 100644 --- a/frontend/src/components/data-table/filter-pills.tsx +++ b/frontend/src/components/data-table/filter-pills.tsx @@ -28,7 +28,7 @@ export const FilterPills = <TData,>({ filters, table }: Props<TData>) => { } return ( - <Badge key={filter.id} variant="secondary"> + <Badge key={filter.id} variant="secondary" className="dark:invert"> {filter.id} {formattedValue}{" "} <span className="cursor-pointer opacity-60 hover:opacity-100 pl-1 py-[2px]" diff --git a/frontend/src/components/data-table/filters.ts b/frontend/src/components/data-table/filters.ts index ca0d058f9c3..d9930ecc43e 100644 --- a/frontend/src/components/data-table/filters.ts +++ b/frontend/src/components/data-table/filters.ts @@ -64,7 +64,7 @@ export const Filter = { value, } as const; }, - select(options: string[]) { + select(options: unknown[]) { return { type: "select", options, diff --git a/frontend/src/components/data-table/header-items.tsx b/frontend/src/components/data-table/header-items.tsx index 2bb15cfc70f..9af2dc0ebc7 100644 --- a/frontend/src/components/data-table/header-items.tsx +++ b/frontend/src/components/data-table/header-items.tsx @@ -20,11 +20,16 @@ import { PinOffIcon, CopyIcon, ChevronsUpDown, - ArrowDownNarrowWideIcon, ArrowDownWideNarrowIcon, + FilterX, + ArrowUpNarrowWideIcon, + ListFilterPlusIcon, + FunnelPlusIcon, + ListFilterIcon, } from "lucide-react"; import { copyToClipboard } from "@/utils/copy"; import { NAMELESS_COLUMN_PREFIX } from "./columns"; +import { Button } from "../ui/button"; export function renderFormatOptions<TData, TValue>( column: Column<TData, TValue>, @@ -150,7 +155,7 @@ export function renderCopyColumn<TData, TValue>(column: Column<TData, TValue>) { ); } -const AscIcon = ArrowDownNarrowWideIcon; +const AscIcon = ArrowUpNarrowWideIcon; const DescIcon = ArrowDownWideNarrowIcon; export function renderSorts<TData, TValue>(column: Column<TData, TValue>) { @@ -179,20 +184,28 @@ export function renderSorts<TData, TValue>(column: Column<TData, TValue>) { ); } -export function renderSortIcon<TData, TValue>(column: Column<TData, TValue>) { +export function renderSortFilterIcon<TData, TValue>( + column: Column<TData, TValue>, +) { if (!column.getCanSort()) { return null; } const isSorted = column.getIsSorted(); + const isFiltered = column.getFilterValue() !== undefined; - return isSorted === "desc" ? ( - <DescIcon className="h-3 w-3" /> - ) : isSorted === "asc" ? ( - <AscIcon className="h-3 w-3" /> - ) : ( - <ChevronsUpDown className="h-3 w-3" /> - ); + let Icon: React.FC<React.SVGProps<SVGSVGElement>>; + if (isFiltered && isSorted) { + Icon = ListFilterPlusIcon; + } else if (isFiltered) { + Icon = FunnelPlusIcon; + } else if (isSorted) { + Icon = isSorted === "desc" ? DescIcon : AscIcon; + } else { + Icon = ChevronsUpDown; + } + + return <Icon className="h-3 w-3" />; } export function renderDataType<TData, TValue>(column: Column<TData, TValue>) { @@ -210,3 +223,72 @@ export function renderDataType<TData, TValue>(column: Column<TData, TValue>) { </> ); } + +export const ClearFilterMenuItem = <TData, TValue>({ + column, +}: { + column: Column<TData, TValue>; +}) => ( + <DropdownMenuItem onClick={() => column.setFilterValue(undefined)}> + <FilterX className="mo-dropdown-icon" /> + Clear filter + </DropdownMenuItem> +); + +export function renderFilterByValues<TData, TValue>( + column: Column<TData, TValue>, + setIsFilterValueOpen: (open: boolean) => void, +) { + const canFilter = column.getCanFilter(); + if (!canFilter) { + return null; + } + + const columnType = column.columnDef.meta?.dataType; + // skip boolean as this can be easily filtered through normal filters + if (columnType === "boolean") { + return null; + } + + // there is not yet good support for filtering on lists, dicts, etc. + const filterType = column.columnDef.meta?.filterType; + if (!filterType) { + return null; + } + + return ( + <DropdownMenuSub> + <DropdownMenuItem onClick={() => setIsFilterValueOpen(true)}> + <ListFilterIcon className="mo-dropdown-icon" /> + Filter by values + </DropdownMenuItem> + </DropdownMenuSub> + ); +} + +export const FilterButtons = ({ + onApply, + onClear, + clearButtonDisabled, +}: { + onApply: () => void; + onClear: () => void; + clearButtonDisabled?: boolean; +}) => { + return ( + <div className="flex gap-2 px-2 justify-between"> + <Button variant="link" size="sm" onClick={onApply}> + Apply + </Button> + <Button + variant="linkDestructive" + size="sm" + className="" + onClick={onClear} + disabled={clearButtonDisabled} + > + Clear + </Button> + </div> + ); +}; diff --git a/frontend/src/components/datasets/icons.tsx b/frontend/src/components/datasets/icons.tsx index 922ccfb3c30..ff50ec74b05 100644 --- a/frontend/src/components/datasets/icons.tsx +++ b/frontend/src/components/datasets/icons.tsx @@ -11,17 +11,20 @@ import { ClockIcon, CurlyBracesIcon, } from "lucide-react"; +import type { SelectableDataType } from "../data-table/chart-transforms/types"; /** * Maps a data type to an icon. */ -export const DATA_TYPE_ICON: Record<DataType, LucideIcon> = { - boolean: ToggleLeftIcon, - date: CalendarIcon, - time: ClockIcon, - datetime: CalendarClockIcon, - number: HashIcon, - string: TypeIcon, - integer: HashIcon, - unknown: CurlyBracesIcon, -}; +export const DATA_TYPE_ICON: Record<DataType | SelectableDataType, LucideIcon> = + { + boolean: ToggleLeftIcon, + date: CalendarIcon, + time: ClockIcon, + datetime: CalendarClockIcon, + temporal: CalendarClockIcon, + number: HashIcon, + string: TypeIcon, + integer: HashIcon, + unknown: CurlyBracesIcon, + }; diff --git a/frontend/src/components/editor/app-container.tsx b/frontend/src/components/editor/app-container.tsx index fd51682c194..5f36c3488f4 100644 --- a/frontend/src/components/editor/app-container.tsx +++ b/frontend/src/components/editor/app-container.tsx @@ -34,6 +34,7 @@ export const AppContainer: React.FC<PropsWithChildren<Props>> = ({ data-config-width={width} data-connection-state={connectionState} className={cn( + "mathjax_ignore", connectionState === WebSocketState.CLOSED && "disconnected", "bg-background w-full h-full text-textColor", "flex flex-col overflow-y-auto", diff --git a/frontend/src/components/editor/cell/cell-actions.tsx b/frontend/src/components/editor/cell/cell-actions.tsx index 07eb7462ddd..3b51de40aa6 100644 --- a/frontend/src/components/editor/cell/cell-actions.tsx +++ b/frontend/src/components/editor/cell/cell-actions.tsx @@ -65,7 +65,11 @@ const CellActionsDropdownInternal = ( })); const content = ( - <PopoverContent className="w-[300px] p-0 pt-1" {...restoreFocus}> + <PopoverContent + className="w-[300px] p-0 pt-1 overflow-auto" + scrollable={true} + {...restoreFocus} + > <Command> <CommandInput placeholder="Search actions..." className="h-6 m-1" /> <CommandList> diff --git a/frontend/src/components/editor/cell/cell-context-menu.tsx b/frontend/src/components/editor/cell/cell-context-menu.tsx index 8adb725a008..c7bdf76b6c7 100644 --- a/frontend/src/components/editor/cell/cell-context-menu.tsx +++ b/frontend/src/components/editor/cell/cell-context-menu.tsx @@ -171,7 +171,7 @@ export const CellActionsContextMenu = ({ children, ...props }: Props) => { > {children} </ContextMenuTrigger> - <ContextMenuContent className="w-[300px]"> + <ContextMenuContent className="w-[300px]" scrollable={true}> {allActions.map((group, i) => ( <Fragment key={i}> {group.map((action) => { diff --git a/frontend/src/components/editor/cell/code/cell-editor.tsx b/frontend/src/components/editor/cell/code/cell-editor.tsx index 2a95e039c36..a596311a5d3 100644 --- a/frontend/src/components/editor/cell/code/cell-editor.tsx +++ b/frontend/src/components/editor/cell/code/cell-editor.tsx @@ -476,7 +476,7 @@ const CellCodeMirrorEditor = React.forwardRef( return ( <div - className={cn("cm", className)} + className={cn("cm mathjax_ignore", className)} ref={(r) => { if (ref) { mergeRefs(ref, internalRef)(r); diff --git a/frontend/src/components/editor/output/Outputs.css b/frontend/src/components/editor/output/Outputs.css index e8215b471d2..26bd4944a45 100644 --- a/frontend/src/components/editor/output/Outputs.css +++ b/frontend/src/components/editor/output/Outputs.css @@ -34,11 +34,17 @@ > pre, > ul, + > ol, + > blockquote, div.codehilite, /* Target <span> that is not empty and does not consist entirely of child elements; * this makes sure text with embedded HTML (such as a slider) has a max width, * but standalone elements (like tables, plots) don't get a max width. */ - > span.paragraph:not(:has(> :only-child)):not(:empty) { + > span.paragraph:not(:has(> :only-child)):not(:empty), + /* Target span.paragraph that only contains basic elements */ + > span.paragraph:not( + :has(> :not(code, a, strong, i, b, del, em, u, mark, sub, sup)) + ) { max-width: var(--markdown-max-width); } } diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx index 0c202fb97f0..4755b9bea96 100644 --- a/frontend/src/components/ui/accordion.tsx +++ b/frontend/src/components/ui/accordion.tsx @@ -41,8 +41,10 @@ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< React.ElementRef<typeof AccordionPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & { + wrapperClassName?: string; + } +>(({ className, children, wrapperClassName, ...props }, ref) => ( <AccordionPrimitive.Content ref={ref} className={cn( @@ -51,7 +53,7 @@ const AccordionContent = React.forwardRef< )} {...props} > - <div className="pb-4 pt-0">{children}</div> + <div className={cn("pb-4 pt-0", wrapperClassName)}>{children}</div> </AccordionPrimitive.Content> )); AccordionContent.displayName = AccordionPrimitive.Content.displayName; diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx index 17cdeca8da7..405a726b392 100644 --- a/frontend/src/components/ui/context-menu.tsx +++ b/frontend/src/components/ui/context-menu.tsx @@ -66,13 +66,26 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { + scrollable?: boolean; + } +>(({ className, scrollable = true, ...props }, ref) => ( <ContextMenuPortal> <StyleNamespace> <ContextMenuPrimitive.Content ref={ref} - className={cn(menuContentCommon(), contentCommon, className)} + className={cn( + menuContentCommon(), + contentCommon, + scrollable && "overflow-auto", + className, + )} + style={{ + ...props.style, + maxHeight: scrollable + ? "calc(var(--radix-context-menu-content-available-height) - 30px)" + : undefined, + }} {...props} /> </StyleNamespace> diff --git a/frontend/src/components/ui/draggable-popover.tsx b/frontend/src/components/ui/draggable-popover.tsx new file mode 100644 index 00000000000..ad91f517495 --- /dev/null +++ b/frontend/src/components/ui/draggable-popover.tsx @@ -0,0 +1,68 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import { useRef, useState } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { GripHorizontalIcon } from "lucide-react"; +import type * as PopoverPrimitive from "@radix-ui/react-popover"; + +interface DraggablePopoverProps extends PopoverPrimitive.PopoverProps { + children: React.ReactNode; + className?: string; +} + +export const DraggablePopover = ({ + children, + className, + ...props +}: DraggablePopoverProps) => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const dragStartPos = useRef({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + + const handleMouseDown = (e: React.MouseEvent) => { + dragStartPos.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + setIsDragging(true); + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + const handleMouseMove = (e: MouseEvent) => { + setPosition({ + x: e.clientX - dragStartPos.current.x, + y: e.clientY - dragStartPos.current.y, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + return ( + <Popover {...props}> + <PopoverTrigger /> + <PopoverContent + className={className} + style={{ + position: "fixed", + left: position.x, + top: position.y, + }} + > + <div + onMouseDown={handleMouseDown} + className={`flex items-center justify-center absolute top-0 left-1/2 -translate-x-1/2 ${ + isDragging ? "cursor-grabbing" : "cursor-grab" + }`} + > + <GripHorizontalIcon className="h-5 w-5 mt-1 text-muted-foreground/40" /> + </div> + {children} + </PopoverContent> + </Popover> + ); +}; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index a8bb93ea23d..dbeff13e153 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -61,8 +61,10 @@ DropdownMenuSubContent.displayName = const DropdownMenuContent = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> ->(({ className, sideOffset = 4, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { + scrollable?: boolean; + } +>(({ className, scrollable = true, sideOffset = 4, ...props }, ref) => ( <DropdownMenuPortal> <StyleNamespace> <DropdownMenuPrimitive.Content @@ -71,8 +73,15 @@ const DropdownMenuContent = React.forwardRef< className={cn( menuContentCommon(), "animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + scrollable && "overflow-auto", className, )} + style={{ + ...props.style, + maxHeight: scrollable + ? "calc(var(--radix-dropdown-menu-content-available-height) - 30px)" + : undefined, + }} {...props} /> </StyleNamespace> diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx index 3ee8d534069..53b7497f4e9 100644 --- a/frontend/src/components/ui/popover.tsx +++ b/frontend/src/components/ui/popover.tsx @@ -16,10 +16,18 @@ const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & { portal?: boolean; + scrollable?: boolean; } >( ( - { className, align = "center", sideOffset = 4, portal = true, ...props }, + { + className, + align = "center", + sideOffset = 4, + portal = true, + scrollable = false, + ...props + }, ref, ) => { const content = ( @@ -31,7 +39,14 @@ const PopoverContent = React.forwardRef< className={cn( "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, + scrollable && "overflow-auto", )} + style={{ + ...props.style, + maxHeight: scrollable + ? "calc(var(--radix-popover-content-available-height) - 30px)" + : undefined, + }} {...props} /> </StyleNamespace> diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index 0fe2c0ddbc4..ccd40374246 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -114,8 +114,10 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { + subtitle?: React.ReactNode; // Subtitle is not displayed in input field + } +>(({ className, children, subtitle, ...props }, ref) => ( <SelectPrimitive.Item ref={ref} className={cn( @@ -136,6 +138,7 @@ const SelectItem = React.forwardRef< > {children} </SelectPrimitive.ItemText> + {subtitle} </SelectPrimitive.Item> )); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/frontend/src/core/cells/ids.ts b/frontend/src/core/cells/ids.ts index 8bb33d2f3b8..2bd1653f63a 100644 --- a/frontend/src/core/cells/ids.ts +++ b/frontend/src/core/cells/ids.ts @@ -47,6 +47,32 @@ export const HTMLCellId = { findElement(element: Element): (Element & { id: HTMLCellId }) | null { return element.closest('div[id^="cell-"]'); }, + + /** + * Find the cell element through shadow DOMs. + */ + findElementThroughShadowDOMs( + element: Element, + ): (Element & { id: HTMLCellId }) | null { + let currentElement: Element | null = element; + + while (currentElement) { + const cellElement = HTMLCellId.findElement(currentElement); + if (cellElement) { + return cellElement; + } + + const root = currentElement.getRootNode(); + currentElement = + root instanceof ShadowRoot ? root.host : currentElement.parentElement; + + if (currentElement === root) { + break; + } + } + + return null; + }, }; /** diff --git a/frontend/src/core/codemirror/__tests__/format.test.ts b/frontend/src/core/codemirror/__tests__/format.test.ts new file mode 100644 index 00000000000..eae35994543 --- /dev/null +++ b/frontend/src/core/codemirror/__tests__/format.test.ts @@ -0,0 +1,224 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorView } from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import { python } from "@codemirror/lang-python"; +import type { CellId } from "@/core/cells/ids"; +import { formatEditorViews, formatAll, formatSQL } from "../format"; +import { + adaptiveLanguageConfiguration, + switchLanguage, +} from "../language/extension"; +import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys"; +import { sendFormat } from "@/core/network/requests"; +import { getNotebook } from "@/core/cells/cells"; +import { notebookCellEditorViews } from "@/core/cells/utils"; +import { getResolvedMarimoConfig } from "@/core/config/config"; +import type { NotebookState } from "@/core/cells/cells"; +import { cellActionsState, type CodemirrorCellActions } from "../cells/state"; +import type { MarimoConfig } from "@/core/network/types"; +import { cellIdState } from "../config/extension"; + +vi.mock("@/core/network/requests", () => ({ + sendFormat: vi.fn(), +})); + +vi.mock("@/core/cells/cells", () => ({ + getNotebook: vi.fn(), +})); + +vi.mock("@/core/cells/utils", () => ({ + notebookCellEditorViews: vi.fn(), +})); + +vi.mock("@/core/config/config", () => ({ + getResolvedMarimoConfig: vi.fn(), +})); + +const updateCellCode = vi.fn(); + +function createEditor(content: string, cellId: CellId) { + const state = EditorState.create({ + doc: content, + extensions: [ + python(), + adaptiveLanguageConfiguration({ + cellId, + completionConfig: { + activate_on_typing: true, + copilot: false, + codeium_api_key: null, + }, + hotkeys: new OverridingHotkeyProvider({}), + placeholderType: "marimo-import", + lspConfig: {}, + }), + cellIdState.of(cellId), + cellActionsState.of({ + updateCellCode, + } as unknown as CodemirrorCellActions), + ], + }); + + const view = new EditorView({ + state, + parent: document.body, + }); + + return view; +} + +const mockConfig = { + formatting: { line_length: 88 }, +} as MarimoConfig; + +beforeEach(() => { + updateCellCode.mockClear(); +}); + +describe("format", () => { + describe("formatEditorViews", () => { + it("should format code in editor views", async () => { + const cellId1 = "1" as CellId; + const cellId2 = "2" as CellId; + const views = { + [cellId1]: createEditor("import numpy as np", cellId1), + [cellId2]: createEditor("import pandas as pd", cellId2), + }; + + const formattedCode1 = "import numpy as np"; + const formattedCode2 = "import pandas as pd"; + + vi.mocked(sendFormat).mockResolvedValueOnce({ + codes: { + [cellId1]: formattedCode1, + [cellId2]: formattedCode2, + }, + }); + + vi.mocked(getResolvedMarimoConfig).mockReturnValueOnce(mockConfig); + + await formatEditorViews(views); + + expect(sendFormat).toHaveBeenCalledWith({ + codes: { + [cellId1]: "import numpy as np", + [cellId2]: "import pandas as pd", + }, + lineLength: 88, + }); + + expect(views[cellId1].state.doc.toString()).toBe(formattedCode1); + expect(views[cellId2].state.doc.toString()).toBe(formattedCode2); + expect(updateCellCode).toHaveBeenCalledWith({ + cellId: cellId1, + code: formattedCode1, + formattingChange: true, + }); + expect(updateCellCode).toHaveBeenCalledWith({ + cellId: cellId2, + code: formattedCode2, + formattingChange: true, + }); + }); + + it("should not update editor if formatted code is same as original", async () => { + const cellId = "1" as CellId; + const originalCode = "import numpy as np"; + const views = { + [cellId]: createEditor(originalCode, cellId), + }; + + vi.mocked(sendFormat).mockResolvedValueOnce({ + codes: { + [cellId]: originalCode, + }, + }); + + vi.mocked(getResolvedMarimoConfig).mockReturnValueOnce(mockConfig); + + await formatEditorViews(views); + + expect(views[cellId].state.doc.toString()).toBe(originalCode); + expect(updateCellCode).not.toHaveBeenCalled(); + }); + }); + + describe("formatAll", () => { + it("should format all cells in notebook", async () => { + const cellId1 = "1" as CellId; + const cellId2 = "2" as CellId; + const views = { + [cellId1]: createEditor("import numpy as np", cellId1), + [cellId2]: createEditor("import pandas as pd", cellId2), + }; + + vi.mocked(getNotebook).mockReturnValueOnce({} as NotebookState); + vi.mocked(notebookCellEditorViews).mockReturnValueOnce(views); + vi.mocked(sendFormat).mockResolvedValueOnce({ + codes: { + [cellId1]: "import numpy as np", + [cellId2]: "import pandas as pd", + }, + }); + + vi.mocked(getResolvedMarimoConfig).mockReturnValueOnce(mockConfig); + + await formatAll(); + + expect(sendFormat).toHaveBeenCalledWith({ + codes: { + [cellId1]: "import numpy as np", + [cellId2]: "import pandas as pd", + }, + lineLength: 88, + }); + + expect(updateCellCode).toHaveBeenCalledWith({ + cellId: cellId1, + code: "import numpy as np", + formattingChange: true, + }); + }); + }); + + describe("formatSQL", () => { + it("should format SQL code", async () => { + const cellId = "1" as CellId; + const editor = createEditor("SELECT * FROM table WHERE id = 1", cellId); + switchLanguage(editor, "sql"); + + await formatSQL(editor); + + // Check that the SQL was formatted + expect(editor.state.doc.toString()).toMatchInlineSnapshot(` + "SELECT + * + FROM + table + WHERE + id = 1" + `); + expect(updateCellCode).toHaveBeenCalledWith({ + cellId: cellId, + code: editor.state.doc.toString(), + formattingChange: true, + }); + }); + + it("should not format if language adapter is not SQL", async () => { + const cellId = "1" as CellId; + const editor = createEditor("SELECT * FROM table WHERE id = 1", cellId); + switchLanguage(editor, "python"); + + await formatSQL(editor); + + // Check that the SQL was not formatted + expect(editor.state.doc.toString()).toBe( + "SELECT * FROM table WHERE id = 1", + ); + expect(updateCellCode).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/core/codemirror/format.ts b/frontend/src/core/codemirror/format.ts index 04dd1c4ea91..071c9d3a6da 100644 --- a/frontend/src/core/codemirror/format.ts +++ b/frontend/src/core/codemirror/format.ts @@ -13,6 +13,10 @@ import { import { StateEffect } from "@codemirror/state"; import { getResolvedMarimoConfig } from "../config/config"; import { cellActionsState } from "./cells/state"; +import { languageAdapterState } from "./language/extension"; +import { Logger } from "@/utils/Logger"; +import { cellIdState } from "./config/extension"; +import { getIndentUnit } from "@codemirror/language"; export const formattingChangeEffect = StateEffect.define<boolean>(); @@ -62,3 +66,51 @@ export function formatAll() { const views = notebookCellEditorViews(getNotebook()); return formatEditorViews(views); } + +/** + * Format the SQL code in the editor view. + * + * This is currently only used by explicitly clicking the format button. + * We do not use it for auto-formatting onSave or globally because + * SQL formatting is much more opinionated than Python formatting, and we + * don't want to tie the two together (just yet). + */ +export async function formatSQL(editor: EditorView) { + // Lazy import sql-formatter + const { formatDialect, duckdb } = await import("sql-formatter"); + + // Get language adapter + const languageAdapter = editor.state.field(languageAdapterState); + const tabWidth = getIndentUnit(editor.state); + if (languageAdapter.type !== "sql") { + Logger.error("Language adapter is not SQL"); + return; + } + + const codeAsSQL = editor.state.doc.toString(); + const formattedSQL = formatDialect(codeAsSQL, { + dialect: duckdb, + tabWidth: tabWidth, + useTabs: false, + }); + + // Update Python in the notebook state + const codeAsPython = languageAdapter.transformIn(formattedSQL)[0]; + const actions = editor.state.facet(cellActionsState); + const cellId = editor.state.facet(cellIdState); + actions.updateCellCode({ + cellId, + code: codeAsPython, + formattingChange: true, + }); + + // Update editor with formatted SQL + editor.dispatch({ + changes: { + from: 0, + to: editor.state.doc.length, + insert: formattedSQL, + }, + effects: [formattingChangeEffect.of(true)], + }); +} diff --git a/frontend/src/core/codemirror/language/panel.tsx b/frontend/src/core/codemirror/language/panel.tsx index 4d102d147a6..59236995ba1 100644 --- a/frontend/src/core/codemirror/language/panel.tsx +++ b/frontend/src/core/codemirror/language/panel.tsx @@ -10,7 +10,7 @@ import { INTERNAL_SQL_ENGINES, } from "@/core/datasets/data-source-connections"; import { useAtomValue } from "jotai"; -import { AlertCircle, CircleHelpIcon } from "lucide-react"; +import { AlertCircle, CircleHelpIcon, PaintRollerIcon } from "lucide-react"; import { Select, SelectContent, @@ -25,6 +25,11 @@ import { DatabaseLogo } from "@/components/databases/icon"; import { transformDisplayName } from "@/components/databases/display"; import { useNonce } from "@/hooks/useNonce"; import type { DataSourceConnection } from "@/core/kernel/messages"; +import { formatSQL } from "../format"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipProvider } from "@/components/ui/tooltip"; + +const Divider = () => <div className="h-4 border-r border-border" />; export const LanguagePanelComponent: React.FC<{ view: EditorView; @@ -83,27 +88,43 @@ export const LanguagePanelComponent: React.FC<{ languageAdapter={languageAdapter} onChange={triggerUpdate} /> - <label className="flex items-center gap-2 ml-auto"> - <input - type="checkbox" - onChange={(e) => { - languageAdapter.setShowOutput(!e.target.checked); - triggerUpdate(); - }} - checked={!languageAdapter.showOutput} - /> - <span className="select-none">Hide output</span> - </label> + <div className="flex items-center gap-2 ml-auto"> + <Tooltip content="Format SQL"> + <Button + variant="text" + size="icon" + onClick={async () => { + await formatSQL(view); + }} + > + <PaintRollerIcon className="h-3 w-3" /> + </Button> + </Tooltip> + <Divider /> + <label className="flex items-center gap-2"> + <input + type="checkbox" + onChange={(e) => { + languageAdapter.setShowOutput(!e.target.checked); + triggerUpdate(); + }} + checked={!languageAdapter.showOutput} + /> + <span className="select-none">Hide output</span> + </label> + </div> </div> ); } return ( - <div className="flex justify-between items-center gap-4 pl-2 pt-2"> - {actions} - {showDivider && <div className="h-4 border-r border-border" />} - {languageAdapter.type} - </div> + <TooltipProvider> + <div className="flex justify-between items-center gap-4 pl-2 pt-2"> + {actions} + {showDivider && <Divider />} + {languageAdapter.type} + </div> + </TooltipProvider> ); }; @@ -190,6 +211,7 @@ const SQLEngineSelect: React.FC<SelectProps> = ({ className="flex items-center gap-1" href={HELP_URL} target="_blank" + rel="noreferrer" > <CircleHelpIcon className="h-3 w-3" /> <span>How to add a database connection</span> diff --git a/frontend/src/css/app/Cell.css b/frontend/src/css/app/Cell.css index 867837c4432..19b2979ff01 100644 --- a/frontend/src/css/app/Cell.css +++ b/frontend/src/css/app/Cell.css @@ -42,6 +42,12 @@ overflow: auto; } + /* Special case for particular components */ + .output-area:has(> .output > marimo-ui-element > marimo-table) { + max-height: none; + overflow: hidden; + } + & > :first-child { border-top-left-radius: 9px; border-top-right-radius: 9px; diff --git a/frontend/src/css/common.css b/frontend/src/css/common.css index 1c95950609f..e5e1a8cd1d9 100644 --- a/frontend/src/css/common.css +++ b/frontend/src/css/common.css @@ -36,3 +36,8 @@ .mo-dropdown-icon { @apply mr-2 h-3.5 w-3.5 text-muted-foreground/70; } + +.scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} diff --git a/frontend/src/plugins/impl/DataTablePlugin.tsx b/frontend/src/plugins/impl/DataTablePlugin.tsx index c8fdc22a804..6570db3edbb 100644 --- a/frontend/src/plugins/impl/DataTablePlugin.tsx +++ b/frontend/src/plugins/impl/DataTablePlugin.tsx @@ -50,8 +50,8 @@ import { filterToFilterCondition, type ColumnFilterValue, } from "@/components/data-table/filters"; -import { vegaLoadData } from "./vega/loader"; import { isStaticNotebook } from "@/core/static/static-state"; +import { vegaLoadData } from "./vega/loader"; type CsvURL = string; type TableData<T> = T[] | CsvURL; @@ -72,6 +72,13 @@ export type GetDataUrl = (opts: {}) => Promise<{ format: "csv" | "json" | "arrow"; }>; +export type CalculateTopKRows = <T>(req: { + column: string; + k: number; +}) => Promise<{ + data: Array<[unknown, number]>; +}>; + /** * Arguments for a data table * @@ -119,6 +126,7 @@ type DataTableFunctions = { }>; get_data_url?: GetDataUrl; get_row_ids?: GetRowIds; + calculate_top_k_rows?: CalculateTopKRows; }; type S = Array<number | string | { rowId: string; columnName?: string }>; @@ -226,6 +234,13 @@ export const DataTablePlugin = createPlugin<S>("marimo-table") format: z.enum(["csv", "json", "arrow"]), }), ), + calculate_top_k_rows: rpc + .input(z.object({ column: z.string(), k: z.number() })) + .output( + z.object({ + data: z.array(z.tuple([z.any(), z.number()])), + }), + ), }) .renderer((props) => { return ( @@ -287,6 +302,8 @@ interface DataTableProps<T> extends Data<T>, DataTableFunctions { chartsFeatureEnabled?: boolean; } +export type SetFilters = OnChangeFn<ColumnFiltersState>; + interface DataTableSearchProps { // Pagination paginationState: PaginationState; @@ -300,7 +317,7 @@ interface DataTableSearchProps { reloading: boolean; // Filters filters?: ColumnFiltersState; - setFilters?: OnChangeFn<ColumnFiltersState>; + setFilters?: SetFilters; hasStableRowId: boolean; } @@ -403,7 +420,6 @@ export const LoadingDataTableComponent = memo( totalRows = searchResults.total_rows; cellStyles = searchResults.cell_styles || {}; } - // If we already have the data, return it if (Array.isArray(tableData)) { return { @@ -575,6 +591,7 @@ const DataTableComponent = ({ cellStyles, toggleDisplayHeader, chartsFeatureEnabled, + calculate_top_k_rows, }: DataTableProps<unknown> & DataTableSearchProps & { data: unknown[]; @@ -618,6 +635,7 @@ const DataTableComponent = ({ wrappedColumns: memoizedWrappedColumns, // Only show data types if they are explicitly set showDataTypes: showDataTypes, + calculateTopKRows: calculate_top_k_rows, }), [ selection, @@ -627,6 +645,7 @@ const DataTableComponent = ({ memoizedFieldTypes, memoizedTextJustifyColumns, memoizedWrappedColumns, + calculate_top_k_rows, ], ); diff --git a/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx b/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx index 574a81a8e4c..7496acca21b 100644 --- a/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx +++ b/frontend/src/plugins/impl/plotly/PlotlyPlugin.tsx @@ -112,7 +112,7 @@ export const PlotlyComponent = memo( // Used for rendering LaTeX. TODO: Serve this library from Marimo const scriptStatus = useScript( - "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-MML-AM_CHTML", + "https://cdn.jsdelivr.net/npm/mathjax-full@3.2.2/es5/tex-mml-svg.min.js", ); const isScriptLoaded = scriptStatus === "ready"; diff --git a/frontend/src/plugins/impl/vega/__tests__/__snapshots__/make-selectable.test.ts.snap b/frontend/src/plugins/impl/vega/__tests__/__snapshots__/make-selectable.test.ts.snap index 5d34c5f13a3..6747ff56dd6 100644 --- a/frontend/src/plugins/impl/vega/__tests__/__snapshots__/make-selectable.test.ts.snap +++ b/frontend/src/plugins/impl/vega/__tests__/__snapshots__/make-selectable.test.ts.snap @@ -1061,6 +1061,22 @@ exports[`makeSelectable > should work for multi-layered charts with different se }, { "encoding": { + "opacity": { + "condition": { + "test": { + "and": [ + { + "param": "select_point_1", + }, + { + "param": "select_interval_1", + }, + ], + }, + "value": 1, + }, + "value": 0.2, + }, "text": { "aggregate": "min", "field": "temp_min", @@ -1079,12 +1095,61 @@ exports[`makeSelectable > should work for multi-layered charts with different se }, "mark": { "align": "right", + "cursor": "pointer", "dx": -5, + "tooltip": true, "type": "text", }, + "params": [ + { + "name": "select_point_1", + "select": { + "encodings": [ + "x", + "y", + ], + "on": "click[!event.metaKey]", + "type": "point", + }, + }, + { + "name": "select_interval_1", + "select": { + "encodings": [ + "x", + "y", + ], + "mark": { + "fill": "#669EFF", + "fillOpacity": 0.07, + "stroke": "#669EFF", + "strokeOpacity": 0.4, + }, + "on": "[mousedown[!event.metaKey], mouseup] > mousemove[!event.metaKey]", + "translate": "[mousedown[!event.metaKey], mouseup] > mousemove[!event.metaKey]", + "type": "interval", + }, + }, + ], }, { "encoding": { + "opacity": { + "condition": { + "test": { + "and": [ + { + "param": "select_point_2", + }, + { + "param": "select_interval_2", + }, + ], + }, + "value": 1, + }, + "value": 0.2, + }, "text": { "aggregate": "max", "field": "temp_max", @@ -1103,9 +1168,42 @@ exports[`makeSelectable > should work for multi-layered charts with different se }, "mark": { "align": "left", + "cursor": "pointer", "dx": 5, + "tooltip": true, "type": "text", }, + "params": [ + { + "name": "select_point_2", + "select": { + "encodings": [ + "x", + "y", + ], + "on": "click[!event.metaKey]", + "type": "point", + }, + }, + { + "name": "select_interval_2", + "select": { + "encodings": [ + "x", + "y", + ], + "mark": { + "fill": "#669EFF", + "fillOpacity": 0.07, + "stroke": "#669EFF", + "strokeOpacity": 0.4, + }, + "on": "[mousedown[!event.metaKey], mouseup] > mousemove[!event.metaKey]", + "translate": "[mousedown[!event.metaKey], mouseup] > mousemove[!event.metaKey]", + "type": "interval", + }, + }, + ], }, ], } diff --git a/frontend/src/plugins/impl/vega/__tests__/make-selectable.test.ts b/frontend/src/plugins/impl/vega/__tests__/make-selectable.test.ts index e620160aa6f..e2b0e3873df 100644 --- a/frontend/src/plugins/impl/vega/__tests__/make-selectable.test.ts +++ b/frontend/src/plugins/impl/vega/__tests__/make-selectable.test.ts @@ -354,6 +354,10 @@ describe("makeSelectable", () => { "select_point_0", "select_interval_0", "pan_zoom", + "select_point_1", + "select_interval_1", + "select_point_2", + "select_interval_2", ]); }); diff --git a/frontend/src/plugins/impl/vega/make-selectable.ts b/frontend/src/plugins/impl/vega/make-selectable.ts index 57eab2d0363..646897cd475 100644 --- a/frontend/src/plugins/impl/vega/make-selectable.ts +++ b/frontend/src/plugins/impl/vega/make-selectable.ts @@ -141,8 +141,8 @@ function makeChartSelectable( return spec; } - // We don't do anything if the mark is text or geoshape - if (mark === "geoshape" || mark === "text") { + // We don't do anything if the mark is geoshape + if (mark === "geoshape") { return spec; } @@ -214,15 +214,7 @@ function makeChartInteractive<T extends GenericVegaSpec>(spec: T): T { return spec; } - let mark: Mark; - try { - mark = Marks.getMarkType(spec.mark); - } catch { - return spec; - } - - // We don't do anything if the mark is text - if (mark === "text") { + if (!Marks.isInteractive(spec.mark)) { return spec; } @@ -240,10 +232,10 @@ function makeChartInteractive<T extends GenericVegaSpec>(spec: T): T { function getBestSelectionForMark(mark: Mark): SelectionType[] | undefined { switch (mark) { - case "text": case "arc": case "area": return ["point"]; + case "text": case "bar": return ["point", "interval"]; // there is no best selection for line diff --git a/lsp/package.json b/lsp/package.json index 825a6623987..5d1a0fe2eb4 100644 --- a/lsp/package.json +++ b/lsp/package.json @@ -17,7 +17,7 @@ "jsonrpc-ws-proxy": "^0.0.5", "minimist": "^1.2.8", "tsup": "^8.4.0", - "typescript": "^5.8.2", + "typescript": "^5.8.3", "ws": "^8.18.1" }, "packageManager": "pnpm@9.15.9" diff --git a/lsp/pnpm-lock.yaml b/lsp/pnpm-lock.yaml index 1db872d5de8..e77b38d62a9 100644 --- a/lsp/pnpm-lock.yaml +++ b/lsp/pnpm-lock.yaml @@ -32,10 +32,10 @@ importers: version: 1.2.8 tsup: specifier: ^8.4.0 - version: 8.4.0(typescript@5.8.2) + version: 8.4.0(typescript@5.8.3) typescript: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.8.3 + version: 5.8.3 ws: specifier: ^8.18.1 version: 8.18.1 @@ -645,8 +645,8 @@ packages: typescript: optional: true - typescript@5.8.2: - resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -1164,7 +1164,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.4.0(typescript@5.8.2): + tsup@8.4.0(typescript@5.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.0) cac: 6.7.14 @@ -1183,14 +1183,14 @@ snapshots: tinyglobby: 0.2.12 tree-kill: 1.2.2 optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - jiti - supports-color - tsx - yaml - typescript@5.8.2: {} + typescript@5.8.3: {} undici-types@6.19.8: {} diff --git a/marimo/__init__.py b/marimo/__init__.py index 2b3af8031b8..94a8939cdba 100644 --- a/marimo/__init__.py +++ b/marimo/__init__.py @@ -82,7 +82,7 @@ "video", "vstack", ] -__version__ = "0.13.0" +__version__ = "0.13.1" import marimo._ai as ai import marimo._islands as islands diff --git a/marimo/_cli/sandbox.py b/marimo/_cli/sandbox.py index bccebaf557a..a7813159eb5 100644 --- a/marimo/_cli/sandbox.py +++ b/marimo/_cli/sandbox.py @@ -195,6 +195,7 @@ def maybe_prompt_run_in_sandbox(name: str | None) -> bool: bold=True, ), default=True, + err=True, ) else: echo( @@ -322,6 +323,8 @@ def construct_uv_command( # sandboxed notebook shouldn't pick up existing pyproject.toml, # which may conflict with the sandbox requirements "--no-project", + # trade installation time for faster start time + "--compile-bytecode", "--with-requirements", temp_file_path, ] @@ -372,7 +375,7 @@ def run_in_sandbox( args, name, additional_features or [], additional_deps or [] ) - echo(f"Running in a sandbox: {muted(' '.join(uv_cmd))}") + echo(f"Running in a sandbox: {muted(' '.join(uv_cmd))}", err=True) env = os.environ.copy() env["MARIMO_MANAGE_SCRIPT_METADATA"] = "true" @@ -382,10 +385,14 @@ def run_in_sandbox( def handler(sig: int, frame: Any) -> None: del sig del frame - if sys.platform == "win32": - os.kill(process.pid, signal.CTRL_C_EVENT) - else: - os.kill(process.pid, signal.SIGINT) + try: + if sys.platform == "win32": + os.kill(process.pid, signal.CTRL_C_EVENT) + else: + os.kill(process.pid, signal.SIGINT) + except ProcessLookupError: + # Process may have already been terminated. + pass signal.signal(signal.SIGINT, handler) diff --git a/marimo/_output/formatters/altair_formatters.py b/marimo/_output/formatters/altair_formatters.py index 96ebf260db0..543a46e7f5b 100644 --- a/marimo/_output/formatters/altair_formatters.py +++ b/marimo/_output/formatters/altair_formatters.py @@ -2,13 +2,16 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from marimo._config.config import Theme from marimo._messaging.mimetypes import KnownMimeType, MimeBundleOrTuple from marimo._output.formatters.formatter_factory import FormatterFactory from marimo._plugins.core.media import io_to_data_url from marimo._plugins.ui._impl.altair_chart import maybe_make_full_width +from marimo._plugins.ui._impl.charts.altair_transformer import ( + sanitize_nan_infs, +) if TYPE_CHECKING: import altair @@ -73,7 +76,7 @@ def _show_chart(chart: altair.Chart) -> tuple[KnownMimeType, str]: if alt.data_transformers.active.startswith("vegafusion"): return ( "application/vnd.vega.v5+json", - chart.to_json(format="vega"), + chart_to_json(chart=chart, spec_format="vega"), ) # If the user has not set the max_rows option, we set it to 20_000 @@ -88,7 +91,7 @@ def _show_chart(chart: altair.Chart) -> tuple[KnownMimeType, str]: # Return the chart as a vega-lite chart with embed options return ( "application/vnd.vegalite.v5+json", - chart.to_json(validate=False), + chart_to_json(chart=chart, validate=False), ) def apply_theme(self, theme: Theme) -> None: @@ -118,3 +121,23 @@ def _apply_embed_options(chart: altair.Chart) -> altair.Chart: }, } return chart + + +def chart_to_json( + chart: altair.Chart, + spec_format: Literal["vega", "vega-lite"] = "vega-lite", + validate: bool = True, +) -> str: + """ + Convert an altair chart to a JSON string. + + This function is a wrapper around the altair.Chart.to_json method. + It sanitizes the data in the chart if necessary and validates the spec. + """ + try: + return chart.to_json( + format=spec_format, validate=validate, allow_nan=False + ) + except ValueError: + chart.data = sanitize_nan_infs(chart.data) + return chart.to_json(format=spec_format, validate=validate) diff --git a/marimo/_output/formatters/df_formatters.py b/marimo/_output/formatters/df_formatters.py index 5e05ef49707..6ea71f674b0 100644 --- a/marimo/_output/formatters/df_formatters.py +++ b/marimo/_output/formatters/df_formatters.py @@ -6,7 +6,7 @@ from marimo._output.formatters.formatter_factory import FormatterFactory from marimo._output.md import md from marimo._plugins.ui._impl import tabs -from marimo._plugins.ui._impl.table import table +from marimo._plugins.ui._impl.table import get_default_table_page_size, table LOGGER = _loggers.marimo_logger() @@ -33,43 +33,44 @@ def register(self) -> None: from marimo._output import formatting - if include_opinionated(): + if not include_opinionated(): + return - @formatting.opinionated_formatter(pl.DataFrame) - def _show_marimo_dataframe( - df: pl.DataFrame, - ) -> tuple[KnownMimeType, str]: - try: - return table(df, selection=None, pagination=True)._mime_() - except Exception as e: - LOGGER.warning("Failed to format DataFrame: %s", e) - return ("text/html", df._repr_html_()) - - @formatting.opinionated_formatter(pl.Series) - def _show_marimo_series( - series: pl.Series, - ) -> tuple[KnownMimeType, str]: - try: - # Table need a column name for operations - if series.name is None or series.name == "": - df = pl.DataFrame({"value": series}) - else: - df = series.to_frame() - return table(df, selection=None, pagination=True)._mime_() - except Exception as e: - LOGGER.warning("Failed to format Series: %s", e) - return ("text/html", series._repr_html_()) - - @formatting.opinionated_formatter(pl.LazyFrame) - def _show_marimo_lazyframe( - df: pl.LazyFrame, - ) -> tuple[KnownMimeType, str]: - return tabs.tabs( - { - "Table": table.lazy(df), - "Query plan": md(df._repr_html_()), - } - )._mime_() + @formatting.opinionated_formatter(pl.DataFrame) + def _show_marimo_dataframe( + df: pl.DataFrame, + ) -> tuple[KnownMimeType, str]: + try: + return table(df, selection=None, pagination=True)._mime_() + except Exception as e: + LOGGER.warning("Failed to format DataFrame: %s", e) + return ("text/html", df._repr_html_()) + + @formatting.opinionated_formatter(pl.Series) + def _show_marimo_series( + series: pl.Series, + ) -> tuple[KnownMimeType, str]: + try: + # Table need a column name for operations + if series.name is None or series.name == "": + df = pl.DataFrame({"value": series}) + else: + df = series.to_frame() + return table(df, selection=None, pagination=True)._mime_() + except Exception as e: + LOGGER.warning("Failed to format Series: %s", e) + return ("text/html", series._repr_html_()) + + @formatting.opinionated_formatter(pl.LazyFrame) + def _show_marimo_lazyframe( + df: pl.LazyFrame, + ) -> tuple[KnownMimeType, str]: + return tabs.tabs( + { + "Table": table.lazy(df), + "Query plan": md(df._repr_html_()), + } + )._mime_() class PyArrowFormatter(FormatterFactory): @@ -82,10 +83,61 @@ def register(self) -> None: from marimo._output import formatting - if include_opinionated(): + if not include_opinionated(): + return + + @formatting.opinionated_formatter(pa.Table) + def _show_marimo_dataframe( + df: pa.Table, + ) -> tuple[KnownMimeType, str]: + return table(df, selection=None, pagination=True)._mime_() + + +class PySparkFormatter(FormatterFactory): + @staticmethod + def package_name() -> str: + return "pyspark" - @formatting.opinionated_formatter(pa.Table) - def _show_marimo_dataframe( - df: pa.Table, + def register(self) -> None: + try: + from pyspark.sql.connect.dataframe import ( # type: ignore[import-not-found] + DataFrame as pyspark_connect_DataFrame, + ) + except (ImportError, ModuleNotFoundError): + pyspark_connect_DataFrame = None + + try: + from pyspark.sql.dataframe import ( # type: ignore[import-not-found] + DataFrame as pyspark_DataFrame, + ) + except (ImportError, ModuleNotFoundError): + pyspark_DataFrame = None + + from marimo._output import formatting + + if not include_opinionated(): + return + + if pyspark_connect_DataFrame is not None: + + @formatting.opinionated_formatter(pyspark_connect_DataFrame) + def _show_connect_df( + df: pyspark_connect_DataFrame, ) -> tuple[KnownMimeType, str]: - return table(df, selection=None, pagination=True)._mime_() + # pyspark.sql.connect.dataframe.DataFrame is not yet supported by narwhals + # so we convert it to Arrow. + # NOTE: this is no longer lazy, but will only load the first page of data + # See https://github.com/narwhals-dev/narwhals/issues/2189 + return table( + df.limit(get_default_table_page_size()).toArrow(), + selection=None, + pagination=False, + _internal_lazy=True, + _internal_preload=True, + )._mime_() + + if pyspark_DataFrame is not None: + + @formatting.opinionated_formatter(pyspark_DataFrame) + def _show_df(df: pyspark_DataFrame) -> tuple[KnownMimeType, str]: + return table.lazy(df)._mime_() diff --git a/marimo/_output/formatters/formatters.py b/marimo/_output/formatters/formatters.py index 7de37ced0ef..f13ac84ab96 100644 --- a/marimo/_output/formatters/formatters.py +++ b/marimo/_output/formatters/formatters.py @@ -18,6 +18,7 @@ from marimo._output.formatters.df_formatters import ( PolarsFormatter, PyArrowFormatter, + PySparkFormatter, ) from marimo._output.formatters.formatter_factory import FormatterFactory from marimo._output.formatters.holoviews_formatters import HoloViewsFormatter @@ -48,6 +49,7 @@ PandasFormatter.package_name(): PandasFormatter(), PolarsFormatter.package_name(): PolarsFormatter(), PyArrowFormatter.package_name(): PyArrowFormatter(), + PySparkFormatter.package_name(): PySparkFormatter(), PygWalkerFormatter.package_name(): PygWalkerFormatter(), PlotlyFormatter.package_name(): PlotlyFormatter(), SeabornFormatter.package_name(): SeabornFormatter(), diff --git a/marimo/_output/formatters/tqdm_formatters.py b/marimo/_output/formatters/tqdm_formatters.py index 9b7dc645677..a6b5b50a5d1 100644 --- a/marimo/_output/formatters/tqdm_formatters.py +++ b/marimo/_output/formatters/tqdm_formatters.py @@ -30,6 +30,15 @@ def __init__(self, *args: Any, **kwargs: Any): total=total, ) + def update(self, n: int = 1) -> None: + """Update the progress bar by incrementing it by n. + + Args: + n (int, optional): Number of iterations to increment by. Defaults to 1. + """ + if hasattr(self, "progress") and self.progress is not None: + self.progress.update(increment=n) + class TqdmFormatter(FormatterFactory): @staticmethod diff --git a/marimo/_plugins/core/json_encoder.py b/marimo/_plugins/core/json_encoder.py index 313c05d98b1..b70eeebb9b3 100644 --- a/marimo/_plugins/core/json_encoder.py +++ b/marimo/_plugins/core/json_encoder.py @@ -64,8 +64,8 @@ def _convert_to_json(self, o: Any) -> Any: if isinstance(o, (dict, list)): return o - # Handle range - if isinstance(o, range): + # Handle range and tuple + if isinstance(o, range) or isinstance(o, tuple): return list(o) # Handle MIME objects @@ -147,11 +147,22 @@ def _convert_to_json(self, o: Any) -> Any: # Handle objects with __slots__ if hasattr(o, "__slots__"): - return { - slot: self._convert_to_json(getattr(o, slot)) - for slot in o.__slots__ # type: ignore - if hasattr(o, slot) - } + slots = getattr(o, "__slots__", None) + if slots is not None: + # Reported error that sometimes poorly formed objects do get passed + # in. + try: + slots = iter(slots) + except TypeError as e: + raise TypeError( + "__slots__ expected to be tuple or list (or at least " + f"iterable), but got {type(slots)} for {type(o)}" + ) from e + return { + slot: self._convert_to_json(getattr(o, slot)) + for slot in slots + if hasattr(o, slot) + } # Handle custom objects with __dict__ if hasattr(o, "__dict__"): diff --git a/marimo/_plugins/ui/_impl/altair_chart.py b/marimo/_plugins/ui/_impl/altair_chart.py index e8b6a6c0895..90c0382d16f 100644 --- a/marimo/_plugins/ui/_impl/altair_chart.py +++ b/marimo/_plugins/ui/_impl/altair_chart.py @@ -79,6 +79,13 @@ def _has_geoshape(spec: altair.TopLevelMixin) -> bool: return False +def _using_vegafusion() -> bool: + """Return True if the current data transformer is vegafusion.""" + import altair + + return altair.data_transformers.active.startswith("vegafusion") # type: ignore + + def _filter_dataframe( native_df: IntoDataFrame, selection: ChartSelection ) -> IntoDataFrame: @@ -378,6 +385,17 @@ def __init__( ) chart_selection = False + if _using_vegafusion() and ( + has_chart_selection or has_legend_selection + ): + chart_selection = False + legend_selection = False + sys.stderr.write( + "Selection is not yet supported while using vegafusion with mo.ui.altair_chart.\n" + "You can follow the progress here: " + "https://github.com/marimo-team/marimo/issues/4601" + ) + self.dataframe: Optional[ChartDataType] = ( self._get_dataframe_from_chart(chart) ) @@ -545,7 +563,7 @@ def value(self) -> ChartDataType: from altair import Undefined value = super().value - if value is Undefined: + if value is Undefined: # type: ignore sys.stderr.write( "The underlying chart data is not available in layered" " or stacked charts. " diff --git a/marimo/_plugins/ui/_impl/charts/altair_transformer.py b/marimo/_plugins/ui/_impl/charts/altair_transformer.py index 11ec04d6461..8c573bf20ef 100644 --- a/marimo/_plugins/ui/_impl/charts/altair_transformer.py +++ b/marimo/_plugins/ui/_impl/charts/altair_transformer.py @@ -136,6 +136,21 @@ def _maybe_sanitize_dataframe(data: Any) -> Any: return data +def sanitize_nan_infs(data: Any) -> Any: + """Sanitize NaN and Inf values in Dataframes for JSON serialization.""" + if can_narwhalify(data): + narwhals_data = nw.from_native(data) + res = narwhals_data.with_columns( + nw.when(nw.col(col).is_nan() | ~nw.col(col).is_finite()) + .then(None) + .otherwise(nw.col(col)) + .name.keep() + for col in narwhals_data.columns + ) + return res.to_native() + return data + + def register_transformers() -> None: """ Register custom data transformers for Altair. diff --git a/marimo/_plugins/ui/_impl/dataframes/transforms/handlers.py b/marimo/_plugins/ui/_impl/dataframes/transforms/handlers.py index 7b1f2e61be0..45ac51967a2 100644 --- a/marimo/_plugins/ui/_impl/dataframes/transforms/handlers.py +++ b/marimo/_plugins/ui/_impl/dataframes/transforms/handlers.py @@ -321,7 +321,11 @@ def handle_filter_rows( elif condition.operator == "ends_with": condition_expr = column.str.ends_with(value_str) elif condition.operator == "in": - condition_expr = column.is_in(value or []) + # is_in doesn't support None values, so we need to handle them separately + if value is not None and None in value: + condition_expr = column.is_in(value) | column.is_null() + else: + condition_expr = column.is_in(value or []) else: assert_never(condition.operator) @@ -551,7 +555,13 @@ def handle_filter_rows( elif condition.operator == "ends_with": filter_conditions.append(column.endswith(value)) elif condition.operator == "in": - filter_conditions.append(column.isin(value)) + # is_in doesn't support None values, so we need to handle them separately + if value is not None and None in value: + filter_conditions.append( + column.isnull() | column.isin(value) + ) + else: + filter_conditions.append(column.isin(value)) else: assert_never(condition.operator) diff --git a/marimo/_plugins/ui/_impl/dataframes/transforms/types.py b/marimo/_plugins/ui/_impl/dataframes/transforms/types.py index d2b6164e963..7fbbe1e59ab 100644 --- a/marimo/_plugins/ui/_impl/dataframes/transforms/types.py +++ b/marimo/_plugins/ui/_impl/dataframes/transforms/types.py @@ -74,9 +74,16 @@ def __hash__(self) -> int: def __post_init__(self) -> None: if self.operator == "in": - assert isinstance(self.value, list), ( - "value must be a list for 'in' operator" - ) + if isinstance(self.value, list): + # Hack to convert to tuple for frozen dataclass + # Only tuples can be hashed + object.__setattr__(self, "value", tuple(self.value)) + elif isinstance(self.value, tuple): + pass + else: + raise ValueError( + "value must be a list or tuple for 'in' operator" + ) @dataclass diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py index 9e790414216..f4d8690622e 100644 --- a/marimo/_plugins/ui/_impl/table.py +++ b/marimo/_plugins/ui/_impl/table.py @@ -136,6 +136,17 @@ class GetDataUrlResponse: format: Literal["csv", "json", "arrow"] +@dataclass +class CalculateTopKRowsArgs: + column: ColumnName + k: int + + +@dataclass +class CalculateTopKRowsResponse: + data: list[tuple[str, int]] + + def get_default_table_page_size() -> int: """Get the default number of rows to display in a table.""" try: @@ -626,6 +637,11 @@ def __init__( arg_cls=EmptyArgs, function=self._get_data_url, ), + Function( + name="calculate_top_k_rows", + arg_cls=CalculateTopKRowsArgs, + function=self._calculate_top_k_rows, + ), ), ) @@ -849,6 +865,21 @@ def _apply_filters_query_sort( return result + def _calculate_top_k_rows( + self, args: CalculateTopKRowsArgs + ) -> CalculateTopKRowsResponse: + """Calculate the top k rows in the table, grouped by column. + Returns a table of the top k rows, grouped by column with the count. + """ + column, k = args.column, args.k + try: + data = self._searched_manager.calculate_top_k_rows(column, k) + return CalculateTopKRowsResponse(data=data) + # Some libs will panic like Polars, which are only caught with BaseException + except BaseException as e: + LOGGER.error("Failed to calculate top k rows: %s", e) + return CalculateTopKRowsResponse(data=[]) + def _style_cells(self, skip: int, take: int) -> Optional[CellStyles]: """Calculate the styling of the cells in the table.""" if self._style_cell is None: diff --git a/marimo/_plugins/ui/_impl/tables/default_table.py b/marimo/_plugins/ui/_impl/tables/default_table.py index a62e11a4e62..737929dcadd 100644 --- a/marimo/_plugins/ui/_impl/tables/default_table.py +++ b/marimo/_plugins/ui/_impl/tables/default_table.py @@ -1,6 +1,8 @@ # Copyright 2024 Marimo. All rights reserved. from __future__ import annotations +import functools +from collections import defaultdict from collections.abc import Sequence from typing import Any, Optional, Union, cast @@ -261,6 +263,51 @@ def search(self, query: str) -> DefaultTableManager: def get_row_headers(self) -> list[str]: return [] + @functools.lru_cache(maxsize=5) # noqa: B019 + def calculate_top_k_rows( + self, column: ColumnName, k: int + ) -> list[tuple[Any, int]]: + column_names = self.get_column_names() + if column not in column_names: + raise ValueError(f"Column {column} not found in table.") + + grouped: dict[str, int] = defaultdict(int) + if isinstance(self.data, dict): + if self.is_column_oriented: + # Handle column-oriented data + for value in cast(list[Any], self.data[column]): + grouped[value] += 1 + else: + # In this case, the data is a dict of key-value pairs + # where the key is the row identifier and the value is the data + for key, value in self.data.items(): + if column == KEY: + grouped[key] += 1 + elif column == VALUE: + grouped[value] += 1 + else: + # Handle row-oriented data + for row in self.data: + if isinstance(row, dict) and column in row: + grouped[row[column]] += 1 + + sorted_grouped = sorted( + grouped.items(), key=lambda x: x[1], reverse=True + ) + top_k = sorted_grouped[:k] + + chosen_column_name = None + for column_name in ["count", "number of rows", "count of rows"]: + if column_name not in column_names: + chosen_column_name = column_name + break + if chosen_column_name is None: + raise ValueError( + "Cannot specify a count column name, please rename your column" + ) + + return [(value, count) for value, count in top_k] + def get_field_type( self, column_name: str ) -> tuple[FieldType, ExternalDataType]: diff --git a/marimo/_plugins/ui/_impl/tables/ibis_table.py b/marimo/_plugins/ui/_impl/tables/ibis_table.py index 718517e92ef..b1e859005bd 100644 --- a/marimo/_plugins/ui/_impl/tables/ibis_table.py +++ b/marimo/_plugins/ui/_impl/tables/ibis_table.py @@ -1,6 +1,7 @@ # Copyright 2024 Marimo. All rights reserved. from __future__ import annotations +import functools from typing import Any, Optional from marimo._data.models import ( @@ -194,6 +195,24 @@ def sort_values( ) return IbisTableManager(sorted_data) + @functools.lru_cache(maxsize=5) # noqa: B019 + def calculate_top_k_rows( + self, column: ColumnName, k: int + ) -> list[tuple[Any, int]]: + count_col_name = f"{column}_count" + result = ( + self.data[[column]] + .value_counts(name=count_col_name) + .order_by(ibis.desc(count_col_name)) + .limit(k) + .execute() + ) + + return [ + (row[0], int(row[1])) + for row in result.itertuples(index=False) + ] + def get_field_type( self, column_name: str ) -> tuple[FieldType, ExternalDataType]: diff --git a/marimo/_plugins/ui/_impl/tables/narwhals_table.py b/marimo/_plugins/ui/_impl/tables/narwhals_table.py index 9855d377d0d..8e5041f59a5 100644 --- a/marimo/_plugins/ui/_impl/tables/narwhals_table.py +++ b/marimo/_plugins/ui/_impl/tables/narwhals_table.py @@ -1,6 +1,7 @@ # Copyright 2024 Marimo. All rights reserved. from __future__ import annotations +import functools import io from functools import cached_property from typing import Any, Optional, Union, cast @@ -159,6 +160,49 @@ def get_row_headers( ) -> list[str]: return [] + @functools.lru_cache(maxsize=5) # noqa: B019 + def calculate_top_k_rows( + self, column: ColumnName, k: int + ) -> list[tuple[Any, int]]: + if isinstance(self.data, nw.LazyFrame): + raise ValueError( + "Cannot calculate top k rows for lazy frames, please collect the data first" + ) + + columns = self.get_column_names() + + if column not in columns: + raise ValueError(f"Column {column} not found in table.") + + # Find a column name for the count that doesn't conflict with existing columns + chosen_column_name: str | None = None + for col in ["count", f"count of {column}", "num_rows"]: + if col not in columns: + chosen_column_name = col + break + if chosen_column_name is None: + raise ValueError( + "Cannot specify a count column name, please rename your column" + ) + + # column is also sorted to ensure nulls are last + result = ( + self.data.group_by(column) + .agg(nw.len().alias(chosen_column_name)) + .sort( + [chosen_column_name, column], descending=True, nulls_last=True + ) + .head(k) + ) + + return [ + ( + unwrap_py_scalar(row[column]), + int(unwrap_py_scalar(row[chosen_column_name])), + ) + for row in result.iter_rows(named=True) + ] + @staticmethod def is_type(value: Any) -> bool: return can_narwhalify(value) diff --git a/marimo/_plugins/ui/_impl/tables/table_manager.py b/marimo/_plugins/ui/_impl/tables/table_manager.py index 858d74ce183..92e6b3603fa 100644 --- a/marimo/_plugins/ui/_impl/tables/table_manager.py +++ b/marimo/_plugins/ui/_impl/tables/table_manager.py @@ -193,6 +193,12 @@ def get_unique_column_values(self, column: str) -> list[str | int | float]: def get_sample_values(self, column: str) -> list[Any]: pass + @abc.abstractmethod + def calculate_top_k_rows( + self, column: ColumnName, k: int + ) -> list[tuple[Any, int]]: + pass + def __repr__(self) -> str: rows = self.get_num_rows(force=False) columns = self.get_num_columns() diff --git a/marimo/_runtime/packages/pypi_package_manager.py b/marimo/_runtime/packages/pypi_package_manager.py index b28a7ae04c6..3de192713f0 100644 --- a/marimo/_runtime/packages/pypi_package_manager.py +++ b/marimo/_runtime/packages/pypi_package_manager.py @@ -118,7 +118,16 @@ class UvPackageManager(PypiPackageManager): async def _install(self, package: str) -> bool: return self.run( - ["uv", "pip", "install", *split_packages(package), "-p", PY_EXE] + [ + "uv", + "pip", + "install", + # trade installation time for faster start time + "--compile", + *split_packages(package), + "-p", + PY_EXE, + ] ) def update_notebook_script_metadata( diff --git a/marimo/_server/api/interrupt.py b/marimo/_server/api/interrupt.py index f044b0d6eb7..fed003e559e 100644 --- a/marimo/_server/api/interrupt.py +++ b/marimo/_server/api/interrupt.py @@ -3,6 +3,7 @@ import asyncio import signal +import time from typing import Callable from marimo._config.settings import GLOBAL_SETTINGS @@ -18,6 +19,7 @@ def __init__(self, quiet: bool, shutdown: Callable[[], None]) -> None: self.shutdown = shutdown self.loop = asyncio.get_event_loop() self.original_handler = signal.getsignal(signal.SIGINT) + self._time_of_last_confirmation: float | None = None def _add_interrupt_handler(self) -> None: try: @@ -31,17 +33,15 @@ def _add_interrupt_handler(self) -> None: lambda signum, frame: self._interrupt_handler(), # noqa: ARG005,E501 ) - def restore_interrupt_handler(self) -> None: - # Restore the original signal handler so re-entering Ctrl+C raises a - # keyboard interrupt instead of calling this function again (input is - # not re-entrant, so it's not safe to call this function again) - try: - self.loop.remove_signal_handler(signal.SIGINT) - except NotImplementedError: - # Windows - signal.signal(signal.SIGINT, self.original_handler) - def _interrupt_handler(self) -> None: + if ( + self._time_of_last_confirmation is not None + and (time.time() - self._time_of_last_confirmation) < 0.1 + ): + # uv can send two SIGINTs for every one sent by the user's Ctrl+C; + # this hack prevents us from spamming the user with confirm messages. + return + # Restore the original signal handler so re-entering Ctrl+C raises a # keyboard interrupt instead of calling this function again (input is # not re-entrant, so it's not safe to call this function again) @@ -55,18 +55,28 @@ def _interrupt_handler(self) -> None: self.shutdown() try: - if GLOBAL_SETTINGS.YES: - self.shutdown() - return + try: + if GLOBAL_SETTINGS.YES: + self.shutdown() + return - response = input( - f"\r{TAB}\033[1;32mAre you sure you want to quit?\033[0m " - "\033[1m(y/n)\033[0m: " - ) - if response.lower().strip() == "y": + response = input( + f"\r{TAB}Are you sure you want to quit? (y/n): " + ) + self._time_of_last_confirmation = time.time() + if response.lower().strip() == "y": + self.shutdown() + return + except (KeyboardInterrupt, EOFError, asyncio.CancelledError): + print_() self.shutdown() return - except (KeyboardInterrupt, EOFError, asyncio.CancelledError): + except KeyboardInterrupt: + # This is a hack to workaround the fact that uv can send two SIGINT for + # every one entered by the user. Without this extra except block, + # when running under uv, two Ctrl-C's from the user (which uv turns + # into three) causes marimo to hang or abort unceremoniously instead + # of cleanly exiting. print_() self.shutdown() return diff --git a/marimo/_server/print.py b/marimo/_server/print.py index e49b047516d..28663b4430c 100644 --- a/marimo/_server/print.py +++ b/marimo/_server/print.py @@ -121,6 +121,7 @@ def print_experimental_features(config: MarimoConfig) -> None: "secrets", "reactive_tests", "toplevel_defs", + "setup_cell", } keys = keys - finished_experiments diff --git a/marimo/_smoke_tests/altair/nans_infinity.py b/marimo/_smoke_tests/altair/nans_infinity.py new file mode 100644 index 00000000000..47cea050f5a --- /dev/null +++ b/marimo/_smoke_tests/altair/nans_infinity.py @@ -0,0 +1,27 @@ +import marimo + +__generated_with = "0.12.10" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + import polars as pl + import pandas as pd + return mo, pd, pl + + +@app.cell +def _(mo, pd, pl): + data = {"x": [1, 2, 3], "y": [float("-inf"), float("nan"), float("inf")]} + pandas_df = pd.DataFrame(data).plot.line(x="x", y="y") + polars_df = pl.DataFrame(data).plot.line(x="x", y="y") + + md = mo.md("### Invalid JSON values should be sanitized for charting") + mo.vstack([md, polars_df, pandas_df], heights=[20, 50, 50]) + return data, md, pandas_df, polars_df + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_smoke_tests/altair/text_selection.py b/marimo/_smoke_tests/altair/text_selection.py new file mode 100644 index 00000000000..e933a3ddf01 --- /dev/null +++ b/marimo/_smoke_tests/altair/text_selection.py @@ -0,0 +1,159 @@ + +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "altair==5.5.0", +# "marimo", +# "vega-datasets==0.9.0", +# ] +# /// + +import marimo + +__generated_with = "0.13.0" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""## Basic Text""") + return + + +@app.cell +def _(mo): + from vega_datasets import data + import altair as alt + + chart = ( + alt.Chart(data.cars()) + .mark_text() + .encode( + x="Horsepower", + y="Miles_per_Gallon", + text="Origin", + ) + ) + + chart = mo.ui.altair_chart(chart) + return alt, chart, data + + +@app.cell +def _(chart, mo): + mo.vstack([chart, chart.value.head()]) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(r"""## Bar chart with text""") + return + + +@app.cell(hide_code=True) +def _(alt, data, mo): + _source = data.barley() + + bars = ( + alt.Chart(_source) + .mark_bar() + .encode( + x=alt.X("sum(yield):Q").stack("zero"), + y=alt.Y("variety:N"), + color=alt.Color("site"), + ) + ) + + text = ( + alt.Chart(_source) + .mark_text(dx=-15, dy=3, color="white") + .encode( + x=alt.X("sum(yield):Q").stack("zero"), + y=alt.Y("variety:N"), + detail="site:N", + text=alt.Text("sum(yield):Q", format=".1f"), + ) + ) + + chart2 = mo.ui.altair_chart(bars + text) + chart2 + return (chart2,) + + +@app.cell +def _(chart2): + chart2.value + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + # Bar chart with double text + + This fails for another reason which is that `month(date)` field gets converted into `month_date` and breaks the backend filtering. + """ + ) + return + + +@app.cell(hide_code=True) +def _(alt, data, mo): + _source = data.seattle_weather() + + bar = ( + alt.Chart(_source) + .mark_bar(cornerRadius=10, height=10) + .encode( + x=alt.X("min(temp_min):Q") + .scale(domain=[-15, 45]) + .title("Temperature (°C)"), + x2="max(temp_max):Q", + y=alt.Y("month(date):O").title(None), + ) + ) + + text_min = ( + alt.Chart(_source) + .mark_text(align="right", dx=-5) + .encode( + x="min(temp_min):Q", y=alt.Y("month(date):O"), text="min(temp_min):Q" + ) + ) + + text_max = ( + alt.Chart(_source) + .mark_text(align="left", dx=5) + .encode( + x="max(temp_max):Q", y=alt.Y("month(date):O"), text="max(temp_max):Q" + ) + ) + + _chart = (bar + text_min + text_max).properties( + title=alt.Title( + text="Temperature variation by month", + subtitle="Seatle weather, 2012-2015", + ) + ) + + chart3 = mo.ui.altair_chart(_chart) + chart3 + return (chart3,) + + +@app.cell +def _(chart3): + chart3.selections + return + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_smoke_tests/issues/4579_mathjax_plotly.py b/marimo/_smoke_tests/issues/4579_mathjax_plotly.py new file mode 100644 index 00000000000..f15394ad749 --- /dev/null +++ b/marimo/_smoke_tests/issues/4579_mathjax_plotly.py @@ -0,0 +1,36 @@ + + +import marimo + +__generated_with = "0.13.0" +app = marimo.App() + + +@app.cell +def _(): + import plotly.graph_objects as go + + # mo.md(f"```\n{foo=!r}\n{bar=!r}\n```"), + go.Figure() + return + + +@app.cell +def _(): + import marimo as mo + import plotly.express as px + + + px.line( + x=[1, 2, 3, 4], + y=[1, 4, 9, 16], + title=r"$\alpha_{1c} = 352 \pm 11 \text{ km s}^{-1}$", + ).update_layout( + xaxis_title=r"$\sqrt{(n_\text{c}(t|{T_\text{early}}))}$", + yaxis_title=r"$d, r \text{ (solar radius)}$", + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_smoke_tests/issues/4624_markdown_max_width.py b/marimo/_smoke_tests/issues/4624_markdown_max_width.py new file mode 100644 index 00000000000..3b5662541eb --- /dev/null +++ b/marimo/_smoke_tests/issues/4624_markdown_max_width.py @@ -0,0 +1,82 @@ + + +import marimo + +__generated_with = "0.13.0" +app = marimo.App(width="full") + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +@app.cell +def _(mo): + mo.md( + r""" + # Hello World in Markdown + + **Bold Text:** Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + *Italic Text:* Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + ~~Strikethrough~~: Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + **Underline Text:** Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + <u>Underline Another Way:</u> Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + [Link Example](https://www.example.com): Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + - **Unordered List:** + - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + - Item 2 + - Item 3 + + 1. **Ordered List:** + 1. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + 2. Second Item + 3. Third Item + + > **Blockquote:** Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + `Inline Code:` Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. + + ``` + Code Block: + For longer code examples, use triple backticks. + def hello_world(): + print("Hello, World!") + ``` + + **Image:** + + ![Alt Text for Image](https://placehold.co/800x400) + + **Horizontal Rule:** + + --- + + Use hyphens (`---`), asterisks (`***`), or underscores (`___`) to create horizontal rules. + + **Table:** + + | Header 1 | Header 2 | Header 3 | + |:--------:|:--------:|:--------:| + | Row 1, Col 1 Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text | Row 1, Col 2 | Row 1, Col 3 | + | Row 2, Col 1 | Row 2, Col 2 | Row 2, Col 3 | + """ + ) + return + + +@app.cell +def _(mo): + mo.md(r"""This cell now contains a link to [marimo](https://docs.marimo.io/), and suddenly all sort of weird things are happening - line widths change, and there is no lie wrap anymore!""") + return + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_smoke_tests/third_party/databricks_connect.py b/marimo/_smoke_tests/third_party/databricks_connect.py new file mode 100644 index 00000000000..ef612dad5e1 --- /dev/null +++ b/marimo/_smoke_tests/third_party/databricks_connect.py @@ -0,0 +1,47 @@ + + +import marimo + +__generated_with = "0.13.0" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + return + + +@app.cell +def _(): + from databricks.connect import DatabricksSession + + spark = DatabricksSession.builder.serverless().getOrCreate() + return (spark,) + + +@app.cell +def _(spark): + df_taxi = spark.read.table("samples.nyctaxi.trips") + type(df_taxi) + return (df_taxi,) + + +@app.cell +def _(df_taxi): + import narwhals as nw + + # As of 1.35.0, this is currently False + print(nw.dependencies.is_pyspark_dataframe(df_taxi)) + print(nw.__version__) + return + + +@app.cell +def _(df_taxi): + df_taxi + return + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_smoke_tests/tqdm_update_test.py b/marimo/_smoke_tests/tqdm_update_test.py new file mode 100644 index 00000000000..df0cd7b1001 --- /dev/null +++ b/marimo/_smoke_tests/tqdm_update_test.py @@ -0,0 +1,46 @@ +# Copyright 2024 Marimo. All rights reserved. +import marimo + +__generated_with = "0.7.13" +app = marimo.App(width="medium") + + +@app.cell +def __(): + from tqdm.notebook import tqdm + import time + return time, tqdm + + +@app.cell +def __(time, tqdm): + # Test regular iteration + for i in tqdm(range(5)): + time.sleep(0.1) + return i, + + +@app.cell +def __(time, tqdm): + # Test manual update method + pbar = tqdm(total=10) + for i in range(10): + time.sleep(0.1) + pbar.update(1) # Explicitly calling update + pbar.close() + return i, + + +@app.cell +def __(time, tqdm): + # Test update with different increment + pbar = tqdm(total=100) + for i in range(0, 100, 10): + time.sleep(0.1) + pbar.update(10) # Update by 10 each time + pbar.close() + return i, + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_utils/narwhals_utils.py b/marimo/_utils/narwhals_utils.py index 5554c1f2a3d..e17ec053f5a 100644 --- a/marimo/_utils/narwhals_utils.py +++ b/marimo/_utils/narwhals_utils.py @@ -158,6 +158,12 @@ def can_narwhalify_lazyframe(df: Any) -> TypeGuard[Any]: """ if nw.dependencies.is_polars_lazyframe(df): return True + if hasattr( + nw.dependencies, "is_pyspark_dataframe" + ) and nw.dependencies.is_pyspark_dataframe(df): + return True + if nw.dependencies.is_dask_dataframe(df): + return True if hasattr(nw.dependencies, "is_duckdb_relation"): if nw.dependencies.is_duckdb_relation(df): return True diff --git a/mkdocs.yml b/mkdocs.yml index 22cdbfd4223..32e51ff1bbc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -240,6 +240,7 @@ nav: - Community: community.md - Contributing: https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md - Code of Conduct: https://github.com/marimo-team/marimo/blob/main/CODE_OF_CONDUCT.md + - Changelog: https://github.com/marimo-team/marimo/releases - Integrations: - Integrations: integrations/index.md - BigQuery: integrations/google_cloud_bigquery.md diff --git a/openapi/package.json b/openapi/package.json index 3a9976adbf3..159c93eccf1 100644 --- a/openapi/package.json +++ b/openapi/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "openapi-typescript": "^7.3.0", - "typescript": "^5.8.2" + "typescript": "^5.8.3" }, "dependencies": { "openapi-fetch": "0.9.7" diff --git a/openapi/pnpm-lock.yaml b/openapi/pnpm-lock.yaml index 096233449a1..0389a45021d 100644 --- a/openapi/pnpm-lock.yaml +++ b/openapi/pnpm-lock.yaml @@ -14,10 +14,10 @@ importers: devDependencies: openapi-typescript: specifier: ^7.3.0 - version: 7.3.0(typescript@5.8.2) + version: 7.3.0(typescript@5.8.3) typescript: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.8.3 + version: 5.8.3 packages: @@ -185,8 +185,8 @@ packages: resolution: {integrity: sha512-bRkIGlXsnGBRBQRAY56UXBm//9qH4bmJfFvq83gSz41N282df+fjy8ofcEgc1sM8geNt5cl6mC2g9Fht1cs8Aw==} engines: {node: '>=16'} - typescript@5.8.2: - resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -331,13 +331,13 @@ snapshots: openapi-typescript-helpers@0.0.8: {} - openapi-typescript@7.3.0(typescript@5.8.2): + openapi-typescript@7.3.0(typescript@5.8.3): dependencies: '@redocly/openapi-core': 1.19.0(supports-color@9.4.0) ansi-colors: 4.1.3 parse-json: 8.1.0 supports-color: 9.4.0 - typescript: 5.8.2 + typescript: 5.8.3 yargs-parser: 21.1.1 transitivePeerDependencies: - encoding @@ -366,7 +366,7 @@ snapshots: type-fest@4.25.0: {} - typescript@5.8.2: {} + typescript@5.8.3: {} uri-js@4.4.1: dependencies: diff --git a/tests/_cli/test_cli_export.py b/tests/_cli/test_cli_export.py index d24713e50db..6e12f45fcfc 100644 --- a/tests/_cli/test_cli_export.py +++ b/tests/_cli/test_cli_export.py @@ -353,7 +353,7 @@ def test_cli_export_html_sandbox(temp_marimo_file: str) -> None: capture_output=True, ) assert p.returncode == 0, p.stderr.decode() - output = p.stdout.decode() + output = p.stderr.decode() # Check for sandbox message assert "Running in a sandbox" in output assert "uv run --isolated" in output @@ -892,7 +892,7 @@ def test_cli_export_ipynb_sandbox(temp_marimo_file: str) -> None: capture_output=True, ) assert p.returncode == 0, p.stderr.decode() - output = p.stdout.decode() + output = p.stderr.decode() # Check for sandbox message assert "Running in a sandbox" in output assert "uv run --isolated" in output diff --git a/tests/_cli/test_sandbox.py b/tests/_cli/test_sandbox.py index 3e941dc5870..a4719c3a24d 100644 --- a/tests/_cli/test_sandbox.py +++ b/tests/_cli/test_sandbox.py @@ -439,6 +439,7 @@ def test_construct_uv_cmd_with_python_version(tmp_path: Path) -> None: assert ">=3.11" in uv_cmd assert "--isolated" in uv_cmd assert "--no-project" in uv_cmd + assert "--compile-bytecode" in uv_cmd assert "--sandbox" not in uv_cmd @@ -517,6 +518,7 @@ def test_construct_uv_cmd_empty_dependencies() -> None: ) assert "--refresh" in uv_cmd assert "--isolated" in uv_cmd + assert "--compile-bytecode" in uv_cmd assert "--no-project" in uv_cmd diff --git a/tests/_output/formatters/test_altair_formatters.py b/tests/_output/formatters/test_altair_formatters.py index 7296c37732b..d819e4534c0 100644 --- a/tests/_output/formatters/test_altair_formatters.py +++ b/tests/_output/formatters/test_altair_formatters.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -10,9 +11,13 @@ from marimo._output.formatters.formatters import register_formatters from marimo._output.formatting import get_formatter from marimo._plugins.ui._impl.altair_chart import maybe_make_full_width +from tests._data.mocks import create_dataframes HAS_DEPS = DependencyManager.altair.has() and DependencyManager.polars.has() +if TYPE_CHECKING: + from narwhals.typing import IntoDataFrame + def get_data(): import polars as pl @@ -131,3 +136,28 @@ def test_altair_formatter_svg(): assert mime == "image/svg+xml" assert content == "<svg></svg>" + + +@pytest.mark.skipif(not HAS_DEPS, reason="altair not installed") +@pytest.mark.parametrize( + "df", + create_dataframes( + {"A": [1, 2, 3], "B": [-float("inf"), float("nan"), float("inf")]}, + include=["polars", "pandas"], + ), +) +def test_altair_formatter_sanitize_nan_infs(df: IntoDataFrame): + AltairFormatter().register() + + import altair as alt + + chart = alt.Chart(df).mark_point().encode(x="A", y="B") + formatter = get_formatter(chart) + assert formatter is not None + mime, content = formatter(chart) + assert mime == "application/vnd.vegalite.v5+json" + assert isinstance(content, str) + + for non_valid_value in ["NaN", "Infinity", "-Infinity"]: + assert non_valid_value not in content + assert content.count('"B": null') == 3 diff --git a/tests/_plugins/core/test_json_encoder.py b/tests/_plugins/core/test_json_encoder.py index a0f47b21973..9c6b74aab7e 100644 --- a/tests/_plugins/core/test_json_encoder.py +++ b/tests/_plugins/core/test_json_encoder.py @@ -414,3 +414,42 @@ def test_range_encoding() -> None: r = range(10) encoded = json.dumps(r, cls=WebComponentEncoder) assert encoded == "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" + + +def test_error_encoding() -> None: + from marimo._messaging.errors import MultipleDefinitionError + from marimo._types.ids import CellId_t + + error_obj = MultipleDefinitionError( + "This is a custom error", (CellId_t("test"), CellId_t("test2")) + ) + encoded = json.dumps(error_obj, cls=WebComponentEncoder) + assert ( + encoded + == '{"name": "This is a custom error", "cells": ["test", "test2"], "type": "multiple-defs"}' + ) + + +def test_invalid_class() -> None: + class InvalidClass: ... + + invalid_obj = InvalidClass() + invalid_obj.__slots__ = None + + encoded = json.dumps(invalid_obj, cls=WebComponentEncoder) + assert encoded == '{"__slots__": null}' + + +def test_empty_slots() -> None: + class ExClass: + __slots__ = [] + + # With a property (as sanity check) + @property + def one(self): + return 1 + + obj = ExClass() + + encoded = json.dumps(obj, cls=WebComponentEncoder) + assert encoded == "{}" diff --git a/tests/_plugins/ui/_impl/dataframes/test_handlers.py b/tests/_plugins/ui/_impl/dataframes/test_handlers.py index b88ebb2cd67..c5c69a4e038 100644 --- a/tests/_plugins/ui/_impl/dataframes/test_handlers.py +++ b/tests/_plugins/ui/_impl/dataframes/test_handlers.py @@ -443,6 +443,72 @@ def test_handle_filter_rows_6( result = apply(df, transform) assert_frame_equal(result, expected) + @staticmethod + @pytest.mark.parametrize( + ("df", "expected"), + [ + ( + pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}), + pd.DataFrame({"A": [1, 2], "B": [4, 5]}), + ), + ( + pl.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}), + pl.DataFrame({"A": [1, 2], "B": [4, 5]}), + ), + ( + ibis.memtable({"A": [1, 2, 3], "B": [4, 5, 6]}), + ibis.memtable({"A": [1, 2], "B": [4, 5]}), + ), + ], + ) + def test_filter_rows_in_operator( + df: DataFrameType, expected: DataFrameType + ) -> None: + transform = FilterRowsTransform( + type=TransformType.FILTER_ROWS, + operation="keep_rows", + where=[Condition(column_id="A", operator="in", value=[1, 2])], + ) + result = apply(df, transform) + assert_frame_equal(result, expected) + + @staticmethod + @pytest.mark.parametrize( + ("df", "expected"), + [ + ( + pd.DataFrame({"A": [1, 2, None], "B": [4, 5, 6]}), + pd.DataFrame({"A": [np.nan], "B": [6]}), + ), + ( + pl.DataFrame({"A": [1, 2, None], "B": [4, 5, 6]}), + pl.DataFrame({"A": [None], "B": [6]}).with_columns( + pl.col("A").cast(pl.Int64) + ), + ), + ( + ibis.memtable( + {"A": [1, 2, None], "B": [4, 5, 6]}, + schema={"A": "int64", "B": "int64"}, + ), + ibis.memtable( + {"A": [None], "B": [6]}, + schema={"A": "int64", "B": "int64"}, + ), + ), + ], + ) + def test_filter_rows_in_operator_null_rows( + df: DataFrameType, expected: DataFrameType + ) -> None: + transform = FilterRowsTransform( + type=TransformType.FILTER_ROWS, + operation="keep_rows", + where=[Condition(column_id="A", operator="in", value=[None])], + ) + result = apply(df, transform) + assert_frame_equal(result, expected) + @staticmethod @pytest.mark.parametrize( ("df", "expected"), diff --git a/tests/_plugins/ui/_impl/tables/test_default_table.py b/tests/_plugins/ui/_impl/tables/test_default_table.py index b2a802286cd..2d73a886719 100644 --- a/tests/_plugins/ui/_impl/tables/test_default_table.py +++ b/tests/_plugins/ui/_impl/tables/test_default_table.py @@ -347,6 +347,37 @@ def test_apply_formatting_with_none_values(self) -> None: ] assert formatted_manager == expected_data + def test_calculate_top_k_rows(self) -> None: + data = [ + {"name": "Alice", "score": 46, "grade": "A"}, + {"name": "Bob", "score": 85, "grade": "A"}, + {"name": "Charlie", "score": 32, "grade": None}, + ] + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("grade", 10) + expected_data = [("A", 2), (None, 1)] + assert result == expected_data + + # test with single value and conflicting column name + data = [{"name": "Alice", "age": 31, "count": date(1994, 5, 24)}] + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("count", 10) + expected_data = [(date(1994, 5, 24), 1)] + assert result == expected_data + + def test_calculate_top_k_rows_nulls(self) -> None: + data = [ + {"name": "Alice", "age": 31, "birth_year": date(1994, 5, 24)}, + {"name": "Bob", "age": 25, "birth_year": None}, + {"name": "Charlie", "age": 35, "birth_year": None}, + {"name": "Dave", "age": 28, "birth_year": date(1994, 5, 24)}, + ] + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("birth_year", 10) + # Nulls should be sorted to the end + expected_data = [(date(1994, 5, 24), 2), (None, 2)] + assert result == expected_data + class TestColumnarDefaultTable(unittest.TestCase): def setUp(self) -> None: @@ -662,6 +693,36 @@ def test_apply_formatting_with_none_values(self) -> None: } assert formatted_manager == expected_data + def test_calculate_top_k_rows(self) -> None: + data = { + "grade": ["A", "A", None], + "name": ["Alice", "Bob", "Charlie"], + } + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("grade", 10) + expected_data = [ + ("A", 2), + (None, 1), + ] + assert result == expected_data + + # Single value + data = {"grade": ["A", "A", "A"]} + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("grade", 10) + expected_data = [("A", 3)] + assert result == expected_data + + def test_calculate_top_k_rows_nulls(self) -> None: + data = {"grade": ["A", "A", None, None]} + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("grade", 10) + expected_data = [ + ("A", 2), + (None, 2), + ] + assert result == expected_data + @pytest.mark.skipif( not HAS_DEPS, reason="optional dependencies not installed" ) @@ -869,6 +930,24 @@ def test_apply_formatting_with_none_values(self) -> None: ] assert formatted_data == expected_data + def test_calculate_top_k_rows(self) -> None: + data = {"grade": "A", "name": "Alice", "another_grade": "A"} + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("value", 10) + expected_data = [("A", 2), ("Alice", 1)] + assert result == expected_data + + result = manager.calculate_top_k_rows("key", 10) + expected_data = [("grade", 1), ("name", 1), ("another_grade", 1)] + assert result == expected_data + + def test_calculate_top_k_rows_nulls(self) -> None: + data = {"grade": "A", "name": None, "another_grade": None} + manager = DefaultTableManager(data) + result = manager.calculate_top_k_rows("value", 10) + expected_data = [(None, 2), ("A", 1)] + assert result == expected_data + @pytest.mark.skipif( not HAS_DEPS, reason="optional dependencies not installed" ) diff --git a/tests/_plugins/ui/_impl/tables/test_ibis_table.py b/tests/_plugins/ui/_impl/tables/test_ibis_table.py index c2e1153393b..cf024964a6c 100644 --- a/tests/_plugins/ui/_impl/tables/test_ibis_table.py +++ b/tests/_plugins/ui/_impl/tables/test_ibis_table.py @@ -336,3 +336,49 @@ def test_sort_values_with_nulls(self) -> None: 3.0, ] assert np.isnan(sorted_data[3]) + + def test_calculate_top_k_rows(self) -> None: + import ibis + + table = ibis.memtable({"A": [2, 3, 3], "B": ["a", "b", "c"]}) + manager = self.factory.create()(table) + result = manager.calculate_top_k_rows("A", 10) + assert result == [(3, 2), (2, 1)] + + # Test equal counts with k limit + table = ibis.memtable({"A": [1, 1, 2, 2, 3]}) + manager = self.factory.create()(table) + result = manager.calculate_top_k_rows("A", 2) + assert len(result) == 2 + assert {(1, 2), (2, 2)} == set(result) + assert all(count == 2 for _, count in result) + + def test_calculate_top_k_rows_nulls(self) -> None: + import ibis + import pandas as pd + + # Test single null value + table = ibis.memtable({"A": [3, None, None]}) + manager = self.factory.create()(table) + result = manager.calculate_top_k_rows("A", 10) + assert len(result) == 2 + assert result[1] == (3, 1) + assert pd.isna(result[0][0]) + assert result[0][1] == 2 + + # Test all null values + table = ibis.memtable({"A": [None, None, None]}) + manager = self.factory.create()(table) + result = manager.calculate_top_k_rows("A", 10) + assert len(result) == 1 + assert pd.isna(result[0][0]) + assert result[0][1] == 3 + + # Test mixed values with nulls + table = ibis.memtable({"A": [1, None, 2, None, 3, None]}) + manager = self.factory.create()(table) + result = manager.calculate_top_k_rows("A", 10) + assert len(result) == 4 + assert pd.isna(result[0][0]) + assert result[0][1] == 3 + assert set(result[1:]) == {(1, 1), (2, 1), (3, 1)} diff --git a/tests/_plugins/ui/_impl/tables/test_narwhals.py b/tests/_plugins/ui/_impl/tables/test_narwhals.py index fac64ab6f0f..3ff7ab49bce 100644 --- a/tests/_plugins/ui/_impl/tables/test_narwhals.py +++ b/tests/_plugins/ui/_impl/tables/test_narwhals.py @@ -995,6 +995,79 @@ def test_get_sample_values(df: Any) -> None: assert sample_values == ["b'bytes1'", "b'bytes2'", "b'bytes3'"] +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +@pytest.mark.parametrize( + "df", + create_dataframes( + { + "A": [1, 2, 3, 3, None, None], + "B": [4, 5, 6, 6, None, None], + }, + include=["pandas", "polars"], + ), +) +def test_calculate_top_k_rows(df: Any) -> None: + manager = NarwhalsTableManager.from_dataframe(df) + result = manager.calculate_top_k_rows("A", 10) + normalized_result = _normalize_result(result) + assert normalized_result == [(3, 2), (None, 2), (2, 1), (1, 1)] + + # Test with limit + result = manager.calculate_top_k_rows("A", 2) + normalized_result = _normalize_result(result) + assert normalized_result == [(3, 2), (None, 2)] + + +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +@pytest.mark.parametrize( + "df", + create_dataframes( + {"count": [1, 2, 3, 3, None, None]}, + include=["pandas", "polars"], + ), +) +def test_calculate_top_k_rows_conflicting_colname(df: Any) -> None: + manager = NarwhalsTableManager.from_dataframe(df) + + # Test original column A + result_a = manager.calculate_top_k_rows("count", 10) + normalized_result_a = _normalize_result(result_a) + assert normalized_result_a == [(3, 2), (None, 2), (2, 1), (1, 1)] + + +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +def test_calculate_top_k_rows_lazy_frame() -> None: + import polars as pl + + df = pl.DataFrame({"A": [1, 2, 3, 3, None, None]}).lazy() + manager = NarwhalsTableManager.from_dataframe(df) + with pytest.raises( + ValueError, match="Cannot calculate top k rows for lazy frames" + ): + manager.calculate_top_k_rows("A", 10) + + +@pytest.mark.xfail( + reason="Narwhals doesn't support group_by for metadata-only frames" +) +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +def test_calculate_top_k_rows_metadata_only_frame() -> None: + import ibis + + df = ibis.memtable({"A": [1, 2, 3, 3, None, None]}) + manager = NarwhalsTableManager.from_dataframe(df) + result = manager.calculate_top_k_rows("A", 10) + assert result == [(3, 2), (None, 2), (2, 1), (1, 1)] + + +def _normalize_result(result: list[tuple[Any, int]]) -> list[tuple[Any, int]]: + """Normalize None and NaN values for comparison.""" + return [ + (None if val is None or isnan(val) else val, count) + for val, count in result + ] + + @pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") @pytest.mark.parametrize( "df", @@ -1109,3 +1182,55 @@ def test_get_field_types_with_many_columns_is_performant(df: Any) -> None: assert total_ms < 500, ( f"Total time: {total_ms}ms for {df.shape[1]} columns with {type(df)}" ) + + +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +class TestCalculateTopKRowsCaching(unittest.TestCase): + def setUp(self) -> None: + import polars as pl + + self.data = pl.DataFrame( + {"name": ["Alice", "Eve", None], "age": [25, 35, None]} + ) + self.manager = NarwhalsTableManager.from_dataframe(self.data) + + def test_calculate_top_k_rows_caching(self) -> None: + """Test that calculate_top_k_rows caching works correctly.""" + # First call should compute the result + result1 = self.manager.calculate_top_k_rows("name", 10) + + # Second call with same args should use cache and return same object + result2 = self.manager.calculate_top_k_rows("name", 10) + assert result1 is result2 + + # Different k value should compute new result + result3 = self.manager.calculate_top_k_rows("name", 5) + assert result3 is not result1 + + # Different column name should compute new result + result4 = self.manager.calculate_top_k_rows("age", 10) + assert result4 is not result1 + + def test_calculate_top_k_rows_cache_invalidation(self) -> None: + """Test that cache is properly invalidated when data changes.""" + # Initial calculation + result1 = self.manager.calculate_top_k_rows("name", 2) + + # Modify the data + import polars as pl + + new_data = pl.DataFrame( + { + "name": ["Alice", "Eve", "Bob", None], + } + ) + + # Create a new manager with the new data + self.manager = NarwhalsTableManager.from_dataframe(new_data) + + # New calculation should be performed and return different result + result2 = self.manager.calculate_top_k_rows("name", 2) + assert result2 is not result1 # Different result due to data change + + # Verify the actual results are different + assert result1 != result2 diff --git a/tests/_plugins/ui/_impl/test_table.py b/tests/_plugins/ui/_impl/test_table.py index 3fa1ee6bacb..b4e86c84010 100644 --- a/tests/_plugins/ui/_impl/test_table.py +++ b/tests/_plugins/ui/_impl/test_table.py @@ -11,6 +11,8 @@ from marimo._plugins import ui from marimo._plugins.ui._impl.dataframes.transforms.types import Condition from marimo._plugins.ui._impl.table import ( + CalculateTopKRowsArgs, + CalculateTopKRowsResponse, DownloadAsArgs, SearchTableArgs, SortArgs, @@ -1638,6 +1640,16 @@ def test_default_table_page_size(): assert get_default_table_page_size() == 10 +def test_calculate_top_k_rows(): + table = ui.table({"A": [1, 3, 3, None, None]}) + result = table._calculate_top_k_rows( + CalculateTopKRowsArgs(column="A", k=10) + ) + assert result == CalculateTopKRowsResponse( + data=[(3, 2), (None, 2), (1, 1)], + ) + + def _convert_data_bytes_to_pandas_df( data: str, data_format: str ) -> pd.DataFrame: diff --git a/tests/_runtime/packages/test_package_managers.py b/tests/_runtime/packages/test_package_managers.py index 85a1378d0d7..df22e572081 100644 --- a/tests/_runtime/packages/test_package_managers.py +++ b/tests/_runtime/packages/test_package_managers.py @@ -4,6 +4,7 @@ from marimo._runtime.packages.package_managers import create_package_manager from marimo._runtime.packages.pypi_package_manager import ( + PY_EXE, MicropipPackageManager, PipPackageManager, RyePackageManager, @@ -223,3 +224,18 @@ def _get_version_map(self) -> dict[str, str]: ] ] runs_calls.clear() + + +async def test_uv_pip_install() -> None: + runs_calls: list[list[str]] = [] + + class MockUvPackageManager(UvPackageManager): + def run(self, command: list[str]) -> bool: + runs_calls.append(command) + return True + + pm = MockUvPackageManager() + await pm._install("foo") + assert runs_calls == [ + ["uv", "pip", "install", "--compile", "foo", "-p", PY_EXE], + ]
- 自定义输入 + 输入控件 - 自定义绘图 + 绘图 - 自定义布局 + 布局