Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tree component #460

Merged
merged 9 commits into from
Jan 22, 2025
Merged

Add tree component #460

merged 9 commits into from
Jan 22, 2025

Conversation

Godisemo
Copy link
Contributor

@Godisemo Godisemo commented Dec 14, 2024

Add tree component.

Example app below

import dash_mantine_components as dmc
from dash import Input, Output, callback, Dash, _dash_renderer
import json

_dash_renderer._set_react_version("18.2.0")

app = Dash()

data = [
    {
        "label": "src",
        "value": "src",
        "children": [
            {
                "label": "components",
                "value": "src/components",
                "children": [
                    {"label": "Accordion.tsx", "value": "src/components/Accordion.tsx"},
                    {"label": "Tree.tsx", "value": "src/components/Tree.tsx"},
                    {"label": "Button.tsx", "value": "src/components/Button.tsx"},
                ],
            },
        ],
    },
    {
        "label": "node_modules",
        "value": "node_modules",
        "children": [
            {
                "label": "react",
                "value": "node_modules/react",
                "children": [
                    {"label": "index.d.ts", "value": "node_modules/react/index.d.ts"},
                    {
                        "label": "package.json",
                        "value": "node_modules/react/package.json",
                    },
                ],
            },
            {
                "label": "@mantine",
                "value": "node_modules/@mantine",
                "children": [
                    {
                        "label": "core",
                        "value": "node_modules/@mantine/core",
                        "children": [
                            {
                                "label": "index.d.ts",
                                "value": "node_modules/@mantine/core/index.d.ts",
                            },
                            {
                                "label": "package.json",
                                "value": "node_modules/@mantine/core/package.json",
                            },
                        ],
                    },
                    {
                        "label": "hooks",
                        "value": "node_modules/@mantine/hooks",
                        "children": [
                            {
                                "label": "index.d.ts",
                                "value": "node_modules/@mantine/hooks/index.d.ts",
                            },
                            {
                                "label": "package.json",
                                "value": "node_modules/@mantine/hooks/package.json",
                            },
                        ],
                    },
                    {
                        "label": "form",
                        "value": "node_modules/@mantine/form",
                        "children": [
                            {
                                "label": "index.d.ts",
                                "value": "node_modules/@mantine/form/index.d.ts",
                            },
                            {
                                "label": "package.json",
                                "value": "node_modules/@mantine/form/package.json",
                            },
                        ],
                    },
                ],
            },
        ],
    },
    {
        "label": "package.json",
        "value": "package.json",
    },
    {
        "label": "tsconfig.json",
        "value": "tsconfig.json",
    },
]

app.layout = dmc.MantineProvider(
    [
        dmc.Group(
            [
                dmc.Tree(
                    id="tree",
                    allowRangeSelection=False,
                    # checkOnSpace=True,
                    clearSelectionOnOutsideClick=True,
                    # expandOnClick=False,
                    # expandOnSpace=False,
                    levelOffset="xl",
                    selectOnClick=True,
                    data=data,
                    checkboxes=True,
                    checked=["node_modules/@mantine/form/index.d.ts"],
                    selected=["node_modules/@mantine/form/index.d.ts"],
                    expanded=[
                        "node_modules",
                        "node_modules/@mantine",
                        "node_modules/@mantine/form",
                        "node_modules/@mantine/form/index.d.ts",
                    ],
                ),
                dmc.CodeHighlight(id="expanded", code="", language="json"),
                dmc.CodeHighlight(id="checked", code="", language="json"),
                dmc.CodeHighlight(id="selected", code="", language="json"),
            ],
            align="flex-start",
            grow=True,
        ),
    ],
    theme={"primaryColor": "pink"},
)


@callback(
    Output("expanded", "code"),
    Output("checked", "code"),
    Output("selected", "code"),
    Input("tree", "expanded"),
    Input("tree", "checked"),
    Input("tree", "selected"),
)
def tree_callback(expanded, checked, selected):
    expanded_code = json.dumps({"expanded": expanded}, indent=4)
    checked_code = json.dumps({"checked": checked}, indent=4)
    selected_code = json.dumps({"selected": selected}, indent=4)
    return expanded_code, checked_code, selected_code


if __name__ == "__main__":
    app.run(debug=True)

Copy link

github-actions bot commented Dec 14, 2024

Test Environment for snehilvj/dash-mantine-components-460
Updated on: 2025-01-20 14:59:07 UTC

@AnnMarieW
Copy link
Collaborator

Hi @Godisemo

Thanks so much for this PR! 🚀

Nice example app too. Here it is hosted on PyCafe

I'll take a closer look in the next couple days and get back to you with more comments and feedback.

In the meantime, since you obviously know a lot about both Mantine and Dash, I hope you don't mind if I ask you a question about an open item in an unrelated PR. (#458) that I'm working on right now:

  • Is it possible to remove the renderDashComponents function from dash-extensions-js? It's used to render components as props in a few components including the Stepper. Is there a better way to do this?
    Here is the function as defined in dash-extensions-js .

@Godisemo
Copy link
Contributor Author

Thank you @AnnMarieW , unfortunately I don't have experience in the part of dash related to your other PR. I updated this PR with the rest of the upstream mantine component properties as well as updated the example app in the first comment. Let me know if there is anything else that is needed when you have had time to look at it.

@AnnMarieW
Copy link
Collaborator

AnnMarieW commented Dec 18, 2024

@Godisemo

A Tree component like this has been a popular request and will make a great addition to Dash. 🎉

When adding components to DMC, our goal is to be aligned as closely as possible with the features and prop names of the upstream Mantine component.

It can be a little tricky to get this right. Let's take for example, the expanded feature:

Here, the expanded prop is defined as:

/** Determines expanded nodes as a list of values or `'*'` for all, `[]` by default */
expanded?: string[] | "*";

However the Mantine Tree component. has the following:

  /** `true` if the node is expanded, applicable only for nodes with `children` */
  expanded: boolean;

  /** A record of `node.value` and boolean values that represent nodes expanded state */
  expandedState: TreeExpandedState;

And the following functions:

 /** Expands node with provided value */
  expand: (value: string) => void;

  /** Expands all nodes */
  expandAllNodes: () => void;

  /** Collapses all nodes */
  collapseAllNodes: () => void;

In the example app, if you click on a node with no children it toggles on and off the expanded list, which is confusing. It also might be better to have a separate prop for setting the expanded state. Currently, If you set expanded='*' either initially or in a callback, the expanded prop is updated with a list of values.

There are also similar features for the checked prop and they should be consistent.

It would be great to get some feedback on the API, prop names etc from @alexcjohnson I think it's best to take a little time with the design now so we don't have breaking changes later.


Update:
Having a separate prop for setting the expanded state is probably a bad idea. It would be hard to sync with the expanded prop. I've been playing around with the current way and it works well. I added buttons for "expand-all" and "collapse-all" to the example app, and used this callback:

@callback(
    Output("tree", "expanded"),
    Input("expand-all", "n_clicks"),
    Input("collapse-all", "n_clicks")
)
def update(e,c):
    if ctx.triggered_id == "expand-all":
        return "*"
    if ctx.triggered_id =="collapse-all":
        return []
    return dash.no_update

@AnnMarieW
Copy link
Collaborator

@Godisemo - quick update. Haven't forgotten about this PR. Just getting caught up after the holidays and will get back to this soon. Thanks for your patience 🙂

@alexcjohnson
Copy link
Collaborator

I think the prop names & behaviors are good. The expanded="*" behavior is a little funny, but I don't see a better alternative so I think we should just document it:

  • If you set expanded="*", when the component is rendered this will be replaced by the list of all expanded nodes. In the app running on py.cafe, the callback listening to expanded never seems to see the "*", only the full list, which is probably what you'd want. But I'm not sure this would always be the case, for example if the tree was in an unrendered tab I bet you'd see the "*" until the user switches to the tree tab and the component is actually rendered. (Same is true if you put invalid entries in the expanded list, they're stripped out of the prop value when Mantine renders this.)
  • A side effect of this, if you change the tree data and want to still have all nodes expanded, you'll need to again set expanded="*" along with the new tree data.

Also: even leaf nodes get listed in expanded (either by expanded="*" or by clicking on them) even though nothing changes visually. I would have called this a bug in this PR (ie don't call tree.toggleExpanded(node.value) unless hasChildren) if it were just about clicking them, but tree.expandAllNodes() seems to include them as well. To me that seems a bug in Mantine itself, but maybe I'm missing something.

Comment on lines 80 to 82
transform: expanded
? "rotate(180deg)"
: "rotate(0deg)",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is how the Mantine docs do it, but I find this very confusing. The standard behavior, that you can see even right here in GitHub, is that a collapsed node has the chevron pointing to the right, and an expanded node has it pointing down.
Screenshot 2025-01-17 at 09 16 10
Which I think would be this:

Suggested change
transform: expanded
? "rotate(180deg)"
: "rotate(0deg)",
transform: expanded
? "rotate(0deg)"
: "rotate(270deg)",

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch - I found those chevrons confusing but couldn't say why. lol

Part of the bigger issue with wrapping this component for Dash is how to customize things like this chevron, and formatting the Tree to include other components like icons. Even this forum topic had a debate on whether the chevron should be to the right or left of the text....

If you were creating an app using React and Mantine, then it's easy to customize all of these things. But how can we design an API for Dash to allow this flexibility?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that's always a challenge when going from a relatively low-level interface like Mantine to a higher-level interface like Dash components. Generally I'd say you don't design for all the flexibility Mantine gives you; you make smart choices for the defaults and then add flexibility where a significant number of users will want it.

And "which side are the chevrons on" might be just such a case - personally I prefer them on the left, and given that that's where Mac, Windows, and GH all put them I think that would be a solid default, but since others like the right side we could certainly add a prop like chevronSide = "left" | "right" | "none"

One note though about putting them on the left: leaf nodes don't get a chevron, but we should probably add blank space as if they did, otherwise leaves won't look like they're really inside their parent folders.

Copy link
Contributor Author

@Godisemo Godisemo Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thought I had on this when making the PR was to add properties for leftSection and rightSection (as for other dmc components) which could then be set to a DashIconify icon which the user can specify themselves. What deterred me from that though was how to deal with open/closed state. Should it be leftSectionOpen and leftSectionClosed or should leftSection optionally take a dict with keys “open” and “closed”?. If you think this would be a way to go and if you have suggestions I’d be happy to update the PR. One additional benefit of having the user be able to specify separate icons instead of rotating using css would be that you can have e.g + and - icons instead of chevrons or triangles.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point about the icon not necessarily being a chevron - so chevronSide would be limiting (also maybe not the most well-known word!). But do you ever see users wanting changeable icons on both sides? That feels weird to me, seems like the icon that changes when the node is opened or closed will either be on the left or right, and if you want an unchanging icon on the other side you can presumably put that in the label?

If we agree on that, then we can have maybe expandIconSide = "left" | "right" | "none"?

Then to the question of changing the icons: The advantage of a CSS rotation is you can animate it (which means we should use -90deg rather than 270deg as I had suggested above). Would animation still work if you specified the same icon with different CSS? Something like:

collapsedIcon=DashIconify(icon="tabler:chevron-down", style={"transform": "rotate(-90deg)", "transition": "transform 0.5s"})
expandedIcon=DashIconify(icon="tabler:chevron-down", style={"transform": "rotate(0deg)", "transition": "transform 0.5s"})

If that preserves the animation, it seems like a good compromise, covering the vast majority of use cases without bloating the API too much.

Copy link
Collaborator

@AnnMarieW AnnMarieW Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the icons based on state, could be done like the Checkbox with the checked or indeterminate states:
https://www.dash-mantine-components.com/components/checkbox#change-icons

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point about the icon not necessarily being a chevron - so chevronSide would be limiting (also maybe not the most well-known word!). But do you ever see users wanting changeable icons on both sides? That feels weird to me, seems like the icon that changes when the node is opened or closed will either be on the left or right, and if you want an unchanging icon on the other side you can presumably put that in the label?

If we agree on that, then we can have maybe expandIconSide = "left" | "right" | "none"?

Then to the question of changing the icons: The advantage of a CSS rotation is you can animate it (which means we should use -90deg rather than 270deg as I had suggested above). Would animation still work if you specified the same icon with different CSS? Something like:

collapsedIcon=DashIconify(icon="tabler:chevron-down", style={"transform": "rotate(-90deg)", "transition": "transform 0.5s"})
expandedIcon=DashIconify(icon="tabler:chevron-down", style={"transform": "rotate(0deg)", "transition": "transform 0.5s"})

If that preserves the animation, it seems like a good compromise, covering the vast majority of use cases without bloating the API too much.

Animation does indeed still work when specifying the same icon. I have updated the PR to include this functionality.

@Godisemo
Copy link
Contributor Author

I think the prop names & behaviors are good. The expanded="*" behavior is a little funny, but I don't see a better alternative so I think we should just document it:

* If you set `expanded="*"`, when the component is rendered this will be replaced by the list of all expanded nodes. In the app running on py.cafe, the callback listening to `expanded` never seems to see the `"*"`, only the full list, which is probably what you'd want. But I'm not sure this would always be the case, for example if the tree was in an unrendered tab I bet you'd see the `"*"` until the user switches to the tree tab and the component is actually rendered. (Same is true if you put invalid entries in the `expanded` list, they're stripped out of the prop value when Mantine renders this.)

* A side effect of this, if you change the tree data and want to still have all nodes expanded, you'll need to again set `expanded="*"` along with the new tree data.

Also: even leaf nodes get listed in expanded (either by expanded="*" or by clicking on them) even though nothing changes visually. I would have called this a bug in this PR (ie don't call tree.toggleExpanded(node.value) unless hasChildren) if it were just about clicking them, but tree.expandAllNodes() seems to include them as well. To me that seems a bug in Mantine itself, but maybe I'm missing something.

The functionality is indeed a bit quirky in upstream Mantine, but as you say I agree we shouldn't do anything about it here.

@Godisemo
Copy link
Contributor Author

I have updated the PR to include three new properties expandedIcon, collapsedIcon and iconSide. By default icon side is left and the expanded icon is the accordeon chevron included in mantine. When no collapsed icon is specified it is assumed that the expanded icon should be rotated into the collapsed state. When a collapsed icon is specified in addition to an expanded icon no transform will be applied.

@AnnMarieW
Copy link
Collaborator

The changes look great! 🎉

Here's a draft of the docs: snehilvj/dmc-docs#147

For the expanded states weirdness, I included the following in the docs - Is it clear enough?

Expanded State in callbacks.

When using the expanded property as a callback input to track the user's selected expanded state, note that the expanded
list may include or exclude leaf nodes (nodes without children) depending on user interaction.

This happens because users can toggle the state of leaf nodes, even though they don’t affect how the tree data is
displayed. To handle this, ensure your callback logic accounts for the possibility that leaf nodes may or may not
be present in the expanded prop.

I only found one issue: Not sure what's causing this error in the console:

Warning: Each child in a list should have a unique "key" prop

@Godisemo are you familiar with Dash testing? If so, you can check out the tests for other components in the tests folder. We just need tests for Dash specific props and Dash callback. Other props that are just passed through to Mantine don't need a test. Let me know if you would like me to help with this. It can be a little tricky to set up if you haven't done it before.

@Godisemo
Copy link
Contributor Author

I only found one issue: Not sure what's causing this error in the console:

Warning: Each child in a list should have a unique "key" prop

I also noticed this, but I concluded it comes from the code highlighting component.

@Godisemo are you familiar with Dash testing? If so, you can check out the tests for other components in the tests folder. We just need tests for Dash specific props and Dash callback. Other props that are just passed through to Mantine don't need a test. Let me know if you would like me to help with this. It can be a little tricky to set up if you haven't done it before.

I will try to add some unit tests.

@AnnMarieW
Copy link
Collaborator

AnnMarieW commented Jan 19, 2025

Update:
Please disregard this comment, I see you just did a commit to fix this 🏆


The console error is not coming from the CodeHightlight component. You can see it even in this basic example:

import dash_mantine_components as dmc
from dash import Dash, _dash_renderer
_dash_renderer._set_react_version("18.2.0")

app = Dash()


data2 = [
    {
        "value": "src",
        "label": "src",
        "children": [
            {"value": "src/components", "label": "components"},
            {"value": "src/hooks", "label": "hooks"},
        ],
    },
    {"value": "package.json", "label": "package.json"},
]


app.layout = dmc.MantineProvider(dmc.Tree(data=data))

if __name__ == "__main__":
    app.run(debug=True)



@AnnMarieW
Copy link
Collaborator

@Godisemo
I'm not looking to add feature creep to this PR, but can you tell me if it's possible to make the label a React node instead of just a string? It would make it much easier to format the text, add icons, or even make it a link. This would be a very useful component for navigation if it supported links.

@Godisemo
Copy link
Contributor Author

@Godisemo I'm not looking to add feature creep to this PR, but can you tell me if it's possible to make the label a React node instead of just a string? It would make it much easier to format the text, add icons, or even make it a link. This would be a very useful component for navigation if it supported links.

@AnnMarieW It might be possible to do, but I think it would require making some sort of custom leaf renderer dmc component.

@AnnMarieW
Copy link
Collaborator

It might be possible to do, but I think it would require making some sort of custom leaf renderer dmc component.

OK, that makes sense. That could be a path forward for a future PR if this is a popular request.

@Godisemo
Copy link
Contributor Author

@AnnMarieW I have added a few tests. Let me know if there is something else that should be updated before this PR can be accepted.

@AnnMarieW
Copy link
Collaborator

@Godisemo
This looks great! Nice tests ! Now just need a changelog entry.

@alexcjohnson - do you have any more comments before merging?


@app.callback(Output("output-checked", "children"), Input("tree", "checked"))
def update_output_checked(checked):
return str(checked)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I was playing with this feature I found the ordering of lists returned by the tree to be confusing, as it depends in odd ways on the order of operations. So although the tests as you've written them are robust for the current version of Mantine, I wouldn't be surprised if in the future they change this. Also the tests themselves would be easier to read if these fields were sorted - which as you've constructed it is also the order on screen.

So I'd suggest changing this line to:

Suggested change
return str(checked)
return str(sorted(checked) if checked else checked)

(the if checked else checked just lets None through without an error, not sure if that's necessary) and the same for expanded and selected (not data of course 😄) and adjusting the test assertions to match.

And perhaps worth mentioning this inconsistent order in the docs?

Copy link
Collaborator

@alexcjohnson alexcjohnson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💃 This looks great to me. Lovely tests 😻 Just one suggestion but that's not blocking.

@Godisemo
Copy link
Contributor Author

Godisemo commented Jan 22, 2025

💃 This looks great to me. Lovely tests 😻 Just one suggestion but that's not blocking.

Good point regarding the tests, I’d be happy to update them before merging this.

@Godisemo
Copy link
Contributor Author

Updated the tests now. @AnnMarieW Thank you for adding the docs for this. Will you also mention what Alex pointed out about the expanded, selected and checked properties being sorted depending on order of operations?

@AnnMarieW
Copy link
Collaborator

Will you also mention what Alex pointed out about the expanded, selected and checked properties being sorted depending on order of operations?

Yes, I'll update the docs to include this.

Thanks so much for adding this component and InlineCodeHighlight. I'm looking forward to the next release - which should be in the next day or two. 🥳

@AnnMarieW AnnMarieW merged commit a8bae12 into snehilvj:master Jan 22, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants