diff --git a/.github/requirements.txt b/.github/requirements.txt index d410eb7ed..47df11979 100644 --- a/.github/requirements.txt +++ b/.github/requirements.txt @@ -3,7 +3,7 @@ pre-commit coverage[toml]>=6.5 pytest dash[testing] -chromedriver-autoinstaller +chromedriver-autoinstaller-fix toml pyyaml openpyxl diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml index aa18ce55c..6c288ad26 100644 --- a/.github/workflows/check-release.yml +++ b/.github/workflows/check-release.yml @@ -84,7 +84,7 @@ jobs: cd .. pip download vizro==${{needs.check-version.outputs.package_version}} -d . --no-deps --timeout 300 pypi=$(md5sum vizro-${{needs.check-version.outputs.package_version}}-py3-none-any.whl) - if [ $local = $pypi ]; then; echo "md5 hash is the same"; else; echo "md5 hash is not the same" exit 1; fi; + if [[ $local = $pypi ]]; then echo "md5 hash is the same"; else echo "md5 hash is not the same"; exit 1; fi version-bump: needs: [check-version, build-publish] @@ -109,7 +109,6 @@ jobs: hatch version patch,dev hatch run changelog:add hatch run schema - hatch run lint || hatch run lint git config user.email "145135826+vizro-svc@users.noreply.github.com" git config user.name "Vizro Team" git add -A diff --git a/.github/workflows/checks-vizro-ai.yml b/.github/workflows/checks-vizro-ai.yml index 09e7bc44b..e85b86435 100644 --- a/.github/workflows/checks-vizro-ai.yml +++ b/.github/workflows/checks-vizro-ai.yml @@ -16,7 +16,6 @@ on: concurrency: group: checks-ai-${{ github.head_ref }} - cancel-in-progress: true env: PYTHONUNBUFFERED: "1" diff --git a/.github/workflows/checks-vizro-core.yml b/.github/workflows/checks-vizro-core.yml index dee429588..6bfc49684 100644 --- a/.github/workflows/checks-vizro-core.yml +++ b/.github/workflows/checks-vizro-core.yml @@ -17,7 +17,6 @@ on: concurrency: group: checks-core-${{ github.head_ref }} - cancel-in-progress: true env: PYTHONUNBUFFERED: "1" diff --git a/.github/workflows/lint-vizro-all.yml b/.github/workflows/lint-vizro-all.yml index ea820acd9..6836ff174 100644 --- a/.github/workflows/lint-vizro-all.yml +++ b/.github/workflows/lint-vizro-all.yml @@ -13,7 +13,6 @@ on: concurrency: group: lint-${{ github.head_ref }} - cancel-in-progress: true env: PYTHONUNBUFFERED: "1" @@ -40,8 +39,5 @@ jobs: - name: Install Hatch run: pip install --upgrade hatch - - name: List dependencies - run: hatch run all.py${{ matrix.python-version }}:pip freeze - - name: Lint - run: hatch run all.py${{ matrix.python-version }}:lint + run: hatch run lint:lint diff --git a/.github/workflows/test-integration-vizro-ai.yml b/.github/workflows/test-integration-vizro-ai.yml index 4671d1d37..eebcd3fcb 100644 --- a/.github/workflows/test-integration-vizro-ai.yml +++ b/.github/workflows/test-integration-vizro-ai.yml @@ -1,55 +1,56 @@ -#name: test-integration-vizro-ai -# -#defaults: -# run: -# working-directory: vizro-core -# -##### TODO: adjust below according to other scripts -#on: -# # push: -# # branches: [main] -# pull_request: -# # branches: -# # - "main" -# -#concurrency: -# group: test-integration-${{ github.head_ref }} -# cancel-in-progress: true -# -#env: -# PYTHONUNBUFFERED: "1" -# FORCE_COLOR: "1" -# -#jobs: -# run: -# name: Python ${{ matrix.python-version }} on ${{ matrix.os }} -# runs-on: ${{ matrix.os }} -# strategy: -# fail-fast: false -# matrix: -# os: [ubuntu-latest, windows-latest] -# python-version: ["3.9", "3.10", "3.11"] -# -# steps: -# - uses: actions/checkout@v4 -# - name: Get branch name -# id: branch-name -# uses: tj-actions/branch-names@v7 -# -# - name: Set up Python ${{ matrix.python-version }} -# uses: actions/setup-python@v4 -# with: -# python-version: ${{ matrix.python-version }} -# -# - uses: actions/cache@v3 -# with: -# path: ${{ env.pythonLocation }} -# key: ${{ matrix.os }}-${{ matrix.python-version }}-${{ steps.branch-name.outputs.current_branch }}-pip-${{ hashFiles('hatch.toml') }}-${{ hashFiles('pyproject.toml') }} -# restore-keys: | -# ${{ matrix.os }}-${{ matrix.python-version }}-${{ steps.branch-name.outputs.current_branch }}-pip- -# -# - name: Run ubuntu integration tests -# if: ${{ matrix.os == 'ubuntu-latest' }} -# run: | -# pip install --upgrade hatch -# hatch run all.py${{ matrix.python-version }}:test-integration +name: test-integration-vizro-ai + +defaults: + run: + working-directory: vizro-ai + +on: + push: + branches: [main] + pull_request: + branches: + - "main" + +concurrency: + group: test-integration-${{ github.head_ref }} + +env: + PYTHONUNBUFFERED: "1" + FORCE_COLOR: "1" + +jobs: + run: + name: Python ${{ matrix.python-version }} on Linux + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Hatch + run: pip install --upgrade hatch + + - name: Run vizro-ai integration tests with pypi vizro + run: | + export OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + export OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE }} + hatch run all.py${{ matrix.python-version }}:test-integration + + - name: Run vizro-ai integration tests with local vizro + run: | + export OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + export OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE }} + cd ../vizro-core + hatch build + cd ../vizro-ai + hatch run all.py${{ matrix.python-version }}:pip install ../vizro-core/dist/vizro*.tar.gz + hatch run all.py${{ matrix.python-version }}:test-integration diff --git a/.github/workflows/test-integration-vizro-core.yml b/.github/workflows/test-integration-vizro-core.yml index c1dc63341..637b3df7b 100644 --- a/.github/workflows/test-integration-vizro-core.yml +++ b/.github/workflows/test-integration-vizro-core.yml @@ -13,7 +13,6 @@ on: concurrency: group: test-integration-${{ github.head_ref }} - cancel-in-progress: true env: PYTHONUNBUFFERED: "1" diff --git a/.github/workflows/test-unit-vizro-ai.yml b/.github/workflows/test-unit-vizro-ai.yml index feaf9d826..7fb4d0523 100644 --- a/.github/workflows/test-unit-vizro-ai.yml +++ b/.github/workflows/test-unit-vizro-ai.yml @@ -13,7 +13,6 @@ on: concurrency: group: test-unit-${{ github.head_ref }} - cancel-in-progress: true env: PYTHONUNBUFFERED: "1" diff --git a/.github/workflows/test-unit-vizro-core.yml b/.github/workflows/test-unit-vizro-core.yml index e5fe02d51..aef297520 100644 --- a/.github/workflows/test-unit-vizro-core.yml +++ b/.github/workflows/test-unit-vizro-core.yml @@ -13,7 +13,6 @@ on: concurrency: group: test-unit-${{ github.head_ref }} - cancel-in-progress: true env: PYTHONUNBUFFERED: "1" diff --git a/tools/tools_requirements.txt b/tools/tools_requirements.txt index a4626e1f1..48a5a0914 100644 --- a/tools/tools_requirements.txt +++ b/tools/tools_requirements.txt @@ -1,2 +1,2 @@ -werkzeug +werkzeug>=3.0.1 requests diff --git a/vizro-ai/.readthedocs.yaml b/vizro-ai/.readthedocs.yaml new file mode 100644 index 000000000..dd8f1f4ac --- /dev/null +++ b/vizro-ai/.readthedocs.yaml @@ -0,0 +1,16 @@ +# Read the Docs configuration file for MkDocs projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + commands: + - pip install hatch + - cd vizro-ai/ && hatch run docs:mkdocs build + - mkdir --parents $READTHEDOCS_OUTPUT + - mv vizro-ai/site/ $READTHEDOCS_OUTPUT/html diff --git a/vizro-ai/README.md b/vizro-ai/README.md index d9685f76c..374ffa851 100644 --- a/vizro-ai/README.md +++ b/vizro-ai/README.md @@ -4,8 +4,12 @@ Vizro-AI is a tool for generating visualizations. ## Disclaimer -Please review the [Disclaimer](docs/pages/explanation/disclaimer.md) before using `vizro-ai` package. +Please review the [Disclaimer](https://vizro-ai.readthedocs.io/en/latest/pages/explanation/disclaimer/) before using `vizro-ai` package. ## Documentation -Here is the link to the [Documentation](docs/pages). +For more information, please refer to our [Documentation](https://vizro-ai.readthedocs.io/en/latest/). + +## Acknowledgment + +Vizro-AI is powered by [LangChain](https://github.com/langchain-ai/langchain). diff --git a/vizro-core/changelog.d/20231027_115356_alexey_snigir_package_check_fix.md b/vizro-ai/changelog.d/20231101_175538_anna_xiong_prompt_refinement.md similarity index 100% rename from vizro-core/changelog.d/20231027_115356_alexey_snigir_package_check_fix.md rename to vizro-ai/changelog.d/20231101_175538_anna_xiong_prompt_refinement.md diff --git a/vizro-core/changelog.d/20231031_104359_anna_xiong_vizro_ai_open_source_initial_commits.md b/vizro-ai/changelog.d/20231102_095201_maximilian_schulz_hosting_vizro_ai.md similarity index 100% rename from vizro-core/changelog.d/20231031_104359_anna_xiong_vizro_ai_open_source_initial_commits.md rename to vizro-ai/changelog.d/20231102_095201_maximilian_schulz_hosting_vizro_ai.md diff --git a/vizro-core/changelog.d/20231031_111125_huong_li_nguyen_fix_table_overflow.md b/vizro-ai/changelog.d/20231102_143844_maximilian_schulz_schema_linting.md similarity index 100% rename from vizro-core/changelog.d/20231031_111125_huong_li_nguyen_fix_table_overflow.md rename to vizro-ai/changelog.d/20231102_143844_maximilian_schulz_schema_linting.md diff --git a/vizro-core/changelog.d/20231101_120104_maximilian_schulz_check_CI.md b/vizro-ai/changelog.d/20231102_163517_alexey_snigir_vizro_ai_integration_tests.md similarity index 100% rename from vizro-core/changelog.d/20231101_120104_maximilian_schulz_check_CI.md rename to vizro-ai/changelog.d/20231102_163517_alexey_snigir_vizro_ai_integration_tests.md diff --git a/vizro-core/changelog.d/20231026_130425_antony.milne_scriv.md b/vizro-ai/changelog.d/20231103_161536_chiara_pullem_incorporate_e2e_feedback.md similarity index 86% rename from vizro-core/changelog.d/20231026_130425_antony.milne_scriv.md rename to vizro-ai/changelog.d/20231103_161536_chiara_pullem_incorporate_e2e_feedback.md index e07d7d130..f1f65e73c 100644 --- a/vizro-core/changelog.d/20231026_130425_antony.milne_scriv.md +++ b/vizro-ai/changelog.d/20231103_161536_chiara_pullem_incorporate_e2e_feedback.md @@ -4,6 +4,12 @@ A new scriv changelog fragment. Uncomment the section that is right (remove the HTML comment wrapper). --> + + - +- 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 ([#1](https://github.com/mckinsey/vizro/pull/1)) +--> + + +# 0.1.6 — 2023-11-09 + +## 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)) + +## Added + +- `Vizro` takes `**kwargs` that are passed through to `Dash` ([#151](https://github.com/mckinsey/vizro/pull/151)) + +## Changed + +- The path to a custom assets folder is now configurable using the `assets_folder` argument when instantiating `Vizro` ([#151](https://github.com/mckinsey/vizro/pull/151)) + +## Fixed + +- Assets are now routed correctly when hosting the dashboard in a subdirectory ([#151](https://github.com/mckinsey/vizro/pull/151)) + +## Security + +- Bump werkzeug version suggested by Snyk to avoid a vulnerability: https://security.snyk.io/vuln/SNYK-PYTHON-WERKZEUG-6035177 ([#128](https://github.com/mckinsey/vizro/pull/128)) + # 0.1.5 — 2023-10-26 diff --git a/vizro-core/changelog.d/20231026_112851_antony.milne_0_1_5.md b/vizro-core/changelog.d/20231026_112851_antony.milne_0_1_5.md deleted file mode 100644 index d57e34cc2..000000000 --- a/vizro-core/changelog.d/20231026_112851_antony.milne_0_1_5.md +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - diff --git a/vizro-core/changelog.d/20231026_114412_runner_11_44_05.md b/vizro-core/changelog.d/20231026_114412_runner_11_44_05.md deleted file mode 100644 index d57e34cc2..000000000 --- a/vizro-core/changelog.d/20231026_114412_runner_11_44_05.md +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - diff --git a/vizro-core/changelog.d/20231109_170931_joseph_perkins_0_1_6.md b/vizro-core/changelog.d/20231109_170931_joseph_perkins_0_1_6.md new file mode 100644 index 000000000..f1f65e73c --- /dev/null +++ b/vizro-core/changelog.d/20231109_170931_joseph_perkins_0_1_6.md @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vizro-core/changelog.d/20231109_174751_joseph_perkins_vizro_core.md b/vizro-core/changelog.d/20231109_174751_joseph_perkins_vizro_core.md new file mode 100644 index 000000000..f1f65e73c --- /dev/null +++ b/vizro-core/changelog.d/20231109_174751_joseph_perkins_vizro_core.md @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vizro-core/changelog.d/20231110_122219_alexey_snigir_check_release_fix.md b/vizro-core/changelog.d/20231110_122219_alexey_snigir_check_release_fix.md new file mode 100644 index 000000000..f1f65e73c --- /dev/null +++ b/vizro-core/changelog.d/20231110_122219_alexey_snigir_check_release_fix.md @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vizro-core/docs/assets/user_guides/actions/actions_table_filter_interaction.png b/vizro-core/docs/assets/user_guides/actions/actions_table_filter_interaction.png new file mode 100644 index 000000000..d70ae4bd3 Binary files /dev/null and b/vizro-core/docs/assets/user_guides/actions/actions_table_filter_interaction.png differ diff --git a/vizro-core/docs/pages/API_reference/vizro.md b/vizro-core/docs/pages/API_reference/vizro.md index 9ce757421..182ba849a 100644 --- a/vizro-core/docs/pages/API_reference/vizro.md +++ b/vizro-core/docs/pages/API_reference/vizro.md @@ -1,3 +1,7 @@ # Vizro ::: vizro + options: + merge_init_into_class: false + docstring_options: + ignore_init_summary: false diff --git a/vizro-core/docs/pages/development/contributing.md b/vizro-core/docs/pages/development/contributing.md index ecf432fce..360f17af8 100644 --- a/vizro-core/docs/pages/development/contributing.md +++ b/vizro-core/docs/pages/development/contributing.md @@ -28,7 +28,7 @@ We use [Hatch](https://hatch.pypa.io/) as a project management tool. To get star !!!note - The above steps are all automated in GitHub Codespaces thanks to the [devcontainer configuration](.devcontainer/devcontainer.json), and the example dashboard should already be running on port `8050`. + The above steps are all automated in GitHub Codespaces thanks to the [devcontainer configuration](https://github.com/mckinsey/vizro/blob/main/.devcontainer/devcontainer.json), and the example dashboard should already be running on port `8050`. If you haven't used Hatch before, it's well worth skimming through [their documentation](https://hatch.pypa.io/), in particular the page on [environments](https://hatch.pypa.io/latest/environment/). Run `hatch env show` to show all of Hatch's environments and available scripts, and take a look at [`hatch.toml`](https://github.com/mckinsey/vizro/tree/main/vizro-core/hatch.toml) to see our Hatch configuration. It is useful handy to [Hatch's tab completion](https://hatch.pypa.io/latest/cli/about/#tab-completion) to explore the Hatch CLI. @@ -114,7 +114,7 @@ line-length = 120 Linting checks are enforced in CI. To run pre-commit hooks locally, there are two options: 1. Run `hatch run pre-commit install` to automatically run the hooks on every commit (you can always skip the checks with `git commit --no-verify`). In case this fails due to `gitleaks`, please read below for an explanation and how to install `go`. -2. Run `hatch run lint` to run `pre-commit` hooks on all files. (You can run eg `hatch run lint mypy` to only run specific linters, here mypy.) +2. Run `hatch run lint` to run `pre-commit` hooks on all files. (You can run e.g. `hatch run lint mypy -a` to only run specific linters, here mypy, on all files.) Note that Hatch's `default` environment specifies `pre-commit` as a dependency but otherwise _does not_ specify dependencies for linting tools such as `black`. These are controlled by [.pre-commit-config.yaml](https://github.com/mckinsey/vizro/blob/main/.pre-commit-config.yaml) and can be updated when required with `pre-commit autoupdate`. diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index 8935ae2b1..d76b3ea77 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -63,8 +63,7 @@ a result, when a dashboard user now clicks the button, all data on the page will dashboard = vm.Dashboard(pages=[page]) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` === "app.yaml" ```yaml @@ -105,24 +104,25 @@ a result, when a dashboard user now clicks the button, all data on the page will ### Filter data by clicking on chart -To enable filtering when clicking on data in a (source) chart, you can add the [`filter_interaction`][vizro.actions.filter_interaction] action function to the [`Graph`][vizro.models.Graph] component. The [`filter_interaction`][vizro.actions.filter_interaction] is currently configured to be triggered on click only. +To enable filtering when clicking on data in a (source) chart, you can add the [`filter_interaction`][vizro.actions.filter_interaction] action function to the [`Graph`][vizro.models.Graph] or [`Table`][vizro.models.Table] component. The [`filter_interaction`][vizro.actions.filter_interaction] is currently configured to be triggered on click only. To configure this chart interaction follow the steps below: -1. Add the action function to the source [`Graph`][vizro.models.Graph] and a list of IDs of the target charts into `targets` +1. Add the action function to the source [`Graph`][vizro.models.Graph] or [`Table`][vizro.models.Table] component and a list of IDs of the target charts into `targets`. ```py actions=[vm.Action(function=filter_interaction(targets=["scatter_relation_2007"]))] ``` -2. Enter the filter columns in the `custom_data` argument of the underlying source chart `function` +2. If the source chart is [`Graph`][vizro.models.Graph], enter the filter columns in the `custom_data` argument of the underlying source chart `function`. ```py Graph(figure=px.scatter(..., custom_data=["continent"])) ``` - Selecting a data point with a corresponding value of "Africa" in the continent column will result in filtering the dataset of target charts to show only entries with "Africa" in the continent column. The same applies when providing multiple columns in `custom_data`. +Selecting a data point with a corresponding value of "Africa" in the continent column will result in filtering the dataset of target charts to show only entries with "Africa" in the continent column. The same applies when providing multiple columns in `custom_data`. !!! tip - You can reset your chart interaction filters by refreshing the page - You can create a "self-interaction" by providing the source chart id as its own `target` +Here is an example of how to configure a chart interaction when the source is a [`Graph`][vizro.models.Graph] component. !!! example "`filter_interaction`" === "app.py" @@ -166,8 +166,7 @@ Graph(figure=px.scatter(..., custom_data=["continent"])) ] ) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` === "app.yaml" ```yaml @@ -199,9 +198,9 @@ Graph(figure=px.scatter(..., custom_data=["continent"])) x: gdpPercap y: lifeExp size: pop - controls: - - column: continent - type: filter + controls: + - column: continent + type: filter title: Filter interaction ``` === "Result" @@ -209,6 +208,83 @@ Graph(figure=px.scatter(..., custom_data=["continent"])) [Graph2]: ../../assets/user_guides/actions/actions_filter_interaction.png +Here is an example of how to configure a chart interaction when the source is a [`Table`][vizro.models.Table] component. + +!!! example "`filter_interaction`" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.actions import filter_interaction + from vizro.tables import dash_data_table + + df_gapminder = px.data.gapminder().query("year == 2007") + + dashboard = vm.Dashboard( + pages=[ + vm.Page( + title="Filter interaction", + components=[ + vm.Table( + figure=dash_data_table(id="dash_datatable_id", data_frame=df_gapminder), + actions=[ + vm.Action(function=filter_interaction(targets=["scatter_relation_2007"])) + ], + ), + vm.Graph( + id="scatter_relation_2007", + figure=px.scatter( + df_gapminder, + x="gdpPercap", + y="lifeExp", + size="pop", + color="continent", + ), + ), + ], + controls=[vm.Filter(column='continent')] + ), + ] + ) + + 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: + - type: table + figure: + _target_: dash_data_table + data_frame: gapminder_2007 + id: dash_datatable_id + actions: + - function: + _target_: filter_interaction + targets: + - scatter_relation_2007 + - type: graph + id: scatter_relation_2007 + figure: + _target_: scatter + data_frame: gapminder_2007 + color: continent + x: gdpPercap + y: lifeExp + size: pop + controls: + - column: continent + type: filter + title: Filter interaction + ``` + === "Result" + [![Table]][Table] + + [Table]: ../../assets/user_guides/actions/actions_table_filter_interaction.png + ## Predefined actions customization Many predefined actions are customizable which helps to achieve more specific desired goal. For specific options, please refer to the [API reference][vizro.actions] on this topic. @@ -264,8 +340,7 @@ The order of action execution is guaranteed, and the next action in the list wil dashboard = vm.Dashboard(pages=[page]) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` === "app.yaml" ```yaml diff --git a/vizro-core/docs/pages/user_guides/assets.md b/vizro-core/docs/pages/user_guides/assets.md index 9672229c6..dced19937 100644 --- a/vizro-core/docs/pages/user_guides/assets.md +++ b/vizro-core/docs/pages/user_guides/assets.md @@ -18,13 +18,6 @@ The user-provided `assets` folder thus always takes precedence. ├── favicon.ico ``` -## Adding static images -We leverage Dash's underlying functionalities to embed images into the app. -For more information, see [here](https://dash.plotly.com/dash-enterprise/static-assets?de-version=5.1#embedding-images-in-your-dash-apps). - -For example, you can leverage the `dash.get_asset_url()` function in your custom components, such that any provided path does not require `assets` as a prefix in the relative path anymore. - - ## Changing the favicon To change the default favicon (website icon appearing in the browser tab), add a file named `favicon.ico` to your `assets` folder. For more information, see [here](https://dash.plotly.com/external-resources#changing-the-favicon). @@ -63,8 +56,6 @@ For reference, all Vizro CSS files can be found [here](https://github.com/mckins dashboard = vm.Dashboard(pages=[page]) - # only required if assets folder is not located at the same directory of app.py - Vizro._user_assets_folder = os.path.abspath("../assets") Vizro().build(dashboard).run() ``` @@ -131,9 +122,6 @@ To achieve this, do the following: dashboard = vm.Dashboard(pages=[page]) - # only required if assets folder is not located at the same directory of app.py - Vizro._user_assets_folder = os.path.abspath("../assets") - Vizro().build(dashboard).run() ``` === "app.yaml" @@ -157,24 +145,17 @@ To achieve this, do the following: [CardCSS]: ../../assets/user_guides/assets/css_change_card.png -???+ note - - CSS properties will be applied with the last served file taking precedence. - Files are served in alphanumerical order. - - **Order of CSS being served to app** +CSS properties will be applied with the last served file taking precedence. The order of serving is: - 1. Dash styling sheets - 2. Vizro external styling sheets - 3. User assets folder - - CSS/JS Files - - Folders - - CSS/JS Files +1. Dash built-in stylesheets +2. Vizro built-in stylesheets +3. User assets folder stylesheets +Within each of these categories, individual files are served in alphanumerical order. ## Changing the `assets` folder path If you do not want to place your `assets` folder in the root directory of your app, you can -also change the reference to your `assets` folder. Note that the path provided needs to be an absolute path. +specify an alternative path through the `assets_folder` argument of the [`Vizro`][vizro.Vizro] class. ```python from vizro import Vizro @@ -183,8 +164,7 @@ import vizro.models as vm page = dashboard = vm.Dashboard(pages=[page]) -Vizro._user_assets_folder = "absolute/path/to/assets" -app = Vizro().build(dashboard).run() +app = Vizro(assets_folder="path/to/assets/folder").build(dashboard).run() ``` diff --git a/vizro-core/docs/pages/user_guides/custom_charts.md b/vizro-core/docs/pages/user_guides/custom_charts.md index 0c8219be6..3dda3f720 100644 --- a/vizro-core/docs/pages/user_guides/custom_charts.md +++ b/vizro-core/docs/pages/user_guides/custom_charts.md @@ -70,8 +70,7 @@ Building on the above, there are several routes one can take. The following exam ) dashboard = vm.Dashboard(pages=[page_0]) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` === "app.yaml" ```yaml @@ -142,8 +141,7 @@ The below examples shows a more involved use-case. We create and style a waterfa ) dashboard = vm.Dashboard(pages=[page_0]) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` === "app.yaml" ```yaml diff --git a/vizro-core/docs/pages/user_guides/custom_components.md b/vizro-core/docs/pages/user_guides/custom_components.md index dc3fdc805..f3872edfd 100644 --- a/vizro-core/docs/pages/user_guides/custom_components.md +++ b/vizro-core/docs/pages/user_guides/custom_components.md @@ -125,7 +125,7 @@ vm.Parameter.add_type("selector", TooltipNonCrossRangeSlider) return html.Div( [ - html.P(self.title, id="range_slider_title") if self.title else None, + html.P(self.title, id="range_slider_title") if self.title else html.Div(hidden=True), html.Div( [ dcc.RangeSlider( @@ -207,8 +207,7 @@ vm.Parameter.add_type("selector", TooltipNonCrossRangeSlider) dashboard = vm.Dashboard(pages=[page]) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` 1. Here we provide a new type for the new component, so it can be distinguished in the discriminated union. @@ -330,8 +329,7 @@ vm.Page.add_type("components", Jumbotron) dashboard = vm.Dashboard(pages=[page]) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` diff --git a/vizro-core/docs/pages/user_guides/dashboard.md b/vizro-core/docs/pages/user_guides/dashboard.md index c17b3ae51..0088aa2c5 100644 --- a/vizro-core/docs/pages/user_guides/dashboard.md +++ b/vizro-core/docs/pages/user_guides/dashboard.md @@ -168,8 +168,7 @@ To create a dashboard, do the following steps: dashboard = yaml.safe_load(Path("dashboard.yaml").read_text(encoding="utf-8")) dashboard = Dashboard(**dashboard) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` === "app.py for json" ```py @@ -185,8 +184,7 @@ To create a dashboard, do the following steps: dashboard = json.loads(Path("dashboard.json").read_text(encoding="utf-8")) dashboard = Dashboard(**dashboard) - if __name__ == "__main__": - Vizro().build(dashboard).run() + Vizro().build(dashboard).run() ``` After running the dashboard, you can access the dashboard via `localhost:8050`. diff --git a/vizro-core/docs/pages/user_guides/integration.md b/vizro-core/docs/pages/user_guides/integration.md index c987f9091..c4c6e1253 100644 --- a/vizro-core/docs/pages/user_guides/integration.md +++ b/vizro-core/docs/pages/user_guides/integration.md @@ -15,23 +15,48 @@ To install Vizro with Kedro support, run: pip install vizro[kedro] ``` -### Using datasets from the Kedro data catalog -Given a Kedro data catalog (either from a kedro project or a `catalog.yml` style file), you can use the following code to -register the datasets with [`kedro_datasets.pandas`](https://docs.kedro.org/en/stable/kedro_datasets.html) type to Vizro's data manager. +### Using datasets from the Kedro Data Catalog +`vizro.integrations.kedro` provides functions to help generate and process a [Kedro Data Catalog](https://docs.kedro.org/en/stable/data/index.html). Given a Kedro Data Catalog `catalog`, the general pattern to add datasets into the [Vizro Data Manager][vizro.managers._data_manager] is: +```python +from vizro.integrations import kedro as kedro_integration +from vizro.managers import data_manager -!!! example "Kedro Data Catalog" - === "app.py (kedro project)" + +for dataset_name, dataset in kedro_integration.datasets_from_catalog(catalog).items(): + data_manager[dataset_name] = dataset +``` + +This imports all datasets of type [`kedro_datasets.pandas`](https://docs.kedro.org/en/stable/kedro_datasets.html) from the Kedro `catalog` into the Vizro `data_manager`. + +The `catalog` variable may have been created in a number of different ways: + +1. Kedro project path. Vizro exposes a helper function `vizro.integrations.kedro.catalog_from_project` to generate a `catalog` given the path to a Kedro project. +2. [Kedro Jupyter session](https://docs.kedro.org/en/stable/notebooks_and_ipython/kedro_and_notebooks.html). This automatically exposes `catalog`. +3. Data Catalog configuration file (e.g. `catalog.yaml`). This can create a `catalog` entirely independently of a Kedro project using [`kedro.io.DataCatalog.from_config`](https://docs.kedro.org/en/stable/kedro.io.DataCatalog.html#kedro.io.DataCatalog.from_config). + +The full code for these different cases is given below. + +!!! example "Import a Kedro Data Catalog to the Vizro Data Manager" + === "app.py (Kedro project path)" ```py from vizro.integrations import kedro as kedro_integration from vizro.managers import data_manager - catalog = kedro_integration.catalog_from_project("/path/to/projects/iris") + catalog = kedro_integration.catalog_from_project("/path/to/kedro/project") for dataset_name, dataset in kedro_integration.datasets_from_catalog(catalog).items(): data_manager[dataset_name] = dataset ``` - === "app.py (use data catalog file YAML syntax without a kedro project)" + === "app.ipynb (Kedro Jupyter session)" + ```py + from vizro.managers import data_manager + + + for dataset_name, dataset in kedro_integration.datasets_from_catalog(catalog).items(): + data_manager[dataset_name] = dataset + ``` + === "app.py (Data Catalog configuration file)" ```py from kedro.io import DataCatalog import yaml @@ -47,7 +72,6 @@ register the datasets with [`kedro_datasets.pandas`](https://docs.kedro.org/en/s ``` - ???+ warning Please note that users of this package are responsible for the content of any custom-created component, diff --git a/vizro-core/docs/pages/user_guides/run.md b/vizro-core/docs/pages/user_guides/run.md index 0e2c9a428..fd210e79c 100644 --- a/vizro-core/docs/pages/user_guides/run.md +++ b/vizro-core/docs/pages/user_guides/run.md @@ -2,9 +2,9 @@ This guide shows you how to launch your dashboard in different ways. By default, your dashboard apps run on localhost. -## Default built-in web server in flask +## Default built-in Flask web server -!!! example "Default flask server" +!!! example "Default built-in Flask web server" === "app.py" ```py from vizro import Vizro @@ -34,8 +34,8 @@ Dash is running on http://127.0.0.1:8050/ * Debug mode: on ``` -## Launch it in jupyter environment -The dashboard application can be launched in jupyter environment in `inline`, `external`, and `jupyterlab` mode. +## Jupyter +The dashboard application can be launched in a Jupyter environment in `inline`, `external`, and `jupyterlab` mode. !!! example "Run in jupyter notebook in inline mode" === "app.ipynb" ```py linenums="1" @@ -59,10 +59,11 @@ The dashboard application can be launched in jupyter environment in `inline`, `e - you can specify `jupyter_mode="external"` and a link will be displayed to direct you to the localhost where the dashboard is running. - you can use tab mode by `jupyter_mode="tab"` to automatically open the app in a new browser -## Launch it via Gunicorn +## Gunicorn !!!warning "In production" - In production, it is recommended **not** to use the default flask server. One of the options here is gunicorn. It is easy to scale the application to serve more users or run more computations, run more "copies" of the app in separate processes. -!!! example "Use gunicorn" + In production, it is recommended **not** to use the default Flask server. One of the options here is Gunicorn. It is easy to scale the application to serve more users or run more computations, run more "copies" of the app in separate processes. + +!!! example "Use Gunicorn" === "app.py" ```py from vizro import Vizro @@ -80,24 +81,28 @@ The dashboard application can be launched in jupyter environment in `inline`, `e dashboard = vm.Dashboard(pages=[page]) app = Vizro().build(dashboard) - server = app.dash.server + server = app.dash.server # (1)! + + if __name__ == "__main__": # (2)! + app.run() ``` -You need to expose the server via `app.dash.server` in order to use gunicorn as your wsgi here. -Run it via + + 1. Expose the underlying Flask app through `app.dash.server`; this will be used by Gunicorn. + 2. Enable the same app to still be run using the built-in Flask server with `python app.py` for development purposes. + +To run using Gunicorn with four worker processes, execute ```bash -gunicorn app:server --workers 3 +gunicorn app:server --workers 4 ``` -in the cmd. For more gunicorn configuration, please refer to [gunicorn docs](https://docs.gunicorn.org/en/stable/configure.html) +in the command line. For more Gunicorn configuration options, please refer to [Gunicorn documentation](https://docs.gunicorn.org/). + +## Deployment +A Vizro app wraps a Dash app, which itself wraps a Flask app. Hence to deploy a Vizro app, similar guidance applies as for the underlying frameworks: -## Deployment of Vizro app -In general, Vizro is returning a Dash app. So if you want to deploy a Vizro app, similar steps apply as if you were to deploy a Dash app. -For more details, see the docs on [Dash for deployment](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps). +- [Flask deployment documentation](https://flask.palletsprojects.com/en/2.0.x/deploying/) +- [Dash deployment documentation](https://dash.plotly.com/deployment) -For reference (where app is a Dash app): +In particular, `app = Vizro()` exposes the Flask app through `app.dash.server`. As in the [above example with Gunicorn](#gunicorn), this provides the application instance to a WSGI server. -| Vizro | Dash | -|:-------------------------------|:--------------------------------| -| Vizro() | app | -| Vizro().build(dashboard) | app (after creating app.layout) | -| Vizro().build(dashboard).run() | app.run() | +[`Vizro`][vizro.Vizro] accepts `**kwargs` that are passed through to `Dash`. This allows you to configure the underlying Dash app using the same [argumentst that are available](https://dash.plotly.com/reference#dash.dash) in `Dash`. For example, in a deployment context, you might like to specify a custom `url_base_pathname` to serve your Vizro app at a specific URL rather than at your domain root. diff --git a/vizro-core/examples/assets/favicon.ico b/vizro-core/examples/assets/favicon.ico new file mode 100644 index 000000000..240c9f541 Binary files /dev/null and b/vizro-core/examples/assets/favicon.ico differ diff --git a/vizro-core/examples/default/app.py b/vizro-core/examples/default/app.py index 4dd4dff5b..ddc527a50 100644 --- a/vizro-core/examples/default/app.py +++ b/vizro-core/examples/default/app.py @@ -1,27 +1,21 @@ """Example to show dashboard configuration.""" -import os import vizro.models as vm import vizro.plotly.express as px from vizro import Vizro from vizro.actions import export_data, filter_interaction +from vizro.tables import dash_data_table df_gapminder = px.data.gapminder() page = vm.Page( title="Testing out tabs: [0, [1, 2 ,3, B], [4, 5, [6, 7], [8]]]", components=[ - vm.Graph( - id="graph-0", - figure=px.line( - df_gapminder, - title="Graph-0", - x="year", - y="lifeExp", - color="continent", - line_group="country", - hover_name="country", - custom_data=["continent"], + vm.Table( + id="table-0", + figure=dash_data_table( + id="dash_datatable-0", + data_frame=df_gapminder, ), actions=[ vm.Action( @@ -163,7 +157,6 @@ # probably need to implement some logic for an on_tab_load action vm.Parameter( targets=[ - "graph-0.y", "graph-1.y", "graph-2.y", "graph-3.y", @@ -182,5 +175,4 @@ dashboard = vm.Dashboard(pages=[page]) if __name__ == "__main__": - Vizro._user_assets_folder = os.path.abspath("../assets") - Vizro().build(dashboard).run() + Vizro(assets_folder="../assets").build(dashboard).run() diff --git a/vizro-core/examples/from_dict/app.py b/vizro-core/examples/from_dict/app.py index f21e91fa9..f36641ef6 100644 --- a/vizro-core/examples/from_dict/app.py +++ b/vizro-core/examples/from_dict/app.py @@ -1,5 +1,4 @@ """Example to show dashboard configuration specified as a dictionary.""" -import os import pandas as pd @@ -8,6 +7,7 @@ from vizro.actions import export_data, filter_interaction from vizro.managers import data_manager from vizro.models import Dashboard +from vizro.tables import dash_data_table def retrieve_gapminder(): @@ -254,7 +254,7 @@ def retrieve_avg_gapminder_year(year: int): "type": "card", "text": """ #### Last updated - July, 2023 + November, 2023 """, }, { @@ -464,32 +464,32 @@ def retrieve_avg_gapminder_year(year: int): page_country = { "title": "Country Analysis", - "layout": {"grid": [[0, 0, 0, 1, 1, 1]] * 7 + [[2, 2, 2, 2, 2, 2]]}, "components": [ { - "type": "graph", - "id": "bar_country", - "figure": px.bar( - "gapminder_country_analysis", - x="year", - y="pop", - color="data", - barmode="group", - labels={"year": "Year", "data": "Data", "pop": "Population"}, - color_discrete_map={"Country": "#afe7f9", "Continent": "#003875"}, + "type": "table", + "id": "table_country", + "title": "Table Country", + "figure": dash_data_table( + id="dash_data_table_country", + data_frame="gapminder", ), + "actions": [ + {"function": filter_interaction(targets=["line_country"])}, + ], }, { "type": "graph", "id": "line_country", "figure": px.line( "gapminder_country_analysis", + title="Line Country", x="year", y="gdpPercap", color="data", labels={"year": "Year", "data": "Data", "gdpPercap": "GDP per capita"}, color_discrete_map={"Country": "#afe7f9", "Continent": "#003875"}, markers=True, + hover_name="country", ), }, { @@ -497,15 +497,15 @@ def retrieve_avg_gapminder_year(year: int): "id": "export_data_button", "text": "Export data", "actions": [ - {"function": export_data(targets=["scatter_relation_2007"])}, + {"function": export_data(targets=["line_country"])}, ], }, ], "controls": [ { "type": "filter", - "column": "country", - "selector": {"type": "dropdown", "title": "Select country", "multi": False, "value": "India"}, + "column": "continent", + "selector": {"type": "dropdown", "title": "Select continent", "multi": False, "value": "Europe"}, }, { "type": "filter", @@ -579,8 +579,8 @@ def retrieve_avg_gapminder_year(year: int): } }, } + dashboard = Dashboard(**dashboard) if __name__ == "__main__": - Vizro._user_assets_folder = os.path.abspath("../assets") - Vizro().build(dashboard).run() + Vizro(assets_folder="../assets").build(dashboard).run() diff --git a/vizro-core/examples/from_json/app.py b/vizro-core/examples/from_json/app.py index bedaf58ca..900914bf3 100644 --- a/vizro-core/examples/from_json/app.py +++ b/vizro-core/examples/from_json/app.py @@ -1,6 +1,5 @@ """Example to show dashboard configuration specified as a JSON file.""" import json -import os from pathlib import Path import pandas as pd @@ -63,5 +62,4 @@ def retrieve_avg_gapminder_year(year: int): dashboard = Dashboard(**dashboard) if __name__ == "__main__": - Vizro._user_assets_folder = os.path.abspath("../assets") - Vizro().build(dashboard).run() + Vizro(assets_folder="../assets").build(dashboard).run() diff --git a/vizro-core/examples/from_json/dashboard.json b/vizro-core/examples/from_json/dashboard.json index 13a7c714b..0ea95ef96 100644 --- a/vizro-core/examples/from_json/dashboard.json +++ b/vizro-core/examples/from_json/dashboard.json @@ -185,7 +185,7 @@ "type": "card" }, { - "text": "#### Last updated\nJuly, 2023\n", + "text": "#### Last updated\nNovember, 2023\n", "type": "card" }, { @@ -374,30 +374,28 @@ "components": [ { "figure": { - "_target_": "bar", - "color": "data", - "data_frame": "gapminder_country_analysis", - "x": "year", - "y": "pop", - "barmode": "group", - "labels": { - "year": "Year", - "data": "Data", - "pop": "Population" - }, - "color_discrete_map": { - "Country": "#afe7f9", - "Continent": "#003875" - } + "_target_": "dash_data_table", + "data_frame": "gapminder", + "id": "dash_data_table_country" }, - "id": "bar_country", - "type": "graph" + "id": "table_country", + "title": "Table Country", + "type": "table", + "actions": [ + { + "function": { + "_target_": "filter_interaction", + "targets": ["line_country"] + } + } + ] }, { "figure": { "_target_": "line", "color": "data", "data_frame": "gapminder_country_analysis", + "title": "Line Country", "x": "year", "y": "gdpPercap", "labels": { @@ -409,7 +407,8 @@ "Country": "#afe7f9", "Continent": "#003875" }, - "markers": "True" + "markers": "True", + "hover_name": "country" }, "id": "line_country", "type": "graph" @@ -422,7 +421,7 @@ { "function": { "_target_": "export_data", - "targets": ["bar_country"] + "targets": ["line_country"] } } ] @@ -430,12 +429,12 @@ ], "controls": [ { - "column": "country", + "column": "continent", "selector": { "type": "dropdown", - "value": "India", + "value": "Europe", "multi": false, - "title": "Select country" + "title": "Select continent" }, "type": "filter" }, @@ -448,18 +447,6 @@ "type": "filter" } ], - "layout": { - "grid": [ - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [2, 2, 2, 2, 2, 2] - ] - }, "title": "Country Analysis" } ], diff --git a/vizro-core/examples/from_yaml/app.py b/vizro-core/examples/from_yaml/app.py index 8e53c3f65..4ab29d514 100644 --- a/vizro-core/examples/from_yaml/app.py +++ b/vizro-core/examples/from_yaml/app.py @@ -1,5 +1,4 @@ """Example to show dashboard configuration specified as a YAML file.""" -import os from pathlib import Path import pandas as pd @@ -63,5 +62,4 @@ def retrieve_avg_gapminder_year(year: int): dashboard = Dashboard(**dashboard) if __name__ == "__main__": - Vizro._user_assets_folder = os.path.abspath("../assets") - Vizro().build(dashboard).run() + Vizro(assets_folder="../assets").build(dashboard).run() diff --git a/vizro-core/examples/from_yaml/dashboard.yaml b/vizro-core/examples/from_yaml/dashboard.yaml index 3a9cea5e7..1c873dbe8 100644 --- a/vizro-core/examples/from_yaml/dashboard.yaml +++ b/vizro-core/examples/from_yaml/dashboard.yaml @@ -183,7 +183,7 @@ pages: type: card - text: | #### Last updated - July, 2023 + November, 2023 type: card - figure: _target_: box @@ -387,25 +387,22 @@ pages: title: Continent Summary - components: - figure: - _target_: bar - color: data - data_frame: gapminder_country_analysis - x: year - y: pop - barmode: group - labels: - year: Year - data: Data - pop: Population - color_discrete_map: - Country: "#afe7f9" - Continent: "#003875" - id: bar_country - type: graph + _target_: dash_data_table + data_frame: gapminder + id: dash_data_table_country + id: table_country + title: Table Country + type: table + actions: + - function: + _target_: filter_interaction + targets: + - line_country - figure: _target_: line color: data data_frame: gapminder_country_analysis + title: Line Country x: year y: gdpPercap labels: @@ -416,6 +413,7 @@ pages: Country: "#afe7f9" Continent: "#003875" markers: True + hover_name: country id: line_country type: graph - type: button @@ -425,30 +423,20 @@ pages: - function: _target_: export_data targets: - - bar_country + - line_country controls: - - column: country + - column: continent selector: type: dropdown - value: India + value: Europe multi: False - title: Select country + title: Select continent type: filter - column: year selector: type: range_slider title: Select timeframe type: filter - layout: - grid: - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [2, 2, 2, 2, 2, 2] title: Country Analysis navigation: pages: diff --git a/vizro-core/examples/jupyter/app.ipynb b/vizro-core/examples/jupyter/app.ipynb index d5ab428a6..3879c378b 100644 --- a/vizro-core/examples/jupyter/app.ipynb +++ b/vizro-core/examples/jupyter/app.ipynb @@ -544,8 +544,7 @@ " ]\n", ")\n", "\n", - "Vizro._user_assets_folder = os.path.abspath(\"../assets\")\n", - "Vizro().build(dashboard).run()" + "Vizro(assets_folder=\"../assets\").build(dashboard).run()" ] } ], diff --git a/vizro-core/hatch.toml b/vizro-core/hatch.toml index bb9d66cc7..615f9c0ab 100644 --- a/vizro-core/hatch.toml +++ b/vizro-core/hatch.toml @@ -16,12 +16,11 @@ scripts = {add = "scriv create --add"} [envs.default] dependencies = [ "devtools[pygments]", - "pre-commit", "coverage[toml]>=6.5", "pytest", "pytest-mock", "dash[testing]", - "chromedriver-autoinstaller", + "chromedriver-autoinstaller-fix", "toml", "pyyaml", "openpyxl" @@ -41,19 +40,18 @@ cov-report = [ "coverage report" ] example = "cd examples/{args:default}; python app.py" -lint = "SKIP=gitleaks pre-commit run {args} --all-files" +lint = "hatch run lint:lint {args:--all-files}" prep-release = [ "hatch version release", "hatch run changelog:scriv collect --add", "rm -rf schemas/*json", "schema", "git add schemas", - "hatch run lint || hatch run lint", "hatch run changelog:add", 'echo "Now raise a PR to merge into main with title: Release of vizro-core $(hatch version)"' ] pypath = "python -c 'import sys; print(sys.executable)'" -schema = "python schemas/generate.py {args}" +schema = ["python schemas/generate.py {args}", 'hatch run lint --files="schemas/$(hatch version).json" > /dev/null'] secrets = "pre-commit run gitleaks --all-files" test = [ "test-unit", @@ -80,6 +78,13 @@ dependencies = [ detached = true scripts = {serve = "mkdocs serve"} +[envs.lint] +dependencies = [ + "pre-commit" +] +detached = true +scripts = {lint = "SKIP=gitleaks pre-commit run {args:--all-files}"} + [publish.index] disable = true diff --git a/vizro-core/pyproject.toml b/vizro-core/pyproject.toml index 6f069606b..11618bb04 100644 --- a/vizro-core/pyproject.toml +++ b/vizro-core/pyproject.toml @@ -27,8 +27,8 @@ dependencies = [ "numpy>=1.22.2", # not directly required, pinned by Snyk to avoid a vulnerability: https://security.snyk.io/vuln/SNYK-PYTHON-NUMPY-2321970 "tornado>=6.3.2", # not directly required, pinned by Snyk to avoid a vulnerability: https://security.snyk.io/vuln/SNYK-PYTHON-TORNADO-5537286 "setuptools>=65.5.1", # not directly required, pinned by Snyk to avoid a vulnerability: https://security.snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-3180412 - "MarkupSafe", # required to sanitize user input - "dash_mantine_components" + "werkzeug>=3.0.1", # not directly required, pinned by Snyk to avoid a vulnerability: https://security.snyk.io/vuln/SNYK-PYTHON-WERKZEUG-6035177 + "MarkupSafe" # required to sanitize user input ] description = "Vizro is a package to facilitate visual analytics." dynamic = ["version"] @@ -57,7 +57,7 @@ exclude_lines = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:" ] -fail_under = 92 +fail_under = 91 show_missing = true skip_covered = true diff --git a/vizro-core/schemas/0.1.6.dev0.json b/vizro-core/schemas/0.1.6.json similarity index 100% rename from vizro-core/schemas/0.1.6.dev0.json rename to vizro-core/schemas/0.1.6.json diff --git a/vizro-core/schemas/0.1.5.json b/vizro-core/schemas/0.1.7.dev0.json similarity index 98% rename from vizro-core/schemas/0.1.5.json rename to vizro-core/schemas/0.1.7.dev0.json index 0e2e91e5d..95b345bcb 100644 --- a/vizro-core/schemas/0.1.5.json +++ b/vizro-core/schemas/0.1.7.dev0.json @@ -161,7 +161,7 @@ }, "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 `[]`.", + "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": { @@ -175,6 +175,11 @@ "enum": ["table"], "type": "string" }, + "title": { + "title": "Title", + "description": "Title of the table", + "type": "string" + }, "actions": { "title": "Actions", "default": [], diff --git a/vizro-core/schemas/generate.py b/vizro-core/schemas/generate.py index c7b9ad381..e0bd86a63 100644 --- a/vizro-core/schemas/generate.py +++ b/vizro-core/schemas/generate.py @@ -1,7 +1,6 @@ """Script to generate JSON schema. For more information, run `hatch run schema --help`.""" import argparse import json -import subprocess import sys from pathlib import Path @@ -22,4 +21,3 @@ print("JSON schema is up to date.") # noqa: T201 else: schema_path.write_text(schema_json) - subprocess.run("hatch run lint", shell=True, stdout=subprocess.DEVNULL) # nosec diff --git a/vizro-core/snyk/requirements.txt b/vizro-core/snyk/requirements.txt index 982b9abd0..a65406ad0 100644 --- a/vizro-core/snyk/requirements.txt +++ b/vizro-core/snyk/requirements.txt @@ -7,6 +7,7 @@ ipython>=8.10.0 numpy>=1.22.2 tornado>=6.3.2 setuptools>=65.5.1 +werkzeug>=3.0.1 MarkupSafe kedro>=0.17.3 wheel>=0.38.0 diff --git a/vizro-core/src/vizro/__init__.py b/vizro-core/src/vizro/__init__.py index 081b799c9..35cc3b86b 100644 --- a/vizro-core/src/vizro/__init__.py +++ b/vizro-core/src/vizro/__init__.py @@ -5,6 +5,6 @@ __all__ = ["Vizro"] -__version__ = "0.1.6.dev0" +__version__ = "0.1.7.dev0" logging.basicConfig(level=os.getenv("VIZRO_LOG_LEVEL", "WARNING")) diff --git a/vizro-core/src/vizro/_vizro.py b/vizro-core/src/vizro/_vizro.py index 8ed00b326..f0f075f3c 100644 --- a/vizro-core/src/vizro/_vizro.py +++ b/vizro-core/src/vizro/_vizro.py @@ -1,7 +1,6 @@ import logging -import os from pathlib import Path -from typing import Dict, List, Tuple +from typing import List import dash import flask @@ -16,24 +15,39 @@ class Vizro: """The main class of the `vizro` package.""" - _user_assets_folder = Path.cwd() / "assets" - _lib_assets_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") - - def __init__(self): - """Initializes Dash.""" - _js, _css = _append_styles(self._lib_assets_folder, STATIC_URL_PREFIX) - self.dash = dash.Dash( - use_pages=True, - pages_folder="", - external_scripts=_js, - external_stylesheets=_css, - assets_folder=self._user_assets_folder, - ) + def __init__(self, **kwargs): + """Initializes Dash app, stored in `self.dash`. - @self.dash.server.route("//") - def serve_static(filepath, url_prefix=STATIC_URL_PREFIX): - """Serve vizro static contents.""" - return flask.send_from_directory(self._lib_assets_folder, filepath) + Args: + kwargs: Passed through to `Dash.__init__`, e.g. `assets_folder`, `url_base_pathname`. See + [Dash documentation](https://dash.plotly.com/reference#dash.dash) for possible arguments. + """ + self.dash = dash.Dash(**kwargs, use_pages=True, pages_folder="") + + # Include Vizro assets (in the static folder) as external scripts and stylesheets. We extend self.dash.config + # objects so the user can specify additional external_scripts and external_stylesheets via kwargs. + vizro_assets_folder = Path(__file__).with_name("static") + static_url_path = self.dash.config.requests_pathname_prefix + STATIC_URL_PREFIX + vizro_css = self._get_external_assets(static_url_path, vizro_assets_folder, "css") + vizro_js = [ + {"src": path, "type": "module"} + for path in self._get_external_assets(static_url_path, vizro_assets_folder, "js") + ] + self.dash.config.external_stylesheets.extend(vizro_css) + self.dash.config.external_scripts.extend(vizro_js) + + # Serve all assets (including files other than css and js) that live in vizro_assets_folder at the + # route /vizro. Based on code in Dash.init_app that serves assets_folder. This respects the case that the + # dashboard is not hosted at the root of the server, e.g. http://www.example.com/dashboard/vizro. + blueprint_prefix = self.dash.config.routes_pathname_prefix.replace("/", "_").replace(".", "_") + self.dash.server.register_blueprint( + flask.Blueprint( + f"{blueprint_prefix}vizro_assets", + self.dash.config.name, + static_folder=vizro_assets_folder, + static_url_path=static_url_path, + ) + ) def build(self, dashboard: Dashboard): """Builds the dashboard. @@ -55,8 +69,8 @@ def run(self, *args, **kwargs): # if type annotated, mkdocstring stops seeing t """Runs the dashboard. Args: - args: Any args to `dash.run_server` - kwargs: Any kwargs to `dash.run_server` + args: Passed through to `dash.run`. + kwargs: Passed through to `dash.run`. """ data_manager._frozen_state = True model_manager._frozen_state = True @@ -88,19 +102,11 @@ def _reset(): dash._pages.CONFIG.clear() dash._pages.CONFIG.__dict__.clear() + @staticmethod + def _get_external_assets(new_path: str, folder: Path, extension: str) -> List[str]: + """Returns a list of paths to assets with given extension in folder, prefixed with new_path. -def _append_styles(walk_dir: str, url_prefix: str) -> Tuple[List[Dict[str, str]], List[str]]: - """Append vizro css and js resources.""" - _vizro_css = [] - _vizro_js = [] - - for current_dir, _, files in sorted(os.walk(walk_dir)): - base = "" if current_dir == walk_dir else os.path.relpath(current_dir, walk_dir).replace("\\", "/") - for f in sorted(files): - path = os.path.join("/" + url_prefix, base, f) if base else os.path.join("/" + url_prefix, f) - extension = os.path.splitext(f)[1] - if extension == ".js": - _vizro_js.append({"src": path, "type": "module"}) - elif extension == ".css": - _vizro_css.append(path) - return _vizro_js, _vizro_css + e.g. with new_path="/vizro", extension="css", folder="/path/to/vizro/vizro-core/src/vizro/static", + we will get ["/vizro/css/accordion.css", "/vizro/css/button.css", ...]. + """ + return sorted((new_path / path.relative_to(folder)).as_posix() for path in folder.rglob(f"*.{extension}")) diff --git a/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py b/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py index df8cebf32..23be06049 100644 --- a/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py +++ b/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py @@ -8,6 +8,8 @@ _get_actions_chains_on_registered_pages, _get_actions_on_registered_pages, ) +from vizro.managers import model_manager +from vizro.managers._model_manager import ModelID logger = logging.getLogger(__name__) @@ -22,12 +24,23 @@ def _build_action_loop_callbacks() -> None: gateway_inputs: List[Input] = [] for actions_chain in actions_chains: + # Recalculating the trigger component id to use the underlying callable object as a trigger component if needed. + actions_chain_trigger_component_id = actions_chain.trigger.component_id + try: + actions_chain_trigger_component = model_manager[ModelID(str(actions_chain_trigger_component_id))] + # Use underlying callable object as a trigger component. + if hasattr(actions_chain_trigger_component, "_callable_object_id"): + actions_chain_trigger_component_id = actions_chain_trigger_component._callable_object_id + # Not all action_chain_trigger_components are included in model_manager e.g. on_page_load_action_trigger + except KeyError: + pass + # Callback that enables gateway callback to work in the multiple page app clientside_callback( ClientsideFunction(namespace="clientside", function_name="trigger_to_global_store"), Output({"type": "gateway_input", "trigger_id": actions_chain.id}, "data"), Input( - component_id=actions_chain.trigger.component_id, + component_id=actions_chain_trigger_component_id, component_property=actions_chain.trigger.component_property, ), State({"type": "gateway_input", "trigger_id": actions_chain.id}, "data"), diff --git a/vizro-core/src/vizro/actions/_action_loop/_get_action_loop_components.py b/vizro-core/src/vizro/actions/_action_loop/_get_action_loop_components.py index 70b877c30..cb69b6210 100644 --- a/vizro-core/src/vizro/actions/_action_loop/_get_action_loop_components.py +++ b/vizro-core/src/vizro/actions/_action_loop/_get_action_loop_components.py @@ -24,7 +24,7 @@ def _get_action_loop_components() -> html.Div: components = [ dcc.Store(id="action_finished"), dcc.Store(id="remaining_actions", data=[]), - html.Div(id="cycle_breaker_div", style={"display": "hidden"}), + html.Div(id="cycle_breaker_div", hidden=True), dcc.Store(id="cycle_breaker_empty_output_store"), ] diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index f1b45b284..e82756e03 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -14,17 +14,27 @@ from vizro.models.types import MultiValueType, SelectorType, SingleValueType if TYPE_CHECKING: - from vizro.models import Action + from vizro.models import Action, VizroBaseModel ValidatedNoneValueType = Union[SingleValueType, MultiValueType, None, List[None]] -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", "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. +class CallbackTriggerDict(TypedDict): + """Represent dash.callback_context.args_grouping item. Shortened as 'ctd' in the code. + + Args: + id: The component ID. If it`s a pattern matching ID, it will be a dict. + property: The component property used in the callback. + value: The value of the component property at the time the callback was fired. + str_id: For pattern matching IDs, it's the stringified dict ID without white spaces. + triggered: A boolean indicating whether this input triggered the callback. + """ + + id: ModelID + property: Literal["clickData", "value", "n_clicks", "active_cell", "derived_viewport_data"] + value: Optional[Any] + str_id: str + triggered: bool # Utility functions for helper functions used in pre-defined actions ---- @@ -36,14 +46,6 @@ def _get_component_actions(component) -> List[Action]: ) -def _validate_selector_value_NONE(value: Union[SingleValueType, MultiValueType]) -> ValidatedNoneValueType: - if value == NONE_OPTION: - return None - elif isinstance(value, list): - return [i for i in value if i != NONE_OPTION] or [None] # type: ignore[list-item, return-value] - return value - - def _apply_filters( data_frame: pd.DataFrame, ctds_filters: List[CallbackTriggerDict], @@ -66,34 +68,98 @@ def _apply_filters( return data_frame -def _apply_filter_interaction( - data_frame: pd.DataFrame, - ctds_filter_interaction: List[CallbackTriggerDict], - target: str, +def _apply_graph_filter_interaction( + data_frame: pd.DataFrame, target: str, ctd_filter_interaction: Dict[str, CallbackTriggerDict] ) -> pd.DataFrame: - for ctd in ctds_filter_interaction: - if not ctd["value"]: + ctd_click_data = ctd_filter_interaction["clickData"] + if not ctd_click_data["value"]: + return data_frame + + source_graph_id: ModelID = ctd_click_data["id"] + source_graph_actions = _get_component_actions(model_manager[source_graph_id]) + try: + custom_data_columns = model_manager[source_graph_id]["custom_data"] # type: ignore[index] + except KeyError as exc: + raise KeyError(f"No `custom_data` argument found for source graph with id {source_graph_id}.") from exc + + customdata = ctd_click_data["value"]["points"][0]["customdata"] + + for action in source_graph_actions: + if target not in action.function["targets"]: continue + for custom_data_idx, column in enumerate(custom_data_columns): + data_frame = data_frame[data_frame[column].isin([customdata[custom_data_idx]])] - source_chart_id = ctd["id"] - source_chart_actions = _get_component_actions(model_manager[source_chart_id]) + return data_frame - try: - custom_data_columns = model_manager[source_chart_id]["custom_data"] # type: ignore[index] - except KeyError as exc: - raise KeyError(f"No `custom_data` argument found for source chart with id {source_chart_id}.") from exc - customdata = ctd["value"]["points"][0]["customdata"] +def _get_parent_vizro_model(_underlying_callable_object_id: str) -> VizroBaseModel: + from vizro.models import VizroBaseModel - for action in source_chart_actions: - if target not in action.function["targets"]: - continue + for _, vizro_base_model in model_manager._items_with_type(VizroBaseModel): + if ( + hasattr(vizro_base_model, "_callable_object_id") + and vizro_base_model._callable_object_id == _underlying_callable_object_id + ): + return vizro_base_model + raise KeyError( + f"No parent Vizro model found for underlying callable object with id: {_underlying_callable_object_id}." + ) + + +def _apply_table_filter_interaction( + data_frame: pd.DataFrame, target: str, ctd_filter_interaction: Dict[str, CallbackTriggerDict] +) -> pd.DataFrame: + ctd_active_cell = ctd_filter_interaction["active_cell"] + ctd_derived_viewport_data = ctd_filter_interaction["derived_viewport_data"] + if not ctd_active_cell["value"] or not ctd_derived_viewport_data["value"]: + return data_frame + + # ctd_active_cell["id"] represents the underlying table id, so we need to fetch its parent Vizro Table actions. + source_table_actions = _get_component_actions(_get_parent_vizro_model(ctd_active_cell["id"])) + + for action in source_table_actions: + if target not in action.function["targets"]: + continue + column = ctd_active_cell["value"]["column_id"] + derived_viewport_data_row = ctd_active_cell["value"]["row"] + clicked_data = ctd_derived_viewport_data["value"][derived_viewport_data_row][column] + data_frame = data_frame[data_frame[column].isin([clicked_data])] - for custom_data_idx, column in enumerate(custom_data_columns): - data_frame = data_frame[data_frame[column].isin([customdata[custom_data_idx]])] return data_frame +def _apply_filter_interaction( + data_frame: pd.DataFrame, + ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], + target: str, +) -> pd.DataFrame: + for ctd_filter_interaction in ctds_filter_interaction: + if "clickData" in ctd_filter_interaction: + data_frame = _apply_graph_filter_interaction( + data_frame=data_frame, + target=target, + ctd_filter_interaction=ctd_filter_interaction, + ) + + if "active_cell" in ctd_filter_interaction and "derived_viewport_data" in ctd_filter_interaction: + data_frame = _apply_table_filter_interaction( + data_frame=data_frame, + target=target, + ctd_filter_interaction=ctd_filter_interaction, + ) + + return data_frame + + +def _validate_selector_value_none(value: Union[SingleValueType, MultiValueType]) -> ValidatedNoneValueType: + if value == NONE_OPTION: + return None + elif isinstance(value, list): + return [i for i in value if i != NONE_OPTION] or [None] # type: ignore[list-item, return-value] + return value + + def _create_target_arg_mapping(dot_separated_strings: List[str]) -> Dict[str, List[str]]: results = defaultdict(list) for string in dot_separated_strings: @@ -131,7 +197,7 @@ def _get_parametrized_config( if hasattr(selector_value, "__iter__") and ALL_OPTION in selector_value: # type: ignore[operator] selector: SelectorType = model_manager[ctd["id"]] selector_value = selector.options - selector_value = _validate_selector_value_NONE(selector_value) + selector_value = _validate_selector_value_none(selector_value) selector_actions = _get_component_actions(model_manager[ctd["id"]]) for action in selector_actions: @@ -154,7 +220,7 @@ def _get_parametrized_config( def _get_filtered_data( targets: List[ModelID], ctds_filters: List[CallbackTriggerDict], - ctds_filter_interaction: List[CallbackTriggerDict], + ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], ) -> Dict[ModelID, pd.DataFrame]: filtered_data = {} for target in targets: @@ -176,9 +242,9 @@ def _get_filtered_data( return filtered_data -def _get_modified_page_charts( +def _get_modified_page_figures( ctds_filter: List[CallbackTriggerDict], - ctds_filter_interaction: List[CallbackTriggerDict], + ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], ctds_parameters: List[CallbackTriggerDict], ctd_theme: CallbackTriggerDict, targets: Optional[List[ModelID]] = None, 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 5830f4305..0b29e0f76 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 @@ -1,13 +1,13 @@ """Contains utilities to create the action_callback_mapping.""" -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, Union from dash import Output, State, dcc from vizro.actions import _parameter, export_data, filter_interaction from vizro.managers import model_manager from vizro.managers._model_manager import ModelID -from vizro.models import Action, Page +from vizro.models import Action, Page, Table from vizro.models._controls import Filter, Parameter from vizro.models.types import ControlType @@ -35,23 +35,42 @@ def _get_inputs_of_controls(page: Page, control_type: ControlType) -> List[State ] -def _get_inputs_of_chart_interactions(page: Page, action_function: Callable[[Any], Dict[str, Any]]) -> List[State]: +def _get_inputs_of_chart_interactions( + page: Page, action_function: Callable[[Any], Dict[str, Any]] +) -> List[Union[State, Dict[str, State]]]: """Gets list of States for selected chart interaction `action_name` of triggered page.""" - chart_interactions_on_page = _get_matching_actions_by_function( + figure_interactions_on_page = _get_matching_actions_by_function( page=page, action_function=action_function, ) - return [ - State( - component_id=model_manager._get_action_trigger(ModelID(str(action.id))).id, - component_property="clickData", # TODO: needs to be refactored to abstract implementation detail - ) - for action in chart_interactions_on_page - ] + inputs = [] + for action in figure_interactions_on_page: + # TODO: Consider do we want to move the following logic into Model implementation + triggered_model = model_manager._get_action_trigger(action_id=ModelID(str(action.id))) + if isinstance(triggered_model, Table): + inputs.append( + { + "active_cell": State( + component_id=triggered_model._callable_object_id, component_property="active_cell" + ), + "derived_viewport_data": State( + component_id=triggered_model._callable_object_id, + component_property="derived_viewport_data", + ), + } + ) + else: + inputs.append( + { + "clickData": State(component_id=triggered_model.id, component_property="clickData"), + } + ) + + return inputs # TODO: Refactor this and util functions once we implement "_get_input_property" method in VizroBaseModel models -def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[State]]: +def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[Union[State, Dict[str, State]]]]: """Creates mapping of pre-defined action names and a list of States.""" action_function = model_manager[action_id].function._function # type: ignore[attr-defined] page: Page = model_manager._get_model_page(model_id=action_id) @@ -66,6 +85,7 @@ def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[State]]: "parameters": ( _get_inputs_of_controls(page=page, control_type=Parameter) if "parameters" in include_inputs else [] ), + # TODO: Probably need to adjust other inputs to follow the same structure List[Dict[str, State]] "filter_interaction": ( _get_inputs_of_chart_interactions(page=page, action_function=filter_interaction.__wrapped__) if "filter_interaction" in include_inputs @@ -80,6 +100,7 @@ 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. diff --git a/vizro-core/src/vizro/actions/_filter_action.py b/vizro-core/src/vizro/actions/_filter_action.py index 16b29b39e..f35daa7b4 100644 --- a/vizro-core/src/vizro/actions/_filter_action.py +++ b/vizro-core/src/vizro/actions/_filter_action.py @@ -6,7 +6,7 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers._model_manager import ModelID from vizro.models.types import capture @@ -31,7 +31,7 @@ def _filter( Returns: Dict mapping target component ids to modified charts/components e.g. {'my_scatter': Figure({})} """ - return _get_modified_page_charts( + return _get_modified_page_figures( targets=targets, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], 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 26b603c6c..31625fe1e 100644 --- a/vizro-core/src/vizro/actions/_on_page_load_action.py +++ b/vizro-core/src/vizro/actions/_on_page_load_action.py @@ -5,7 +5,7 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers._model_manager import ModelID from vizro.models.types import capture @@ -21,9 +21,9 @@ def _on_page_load(targets: List[ModelID], **inputs: Dict[str, Any]) -> Dict[Mode inputs = {'filters': [], 'parameters': ['gdpPercap'], 'filter_interaction': [], 'theme_selector': True} Returns: - Dict mapping targeted chart ids to modified figures e.g. {'my_scatter': Figure({})} + Dict mapping target chart ids to modified figures e.g. {'my_scatter': Figure({})} """ - return _get_modified_page_charts( + return _get_modified_page_figures( targets=targets, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], diff --git a/vizro-core/src/vizro/actions/_parameter_action.py b/vizro-core/src/vizro/actions/_parameter_action.py index 14f492b35..ba77a7762 100644 --- a/vizro-core/src/vizro/actions/_parameter_action.py +++ b/vizro-core/src/vizro/actions/_parameter_action.py @@ -5,13 +5,12 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) 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[ModelID, Any]: """Modifies parameters of targeted charts/components on page. @@ -26,7 +25,7 @@ def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[ModelID, An """ target_ids: List[ModelID] = [target.split(".")[0] for target in targets] # type: ignore[misc] - return _get_modified_page_charts( + return _get_modified_page_figures( targets=target_ids, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], diff --git a/vizro-core/src/vizro/actions/filter_interaction_action.py b/vizro-core/src/vizro/actions/filter_interaction_action.py index 00e30a64c..d3a04f3a8 100644 --- a/vizro-core/src/vizro/actions/filter_interaction_action.py +++ b/vizro-core/src/vizro/actions/filter_interaction_action.py @@ -5,7 +5,7 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers._model_manager import ModelID from vizro.models.types import capture @@ -16,10 +16,12 @@ def filter_interaction( targets: Optional[List[ModelID]] = None, **inputs: Dict[str, Any], ) -> Dict[ModelID, Any]: - """Filters targeted charts/components on page by clicking on data points of the source chart. + """Filters targeted charts/components on page by clicking on data points or table cells of the source chart. - To set up filtering on specific columns of the target chart(s), include these columns in the 'custom_data' - parameter of the source chart e.g. `px.bar(..., custom_data=["species", "sepal_length"])` + To set up filtering on specific columns of the target graph(s), include these columns in the 'custom_data' + parameter of the source graph e.g. `px.bar(..., custom_data=["species", "sepal_length"])`. + If the filter interaction source is a table e.g. `vm.Table(..., actions=[filter_interaction])`, + then the table doesn't need to have a 'custom_data' parameter set up. Args: targets: List of target component ids to filter by chart interaction. If missing, will target all valid @@ -30,7 +32,7 @@ def filter_interaction( Returns: Dict mapping target component ids to modified charts/components e.g. {'my_scatter': Figure({})} """ - return _get_modified_page_charts( + return _get_modified_page_figures( targets=targets, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], diff --git a/vizro-core/src/vizro/models/_components/card.py b/vizro-core/src/vizro/models/_components/card.py index 3933406b6..05600253d 100644 --- a/vizro-core/src/vizro/models/_components/card.py +++ b/vizro-core/src/vizro/models/_components/card.py @@ -39,12 +39,8 @@ def build(self): className="button_container", ) if self.href - else None + else html.Div(hidden=True) ) card_container = "nav_card_container" if self.href else "card_container" - return html.Div( - [text, button], - className=card_container, - id=f"{self.id}_outer", - ) + return html.Div([text, button], className=card_container, id=f"{self.id}_outer") diff --git a/vizro-core/src/vizro/models/_components/form/_user_input.py b/vizro-core/src/vizro/models/_components/form/_user_input.py index edcfeff0e..cfe29a275 100644 --- a/vizro-core/src/vizro/models/_components/form/_user_input.py +++ b/vizro-core/src/vizro/models/_components/form/_user_input.py @@ -36,7 +36,7 @@ class UserInput(VizroBaseModel): def build(self): return html.Div( [ - html.P(self.title) if self.title else None, + html.P(self.title) if self.title else html.Div(hidden=True), dbc.Input( id=self.id, placeholder=self.placeholder, diff --git a/vizro-core/src/vizro/models/_components/form/checklist.py b/vizro-core/src/vizro/models/_components/form/checklist.py index 8fec2870e..b8f0daea6 100644 --- a/vizro-core/src/vizro/models/_components/form/checklist.py +++ b/vizro-core/src/vizro/models/_components/form/checklist.py @@ -41,7 +41,7 @@ def build(self): return html.Div( [ - html.P(self.title) if self.title else None, + html.P(self.title) if self.title else html.Div(hidden=True), dcc.Checklist( id=self.id, options=full_options, diff --git a/vizro-core/src/vizro/models/_components/form/dropdown.py b/vizro-core/src/vizro/models/_components/form/dropdown.py index 5db1a375d..9d01c79a5 100755 --- a/vizro-core/src/vizro/models/_components/form/dropdown.py +++ b/vizro-core/src/vizro/models/_components/form/dropdown.py @@ -53,7 +53,7 @@ def build(self): full_options, default_value = get_options_and_default(options=self.options, multi=self.multi) return html.Div( [ - html.P(self.title) if self.title else None, + html.P(self.title) if self.title else html.Div(hidden=True), dcc.Dropdown( id=self.id, options=full_options, 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 57e460206..782f15d21 100644 --- a/vizro-core/src/vizro/models/_components/form/radio_items.py +++ b/vizro-core/src/vizro/models/_components/form/radio_items.py @@ -42,7 +42,7 @@ def build(self): return html.Div( [ - html.P(self.title) if self.title else None, + html.P(self.title) if self.title else html.Div(hidden=True), dcc.RadioItems( id=self.id, options=full_options, 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 64326c711..4ee181c8d 100644 --- a/vizro-core/src/vizro/models/_components/form/range_slider.py +++ b/vizro-core/src/vizro/models/_components/form/range_slider.py @@ -87,7 +87,7 @@ def build(self): "max": self.max, }, ), - html.P(self.title) if self.title else None, + html.P(self.title) if self.title else html.Div(hidden=True), html.Div( [ dcc.RangeSlider( diff --git a/vizro-core/src/vizro/models/_components/form/slider.py b/vizro-core/src/vizro/models/_components/form/slider.py index 5e6607f7e..4a44e898f 100644 --- a/vizro-core/src/vizro/models/_components/form/slider.py +++ b/vizro-core/src/vizro/models/_components/form/slider.py @@ -81,7 +81,7 @@ def build(self): "max": self.max, }, ), - html.P(self.title) if self.title else None, + html.P(self.title) if self.title else html.Div(hidden=True), html.Div( [ dcc.Slider( diff --git a/vizro-core/src/vizro/models/_components/table.py b/vizro-core/src/vizro/models/_components/table.py index 4dd0a33a3..9b22d4a8f 100644 --- a/vizro-core/src/vizro/models/_components/table.py +++ b/vizro-core/src/vizro/models/_components/table.py @@ -2,6 +2,7 @@ from typing import List, Literal, Optional from dash import dash_table, dcc, html +from pandas import DataFrame from pydantic import Field, PrivateAttr, validator import vizro.tables as vt @@ -31,6 +32,8 @@ class Table(VizroBaseModel): title: Optional[str] = Field(None, description="Title of the table") actions: List[Action] = [] + _callable_object_id: str = PrivateAttr() + # Component properties for actions and interactions _output_property: str = PrivateAttr("children") @@ -52,12 +55,32 @@ def __getitem__(self, arg_name: str): return self.figure[arg_name] @_log_call + def pre_build(self): + if self.actions: + kwargs = self.figure._arguments.copy() + + # This workaround is needed because the underlying table object requires a data_frame + kwargs["data_frame"] = DataFrame() + + # The underlying table object is pre-built, so we can fetch its ID. + underlying_table_object = self.figure._function(**kwargs) + + if not hasattr(underlying_table_object, "id"): + raise ValueError( + "Underlying `Table` callable has no attribute 'id'. To enable actions triggered by the `Table`" + " a valid 'id' has to be provided to the `Table` callable." + ) + + self._callable_object_id = underlying_table_object.id + 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), + html.H3(self.title, className="table-title") if self.title else html.Div(hidden=True), + html.Div( + dash_table.DataTable(**({"id": self._callable_object_id} if self.actions else {})), id=self.id + ), ], className="table-container", id=f"{self.id}_outer", diff --git a/vizro-core/src/vizro/models/_dashboard.py b/vizro-core/src/vizro/models/_dashboard.py index 6f42700dd..11d482706 100644 --- a/vizro-core/src/vizro/models/_dashboard.py +++ b/vizro-core/src/vizro/models/_dashboard.py @@ -1,12 +1,14 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, List, Literal, Optional +from functools import partial +from typing import TYPE_CHECKING, List, Literal, Optional, cast import dash import dash_bootstrap_components as dbc +import dash_daq as daq import plotly.io as pio -from dash import ClientsideFunction, Input, Output, clientside_callback, html +from dash import ClientsideFunction, Input, Output, clientside_callback, get_relative_path, html from pydantic import Field, validator import vizro @@ -25,28 +27,6 @@ def update_theme(on: bool): return "vizro_dark" if on else "vizro_light" -def create_layout_page_404(): - return html.Div( - [ - html.Img(src=STATIC_URL_PREFIX + "/images/errors/error_404.svg"), - html.Div( - [ - html.Div( - [ - html.H3("This page could not be found.", className="heading-3-600"), - html.P("Make sure the URL you entered is correct."), - ], - className="error_text_container", - ), - dbc.Button("Take me home", href="/", className="button_primary"), - ], - className="error_content_container", - ), - ], - className="page_error_container", - ) - - class Dashboard(VizroBaseModel): """Vizro Dashboard to be used within [`Vizro`][vizro._vizro.Vizro.build]. @@ -94,8 +74,10 @@ def pre_build(self): # Note redirect_from=["/"] doesn't work and so the / route must be defined separately. for order, page in enumerate(self.pages): path = page.path if order else "/" - dash.register_page(module=page.id, name=page.title, path=path, order=order, layout=page.build) - self._create_error_page_404() + dash.register_page( + module=page.id, name=page.title, path=path, order=order, layout=partial(self._make_page_layout, page) + ) + dash.register_page(module=MODULE_PAGE_404, layout=self._make_page_404_layout()) @_log_call def build(self): @@ -114,6 +96,37 @@ def build(self): fluid=True, ) + def _make_page_layout(self, page: Page): + # Identical across pages + dashboard_title = ( + html.Div(children=[html.H2(self.title), html.Hr()], className="dashboard_title", id="dashboard_title_outer") + if self.title + else html.Div(hidden=True, id="dashboard_title_outer") + ) + theme_switch = daq.BooleanSwitch( + id="theme_selector", on=True if self.theme == "vizro_dark" else False, persistence=True + ) + + # Shared across pages but slightly differ in content + page_title = html.H2(children=page.title, id="page_title") + navigation = cast(Navigation, self.navigation).build(active_page_id=page.id) + + # Different across pages + page_content = page.build() + control_panel = page_content["control_panel_outer"] + component_container = page_content["component_container_outer"] + + # Arrangement + header = html.Div(children=[page_title, theme_switch], className="header", id="header_outer") + left_side_elements = [dashboard_title, navigation, control_panel] + left_side = ( + html.Div(children=left_side_elements, className="left_side", id="left_side_outer") + if any(left_side_elements) + else html.Div(hidden=True, id="left_side_outer") + ) + right_side = html.Div(children=[header, component_container], className="right_side", id="right_side_outer") + return html.Div([left_side, right_side], className="page_container", id="page_container_outer") + @staticmethod def _update_theme(): clientside_callback( @@ -123,5 +136,23 @@ def _update_theme(): ) @staticmethod - def _create_error_page_404(): - return dash.register_page(module=MODULE_PAGE_404, layout=create_layout_page_404()) + def _make_page_404_layout(): + return html.Div( + [ + html.Img(src=get_relative_path(f"/{STATIC_URL_PREFIX}/images/errors/error_404.svg")), + html.Div( + [ + html.Div( + [ + html.H3("This page could not be found.", className="heading-3-600"), + html.P("Make sure the URL you entered is correct."), + ], + className="error_text_container", + ), + dbc.Button("Take me home", href=get_relative_path("/"), className="button_primary"), + ], + className="error_content_container", + ), + ], + className="page_error_container", + ) diff --git a/vizro-core/src/vizro/models/_navigation/_accordion.py b/vizro-core/src/vizro/models/_navigation/_accordion.py index 6937e6b2b..be00ff619 100644 --- a/vizro-core/src/vizro/models/_navigation/_accordion.py +++ b/vizro-core/src/vizro/models/_navigation/_accordion.py @@ -36,7 +36,7 @@ def coerce_pages_type(cls, pages): def build(self, *, active_page_id=None): # Hide navigation panel if there is only one page if len(list(itertools.chain(*self.pages.values()))) == 1: - return html.Div(className="hidden") + return html.Div(hidden=True) accordion_items = [] for page_group, page_members in self.pages.items(): diff --git a/vizro-core/src/vizro/models/_page.py b/vizro-core/src/vizro/models/_page.py index 1a45c427f..3040388ef 100644 --- a/vizro-core/src/vizro/models/_page.py +++ b/vizro-core/src/vizro/models/_page.py @@ -1,9 +1,7 @@ from __future__ import annotations -from typing import List, Optional, cast +from typing import List, Optional -import dash_bootstrap_components as dbc -import dash_daq as daq from dash import Input, Output, Patch, callback, dcc, html from pydantic import Field, root_validator, validator @@ -12,7 +10,7 @@ from vizro.actions import _on_page_load from vizro.managers import model_manager from vizro.managers._model_manager import DuplicateIDError, ModelID -from vizro.models import Action, Dashboard, Graph, Layout, Navigation, VizroBaseModel +from vizro.models import Action, Graph, Layout, VizroBaseModel from vizro.models._action._actions_chain import ActionsChain, Trigger from vizro.models._models_utils import _log_call, get_unique_grid_component_ids @@ -143,6 +141,11 @@ def pre_build(self): def build(self): self._update_graph_theme() controls_content = [control.build() for control in self.controls] + control_panel = ( + html.Div(children=[*controls_content, html.Hr()], className="control_panel", id="control_panel_outer") + if controls_content + else html.Div(hidden=True, id="control_panel_outer") + ) components_content = [ html.Div( component.build(), @@ -155,7 +158,8 @@ def build(self): self.components, self.layout.component_grid_lines # type: ignore[union-attr] ) ] - return self._make_page_layout(controls_content, components_content) + components_container = self._create_component_container(components_content) + return html.Div([control_panel, components_container]) def _update_graph_theme(self): outputs = [ @@ -175,90 +179,24 @@ def update_graph_theme(theme_selector_on: bool): patched_figure["layout"]["template"] = themes.dark if theme_selector_on else themes.light return [patched_figure] * len(outputs) - @staticmethod - def _create_theme_switch(): - _, dashboard = next(model_manager._items_with_type(Dashboard)) - theme_switch = daq.BooleanSwitch( - id="theme_selector", on=True if dashboard.theme == "vizro_dark" else False, persistence=True - ) - return theme_switch - - @staticmethod - def _create_control_panel(controls_content): - control_panel = html.Div( - children=[*controls_content, html.Hr()], className="control_panel", id="control_panel_outer" - ) - return control_panel if controls_content else None - - def _create_nav_panel(self): - _, dashboard = next(model_manager._items_with_type(Dashboard)) - return cast(Navigation, dashboard.navigation).build(active_page_id=self.id) - def _create_component_container(self, components_content): component_container = html.Div( - children=html.Div( - components_content, - style={ - "gridRowGap": self.layout.row_gap, # type: ignore[union-attr] - "gridColumnGap": self.layout.col_gap, # type: ignore[union-attr] - "gridTemplateColumns": f"repeat({len(self.layout.grid[0])}," # type: ignore[union-attr] - f"minmax({self.layout.col_min_width}, 1fr))", - "gridTemplateRows": f"repeat({len(self.layout.grid)}," # type: ignore[union-attr] - f"minmax({self.layout.row_min_height}, 1fr))", - }, - className="component_container_grid", - ), + children=[ + html.Div( + components_content, + style={ + "gridRowGap": self.layout.row_gap, # type: ignore[union-attr] + "gridColumnGap": self.layout.col_gap, # type: ignore[union-attr] + "gridTemplateColumns": f"repeat({len(self.layout.grid[0])}," # type: ignore[union-attr] + f"minmax({self.layout.col_min_width}, 1fr))", + "gridTemplateRows": f"repeat({len(self.layout.grid)}," # type: ignore[union-attr] + f"minmax({self.layout.row_min_height}, 1fr))", + }, + className="component_container_grid", + ), + dcc.Store(id=f"{ON_PAGE_LOAD_ACTION_PREFIX}_trigger_{self.id}"), + ], className="component_container", id="component_container_outer", ) return component_container - - @staticmethod - def _arrange_containers(page_title, theme_switch, nav_panel, control_panel, component_container): - """Defines div container arrangement on page. - - To change arrangement, one has to change the order in the header, left_side and/or right_side_elements. - """ - _, dashboard = next(model_manager._items_with_type(Dashboard)) - dashboard_title = ( - html.Div( - children=[html.H2(dashboard.title), html.Hr()], className="dashboard_title", id="dashboard_title_outer" - ) - if dashboard.title - else None - ) - - header_elements = [page_title, theme_switch] - left_side_elements = [dashboard_title, nav_panel, control_panel] - header = html.Div(children=header_elements, className="header", id="header_outer") - left_side = ( - html.Div(children=left_side_elements, className="left_side", id="left_side_outer") - if any(left_side_elements) - else None - ) - right_side_elements = [header, component_container] - right_side = html.Div(children=right_side_elements, className="right_side", id="right_side_outer") - return left_side, right_side - - def _make_page_layout(self, controls_content, components_content): - # Create dashboard containers/elements - page_title = html.H2(children=self.title) - theme_switch = self._create_theme_switch() - nav_panel = self._create_nav_panel() - control_panel = self._create_control_panel(controls_content) - component_container = self._create_component_container(components_content) - - # Arrange dashboard containers - left_side, right_side = self._arrange_containers( - page_title=page_title, - theme_switch=theme_switch, - nav_panel=nav_panel, - control_panel=control_panel, - component_container=component_container, - ) - - return dbc.Container( - id=self.id, - children=[dcc.Store(id=f"{ON_PAGE_LOAD_ACTION_PREFIX}_trigger_{self.id}"), left_side, right_side], - className="page_container", - ) diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 83c155da4..05440ddc7 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -188,6 +188,9 @@ class capture: >>> @capture("table") >>> def table_function(): >>> ... + >>> @capture("table") + >>> def plot_function(): + >>> ... >>> @capture("action") >>> def action_function(): >>> ... diff --git a/vizro-core/src/vizro/plotly/express.py b/vizro-core/src/vizro/plotly/express.py index bdaff69b5..e0794d333 100644 --- a/vizro-core/src/vizro/plotly/express.py +++ b/vizro-core/src/vizro/plotly/express.py @@ -10,7 +10,7 @@ from vizro.models.types import capture -# TODO: is there a better way to see if the import is a chart? Don't want to check return type though. -> MS +# TODO: is there a better way to see if the import is a graph? Don't want to check return type though. -> MS # Might also want to define __dir__ or __all__ in order to facilitate IDE completion etc. # TODO: type hints -> MS def __getattr__(name: str) -> Any: diff --git a/vizro-core/src/vizro/static/css/dropdown.css b/vizro-core/src/vizro/static/css/dropdown.css index 5f5fcca63..4f3518926 100644 --- a/vizro-core/src/vizro/static/css/dropdown.css +++ b/vizro-core/src/vizro/static/css/dropdown.css @@ -67,7 +67,9 @@ div.page_container .Select--single .Select-value { > .Select-control .Select-value .Select-value-label { - color: var(--text-primary); + color: var( + --text-primary + ) !important; /* Required so text color don't change caused by adding table */ } /* Tags ---------------------------*/ @@ -145,5 +147,5 @@ wrapper **/ } .Select-input > input { - padding: 0 !important; /*Required so tags don't jump caused by adding table */ + padding: 0 !important; /* Required so tags don't jump caused by adding table */ } diff --git a/vizro-core/tests/integration/test_examples.py b/vizro-core/tests/integration/test_examples.py index 727a9ed55..85885b373 100644 --- a/vizro-core/tests/integration/test_examples.py +++ b/vizro-core/tests/integration/test_examples.py @@ -2,7 +2,7 @@ import os from pathlib import Path -import chromedriver_autoinstaller +import chromedriver_autoinstaller_fix import pytest from vizro import Vizro @@ -22,7 +22,7 @@ def setup_integration_test_environment(monkeypatch_session): monkeypatch_session.setenv("DASH_DEBUG", "false") # We only need to install chromedriver outside CI. if not os.getenv("CI"): - chromedriver_autoinstaller.install() + chromedriver_autoinstaller_fix.install() @pytest.fixture diff --git a/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py b/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py index dcabb030d..d7698380f 100644 --- a/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py +++ b/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py @@ -9,7 +9,7 @@ import vizro.models as vm import vizro.plotly.express as px from vizro import Vizro -from vizro.actions import export_data +from vizro.actions import export_data, filter_interaction from vizro.actions._action_loop._get_action_loop_components import _get_action_loop_components from vizro.managers import model_manager @@ -19,7 +19,7 @@ def fundamental_components(): return [ dcc.Store(id="action_finished"), dcc.Store(id="remaining_actions", data=[]), - html.Div(id="cycle_breaker_div", style={"display": "hidden"}), + html.Div(id="cycle_breaker_div", hidden=True), dcc.Store(id="cycle_breaker_empty_output_store"), ] @@ -68,7 +68,7 @@ def trigger_to_actions_chain_mapper_component(request): @pytest.fixture -def managers_one_page_two_components_two_controls(): +def managers_one_page_two_components_two_controls(dash_data_table_with_id): """Instantiates managers with one page that contains two controls and two components.""" vm.Dashboard( pages=[ @@ -76,6 +76,16 @@ def managers_one_page_two_components_two_controls(): id="test_page", title="First page", components=[ + vm.Table( + id="vizro_table", + figure=dash_data_table_with_id, + actions=[ + vm.Action( + id="table_filter_interaction_action", + function=filter_interaction(targets=["scatter_chart"]), + ) + ], + ), vm.Graph( id="scatter_chart", figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap"), @@ -141,10 +151,10 @@ def test_no_components(self): "trigger_to_actions_chain_mapper_component", [ ( - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], ) ], indirect=True, diff --git a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py index 9c7a56e6d..49836398d 100644 --- a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py +++ b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py @@ -29,7 +29,7 @@ def export_data(): @pytest.fixture -def managers_one_page_four_controls_two_graphs_filter_interaction(request): +def managers_one_page_four_controls_three_figures_filter_interaction(request, dash_data_table_with_id): """Instantiates managers with one page that contains four controls, two graphs and filter interaction.""" # If the fixture is parametrised set the targets. Otherwise, set export_data without targets. export_data_action_function = export_data(targets=request.param) if hasattr(request, "param") else export_data() @@ -50,6 +50,16 @@ def managers_one_page_four_controls_two_graphs_filter_interaction(request): figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap", custom_data=["continent"]), actions=[vm.Action(id="custom_action", function=custom_action_example())], ), + vm.Table( + id="vizro_table", + figure=dash_data_table_with_id, + actions=[ + vm.Action( + id="table_filter_interaction_action", + function=filter_interaction(targets=["scatter_chart", "scatter_chart_2"]), + ) + ], + ), vm.Button( id="export_data_button", actions=[ @@ -81,6 +91,16 @@ def managers_one_page_four_controls_two_graphs_filter_interaction(request): value="lifeExp", ), ), + vm.Parameter( + id="vizro_table_row_selectable", + targets=["vizro_table.row_selectable"], + selector=vm.Dropdown( + id="parameter_table_row_selectable", + options=["multi", "single"], + multi=False, + value="single", + ), + ), ], ) Vizro._pre_build() @@ -96,9 +116,16 @@ def action_callback_inputs_expected(): "parameters": [ dash.State("parameter_x_selector", "value"), dash.State("parameter_y_selector", "value"), + dash.State("parameter_table_row_selectable", "value"), ], "filter_interaction": [ - dash.State("scatter_chart", "clickData"), + { + "clickData": dash.State("scatter_chart", "clickData"), + }, + { + "active_cell": dash.State("underlying_table_id", "active_cell"), + "derived_viewport_data": dash.State("underlying_table_id", "derived_viewport_data"), + }, ], "theme_selector": dash.State("theme_selector", "on"), } @@ -121,7 +148,13 @@ def export_data_inputs_expected(): ], "parameters": [], "filter_interaction": [ - dash.State("scatter_chart", "clickData"), + { + "clickData": dash.State("scatter_chart", "clickData"), + }, + { + "active_cell": dash.State("underlying_table_id", "active_cell"), + "derived_viewport_data": dash.State("underlying_table_id", "derived_viewport_data"), + }, ], "theme_selector": [], } @@ -145,7 +178,7 @@ def export_data_components_expected(request): ] -@pytest.mark.usefixtures("managers_one_page_four_controls_two_graphs_filter_interaction") +@pytest.mark.usefixtures("managers_one_page_four_controls_three_figures_filter_interaction") class TestCallbackMapping: """Tests action callback mapping for predefined and custom actions.""" @@ -176,9 +209,17 @@ def test_action_callback_mapping_inputs(self, action_id, callback_mapping_inputs [ {"component_id": "scatter_chart", "component_property": "figure"}, {"component_id": "scatter_chart_2", "component_property": "figure"}, + {"component_id": "vizro_table", "component_property": "children"}, ], ), ("filter_interaction_action", [{"component_id": "scatter_chart_2", "component_property": "figure"}]), + ( + "table_filter_interaction_action", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), ( "parameter_action_parameter_x", [ @@ -186,11 +227,25 @@ def test_action_callback_mapping_inputs(self, action_id, callback_mapping_inputs {"component_id": "scatter_chart_2", "component_property": "figure"}, ], ), + ( + "parameter_action_parameter_y", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), ( "on_page_load_action_action_test_page", [ {"component_id": "scatter_chart", "component_property": "figure"}, {"component_id": "scatter_chart_2", "component_property": "figure"}, + {"component_id": "vizro_table", "component_property": "children"}, + ], + ), + ( + "parameter_action_vizro_table_row_selectable", + [ + {"component_id": "vizro_table", "component_property": "children"}, ], ), ], @@ -205,7 +260,7 @@ def test_action_callback_mapping_outputs(self, action_id, action_callback_output @pytest.mark.parametrize( "export_data_outputs_expected", - [("scatter_chart", "scatter_chart_2")], + [("scatter_chart", "scatter_chart_2", "vizro_table")], indirect=True, ) def test_export_data_no_targets_set_mapping_outputs(self, export_data_outputs_expected): @@ -217,17 +272,17 @@ def test_export_data_no_targets_set_mapping_outputs(self, export_data_outputs_ex assert result == export_data_outputs_expected @pytest.mark.parametrize( - "managers_one_page_four_controls_two_graphs_filter_interaction, export_data_outputs_expected", + "managers_one_page_four_controls_three_figures_filter_interaction, export_data_outputs_expected", [ - (None, ["scatter_chart", "scatter_chart_2"]), - ([], ["scatter_chart", "scatter_chart_2"]), + (None, ["scatter_chart", "scatter_chart_2", "vizro_table"]), + ([], ["scatter_chart", "scatter_chart_2", "vizro_table"]), (["scatter_chart"], ["scatter_chart"]), (["scatter_chart", "scatter_chart_2"], ["scatter_chart", "scatter_chart_2"]), ], indirect=True, ) def test_export_data_targets_set_mapping_outputs( - self, managers_one_page_four_controls_two_graphs_filter_interaction, export_data_outputs_expected + self, managers_one_page_four_controls_three_figures_filter_interaction, export_data_outputs_expected ): result = _get_action_callback_mapping( action_id="export_data_action", @@ -238,7 +293,7 @@ def test_export_data_targets_set_mapping_outputs( @pytest.mark.parametrize( "export_data_components_expected", - [("scatter_chart", "scatter_chart_2")], + [("scatter_chart", "scatter_chart_2", "vizro_table")], indirect=True, ) def test_export_data_no_targets_set_mapping_components(self, export_data_components_expected): @@ -252,17 +307,17 @@ def test_export_data_no_targets_set_mapping_components(self, export_data_compone assert result == expected @pytest.mark.parametrize( - "managers_one_page_four_controls_two_graphs_filter_interaction, export_data_components_expected", + "managers_one_page_four_controls_three_figures_filter_interaction, export_data_components_expected", [ - (None, ["scatter_chart", "scatter_chart_2"]), - ([], ["scatter_chart", "scatter_chart_2"]), + (None, ["scatter_chart", "scatter_chart_2", "vizro_table"]), + ([], ["scatter_chart", "scatter_chart_2", "vizro_table"]), (["scatter_chart"], ["scatter_chart"]), (["scatter_chart", "scatter_chart_2"], ["scatter_chart", "scatter_chart_2"]), ], indirect=True, ) def test_export_data_targets_set_mapping_components( - self, managers_one_page_four_controls_two_graphs_filter_interaction, export_data_components_expected + self, managers_one_page_four_controls_three_figures_filter_interaction, export_data_components_expected ): result_components = _get_action_callback_mapping( action_id="export_data_action", diff --git a/vizro-core/tests/unit/vizro/actions/conftest.py b/vizro-core/tests/unit/vizro/actions/conftest.py index 566f2bbbe..4060db269 100644 --- a/vizro-core/tests/unit/vizro/actions/conftest.py +++ b/vizro-core/tests/unit/vizro/actions/conftest.py @@ -57,3 +57,19 @@ def managers_one_page_two_graphs_one_button(box_chart, scatter_chart): ], ) Vizro._pre_build() + + +@pytest.fixture +def managers_one_page_two_graphs_one_table_one_button(box_chart, scatter_chart, dash_data_table_with_id): + """Instantiates a simple model_manager and data_manager with a page, two graph models and the button component.""" + vm.Page( + id="test_page", + title="My first dashboard", + components=[ + vm.Graph(id="box_chart", figure=box_chart), + vm.Graph(id="scatter_chart", figure=scatter_chart), + vm.Table(id="vizro_table", figure=dash_data_table_with_id), + vm.Button(id="button"), + ], + ) + Vizro._pre_build() diff --git a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py index 1fa8a5699..7f9440976 100644 --- a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py @@ -12,13 +12,15 @@ @pytest.fixture -def target_scatter_filtered_pop_filter_interaction_continent(request, gapminder_2007): - pop_filter, continent_filter_interaction = request.param +def target_scatter_filter_and_filter_interaction(request, gapminder_2007): + pop_filter, continent_filter_interaction, country_table_filter_interaction = request.param data = gapminder_2007 if pop_filter: data = data[data["pop"].between(pop_filter[0], pop_filter[1], inclusive="both")] if continent_filter_interaction: data = data[data["continent"].isin(continent_filter_interaction)] + if country_table_filter_interaction: + data = data[data["country"].isin(country_table_filter_interaction)] return data @@ -44,23 +46,13 @@ def managers_one_page_without_graphs_one_button(): @pytest.fixture def callback_context_export_data(request): - """Mock dash.callback_context that represents on page load.""" - targets, pop_filter, continent_filter_interaction = request.param - mock_callback_context = { - "args_grouping": { - "filters": [ - CallbackTriggerDict( - id="pop_filter", - property="value", - value=pop_filter, - str_id="pop_filter", - triggered=False, - ) - ] - if pop_filter - else [], - "filter_interaction": [ - CallbackTriggerDict( + """Mock dash.callback_context that represents filters and filter interactions applied.""" + targets, pop_filter, continent_filter_interaction, country_table_filter_interaction = request.param + args_grouping_filter_interaction = [] + if continent_filter_interaction: + args_grouping_filter_interaction.append( + { + "clickData": CallbackTriggerDict( id="box_chart", property="clickData", value={ @@ -73,9 +65,52 @@ def callback_context_export_data(request): str_id="box_chart", triggered=False, ) + } + ) + if country_table_filter_interaction: + args_grouping_filter_interaction.append( + { + "active_cell": CallbackTriggerDict( + id="underlying_table_id", + property="active_cell", + value={"row": 0, "column": 0, "column_id": "country"}, + str_id="underlying_table_id", + triggered=False, + ), + "derived_viewport_data": CallbackTriggerDict( + id="underlying_table_id", + property="derived_viewport_data", + value=[ + { + "country": "Algeria", + "continent": "Africa", + "year": 2007, + }, + { + "country": "Egypt", + "continent": "Africa", + "year": 2007, + }, + ], + str_id="underlying_table_id", + triggered=False, + ), + } + ) + mock_callback_context = { + "args_grouping": { + "filters": [ + CallbackTriggerDict( + id="pop_filter", + property="value", + value=pop_filter, + str_id="pop_filter", + triggered=False, + ) ] - if continent_filter_interaction + if pop_filter else [], + "filter_interaction": args_grouping_filter_interaction, }, "outputs_list": [ {"id": {"action_id": "test_action", "target_id": target, "type": "download-dataframe"}, "property": "data"} @@ -88,7 +123,7 @@ def callback_context_export_data(request): class TestExportData: @pytest.mark.usefixtures("managers_one_page_without_graphs_one_button") - @pytest.mark.parametrize("callback_context_export_data", [([[], None, None])], indirect=True) + @pytest.mark.parametrize("callback_context_export_data", [([[], None, None, None])], indirect=True) def test_no_graphs_no_targets(self, callback_context_export_data): # Add action to relevant component model_manager["button"].actions = [vm.Action(id="test_action", function=export_data())] @@ -100,7 +135,7 @@ def test_no_graphs_no_targets(self, callback_context_export_data): @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_export_data", [([["scatter_chart", "box_chart"], None, None])], indirect=True + "callback_context_export_data", [([["scatter_chart", "box_chart"], None, None, None])], indirect=True ) def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): # Add action to relevant component @@ -119,8 +154,8 @@ def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): @pytest.mark.parametrize( "callback_context_export_data, targets", [ - ([["scatter_chart", "box_chart"], None, None], None), - ([["scatter_chart", "box_chart"], None, None], []), + ([["scatter_chart", "box_chart"], None, None, None], None), + ([["scatter_chart", "box_chart"], None, None, None], []), ], indirect=["callback_context_export_data"], ) @@ -138,7 +173,7 @@ def test_graphs_false_targets(self, callback_context_export_data, targets, gapmi assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") - @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None)], indirect=True) + @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None, None)], indirect=True) def test_one_target(self, callback_context_export_data, gapminder_2007): # Add action to relevant component model_manager["button"].actions = [vm.Action(id="test_action", function=export_data(targets=["scatter_chart"]))] @@ -153,7 +188,7 @@ def test_one_target(self, callback_context_export_data, gapminder_2007): @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_export_data", [(["scatter_chart", "box_chart"], None, None)], indirect=True + "callback_context_export_data", [(["scatter_chart", "box_chart"], None, None, None)], indirect=True ) def test_multiple_targets(self, callback_context_export_data, gapminder_2007): # Add action to relevant component @@ -171,7 +206,7 @@ def test_multiple_targets(self, callback_context_export_data, gapminder_2007): assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") - @pytest.mark.parametrize("callback_context_export_data", [(["invalid_target_id"], None, None)], indirect=True) + @pytest.mark.parametrize("callback_context_export_data", [(["invalid_target_id"], None, None, None)], indirect=True) def test_invalid_target( self, callback_context_export_data, @@ -187,19 +222,17 @@ def test_invalid_target( @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_export_data, " - "target_scatter_filtered_pop_filter_interaction_continent, " - "target_box_filtered_pop", + "callback_context_export_data, " "target_scatter_filter_and_filter_interaction, " "target_box_filtered_pop", [ ( - [["scatter_chart", "box_chart"], [10**6, 10**7], None], - [[10**6, 10**7], None], + [["scatter_chart", "box_chart"], [10**6, 10**7], None, None], + [[10**6, 10**7], None, None], [10**6, 10**7], ), - ([["scatter_chart", "box_chart"], None, "Africa"], [None, ["Africa"]], None), + ([["scatter_chart", "box_chart"], None, "Africa", None], [None, ["Africa"], None], None), ( - [["scatter_chart", "box_chart"], [10**6, 10**7], "Africa"], - [[10**6, 10**7], ["Africa"]], + [["scatter_chart", "box_chart"], [10**6, 10**7], "Africa", None], + [[10**6, 10**7], ["Africa"], None], [10**6, 10**7], ), ], @@ -208,7 +241,58 @@ def test_invalid_target( def test_multiple_targets_with_filter_and_filter_interaction( self, callback_context_export_data, - target_scatter_filtered_pop_filter_interaction_continent, + target_scatter_filter_and_filter_interaction, + target_box_filtered_pop, + ): + # Creating and adding a Filter object to the existing Page + pop_filter = vm.Filter(column="pop", selector=vm.RangeSlider(id="pop_filter")) + model_manager["test_page"].controls = [pop_filter] + # Adds a default _filter Action to the filter selector objects + pop_filter.pre_build() + + # Add filter_interaction Action to scatter_chart component + model_manager["box_chart"].actions = [ + vm.Action(id="filter_interaction", function=filter_interaction(targets=["scatter_chart"])) + ] + + # Add export_data action to relevant component + model_manager["button"].actions = [ + vm.Action(id="test_action", function=export_data(targets=["scatter_chart", "box_chart"])) + ] + + # Run action by picking the above added export_data action function and executing it with () + result = model_manager["test_action"].function() + + assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" + assert result["download-dataframe_scatter_chart"][ + "content" + ] == target_scatter_filter_and_filter_interaction.to_csv(index=False) + + assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" + assert result["download-dataframe_box_chart"]["content"] == target_box_filtered_pop.to_csv(index=False) + + @pytest.mark.usefixtures("managers_one_page_two_graphs_one_table_one_button") + @pytest.mark.parametrize( + "callback_context_export_data, " "target_scatter_filter_and_filter_interaction, " "target_box_filtered_pop", + [ + ( + [["scatter_chart", "box_chart"], [10**6, 10**7], None, "Algeria"], + [[10**6, 10**7], None, ["Algeria"]], + [10**6, 10**7], + ), + ([["scatter_chart", "box_chart"], None, "Africa", "Algeria"], [None, ["Africa"], ["Algeria"]], None), + ( + [["scatter_chart", "box_chart"], [10**6, 10**7], "Africa", "Algeria"], + [[10**6, 10**7], ["Africa"], ["Algeria"]], + [10**6, 10**7], + ), + ], + indirect=True, + ) + def test_multiple_targets_with_filter_and_filter_interaction_and_table( + self, + callback_context_export_data, + target_scatter_filter_and_filter_interaction, target_box_filtered_pop, ): # Creating and adding a Filter object to the existing Page @@ -222,6 +306,10 @@ def test_multiple_targets_with_filter_and_filter_interaction( vm.Action(id="filter_interaction", function=filter_interaction(targets=["scatter_chart"])) ] + # Add table filter_interaction Action to scatter_chart component + model_manager["vizro_table"].actions = [vm.Action(function=filter_interaction(targets=["scatter_chart"]))] + model_manager["vizro_table"].pre_build() + # Add export_data action to relevant component model_manager["button"].actions = [ vm.Action(id="test_action", function=export_data(targets=["scatter_chart", "box_chart"])) @@ -233,7 +321,7 @@ def test_multiple_targets_with_filter_and_filter_interaction( assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" assert result["download-dataframe_scatter_chart"][ "content" - ] == target_scatter_filtered_pop_filter_interaction_continent.to_csv(index=False) + ] == target_scatter_filter_and_filter_interaction.to_csv(index=False) assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" assert result["download-dataframe_box_chart"]["content"] == target_box_filtered_pop.to_csv(index=False) diff --git a/vizro-core/tests/unit/vizro/actions/test_filter_action.py b/vizro-core/tests/unit/vizro/actions/test_filter_action.py index 2291adb71..ccb84a66b 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_action.py @@ -61,7 +61,7 @@ def callback_context_filter_continent(request): @pytest.fixture def callback_context_filter_continent_and_pop(request): - """Mock dash.callback_context that represents continent Filter value selection.""" + """Mock dash.callback_context that represents continent and pop Filter value selection.""" continent, pop = request.param mock_callback_context = { "args_grouping": { diff --git a/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py b/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py index 337daf3a5..8e3fa5462 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py @@ -1,3 +1,4 @@ +import plotly.express as px import pytest from dash._callback_context import context_value from dash._utils import AttributeDict @@ -11,27 +12,64 @@ @pytest.fixture -def callback_context_click_continent(request): - """Mock dash.callback_context that represents a click on a continent data-point.""" - continent = request.param - mock_callback_context = { - "args_grouping": { - "filters": [], - "filter_interaction": [ - CallbackTriggerDict( +def callback_context_filter_interaction(request): + """Mock dash.callback_context that represents a click on a continent data-point and table selected cell.""" + continent_filter_interaction, country_table_filter_interaction = request.param + + args_grouping_filter_interaction = [] + if continent_filter_interaction: + args_grouping_filter_interaction.append( + { + "clickData": CallbackTriggerDict( id="box_chart", property="clickData", value={ "points": [ { - "customdata": [continent], + "customdata": [continent_filter_interaction], } ] }, str_id="box_chart", triggered=False, ) - ], + } + ) + if country_table_filter_interaction: + args_grouping_filter_interaction.append( + { + "active_cell": CallbackTriggerDict( + id="underlying_table_id", + property="active_cell", + value={"row": 0, "column": 0, "column_id": "country"}, + str_id="underlying_table_id", + triggered=False, + ), + "derived_viewport_data": CallbackTriggerDict( + id="underlying_table_id", + property="derived_viewport_data", + value=[ + { + "country": country_table_filter_interaction, + "continent": "Africa", + "year": 2007, + }, + { + "country": "Egypt", + "continent": "Africa", + "year": 2007, + }, + ], + str_id="underlying_table_id", + triggered=False, + ), + } + ) + + mock_callback_context = { + "args_grouping": { + "filters": [], + "filter_interaction": args_grouping_filter_interaction, "parameters": [], "theme_selector": CallbackTriggerDict( id="theme_selector", @@ -46,15 +84,41 @@ def callback_context_click_continent(request): return context_value -@pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") +@pytest.fixture +def target_scatter_filtered_continent(request, gapminder_2007, scatter_params): + continent, country = request.param + + data = gapminder_2007 + if continent: + data = data[data["continent"].isin([continent])] + if country: + data = data[data["country"].isin([country])] + + return px.scatter(data, **scatter_params).update_layout(margin_t=24) + + +@pytest.fixture +def target_box_filtered_continent(request, gapminder_2007, box_params): + continent, country = request.param + + data = gapminder_2007 + if continent: + data = data[data["continent"].isin([continent])] + if country: + data = data[data["country"].isin([country])] + + return px.box(data, **box_params).update_layout(margin_t=24) + + +@pytest.mark.usefixtures("managers_one_page_two_graphs_one_table_one_button") class TestFilterInteraction: - @pytest.mark.parametrize("callback_context_click_continent", ["Africa", "Europe"], indirect=True) + @pytest.mark.parametrize("callback_context_filter_interaction", [("Africa", None), ("Europe", None)], indirect=True) def test_filter_interaction_without_targets_temporary_behavior( # temporary fix, see below test self, - callback_context_click_continent, + callback_context_filter_interaction, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [vm.Action(id="test_action", function=filter_interaction())] + model_manager["box_chart"].actions = [vm.Action(id="test_action", function=filter_interaction())] # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() @@ -63,18 +127,22 @@ def test_filter_interaction_without_targets_temporary_behavior( # temporary fix @pytest.mark.xfail # This is the desired behavior, ie when no target is provided, then all charts filtered @pytest.mark.parametrize( - "callback_context_click_continent,target_scatter_filtered_continent,target_box_filtered_continent", - [("Africa", "Africa", "Africa"), ("Europe", "Europe", "Europe"), ("Americas", "Americas", "Americas")], + "callback_context_filter_interaction," "target_scatter_filtered_continent," "target_box_filtered_continent", + [ + (("Africa", None), ("Africa", None), ("Africa", None)), + (("Europe", None), ("Europe", None), ("Europe", None)), + (("Americas", None), ("Americas", None), ("Americas", None)), + ], indirect=True, ) def test_filter_interaction_without_targets_desired_behavior( self, - callback_context_click_continent, + callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [vm.Action(id="test_action", function=filter_interaction())] + model_manager["box_chart"].actions = [vm.Action(id="test_action", function=filter_interaction())] # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() @@ -83,17 +151,21 @@ def test_filter_interaction_without_targets_desired_behavior( assert result["box_chart"] == target_box_filtered_continent @pytest.mark.parametrize( - "callback_context_click_continent,target_scatter_filtered_continent", - [("Africa", ["Africa"]), ("Europe", ["Europe"]), ("Americas", ["Americas"])], + "callback_context_filter_interaction,target_scatter_filtered_continent", + [ + (("Africa", None), ("Africa", None)), + (("Europe", None), ("Europe", None)), + (("Americas", None), ("Americas", None)), + ], indirect=True, ) def test_filter_interaction_with_one_target( self, - callback_context_click_continent, + callback_context_filter_interaction, target_scatter_filtered_continent, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [ + model_manager["box_chart"].actions = [ vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart"])) ] @@ -103,22 +175,22 @@ def test_filter_interaction_with_one_target( assert result["scatter_chart"] == target_scatter_filtered_continent @pytest.mark.parametrize( - "callback_context_click_continent,target_scatter_filtered_continent,target_box_filtered_continent", + "callback_context_filter_interaction,target_scatter_filtered_continent,target_box_filtered_continent", [ - ("Africa", ["Africa"], ["Africa"]), - ("Europe", ["Europe"], ["Europe"]), - ("Americas", ["Americas"], ["Americas"]), + (("Africa", None), ("Africa", None), ("Africa", None)), + (("Europe", None), ("Europe", None), ("Europe", None)), + (("Americas", None), ("Americas", None), ("Americas", None)), ], indirect=True, ) def test_filter_interaction_with_two_target( self, - callback_context_click_continent, + callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [ + model_manager["box_chart"].actions = [ vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart", "box_chart"])) ] @@ -130,18 +202,104 @@ def test_filter_interaction_with_two_target( @pytest.mark.xfail # This (or similar code) should raise a Value/Validation error explaining next steps @pytest.mark.parametrize("target", ["scatter_chart", ["scatter_chart"]]) - @pytest.mark.parametrize("callback_context_click_continent", ["Africa", "Europe"], indirect=True) + @pytest.mark.parametrize("callback_context_filter_interaction", [("Africa", None), ("Europe", None)], indirect=True) def test_filter_interaction_with_invalid_targets( self, target, - callback_context_click_continent, + callback_context_filter_interaction, ): with pytest.raises(ValueError, match="Target invalid_target not found in model_manager."): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [ + model_manager["box_chart"].actions = [ vm.Action(id="test_action", function=filter_interaction(targets=target)) ] + @pytest.mark.parametrize( + "callback_context_filter_interaction,target_scatter_filtered_continent", + [ + ((None, "Algeria"), (None, "Algeria")), + ((None, "Albania"), (None, "Albania")), + ((None, "Argentina"), (None, "Argentina")), + ], + indirect=True, + ) + def test_table_filter_interaction_with_one_target( + self, + callback_context_filter_interaction, + target_scatter_filtered_continent, + ): + model_manager["box_chart"].actions = [ + vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart"])) + ] + + model_manager["vizro_table"].actions = [vm.Action(function=filter_interaction(targets=["scatter_chart"]))] + model_manager["vizro_table"].pre_build() + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["scatter_chart"] == target_scatter_filtered_continent + + @pytest.mark.parametrize( + "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", + [ + ((None, "Algeria"), (None, "Algeria"), (None, "Algeria")), + ((None, "Albania"), (None, "Albania"), (None, "Albania")), + ((None, "Argentina"), (None, "Argentina"), (None, "Argentina")), + ], + indirect=True, + ) + def test_table_filter_interaction_with_two_targets( + self, + callback_context_filter_interaction, + target_scatter_filtered_continent, + target_box_filtered_continent, + ): + model_manager["box_chart"].actions = [ + vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + + model_manager["vizro_table"].actions = [ + vm.Action(function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + model_manager["vizro_table"].pre_build() + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["scatter_chart"] == target_scatter_filtered_continent + assert result["box_chart"] == target_box_filtered_continent + + @pytest.mark.parametrize( + "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", + [ + (("Africa", "Algeria"), ("Africa", "Algeria"), ("Africa", "Algeria")), + (("Europe", "Albania"), ("Europe", "Albania"), ("Europe", "Albania")), + (("Americas", "Argentina"), ("Americas", "Argentina"), ("Americas", "Argentina")), + ], + indirect=True, + ) + def test_mixed_chart_and_table_filter_interaction_with_two_targets( + self, + callback_context_filter_interaction, + target_scatter_filtered_continent, + target_box_filtered_continent, + ): + model_manager["box_chart"].actions = [ + vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + + model_manager["vizro_table"].actions = [ + vm.Action(function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + model_manager["vizro_table"].pre_build() + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["scatter_chart"] == target_scatter_filtered_continent + assert result["box_chart"] == target_box_filtered_continent + # TODO: Simplify parametrization, such that we have less repetitive code # TODO: Eliminate above xfails # TODO: Complement tests above with backend tests (currently the targets are also taken from model_manager! diff --git a/vizro-core/tests/unit/vizro/conftest.py b/vizro-core/tests/unit/vizro/conftest.py index ba3cff2bd..85a80464c 100644 --- a/vizro-core/tests/unit/vizro/conftest.py +++ b/vizro-core/tests/unit/vizro/conftest.py @@ -31,6 +31,11 @@ def standard_dash_table(gapminder): return dash_data_table(data_frame=gapminder) +@pytest.fixture +def dash_data_table_with_id(gapminder): + return dash_data_table(id="underlying_table_id", data_frame=gapminder) + + @pytest.fixture def standard_go_chart(gapminder): return go.Figure(data=go.Scatter(x=gapminder["gdpPercap"], y=gapminder["lifeExp"], mode="markers")) diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py b/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py index a5a74407a..dba28f7b6 100644 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py @@ -21,7 +21,7 @@ def expected_range_slider_default(): "max": None, }, ), - None, + html.Div(hidden=True), html.Div( [ dcc.RangeSlider( diff --git a/vizro-core/tests/unit/vizro/models/_components/test_table.py b/vizro-core/tests/unit/vizro/models/_components/test_table.py index ca9eedc1b..eedd2d83c 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_table.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_table.py @@ -8,6 +8,7 @@ import vizro.models as vm import vizro.plotly.express as px +from vizro.actions import filter_interaction from vizro.managers import data_manager from vizro.models._action._action import Action from vizro.tables import dash_data_table @@ -28,7 +29,7 @@ def expected_table(): return dcc.Loading( html.Div( [ - None, + html.Div(hidden=True), html.Div(dash_table.DataTable(), id="text_table"), ], className="table-container", @@ -39,6 +40,27 @@ def expected_table(): ) +@pytest.fixture +def expected_table_with_id(): + return dcc.Loading( + html.Div( + [ + html.Div(hidden=True), + html.Div(dash_table.DataTable(id="underlying_table_id"), id="text_table"), + ], + className="table-container", + id="text_table_outer", + ), + color="grey", + parent_className="loading-container", + ) + + +@pytest.fixture +def filter_interaction_action(): + return vm.Action(function=filter_interaction()) + + class TestDunderMethodsTable: def test_create_graph_mandatory_only(self, standard_dash_table): table = vm.Table(figure=standard_dash_table) @@ -113,13 +135,58 @@ def test_process_figure_data_frame_df(self, standard_dash_table, gapminder): table_with_str_df.figure["data_frame"] +class TestPreBuildTable: + def test_pre_build_no_actions_no_underlying_table_id(self, standard_dash_table): + table = vm.Table( + id="text_table", + figure=standard_dash_table, + ) + table.pre_build() + + assert hasattr(table, "_callable_object_id") is False + + def test_pre_build_actions_no_underlying_table_id_exception(self, standard_dash_table, filter_interaction_action): + table = vm.Table( + id="text_table", + figure=standard_dash_table, + actions=[filter_interaction_action], + ) + with pytest.raises(ValueError, match="Underlying `Table` callable has no attribute 'id'"): + table.pre_build() + + def test_pre_build_actions_underlying_table_id(self, dash_data_table_with_id, filter_interaction_action): + table = vm.Table( + id="text_table", + figure=dash_data_table_with_id, + actions=[filter_interaction_action], + ) + table.pre_build() + + assert table._callable_object_id == "underlying_table_id" + + class TestBuildTable: - def test_table_build(self, standard_dash_table, expected_table): + def test_table_build_mandatory_only(self, standard_dash_table, expected_table): table = vm.Table( id="text_table", figure=standard_dash_table, ) + table.pre_build() + 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 + + def test_table_build_with_id(self, dash_data_table_with_id, filter_interaction_action, expected_table_with_id): + table = vm.Table( + id="text_table", + figure=dash_data_table_with_id, + actions=[filter_interaction_action], + ) + + table.pre_build() + + result = json.loads(json.dumps(table.build(), cls=plotly.utils.PlotlyJSONEncoder)) + expected = json.loads(json.dumps(expected_table_with_id, cls=plotly.utils.PlotlyJSONEncoder)) + assert result == expected diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py index 17d483450..c96a782cf 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py @@ -62,5 +62,5 @@ def test_accordion_build_pages_as_dict(self, pages_as_dict, accordion_from_pages def test_single_page_and_hidden_div(self): accordion = Accordion(pages=["Page 1"]).build() result = json.loads(json.dumps(accordion, cls=plotly.utils.PlotlyJSONEncoder)) - expected = json.loads(json.dumps(html.Div(className="hidden"), cls=plotly.utils.PlotlyJSONEncoder)) + expected = json.loads(json.dumps(html.Div(hidden=True), cls=plotly.utils.PlotlyJSONEncoder)) assert result == expected diff --git a/vizro-core/tests/unit/vizro/models/test_dashboard.py b/vizro-core/tests/unit/vizro/models/test_dashboard.py index f64b84268..43353b85d 100644 --- a/vizro-core/tests/unit/vizro/models/test_dashboard.py +++ b/vizro-core/tests/unit/vizro/models/test_dashboard.py @@ -1,5 +1,6 @@ import json from collections import OrderedDict +from functools import partial import dash import dash_bootstrap_components as dbc @@ -11,7 +12,7 @@ import vizro import vizro.models as vm from vizro.actions._action_loop._action_loop import ActionLoop -from vizro.models._dashboard import create_layout_page_404, update_theme +from vizro.models._dashboard import update_theme @pytest.fixture() @@ -29,7 +30,7 @@ def dashboard_container(): @pytest.fixture() -def mock_page_registry(page1, page2): +def mock_page_registry(dashboard, page1, page2): return OrderedDict( { "Page 1": { @@ -44,12 +45,12 @@ def mock_page_registry(page1, page2): "description": "", "order": 0, "supplied_order": 0, - "supplied_layout": page1.build, + "supplied_layout": partial(dashboard._make_page_layout, page1), "supplied_image": None, "image": None, "image_url": None, "redirect_from": None, - "layout": page1.build, + "layout": partial(dashboard._make_page_layout, page1), "relative_path": "/", }, "Page 2": { @@ -64,12 +65,12 @@ def mock_page_registry(page1, page2): "description": "", "order": 1, "supplied_order": 1, - "supplied_layout": page2.build, + "supplied_layout": partial(dashboard._make_page_layout, page2), "supplied_image": None, "image": None, "image_url": None, "redirect_from": None, - "layout": page2.build, + "layout": partial(dashboard._make_page_layout, page2), "relative_path": "/page-2", }, "not_found_404": { @@ -84,12 +85,12 @@ def mock_page_registry(page1, page2): "description": "", "order": None, "supplied_order": None, - "supplied_layout": create_layout_page_404(), + "supplied_layout": dashboard._make_page_404_layout(), "supplied_image": None, "image": None, "image_url": None, "redirect_from": None, - "layout": create_layout_page_404(), + "layout": dashboard._make_page_404_layout(), "relative_path": "/not-found-404", }, } @@ -141,8 +142,9 @@ def test_dashboard_page_registry(self, dashboard, mock_page_registry): # Str conversion required as comparison of OrderedDict values result in False otherwise assert str(result.items()) == str(expected.items()) - def test_create_layout_page_404(self): - result = create_layout_page_404() + def test_create_layout_page_404(self, dashboard, mocker): + mocker.patch("vizro.models._dashboard.get_relative_path") + result = dashboard._make_page_404_layout() result_image = result.children[0] result_div = result.children[1]