diff --git a/vizro-core/changelog.d/20231030_132538_maximilian_schulz_table_component_MS.md b/vizro-core/changelog.d/20231030_132538_maximilian_schulz_table_component_MS.md new file mode 100644 index 000000000..bbebc9f4c --- /dev/null +++ b/vizro-core/changelog.d/20231030_132538_maximilian_schulz_table_component_MS.md @@ -0,0 +1,44 @@ +<!-- +A new scriv changelog fragment. + +Uncomment the section that is right (remove the HTML comment wrapper). +--> + +### Highlights ✨ + +- Release of the Vizro Dash DataTable. Visit the [user guide on tables](https://vizro.readthedocs.io/en/stable/pages/user_guides/table/) to learn more. ([#114](https://github.com/mckinsey/vizro/pull/114)) + +<!-- +### Removed + +- A bullet item for the Removed category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1)) + +--> +<!-- ### Added + +- A bullet item for the Added category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#114](https://github.com/mckinsey/vizro/pull/114)) --> + +<!-- +### Changed + +- A bullet item for the Changed category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1)) + +--> +<!-- +### Deprecated + +- A bullet item for the Deprecated category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1)) + +--> +<!-- +### Fixed + +- A bullet item for the Fixed category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1)) + +--> +<!-- +### Security + +- A bullet item for the Security category with a link to the relevant PR at the end of your entry, e.g. Enable feature XXX ([#1](https://github.com/mckinsey/vizro/pull/1)) + +--> diff --git a/vizro-core/docs/assets/user_guides/table/custom_table.png b/vizro-core/docs/assets/user_guides/table/custom_table.png new file mode 100644 index 000000000..990d57a85 Binary files /dev/null and b/vizro-core/docs/assets/user_guides/table/custom_table.png differ diff --git a/vizro-core/docs/assets/user_guides/table/styled_table.png b/vizro-core/docs/assets/user_guides/table/styled_table.png new file mode 100644 index 000000000..773aaa4b3 Binary files /dev/null and b/vizro-core/docs/assets/user_guides/table/styled_table.png differ diff --git a/vizro-core/docs/assets/user_guides/table/table.png b/vizro-core/docs/assets/user_guides/table/table.png new file mode 100644 index 000000000..d9f960dd1 Binary files /dev/null and b/vizro-core/docs/assets/user_guides/table/table.png differ diff --git a/vizro-core/docs/pages/user_guides/components.md b/vizro-core/docs/pages/user_guides/components.md index 1ca750935..0018964bd 100755 --- a/vizro-core/docs/pages/user_guides/components.md +++ b/vizro-core/docs/pages/user_guides/components.md @@ -1,89 +1,9 @@ -# How to use charts/components +# How to use cards & buttons -This guide shows you how to use charts/components to visualize your data in the dashboard. +This guide shows you how to use cards and buttons to visualize and interact with your data in the dashboard. The [`Page`][vizro.models.Page] models accepts the `components` argument, where you can enter your visual content e.g. -[`Graph`][vizro.models.Graph], [`Card`][vizro.models.Card] or [`Button`][vizro.models.Button]. - -## Graph - -The [`Graph`][vizro.models.Graph] model is the most used component in many dashboards, allowing you to visualize data in a variety of ways. - -You can add a [`Graph`][vizro.models.Graph] -to your dashboard by inserting the [`Graph`][vizro.models.Graph] model into the `components` argument of the -[`Page`][vizro.models.Page] model. You will need to specify the `figure` argument, where you can enter any of the -currently available charts of the open source library [`plotly.express`](https://plotly.com/python/plotly-express/). - -!!! note - - Note that in order to use the [`plotly.express`](https://plotly.com/python/plotly-express/) chart in a Vizro dashboard, you need to import it as `import vizro.plotly.express as px`. - This leaves any of the [`plotly.express`](https://plotly.com/python/plotly-express/) functionality untouched, but allows _direct insertion_ into the [`Graph`][vizro.models.Graph] model _as is_. - -!!! example "Graph" - === "app.py" - ```py - import vizro.models as vm - import vizro.plotly.express as px - from vizro import Vizro - - df = px.data.iris() - - page = vm.Page( - title="My first page", - components=[ - vm.Graph( - id="my_chart", - figure=px.scatter_matrix( - df, dimensions=["sepal_length", "sepal_width", "petal_length", "petal_width"], color="species" - ), - ), - ], - controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], - ) - - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - === "app.yaml" - ```yaml - # Still requires a .py to register data connector in Data Manager and parse yaml configuration - # See from_yaml example - pages: - - components: - - figure: - _target_: scatter_matrix - color: species - data_frame: iris - dimensions: ["sepal_length", "sepal_width", "petal_length", "petal_width"] - id: my_chart - type: graph - controls: - - column: continent - type: filter - - selector: - title: Species - type: dropdown - title: My first page - ``` - === "Result" - [![Graph]][Graph] - - [Graph]: ../../assets/user_guides/components/graph1.png - -Note that in the above example we directly inserted the chart into the `figure` argument for the `.py` version. This is also the simplest way to connect your chart to a Pandas `DataFrame` - for other connections, please refer to [this guide](data.md). For the `yaml` version, we simply referred to the [`plotly.express`](https://plotly.com/python/plotly-express/) name by string. - - -???+ info - - When importing Vizro, we automatically set the `plotly` [default template](https://plotly.com/python/templates/#specifying-a-default-themes) to - a custom designed template. In case you would like to set the default back, simply run - ```py - import plotly.io as pio - pio.templates.default = "plotly" - ``` - or enter your desired template into any `plotly.express` chart as `template="plotly"` on a case-by-case basis. - Note that we do not recommend the above steps for use in dashboards, as other templates will look out-of-sync with overall dashboard design. +[`Graph`][vizro.models.Graph], [`Table`][vizro.models.Table], [`Card`][vizro.models.Card] or [`Button`][vizro.models.Button]. ## Card diff --git a/vizro-core/docs/pages/user_guides/graph.md b/vizro-core/docs/pages/user_guides/graph.md new file mode 100755 index 000000000..8f5c91a23 --- /dev/null +++ b/vizro-core/docs/pages/user_guides/graph.md @@ -0,0 +1,89 @@ +# How to use graphs + +This guide shows you how to use graphs to visualize your data in the dashboard. + +The [`Page`][vizro.models.Page] models accepts the `components` argument, where you can enter your visual content e.g. +[`Graph`][vizro.models.Graph], [`Table`][vizro.models.Table], [`Card`][vizro.models.Card] or [`Button`][vizro.models.Button]. + +## Graph + +The [`Graph`][vizro.models.Graph] model is the most used component in many dashboards, allowing you to visualize data in a variety of ways. + +To add a [`Graph`][vizro.models.Graph] to your page, do the following: + +- insert the [`Graph`][vizro.models.Graph] model into the `components` argument of the +[`Page`][vizro.models.Page] model +- enter any of the currently available charts of the open source library [`plotly.express`](https://plotly.com/python/plotly-express/) into the `figure` argument + +!!! note + + In order to use the [`plotly.express`](https://plotly.com/python/plotly-express/) chart in a Vizro dashboard, you need to import it as `import vizro.plotly.express as px`. + This leaves any of the [`plotly.express`](https://plotly.com/python/plotly-express/) functionality untouched, but allows _direct insertion_ into the [`Graph`][vizro.models.Graph] model _as is_. + + + +!!! example "Graph" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + + df = px.data.iris() + + page = vm.Page( + title="My first page", + components=[ + vm.Graph( + id="my_chart", + figure=px.scatter_matrix( + df, dimensions=["sepal_length", "sepal_width", "petal_length", "petal_width"], color="species" + ), + ), + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Still requires a .py to register data connector in Data Manager and parse yaml configuration + # See from_yaml example + pages: + - components: + - figure: + _target_: scatter_matrix + color: species + data_frame: iris + dimensions: ["sepal_length", "sepal_width", "petal_length", "petal_width"] + id: my_chart + type: graph + controls: + - column: species + type: filter + selector: + title: Species + type: dropdown + title: My first page + ``` + === "Result" + [![Graph]][Graph] + + [Graph]: ../../assets/user_guides/components/graph1.png + +Note that in the above example we directly inserted the chart into the `figure` argument for the `.py` version. This is also the simplest way to connect your chart to a Pandas `DataFrame` - for other connections, please refer to [this guide on data connections](data.md). For the `yaml` version, we simply referred to the [`plotly.express`](https://plotly.com/python/plotly-express/) name by string. + + +??? info "Vizro automatically sets the plotly default template" + + When importing Vizro, we automatically set the `plotly` [default template](https://plotly.com/python/templates/#specifying-a-default-themes) to + a custom designed template. In case you would like to set the default back, simply run + ```py + import plotly.io as pio + pio.templates.default = "plotly" + ``` + or enter your desired template into any `plotly.express` chart as `template="plotly"` on a case-by-case basis. + Note that we do not recommend the above steps for use in dashboards, as other templates will look out-of-sync with overall dashboard design. diff --git a/vizro-core/docs/pages/user_guides/table.md b/vizro-core/docs/pages/user_guides/table.md new file mode 100755 index 000000000..81304b2b7 --- /dev/null +++ b/vizro-core/docs/pages/user_guides/table.md @@ -0,0 +1,287 @@ +# How to use tables + +This guide shows you how to use tables to visualize your data in the dashboard. + +The [`Page`][vizro.models.Page] models accepts the `components` argument, where you can enter your visual content e.g. +[`Graph`][vizro.models.Graph], [`Table`][vizro.models.Table], [`Card`][vizro.models.Card] or [`Button`][vizro.models.Button]. + +## Table + +The [`Table`][vizro.models.Table] model allows you to visualize data in a tabular format. + +To add a [`Table`][vizro.models.Table] to your page, do the following: + +- insert the [`Table`][vizro.models.Table] model into the `components` argument of the +[`Page`][vizro.models.Page] model +- enter any of the currently available table functions + +See below for an overview of currently supported table functions. + +### Dash DataTable + +The [Dash DataTable](https://dash.plotly.com/datatable) is an interactive table component designed for viewing, editing, and exploring large datasets. + +You can use the [Dash DataTable](https://dash.plotly.com/datatable) in Vizro by importing +```py +from vizro.tables import dash_data_table +``` +The Vizro version of this table differs in one way from the original table: it requires the user to provide a pandas dataframe as source of data. +This must be entered under the argument `data_frame`. +All other [parameters of the Dash DataTable](https://dash.plotly.com/datatable/reference) can be entered as keyword arguments. Note that we are +setting some defaults for some of the arguments to help with styling. + +!!! example "Dash DataTable" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.tables import dash_data_table + + df = px.data.gapminder().query("year == 2007") + + page = vm.Page( + title="Example of a Dash DataTable", + components=[ + vm.Table(id="table", title="Dash DataTable", figure=dash_data_table(data_frame=df)), + ], + controls=[vm.Filter(column="continent")], + ) + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Still requires a .py to register data connector in Data Manager and parse yaml configuration + # See from_yaml example + pages: + - components: + - figure: + _target_: dash_data_table + data_frame: gapminder_2007 + title: Dash DataTable + id: table + type: table + controls: + - column: continent + type: filter + title: Example of a Dash DataTable + ``` + === "Result" + [![Table]][Table] + + [Table]: ../../assets/user_guides/table/table.png + +#### Styling/Modifying the Dash DataTable + +As mentioned above, all [parameters of the Dash DataTable](https://dash.plotly.com/datatable/reference) can be entered as keyword arguments. Below you can find +an example of a styled table where some conditional formatting is applied. There are many more ways to alter the table beyond this showcase. + +??? example "Styled Dash DataTable" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.tables import dash_data_table + + df = px.data.gapminder().query("year == 2007") + + column_definitions = [ + {"name": "country", "id": "country", "type": "text", "editable": False}, + {"name": "continent", "id": "continent", "type": "text"}, + {"name": "year", "id": "year", "type": "datetime"}, + {"name": "lifeExp", "id": "lifeExp", "type": "numeric"}, + {"name": "pop", "id": "pop", "type": "numeric"}, + {"name": "gdpPercap", "id": "gdpPercap", "type": "numeric"}, + ] + + style_data_conditional = [ + { + "if": { + "column_id": "year", + }, + "backgroundColor": "dodgerblue", + "color": "white", + }, + {"if": {"filter_query": "{lifeExp} < 55", "column_id": "lifeExp"}, "backgroundColor": "#85144b", "color": "white"}, + { + "if": {"filter_query": "{gdpPercap} > 10000", "column_id": "gdpPercap"}, + "backgroundColor": "green", + "color": "white", + }, + {"if": {"column_type": "text"}, "textAlign": "left"}, + { + "if": {"state": "active"}, + "backgroundColor": "rgba(0, 116, 217, 0.3)", + "border": "1px solid rgb(0, 116, 217)", + }, + ] + + style_header_conditional = [{"if": {"column_type": "text"}, "textAlign": "left"}] + + page = vm.Page( + title="Example of a styled Dash DataTable", + components=[ + vm.Table( + id="table", + title="Styled table", + figure=dash_data_table( + data_frame=df, + columns=column_definitions, + sort_action="native", + editable=True, + style_data_conditional=style_data_conditional, + style_header_conditional=style_header_conditional, + ), + ), + ], + controls=[vm.Filter(column="continent")], + ) + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Still requires a .py to register data connector in Data Manager and parse yaml configuration + # See from_yaml example + pages: + - components: + - figure: + _target_: dash_data_table + data_frame: gapminder_2007 + sort_action: native + editable: true + columns: + - name: country + id: country + type: text + editable: false + - name: continent + id: continent + type: text + - name: year + id: year + type: datetime + - name: lifeExp + id: lifeExp + type: numeric + - name: pop + id: pop + type: numeric + - name: gdpPercap + id: gdpPercap + type: numeric + style_data_conditional: + - if: + column_id: year + backgroundColor: dodgerblue + color: white + - if: + filter_query: "{lifeExp} < 55" + column_id: lifeExp + backgroundColor: "#85144b" + color: white + - if: + filter_query: "{gdpPercap} > 10000" + column_id: gdpPercap + backgroundColor: green + color: white + - if: + column_type: text + textAlign: left + - if: + state: active + backgroundColor: rgba(0, 116, 217, 0.3) + border: 1px solid rgb(0, 116, 217) + id: table + type: table + controls: + - column: continent + type: filter + title: Dash DataTable + + ``` + === "Result" + [![Table2]][Table2] + + [Table2]: ../../assets/user_guides/table/styled_table.png + +#### Custom Table + +In case you want to add custom logic to a Dash DataTable, e.g. when requiring computations that can be controlled by parameters, it is possible to +create a custom Dash DataTable in Vizro. + +For this, similar to how one would create a [custom chart](../user_guides/custom_charts.md), simply do the following: + +- define a function that returns a `dash_table.DataTable` object +- decorate it with the `@capture("table")` decorator +- the function must accept a `data_frame` argument (of type `pandas.DataFrame`) +- the table should be derived from and require only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) + + +The following example shows a possible version of a custom table. In this case the argument `chosen_columns` was added, which you can control with a parameter: + +??? example "Custom Dash DataTable" + === "app.py" + ```py + from typing import List + + from dash import dash_table + + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.models.types import capture + + df = px.data.gapminder().query("year == 2007") + + + @capture("table") + def my_custom_table(data_frame=None, chosen_columns: List[str] = None): + """Custom table.""" + columns = [{"name": i, "id": i} for i in chosen_columns] + defaults = { + "style_as_list_view": True, + "style_data": {"border_bottom": "1px solid var(--border-subtle-alpha-01)", "height": "40px"}, + "style_header": { + "border_bottom": "1px solid var(--state-overlays-selected-hover)", + "border_top": "1px solid var(--main-container-bg-color)", + "height": "32px", + }, + } + return dash_table.DataTable(data=data_frame.to_dict("records"), columns=columns, **defaults) + + + page = vm.Page( + title="Example of a custom Dash DataTable", + components=[ + vm.Table( + id="custom_table", + title="Custom Dash DataTable", + figure=my_custom_table( + data_frame=df, chosen_columns=["country", "continent", "lifeExp", "pop", "gdpPercap"] + ), + ), + ], + controls=[ + vm.Parameter( + targets=["custom_table.chosen_columns"], + selector=vm.Dropdown(title="Choose columns", options=df.columns.to_list(), multi=True), + ) + ], + ) + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom tables are currently only possible via python configuration + ``` + === "Result" + [![Table3]][Table3] + + [Table3]: ../../assets/user_guides/table/custom_table.png diff --git a/vizro-core/mkdocs.yml b/vizro-core/mkdocs.yml index 5f925659f..65a9ba483 100644 --- a/vizro-core/mkdocs.yml +++ b/vizro-core/mkdocs.yml @@ -12,7 +12,10 @@ nav: - Pages: pages/user_guides/pages.md - Run Methods: pages/user_guides/run.md - Components: - - Charts, Cards, Buttons: pages/user_guides/components.md + - Graphs: pages/user_guides/graph.md + - Tables: pages/user_guides/table.md + - Cards & Buttons: pages/user_guides/components.md + - Controls: - Filters: pages/user_guides/filters.md - Parameters: pages/user_guides/parameters.md diff --git a/vizro-core/schemas/0.1.5.json b/vizro-core/schemas/0.1.5.json index a1cf7a7fc..0e2e91e5d 100644 --- a/vizro-core/schemas/0.1.5.json +++ b/vizro-core/schemas/0.1.5.json @@ -159,6 +159,33 @@ }, "additionalProperties": false }, + "Table": { + "title": "Table", + "description": "Wrapper for table components to visualize in dashboard.\n\nArgs:\n type (Literal[\"table\"]): Defaults to `\"table\"`.\n table (CapturedCallable): Table like object to be displayed. Current choices include:\n [`dash_table.DataTable`](https://dash.plotly.com/datatable).\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", + "type": "object", + "properties": { + "id": { + "title": "Id", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "type": "string" + }, + "type": { + "title": "Type", + "default": "table", + "enum": ["table"], + "type": "string" + }, + "actions": { + "title": "Actions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Action" + } + } + }, + "additionalProperties": false + }, "Layout": { "title": "Layout", "description": "Grid specification to place chart/components on the [`Page`][vizro.models.Page].\n\nArgs:\n grid (List[List[int]]): Grid specification to arrange components on screen.\n row_gap (str): Gap between rows in px. Defaults to `\"12px\"`.\n col_gap (str): Gap between columns in px. Defaults to `\"12px\"`.\n row_min_height (str): Minimum row height in px. Defaults to `\"0px\"`.\n col_min_width (str): Minimum column width in px. Defaults to `\"0px\"`.", @@ -628,7 +655,7 @@ }, "Filter": { "title": "Filter", - "description": "Filter the data supplied to `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> print(repr(Filter(column=\"species\")))\n\nArgs:\n type (Literal[\"filter\"]): Defaults to `\"filter\"`.\n column (str): Column of `DataFrame` to filter.\n targets (List[str]): Target component to be affected by filter. If none are given then target all components on\n the page that use `column`.\n selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`.", + "description": "Filter the data supplied to `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> print(repr(Filter(column=\"species\")))\n\nArgs:\n type (Literal[\"filter\"]): Defaults to `\"filter\"`.\n column (str): Column of `DataFrame` to filter.\n targets (List[ModelID]): Target component to be affected by filter. If none are given then target all components\n on the page that use `column`.\n selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`.", "type": "object", "properties": { "id": { @@ -796,7 +823,8 @@ "mapping": { "button": "#/definitions/Button", "card": "#/definitions/Card", - "graph": "#/definitions/Graph" + "graph": "#/definitions/Graph", + "table": "#/definitions/Table" } }, "oneOf": [ @@ -808,6 +836,9 @@ }, { "$ref": "#/definitions/Graph" + }, + { + "$ref": "#/definitions/Table" } ] } diff --git a/vizro-core/schemas/0.1.6.dev0.json b/vizro-core/schemas/0.1.6.dev0.json index a1cf7a7fc..95b345bcb 100644 --- a/vizro-core/schemas/0.1.6.dev0.json +++ b/vizro-core/schemas/0.1.6.dev0.json @@ -159,6 +159,38 @@ }, "additionalProperties": false }, + "Table": { + "title": "Table", + "description": "Wrapper for table components to visualize in dashboard.\n\nArgs:\n type (Literal[\"table\"]): Defaults to `\"table\"`.\n figure (CapturedCallable): Table like object to be displayed. Current choices include:\n [`dash_table.DataTable`](https://dash.plotly.com/datatable).\n title (str): Title of the table. Defaults to `None`.\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", + "type": "object", + "properties": { + "id": { + "title": "Id", + "description": "ID to identify model. Must be unique throughout the whole dashboard.When no ID is chosen, ID will be automatically generated.", + "type": "string" + }, + "type": { + "title": "Type", + "default": "table", + "enum": ["table"], + "type": "string" + }, + "title": { + "title": "Title", + "description": "Title of the table", + "type": "string" + }, + "actions": { + "title": "Actions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Action" + } + } + }, + "additionalProperties": false + }, "Layout": { "title": "Layout", "description": "Grid specification to place chart/components on the [`Page`][vizro.models.Page].\n\nArgs:\n grid (List[List[int]]): Grid specification to arrange components on screen.\n row_gap (str): Gap between rows in px. Defaults to `\"12px\"`.\n col_gap (str): Gap between columns in px. Defaults to `\"12px\"`.\n row_min_height (str): Minimum row height in px. Defaults to `\"0px\"`.\n col_min_width (str): Minimum column width in px. Defaults to `\"0px\"`.", @@ -628,7 +660,7 @@ }, "Filter": { "title": "Filter", - "description": "Filter the data supplied to `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> print(repr(Filter(column=\"species\")))\n\nArgs:\n type (Literal[\"filter\"]): Defaults to `\"filter\"`.\n column (str): Column of `DataFrame` to filter.\n targets (List[str]): Target component to be affected by filter. If none are given then target all components on\n the page that use `column`.\n selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`.", + "description": "Filter the data supplied to `targets` on the [`Page`][vizro.models.Page].\n\nExamples:\n >>> print(repr(Filter(column=\"species\")))\n\nArgs:\n type (Literal[\"filter\"]): Defaults to `\"filter\"`.\n column (str): Column of `DataFrame` to filter.\n targets (List[ModelID]): Target component to be affected by filter. If none are given then target all components\n on the page that use `column`.\n selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`.", "type": "object", "properties": { "id": { @@ -796,7 +828,8 @@ "mapping": { "button": "#/definitions/Button", "card": "#/definitions/Card", - "graph": "#/definitions/Graph" + "graph": "#/definitions/Graph", + "table": "#/definitions/Table" } }, "oneOf": [ @@ -808,6 +841,9 @@ }, { "$ref": "#/definitions/Graph" + }, + { + "$ref": "#/definitions/Table" } ] } diff --git a/vizro-core/schemas/generate.py b/vizro-core/schemas/generate.py index a171a3129..c7b9ad381 100644 --- a/vizro-core/schemas/generate.py +++ b/vizro-core/schemas/generate.py @@ -22,4 +22,4 @@ print("JSON schema is up to date.") # noqa: T201 else: schema_path.write_text(schema_json) - subprocess.run(f"pre-commit run prettier --files {schema_path}", shell=True, stdout=subprocess.DEVNULL) # nosec + subprocess.run("hatch run lint", shell=True, stdout=subprocess.DEVNULL) # nosec diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index 7e35deee6..f1b45b284 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -21,7 +21,7 @@ class CallbackTriggerDict(TypedDict): # shortened as 'ctd' id: ModelID # the component ID. If it`s a pattern matching ID, it will be a dict. - property: Literal["clickData", "value", "n_clicks"] # the component property used in the callback. + property: Literal["clickData", "value", "n_clicks", "active_cell"] # the component property used in the callback. value: Optional[Any] # the value of the component property at the time the callback was fired. str_id: str # for pattern matching IDs, it`s the stringified dict ID with no white spaces. triggered: bool # a boolean indicating whether this input triggered the callback. @@ -113,17 +113,21 @@ def _update_nested_graph_properties(graph_config: Dict[str, Any], dot_separated_ return graph_config -def _get_parametrized_config(targets: List[str], parameters: List[CallbackTriggerDict]) -> Dict[str, Dict[str, Any]]: +def _get_parametrized_config( + targets: List[ModelID], parameters: List[CallbackTriggerDict] +) -> Dict[ModelID, Dict[str, Any]]: parameterized_config = {} for target in targets: # TODO - avoid calling _captured_callable. Once we have done this we can remove _arguments from # CapturedCallable entirely. - graph_config = deepcopy(model_manager[target].figure._arguments) # type: ignore[index, attr-defined] + graph_config = deepcopy(model_manager[target].figure._arguments) # type: ignore[attr-defined] if "data_frame" in graph_config: graph_config.pop("data_frame") for ctd in parameters: - selector_value = ctd["value"] + selector_value = ctd[ + "value" + ] # TODO: needs to be refactored so that it is independent of implementation details if hasattr(selector_value, "__iter__") and ALL_OPTION in selector_value: # type: ignore[operator] selector: SelectorType = model_manager[ctd["id"]] selector_value = selector.options @@ -148,10 +152,10 @@ def _get_parametrized_config(targets: List[str], parameters: List[CallbackTrigge # Helper functions used in pre-defined actions ---- def _get_filtered_data( - targets: List[str], + targets: List[ModelID], ctds_filters: List[CallbackTriggerDict], ctds_filter_interaction: List[CallbackTriggerDict], -) -> Dict[str, pd.DataFrame]: +) -> Dict[ModelID, pd.DataFrame]: filtered_data = {} for target in targets: data_frame = data_manager._get_component_data(target) @@ -177,8 +181,8 @@ def _get_modified_page_charts( ctds_filter_interaction: List[CallbackTriggerDict], ctds_parameters: List[CallbackTriggerDict], ctd_theme: CallbackTriggerDict, - targets: Optional[List[str]] = None, -) -> Dict[str, Any]: + targets: Optional[List[ModelID]] = None, +) -> Dict[ModelID, Any]: if not targets: targets = [] filtered_data = _get_filtered_data( @@ -192,12 +196,12 @@ def _get_modified_page_charts( parameters=ctds_parameters, ) - outputs = { - target: model_manager[target]( # type: ignore[index, operator] - data_frame=filtered_data[target], - **parameterized_config[target], - ).update_layout(template="vizro_dark" if ctd_theme["value"] else "vizro_light") - for target in targets - } + outputs = {} + for target in targets: + outputs[target] = model_manager[target]( # type: ignore[operator] + data_frame=filtered_data[target], **parameterized_config[target] + ) + if hasattr(outputs[target], "update_layout"): + outputs[target].update_layout(template="vizro_dark" if ctd_theme["value"] else "vizro_light") return outputs diff --git a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py index 2954e1676..2d6b92c39 100644 --- a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py +++ b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py @@ -89,7 +89,7 @@ def _get_inputs_of_controls(action_id: ModelID, control_type: ControlType) -> Li return [ State( component_id=control.selector.id, - component_property="value", + component_property=control.selector._input_property, ) for control in page.controls if isinstance(control, control_type) @@ -107,7 +107,7 @@ def _get_inputs_of_chart_interactions( return [ State( component_id=_get_triggered_model(action_id=ModelID(str(action.id))).id, - component_property="clickData", + component_property="clickData", # TODO: needs to be refactored to abstract implementation detail ) for action in chart_interactions_on_page ] @@ -145,6 +145,10 @@ def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[State]]: def _get_action_callback_outputs(action_id: ModelID) -> Dict[str, Output]: """Creates mapping of target names and their Output.""" action_function = model_manager[action_id].function._function # type: ignore[attr-defined] + # The right solution for mypy here is to not e.g. define new attributes on the base but instead to get mypy to + # recognize that model_manager[action_id] is of type Action and hence has the function attribute. + # Ideally model_manager.__getitem__ would handle this itself, possibly with suitable use of a cast. + # If not then we can do the cast to Action at the point of consumption here to avoid needing mypy ignores. try: targets = model_manager[action_id].function["targets"] # type: ignore[attr-defined] @@ -160,7 +164,7 @@ def _get_action_callback_outputs(action_id: ModelID) -> Dict[str, Output]: return { target: Output( component_id=target, - component_property="figure", + component_property=model_manager[target]._output_property, # type: ignore[attr-defined] allow_duplicate=True, ) for target in targets diff --git a/vizro-core/src/vizro/actions/_filter_action.py b/vizro-core/src/vizro/actions/_filter_action.py index 4516a96dd..16b29b39e 100644 --- a/vizro-core/src/vizro/actions/_filter_action.py +++ b/vizro-core/src/vizro/actions/_filter_action.py @@ -8,16 +8,17 @@ from vizro.actions._actions_utils import ( _get_modified_page_charts, ) +from vizro.managers._model_manager import ModelID from vizro.models.types import capture @capture("action") def _filter( filter_column: str, - targets: List[str], + targets: List[ModelID], filter_function: Callable[[pd.Series, Any], pd.Series], **inputs: Dict[str, Any], -) -> Dict[str, Any]: +) -> Dict[ModelID, Any]: """Filters targeted charts/components on page by interaction with `Filter` control. Args: diff --git a/vizro-core/src/vizro/actions/_on_page_load_action.py b/vizro-core/src/vizro/actions/_on_page_load_action.py index 28e513b31..268fbfe60 100644 --- a/vizro-core/src/vizro/actions/_on_page_load_action.py +++ b/vizro-core/src/vizro/actions/_on_page_load_action.py @@ -13,7 +13,7 @@ @capture("action") -def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Dict[str, Any]: +def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: """Applies controls to charts on page once the page is opened (or refreshed). Args: diff --git a/vizro-core/src/vizro/actions/_parameter_action.py b/vizro-core/src/vizro/actions/_parameter_action.py index caf9faebf..14f492b35 100644 --- a/vizro-core/src/vizro/actions/_parameter_action.py +++ b/vizro-core/src/vizro/actions/_parameter_action.py @@ -7,12 +7,13 @@ from vizro.actions._actions_utils import ( _get_modified_page_charts, ) +from vizro.managers._model_manager import ModelID from vizro.models.types import capture # TODO - consider using dash.Patch() for parameter action @capture("action") -def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[str, Any]: +def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: """Modifies parameters of targeted charts/components on page. Args: @@ -23,10 +24,10 @@ def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[str, Any]: Returns: Dict mapping target component ids to modified charts/components e.g. {'my_scatter': Figure({})} """ - targets = [target.split(".")[0] for target in targets] + target_ids: List[ModelID] = [target.split(".")[0] for target in targets] # type: ignore[misc] return _get_modified_page_charts( - targets=targets, + targets=target_ids, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], ctds_parameters=ctx.args_grouping["parameters"], diff --git a/vizro-core/src/vizro/actions/export_data_action.py b/vizro-core/src/vizro/actions/export_data_action.py index 5af593235..afaded8cf 100644 --- a/vizro-core/src/vizro/actions/export_data_action.py +++ b/vizro-core/src/vizro/actions/export_data_action.py @@ -9,12 +9,13 @@ _get_filtered_data, ) from vizro.managers import model_manager +from vizro.managers._model_manager import ModelID from vizro.models.types import capture @capture("action") def export_data( - targets: Optional[List[str]] = None, + targets: Optional[List[ModelID]] = None, file_format: Literal["csv", "xlsx"] = "csv", **inputs: Dict[str, Any], ) -> Dict[str, Any]: @@ -40,7 +41,7 @@ def export_data( if isinstance(output["id"], dict) and output["id"]["type"] == "download-dataframe" ] for target in targets: - if target not in model_manager: # type: ignore[operator] + if target not in model_manager: raise ValueError(f"Component '{target}' does not exist.") data_frames = _get_filtered_data( @@ -50,7 +51,7 @@ def export_data( ) callback_outputs = {} - for _, target_id in enumerate(targets): + for target_id in targets: if file_format == "csv": writer = data_frames[target_id].to_csv elif file_format == "xlsx": diff --git a/vizro-core/src/vizro/actions/filter_interaction_action.py b/vizro-core/src/vizro/actions/filter_interaction_action.py index 51321a8ab..00e30a64c 100644 --- a/vizro-core/src/vizro/actions/filter_interaction_action.py +++ b/vizro-core/src/vizro/actions/filter_interaction_action.py @@ -7,14 +7,15 @@ from vizro.actions._actions_utils import ( _get_modified_page_charts, ) +from vizro.managers._model_manager import ModelID from vizro.models.types import capture @capture("action") def filter_interaction( - targets: Optional[List[str]] = None, + targets: Optional[List[ModelID]] = None, **inputs: Dict[str, Any], -) -> Dict[str, Any]: +) -> Dict[ModelID, Any]: """Filters targeted charts/components on page by clicking on data points of the source chart. To set up filtering on specific columns of the target chart(s), include these columns in the 'custom_data' diff --git a/vizro-core/src/vizro/managers/_model_manager.py b/vizro-core/src/vizro/managers/_model_manager.py index 160427e4c..cc1b9984d 100644 --- a/vizro-core/src/vizro/managers/_model_manager.py +++ b/vizro-core/src/vizro/managers/_model_manager.py @@ -19,8 +19,6 @@ class DuplicateIDError(ValueError): """Useful for providing a more explicit error message when a model has id set automatically, e.g. Page.""" - pass - class ModelManager: def __init__(self): diff --git a/vizro-core/src/vizro/models/__init__.py b/vizro-core/src/vizro/models/__init__.py index 776a4bfab..e7a517220 100644 --- a/vizro-core/src/vizro/models/__init__.py +++ b/vizro-core/src/vizro/models/__init__.py @@ -1,7 +1,7 @@ # Keep this import at the top to avoid circular imports since it's used in every model. from ._base import VizroBaseModel # noqa: I001 from ._action import Action -from ._components import Card, Graph +from ._components import Card, Graph, Table from ._components.form import Button, Checklist, Dropdown, RadioItems, RangeSlider, Slider from ._controls import Filter, Parameter from ._navigation.navigation import Navigation @@ -9,7 +9,7 @@ from ._layout import Layout from ._page import Page -Page.update_forward_refs(Button=Button, Card=Card, Filter=Filter, Graph=Graph, Parameter=Parameter) +Page.update_forward_refs(Button=Button, Card=Card, Filter=Filter, Graph=Graph, Parameter=Parameter, Table=Table) Dashboard.update_forward_refs(Page=Page, Navigation=Navigation) # Please keep alphabetically ordered @@ -29,5 +29,6 @@ "RadioItems", "RangeSlider", "Slider", + "Table", "VizroBaseModel", ] diff --git a/vizro-core/src/vizro/models/_components/__init__.py b/vizro-core/src/vizro/models/_components/__init__.py index fb92f705d..aaa15d36f 100644 --- a/vizro-core/src/vizro/models/_components/__init__.py +++ b/vizro-core/src/vizro/models/_components/__init__.py @@ -2,5 +2,6 @@ from vizro.models._components.button import Button from vizro.models._components.card import Card from vizro.models._components.graph import Graph +from vizro.models._components.table import Table -__all__ = ["Button", "Card", "Graph"] +__all__ = ["Button", "Card", "Graph", "Table"] diff --git a/vizro-core/src/vizro/models/_components/_components_utils.py b/vizro-core/src/vizro/models/_components/_components_utils.py new file mode 100644 index 000000000..6f6e7c275 --- /dev/null +++ b/vizro-core/src/vizro/models/_components/_components_utils.py @@ -0,0 +1,33 @@ +import logging + +from vizro.managers import data_manager + +logger = logging.getLogger(__name__) + + +def _process_callable_data_frame(captured_callable, values): + data_frame = captured_callable["data_frame"] + + # Enable running e.g. px.scatter("iris") from the Python API and specification of "data_frame": "iris" through JSON. + # In these cases, data already exists in the data manager and just needs to be linked to the component. + if isinstance(data_frame, str): + data_manager._add_component(values["id"], data_frame) + return captured_callable + + # Standard case for px.scatter(df: pd.DataFrame). + # Extract dataframe from the captured function and put it into the data manager. + dataset_name = str(id(data_frame)) + + logger.debug("Adding data to data manager for Figure with id %s", values["id"]) + # If the dataset already exists in the data manager then it's not a problem, it just means that we don't need + # to duplicate it. Just log the exception for debugging purposes. + try: + data_manager[dataset_name] = data_frame + except ValueError as exc: + logger.debug(exc) + + data_manager._add_component(values["id"], dataset_name) + + # No need to keep the data in the captured function any more so remove it to save memory. + del captured_callable["data_frame"] + return captured_callable diff --git a/vizro-core/src/vizro/models/_components/form/checklist.py b/vizro-core/src/vizro/models/_components/form/checklist.py index 3bb4aacff..8fec2870e 100644 --- a/vizro-core/src/vizro/models/_components/form/checklist.py +++ b/vizro-core/src/vizro/models/_components/form/checklist.py @@ -1,7 +1,7 @@ from typing import List, Literal, Optional from dash import dcc, html -from pydantic import Field, root_validator, validator +from pydantic import Field, PrivateAttr, root_validator, validator from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -27,6 +27,9 @@ class Checklist(VizroBaseModel): title: Optional[str] = Field(None, description="Title to be displayed") actions: List[Action] = [] + # Component properties for actions and interactions + _input_property: str = PrivateAttr("value") + # Re-used validators _set_actions = _action_validator_factory("value") _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) diff --git a/vizro-core/src/vizro/models/_components/form/dropdown.py b/vizro-core/src/vizro/models/_components/form/dropdown.py index 4b8e8014a..5db1a375d 100755 --- a/vizro-core/src/vizro/models/_components/form/dropdown.py +++ b/vizro-core/src/vizro/models/_components/form/dropdown.py @@ -1,7 +1,7 @@ from typing import List, Literal, Optional, Union from dash import dcc, html -from pydantic import Field, root_validator, validator +from pydantic import Field, PrivateAttr, root_validator, validator from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -31,6 +31,9 @@ class Dropdown(VizroBaseModel): title: Optional[str] = Field(None, description="Title to be displayed") actions: List[Action] = [] + # Component properties for actions and interactions + _input_property: str = PrivateAttr("value") + # Re-used validators _set_actions = _action_validator_factory("value") _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) diff --git a/vizro-core/src/vizro/models/_components/form/radio_items.py b/vizro-core/src/vizro/models/_components/form/radio_items.py index cfccf333e..57e460206 100644 --- a/vizro-core/src/vizro/models/_components/form/radio_items.py +++ b/vizro-core/src/vizro/models/_components/form/radio_items.py @@ -1,7 +1,7 @@ from typing import List, Literal, Optional from dash import dcc, html -from pydantic import Field, root_validator, validator +from pydantic import Field, PrivateAttr, root_validator, validator from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -28,6 +28,9 @@ class RadioItems(VizroBaseModel): title: Optional[str] = Field(None, description="Title to be displayed") actions: List[Action] = [] + # Component properties for actions and interactions + _input_property: str = PrivateAttr("value") + # Re-used validators _set_actions = _action_validator_factory("value") _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) diff --git a/vizro-core/src/vizro/models/_components/form/range_slider.py b/vizro-core/src/vizro/models/_components/form/range_slider.py index fc38daf75..64326c711 100644 --- a/vizro-core/src/vizro/models/_components/form/range_slider.py +++ b/vizro-core/src/vizro/models/_components/form/range_slider.py @@ -1,7 +1,7 @@ from typing import Dict, List, Literal, Optional from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html -from pydantic import Field, validator +from pydantic import Field, PrivateAttr, validator from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -43,6 +43,9 @@ class RangeSlider(VizroBaseModel): title: Optional[str] = Field(None, description="Title to be displayed.") actions: List[Action] = [] + # Component properties for actions and interactions + _input_property: str = PrivateAttr("value") + # Re-used validators _validate_max = validator("max", allow_reuse=True)(validate_max) _validate_value = validator("value", allow_reuse=True)(validate_slider_value) diff --git a/vizro-core/src/vizro/models/_components/form/slider.py b/vizro-core/src/vizro/models/_components/form/slider.py index 291ca5030..5e6607f7e 100644 --- a/vizro-core/src/vizro/models/_components/form/slider.py +++ b/vizro-core/src/vizro/models/_components/form/slider.py @@ -1,7 +1,7 @@ from typing import Dict, List, Literal, Optional from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html -from pydantic import Field, validator +from pydantic import Field, PrivateAttr, validator from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -41,6 +41,9 @@ class Slider(VizroBaseModel): title: Optional[str] = Field(None, description="Title to be displayed.") actions: List[Action] = [] + # Component properties for actions and interactions + _input_property: str = PrivateAttr("value") + # Re-used validators _validate_max = validator("max", allow_reuse=True)(validate_max) _validate_value = validator("value", allow_reuse=True)(validate_slider_value) diff --git a/vizro-core/src/vizro/models/_components/graph.py b/vizro-core/src/vizro/models/_components/graph.py index d05cc37bf..253007582 100644 --- a/vizro-core/src/vizro/models/_components/graph.py +++ b/vizro-core/src/vizro/models/_components/graph.py @@ -3,12 +3,13 @@ from dash import dcc from plotly import graph_objects as go -from pydantic import Field, validator +from pydantic import Field, PrivateAttr, validator import vizro.plotly.express as px from vizro.managers import data_manager from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory +from vizro.models._components._components_utils import _process_callable_data_frame from vizro.models._models_utils import _log_call from vizro.models.types import CapturedCallable @@ -40,36 +41,12 @@ class Graph(VizroBaseModel): figure: CapturedCallable = Field(..., import_path=px) actions: List[Action] = [] + # Component properties for actions and interactions + _output_property: str = PrivateAttr("figure") + # Re-used validators _set_actions = _action_validator_factory("clickData") - - @validator("figure") - def process_figure_data_frame(cls, figure, values): - data_frame = figure["data_frame"] - - # Enable running px.scatter("iris") from the Python API and specification of "data_frame": "iris" through JSON. - # In these cases, data already exists in the data manager and just needs to be linked to the component. - if isinstance(data_frame, str): - data_manager._add_component(values["id"], data_frame) - return figure - - # Standard case for px.scatter(df: pd.DataFrame). - # Extract dataframe from the captured function and put it into the data manager. - dataset_name = str(id(data_frame)) - - logger.debug("Adding data to data manager for Graph with id %s", values["id"]) - # If the dataset already exists in the data manager then it's not a problem, it just means that we don't need - # to duplicate it. Just log the exception for debugging purposes. - try: - data_manager[dataset_name] = data_frame - except ValueError as exc: - logger.debug(exc) - - data_manager._add_component(values["id"], dataset_name) - - # No need to keep the data in the captured function any more so remove it to save memory. - del figure["data_frame"] - return figure + _validate_callable = validator("figure", allow_reuse=True)(_process_callable_data_frame) # Convenience wrapper/syntactic sugar. def __call__(self, **kwargs): diff --git a/vizro-core/src/vizro/models/_components/table.py b/vizro-core/src/vizro/models/_components/table.py new file mode 100644 index 000000000..8215cdacc --- /dev/null +++ b/vizro-core/src/vizro/models/_components/table.py @@ -0,0 +1,65 @@ +import logging +from typing import List, Literal, Optional + +from dash import dash_table, dcc, html +from pydantic import Field, PrivateAttr, validator + +import vizro.tables as vt +from vizro.managers import data_manager +from vizro.models import Action, VizroBaseModel +from vizro.models._action._actions_chain import _action_validator_factory +from vizro.models._components._components_utils import _process_callable_data_frame +from vizro.models._models_utils import _log_call +from vizro.models.types import CapturedCallable + +logger = logging.getLogger(__name__) + + +class Table(VizroBaseModel): + """Wrapper for table components to visualize in dashboard. + + Args: + type (Literal["table"]): Defaults to `"table"`. + figure (CapturedCallable): Table like object to be displayed. Current choices include: + [`dash_table.DataTable`](https://dash.plotly.com/datatable). + title (str): Title of the table. Defaults to `None`. + actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`. + """ + + type: Literal["table"] = "table" + figure: CapturedCallable = Field(..., import_path=vt, description="Table to be visualized on dashboard") + title: Optional[str] = Field(None, description="Title of the table") + actions: List[Action] = [] + + # Component properties for actions and interactions + _output_property: str = PrivateAttr("children") + + # validator + set_actions = _action_validator_factory("active_cell") # type: ignore[pydantic-field] + _validate_callable = validator("figure", allow_reuse=True, always=True)(_process_callable_data_frame) + + # Convenience wrapper/syntactic sugar. + def __call__(self, **kwargs): + kwargs.setdefault("data_frame", data_manager._get_component_data(self.id)) # type: ignore[arg-type] + return self.figure(**kwargs) + + # Convenience wrapper/syntactic sugar. + def __getitem__(self, arg_name: str): + # pydantic discriminated union validation seems to try Table["type"], which throws an error unless we + # explicitly redirect it to the correct attribute. + if arg_name == "type": + return self.type + return self.figure[arg_name] + + @_log_call + def build(self): + return dcc.Loading( + html.Div( + [ + html.H3(self.title, className="table-title") if self.title else None, + html.Div(dash_table.DataTable(), id=self.id), + ], + className="table-container", + id=f"{self.id}_outer", + ) + ) diff --git a/vizro-core/src/vizro/models/_controls/filter.py b/vizro-core/src/vizro/models/_controls/filter.py index 751d0089d..565a575d1 100644 --- a/vizro-core/src/vizro/models/_controls/filter.py +++ b/vizro-core/src/vizro/models/_controls/filter.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: from vizro.models import Page +from vizro.managers._model_manager import ModelID # TODO: Add temporal when relevant component is available SELECTOR_DEFAULTS = {"numerical": RangeSlider, "categorical": Dropdown} @@ -57,14 +58,14 @@ class Filter(VizroBaseModel): Args: type (Literal["filter"]): Defaults to `"filter"`. column (str): Column of `DataFrame` to filter. - targets (List[str]): Target component to be affected by filter. If none are given then target all components on - the page that use `column`. + targets (List[ModelID]): Target component to be affected by filter. If none are given then target all components + on the page that use `column`. selector (Optional[SelectorType]): See [SelectorType][vizro.models.types.SelectorType]. Defaults to `None`. """ type: Literal["filter"] = "filter" column: str = Field(..., description="Column of DataFrame to filter.") - targets: List[str] = Field( + targets: List[ModelID] = Field( [], description="Target component to be affected by filter. " "If none are given then target all components on the page that use `column`.", diff --git a/vizro-core/src/vizro/models/_page.py b/vizro-core/src/vizro/models/_page.py index ae40c2e48..6e57d89ad 100644 --- a/vizro-core/src/vizro/models/_page.py +++ b/vizro-core/src/vizro/models/_page.py @@ -98,7 +98,7 @@ def __init__(self, **data): @_log_call def pre_build(self): # TODO: Remove default on page load action if possible - if any(isinstance(component, Graph) for component in self.components): + if any(hasattr(component, "figure") for component in self.components): self.actions = [ ActionsChain( id=f"{ON_PAGE_LOAD_ACTION_PREFIX}_{self.id}", diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index ba4ae9eb9..7d4292efc 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -29,10 +29,11 @@ class CapturedCallable: `functools.partial`. Ready-to-use `CapturedCallable` instances are provided by Vizro. In this case refer to the user guide on - [Charts/Graph][graph] or [Actions][pre-defined-actions] to see available choices. + [Charts/Graph][graph], [Table][table] or [Actions][pre-defined-actions] to see available choices. (Advanced) In case you would like to create your own `CapturedCallable`, please refer to the user guide on - [custom charts](../user_guides/custom_charts.md) or [custom actions][custom-actions]. + [custom charts](../user_guides/custom_charts.md), [custom tables][custom-table] or + [custom actions][custom-actions]. """ def __init__(self, function, /, *args, **kwargs): @@ -138,8 +139,9 @@ def _parse_json( elif not isinstance(callable_config, dict): raise ValueError( "You must provide a valid CapturedCallable object. If you are using a plotly express figure, ensure " - "that you are using `import vizro.plotly.express as px`. If you are using a custom figure or action, " - "that your function uses the @capture decorator." + "that you are using `import vizro.plotly.express as px`. If you are using a table figure, make " + "sure you are using `from vizro.tables import dash_data_table`. If you are using a custom figure or " + "action, that your function uses the @capture decorator." ) # Try to import function given in _target_ from the import_path property of the pydantic field. @@ -176,11 +178,15 @@ class capture: """Captures a function call to create a [`CapturedCallable`][vizro.models.types.CapturedCallable]. This is used to add the functionality required to make graphs and actions work in a dashboard. - Typically it should be used as a function decorator. There are two possible modes: `"graph"` and `"action"`. + Typically it should be used as a function decorator. There are three possible modes: `"graph"`, `"table"` and + `"action"`. Examples: >>> @capture("graph") - >>> def plot_function(): + >>> def graph_function(): + >>> ... + >>> @capture("table") + >>> def table_function(): >>> ... >>> @capture("action") >>> def action_function(): @@ -190,15 +196,16 @@ class capture: [custom charts](../user_guides/custom_charts.md). """ - def __init__(self, mode: Literal["graph", "action"]): - """Instantiates the decorator to capture a function call. Valid modes are "graph" and "action".""" + def __init__(self, mode: Literal["graph", "action", "table"]): + """Instantiates the decorator to capture a function call. Valid modes are "graph", "table" and "action".""" self._mode = mode def __call__(self, func, /): """Produces a CapturedCallable or _DashboardReadyFigure. - mode="action" gives a CapturedCallable, while mode="graph" gives a _DashboardReadyFigure that contains a - CapturedCallable. In both cases, the CapturedCallable is based on func and the provided *args and **kwargs. + mode="action" and mode="table" give a CapturedCallable, while mode="graph" gives a _DashboardReadyFigure that + contains a CapturedCallable. In both cases, the CapturedCallable is based on func and the provided + *args and **kwargs. """ if self._mode == "graph": # The more difficult case, where we need to still have a valid plotly figure that renders in a notebook. @@ -244,8 +251,25 @@ def wrapped(*args, **kwargs): return CapturedCallable(func, *args, **kwargs) return wrapped + elif self._mode == "table": + + @functools.wraps(func) + def wrapped(*args, **kwargs): + if "data_frame" not in inspect.signature(func).parameters: + raise ValueError(f"{func.__name__} must have data_frame argument to use capture('table').") + + captured_callable: CapturedCallable = CapturedCallable(func, *args, **kwargs) - raise ValueError("Valid modes of the capture decorator are @capture('graph') and @capture('action').") + try: + captured_callable["data_frame"] + except KeyError as exc: + raise ValueError(f"{func.__name__} must supply a value to data_frame argument.") from exc + return captured_callable + + return wrapped + raise ValueError( + "Valid modes of the capture decorator are @capture('graph'), @capture('action') or @capture('table')." + ) # Types used for selector values and options. Note the docstrings here are rendered on the API reference. @@ -296,14 +320,15 @@ class OptionsDictType(TypedDict): [`Parameter`][vizro.models.Parameter].""" ComponentType = Annotated[ - Union["Button", "Card", "Graph"], + Union["Button", "Card", "Graph", "Table"], Field( discriminator="type", description="Component that makes up part of the layout on the page.", ), ] """Discriminated union. Type of component that makes up part of the layout on the page: -[`Button`][vizro.models.Button], [`Card`][vizro.models.Card] or [`Graph`][vizro.models.Graph].""" +[`Button`][vizro.models.Button], [`Card`][vizro.models.Card], [`Table`][vizro.models.Table] or +[`Graph`][vizro.models.Graph].""" # Types used for pages values in the Navigation model. NavigationPagesType = Annotated[ diff --git a/vizro-core/src/vizro/static/css/dropdown.css b/vizro-core/src/vizro/static/css/dropdown.css index 4db0b0883..5f5fcca63 100644 --- a/vizro-core/src/vizro/static/css/dropdown.css +++ b/vizro-core/src/vizro/static/css/dropdown.css @@ -36,9 +36,15 @@ div.page_container .Select-control { height: 32px; } +div.page_container .Select.is-focused > .Select-control { + background-color: var(--field-enabled); +} + /* User input */ -div.page_container .Select-input { +div.page_container .dash-dropdown .Select-input { + display: block; height: var(--tag-height); + margin-left: unset; } div.page_container.Select-input > input { @@ -139,5 +145,5 @@ wrapper **/ } .Select-input > input { - padding: 0; + padding: 0 !important; /*Required so tags don't jump caused by adding table */ } diff --git a/vizro-core/src/vizro/static/css/scroll_bar.css b/vizro-core/src/vizro/static/css/scroll_bar.css index 6af55c15a..2f1122509 100644 --- a/vizro-core/src/vizro/static/css/scroll_bar.css +++ b/vizro-core/src/vizro/static/css/scroll_bar.css @@ -27,3 +27,7 @@ .component_container::-webkit-scrollbar-thumb { border-color: var(--main-container-bg-color); } + +.table-container::-webkit-scrollbar-thumb { + border-color: var(--surfaces-bg-02); +} diff --git a/vizro-core/src/vizro/static/css/table.css b/vizro-core/src/vizro/static/css/table.css new file mode 100644 index 000000000..2f00727c8 --- /dev/null +++ b/vizro-core/src/vizro/static/css/table.css @@ -0,0 +1,52 @@ +div.right_side + .dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + th { + background-color: var(--main-container-bg-color); +} + +div.right_side + .dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + td { + background-color: var(--main-container-bg-color); +} + +div.right_side + .dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + table { + --hover: var(--main-container-bg-color); +} + +div.right_side + .dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + td + .dash-cell-value.unfocused { + color: var(--text-primary); + font-size: 14px; +} + +div.right_side + .dash-table-container + .dash-spreadsheet-container + .dash-spreadsheet-inner + .column-header-name { + color: var( + --text-primary + ); /* Should be text-secondary when hover color is implemented */ + font-size: 14px; +} + +.table-container { + height: 100%; + width: 100%; + overflow: auto; + padding: 12px; + padding-left: 32px; +} diff --git a/vizro-core/src/vizro/tables/__init__.py b/vizro-core/src/vizro/tables/__init__.py new file mode 100644 index 000000000..5a813ad8c --- /dev/null +++ b/vizro-core/src/vizro/tables/__init__.py @@ -0,0 +1,4 @@ +from vizro.tables.dash_table import dash_data_table + +# Please keep alphabetically ordered +__all__ = ["dash_data_table"] diff --git a/vizro-core/src/vizro/tables/dash_table.py b/vizro-core/src/vizro/tables/dash_table.py new file mode 100644 index 000000000..f28315834 --- /dev/null +++ b/vizro-core/src/vizro/tables/dash_table.py @@ -0,0 +1,35 @@ +"""Module containing the standard implementation of `dash_table.DataTable`.""" +from collections import defaultdict +from typing import Any, Dict, Mapping + +import pandas as pd +from dash import dash_table + +from vizro.models.types import capture + + +def _set_defaults_nested(supplied: Mapping[str, Any], defaults: Mapping[str, Any]) -> Dict[str, Any]: + supplied = defaultdict(dict, supplied) + for default_key, default_value in defaults.items(): + if isinstance(default_value, Mapping): + supplied[default_key] = _set_defaults_nested(supplied[default_key], default_value) + else: + supplied.setdefault(default_key, default_value) + return dict(supplied) + + +@capture("table") +def dash_data_table(data_frame: pd.DataFrame, **kwargs): + """Standard `dash_table.DataTable`.""" + defaults = { + "columns": [{"name": i, "id": i} for i in data_frame.columns], + "style_as_list_view": True, + "style_data": {"border_bottom": "1px solid var(--border-subtle-alpha-01)", "height": "40px"}, + "style_header": { + "border_bottom": "1px solid var(--state-overlays-selected-hover)", + "border_top": "1px solid var(--main-container-bg-color)", + "height": "32px", + }, + } + kwargs = _set_defaults_nested(kwargs, defaults) + return dash_table.DataTable(data=data_frame.to_dict("records"), **kwargs) diff --git a/vizro-core/tests/unit/vizro/conftest.py b/vizro-core/tests/unit/vizro/conftest.py index 3570e6dd9..ba3cff2bd 100644 --- a/vizro-core/tests/unit/vizro/conftest.py +++ b/vizro-core/tests/unit/vizro/conftest.py @@ -1,9 +1,11 @@ """Fixtures to be shared across several tests.""" +import plotly.graph_objects as go import pytest import vizro.models as vm import vizro.plotly.express as px +from vizro.tables import dash_data_table @pytest.fixture @@ -24,6 +26,16 @@ def standard_px_chart(gapminder): ) +@pytest.fixture +def standard_dash_table(gapminder): + return dash_data_table(data_frame=gapminder) + + +@pytest.fixture +def standard_go_chart(gapminder): + return go.Figure(data=go.Scatter(x=gapminder["gdpPercap"], y=gapminder["lifeExp"], mode="markers")) + + @pytest.fixture() def page1(): return vm.Page(title="Page 1", components=[vm.Button(), vm.Button()]) diff --git a/vizro-core/tests/unit/vizro/models/_components/test_graph.py b/vizro-core/tests/unit/vizro/models/_components/test_graph.py index 616d6d4e9..794837a97 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_graph.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_graph.py @@ -14,11 +14,6 @@ from vizro.models._components.graph import create_empty_fig -@pytest.fixture -def standard_go_chart(gapminder): - return go.Figure(data=go.Scatter(x=gapminder["gdpPercap"], y=gapminder["lifeExp"], mode="markers")) - - @pytest.fixture def standard_px_chart_with_str_dataframe(): return px.scatter( diff --git a/vizro-core/tests/unit/vizro/models/_components/test_table.py b/vizro-core/tests/unit/vizro/models/_components/test_table.py new file mode 100644 index 000000000..5ef3d9a83 --- /dev/null +++ b/vizro-core/tests/unit/vizro/models/_components/test_table.py @@ -0,0 +1,123 @@ +"""Unit tests for vizro.models.Table.""" +import json + +import plotly +import pytest +from dash import dash_table, dcc, html +from pydantic import ValidationError + +import vizro.models as vm +import vizro.plotly.express as px +from vizro.managers import data_manager +from vizro.models._action._action import Action +from vizro.tables import dash_data_table + + +@pytest.fixture +def dash_table_with_arguments(): + return dash_data_table(data_frame=px.data.gapminder(), style_header={"border": "1px solid green"}) + + +@pytest.fixture +def dash_table_with_str_dataframe(): + return dash_data_table(data_frame="gapminder") + + +@pytest.fixture +def expected_table(): + return dcc.Loading( + html.Div( + [ + None, + html.Div(dash_table.DataTable(), id="text_table"), + ], + className="table-container", + id="text_table_outer", + ) + ) + + +class TestDunderMethodsTable: + def test_create_graph_mandatory_only(self, standard_dash_table): + table = vm.Table(figure=standard_dash_table) + + assert hasattr(table, "id") + assert table.type == "table" + assert table.figure == standard_dash_table + assert table.actions == [] + + @pytest.mark.parametrize("id", ["id_1", "id_2"]) + def test_create_table_mandatory_and_optional(self, standard_dash_table, id): + table = vm.Table( + figure=standard_dash_table, + id=id, + actions=[], + ) + + assert table.id == id + assert table.type == "table" + assert table.figure == standard_dash_table + + def test_mandatory_figure_missing(self): + with pytest.raises(ValidationError, match="field required"): + vm.Table() + + def test_failed_table_with_no_captured_callable(self, standard_go_chart): + with pytest.raises(ValidationError, match="must provide a valid CapturedCallable object"): + vm.Table( + figure=standard_go_chart, + ) + + @pytest.mark.xfail(reason="This test is failing as we are not yet detecting different types of captured callables") + def test_failed_table_with_wrong_captured_callable(self, standard_px_chart): + with pytest.raises(ValidationError, match="must provide a valid table function vm.Table"): + vm.Table( + figure=standard_px_chart, + ) + + def test_getitem_known_args(self, dash_table_with_arguments): + table = vm.Table(figure=dash_table_with_arguments) + assert table["style_header"] == {"border": "1px solid green"} + assert table["type"] == "table" + + def test_getitem_unknown_args(self, standard_dash_table): + table = vm.Table(figure=standard_dash_table) + with pytest.raises(KeyError): + table["unknown_args"] + + def test_set_action_via_validator(self, standard_dash_table, test_action_function): + table = vm.Table(figure=standard_dash_table, actions=[Action(function=test_action_function)]) + actions_chain = table.actions[0] + assert actions_chain.trigger.component_property == "active_cell" + + +class TestProcessTableDataFrame: + def test_process_figure_data_frame_str_df(self, dash_table_with_str_dataframe, gapminder): + data_manager["gapminder"] = gapminder + table_with_str_df = vm.Table( + id="table", + figure=dash_table_with_str_dataframe, + ) + assert data_manager._get_component_data("table").equals(gapminder) + assert table_with_str_df["data_frame"] == "gapminder" + + def test_process_figure_data_frame_df(self, standard_dash_table, gapminder): + table_with_str_df = vm.Table( + id="table", + figure=standard_dash_table, + ) + assert data_manager._get_component_data("table").equals(gapminder) + with pytest.raises(KeyError, match="'data_frame'"): + table_with_str_df.figure["data_frame"] + + +class TestBuildTable: + def test_table_build(self, standard_dash_table, expected_table): + table = vm.Table( + id="text_table", + figure=standard_dash_table, + ) + + result = json.loads(json.dumps(table.build(), cls=plotly.utils.PlotlyJSONEncoder)) + expected = json.loads(json.dumps(expected_table, cls=plotly.utils.PlotlyJSONEncoder)) + assert result == expected diff --git a/vizro-core/tests/unit/vizro/models/test_page.py b/vizro-core/tests/unit/vizro/models/test_page.py index 78ef54520..0ccac6ac0 100644 --- a/vizro-core/tests/unit/vizro/models/test_page.py +++ b/vizro-core/tests/unit/vizro/models/test_page.py @@ -90,10 +90,15 @@ def test_set_layout_invalid(self): with pytest.raises(ValidationError, match="Number of page and grid components need to be the same."): vm.Page(title="Page 4", components=[vm.Button()], layout=vm.Layout(grid=[[0, 1]])) - def test_valid_component_types(self, standard_px_chart): + def test_valid_component_types(self, standard_px_chart, standard_dash_table): vm.Page( title="Page Title", - components=[vm.Graph(figure=standard_px_chart), vm.Card(text="""# Header 1"""), vm.Button()], + components=[ + vm.Graph(figure=standard_px_chart), + vm.Card(text="""# Header 1"""), + vm.Button(), + vm.Table(figure=standard_dash_table), + ], ) @pytest.mark.parametrize( @@ -101,7 +106,7 @@ def test_valid_component_types(self, standard_px_chart): [vm.Checklist(), vm.Dropdown(), vm.RadioItems(), vm.RangeSlider(), vm.Slider()], ) def test_invalid_component_types(self, test_component): - with pytest.raises(ValidationError, match=re.escape("(allowed values: 'button', 'card', 'graph')")): + with pytest.raises(ValidationError, match=re.escape("(allowed values: 'button', 'card', 'graph', 'table')")): vm.Page(title="Page Title", components=[test_component]) def test_valid_control_types(self, standard_px_chart): diff --git a/vizro-core/tests/unit/vizro/tables/test_dash_table.py b/vizro-core/tests/unit/vizro/tables/test_dash_table.py new file mode 100644 index 000000000..722f89a6b --- /dev/null +++ b/vizro-core/tests/unit/vizro/tables/test_dash_table.py @@ -0,0 +1,22 @@ +import pytest + +from vizro.tables.dash_table import _set_defaults_nested + + +@pytest.fixture +def default_dictionary(): + return {"a": {"b": {"c": 1, "d": 2}}, "e": 3} + + +@pytest.mark.parametrize( + "input, expected", + [ + ({}, {"a": {"b": {"c": 1, "d": 2}}, "e": 3}), # nothing supplied + ({"e": 10}, {"a": {"b": {"c": 1, "d": 2}}, "e": 10}), # flat main key + ({"a": {"b": {"c": 11, "d": 12}}}, {"a": {"b": {"c": 11, "d": 12}}, "e": 3}), # updated multiple nested keys + ({"a": {"b": {"c": 1, "d": {"f": 42}}}}, {"a": {"b": {"c": 1, "d": {"f": 42}}}, "e": 3}), # add new dict + ({"a": {"b": {"c": 5}}}, {"a": {"b": {"c": 5, "d": 2}}, "e": 3}), # arbitrary nesting + ], +) +def test_set_defaults_nested(default_dictionary, input, expected): + assert _set_defaults_nested(input, default_dictionary) == expected