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

[TreeView] Add selectItem and getItemDOMElement methods to the public API #13485

Merged
merged 17 commits into from
Jul 2, 2024

Conversation

flaviendelangle
Copy link
Member

@flaviendelangle flaviendelangle commented Jun 14, 2024

Closes #10113

selectItem doc preview
getItemDOMElement doc preview

@bharatkashyap I propose to expose a method that allow you to easily retrieve the DOM element rather than a method that allow you to scroll.
I first tried to implement something like apiRef.current.scrollToItem(itemId), but I was basically copy-pasting the entire API of the scrollIntoView method.

Your use case can be achieved as follow:

apiRef.current!.selectItem({ event, itemId, isSelected: true });
apiRef.current!.getItemDOMElement(itemId)?.scrollIntoView({ block: 'center' });

And if you are using multi selection you might prefer:

apiRef.current!.selectItem({ event, itemId, isSelected: true, keepExistingSelection: true });
apiRef.current!.getItemDOMElement(itemId)?.scrollIntoView({ block: 'center' });

I'll add some tests once we agree on the DX

@flaviendelangle flaviendelangle self-assigned this Jun 14, 2024
@flaviendelangle flaviendelangle added the component: tree view TreeView, TreeItem. This is the name of the generic UI component, not the React module! label Jun 14, 2024
@@ -56,9 +56,10 @@ Use the `setItemExpansion` API method to change the expansion of an item.
apiRef.current.setItemExpansion(
// The DOM event that triggered the change
event,
// The ID of the item to expand or collapse
// The id of the item to expand or collapse
Copy link
Member Author

Choose a reason for hiding this comment

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

I tried to unify a bit the wording for those boolean parameters throughout the Tree View codebase
And to always have the same descriptions for the params in the JSDoc and in the doc section

@flaviendelangle flaviendelangle marked this pull request as ready for review June 14, 2024 14:33
@bharatkashyap
Copy link
Member

@flaviendelangle This DX looks perfectly agreeable to me 👍🏻

newSelected = [itemId].concat(cleanSelectedItems);
} else {
newSelected = cleanSelectedItems;
}
} else {
// eslint-disable-next-line no-lonely-if
if (newValue === false) {
if (isSelected === false || (isSelected == null && instance.isItemSelected(itemId))) {
Copy link
Member Author

@flaviendelangle flaviendelangle Jun 20, 2024

Choose a reason for hiding this comment

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

Small fix: in single selection, if isSelected is not defined and the item is selected we should de-select.

I fixed the usage of this method in useTreeItemState and useTreeItem2Utils to keep the same behavior on the UI.

Copy link
Contributor

@noraleonte noraleonte left a comment

Choose a reason for hiding this comment

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

Very nice addition 👌 The DX is good, and the docs look nice. Thanks for adding this 🎉 Left a little nitpick, but looks good to me otherwise

@flaviendelangle flaviendelangle merged commit 76832df into mui:master Jul 2, 2024
15 checks passed
@flaviendelangle flaviendelangle deleted the select-item-apiRef branch July 2, 2024 09:28
@yairEO
Copy link

yairEO commented Nov 20, 2024

How does apiRef.current!.selectItem(...) or even (setItemExpansion) can possibly work alongside expandedItems prop?

expandedItems is the most powerful since it is "controlled", thus using selectItem method cannot possibly internally change what is "controlled" externally. This aught to be mentioned in the docs.

I suggest to add a new method to apiRef which returns an array of nodes' ids which leads the path all the way down to the selectItem (supplied nodeId). This result could be manually used when expandedItems is used (as a state controlled externally)

Currently the docs are missing an explicit section for apiRef which explains all its methods with examples.

The above would best fit in such section and also here:

https://deploy-preview-13485--material-ui-x.netlify.app/x/react-tree-view/simple-tree-view/expansion/

Nothing is mentioning the Imperative API collides when the Tree is "controlled".

@flaviendelangle
Copy link
Member Author

flaviendelangle commented Nov 20, 2024

expandedItems is the most powerful since it is "controlled", thus using selectItem method cannot possibly internally change what is "controlled" externally. This aught to be mentioned in the docs.

If you provide expandedItems, then you should also provide a onExpandedItemsChange to update your model whenever it changes. If you provide this callback, calling apiRef.current.selectItem() will call the callback and you can update your prop value.
We could add a note on the doc.

I suggest to add a new method to apiRef which returns an array of nodes' ids which leads the path all the way down to the selectItem (supplied nodeId). This result could be manually used when expandedItems is used (as a state controlled externally)

Could you expand on this? I don't understand your use case.

@yairEO
Copy link

yairEO commented Nov 20, 2024

I had no idea that onExpandedItemsChange is automatically called if apiRef.current.selectItem() method is called. That should be documented.

I assumed, logically, that TreeView will never change a controlled state "under the hood", since that state could have been delicately cherry-picked, and I am uncertain how could the TreeView guess how to consolidate existing expandedItems and apiRef.current.selectItem() into a single expandedItems state.

Since that was my assumption, I thought developers should consolidate the expandedItems state themselves, by getting the nodes ids path which leads to the node which is applied to the "selectItem" method, and manually using it to create a new expandedItems state by invoking setExpandedItems and doing the changing the state.

A simplified example for getItemExpansionPath suggested method:

const apiRef = useTreeViewApiRef();
const [expanded, setExpanded] = useState(...initial array of calculated ids...);

useEffect(() => {
    const expandedPath = apiRef.current?.getItemExpansionPath(selectedId);
    expandedPath && setExpanded(s => s.concat(expandedPath)) // here I could do whatever
}, [apiRef, setExpanded, selectedId]);
  

@flaviendelangle
Copy link
Member Author

flaviendelangle commented Nov 20, 2024

I had no idea that onExpandedItemsChange is automatically called if apiRef.current.selectItem() method is called. That should be documented.

Well, if you call selectItem, you change the selected items, so onExpandedItemsChange is called just like for any other expansion change.

I assumed, logically, that TreeView will never change a controlled state "under the hood"

We don't change the controlled state "under the hood", we call the callback that lets you update the controlled state.
Just like when you control an <input />, any change to this input will call onChange.

If you control expandedItems and your user expands an item on the UI, it wil call onExpandedItemsChange to update the controlled model (and you can refuse to update it in which case the UI will not expand the element).
The behavior is exactly the same with apiRef.selectItem, if you control the model, you can choose to update it or not.

But note that the goal of those imperative methods is to allow you to update imperatively the expanded items without having to control the model to do it.
You can still control it if you have other needs that requires it, but if you are only controlling it to expand imperatively an item, then you should be able to not control it at all.

For your code snippet, if I understand correctly your use case, you are trying to expand a deeply nested item while making sure all it's parent are also expanded in the process (to always have the selected item always visible).
The way to do it in our new API would probably either:

  1. To add a new shouldExpandAncestors property to the apiRef.selectItem() method. If true, selecting an item would automatically expand all its collapsed ancestors to make sure the item is visible.
apiRef.current.selectItem({
  event,
  itemId,
  shouldExpandAncestors: true,
});
  1. To add a new shouldExpandAncestors property to the apiRef.setItemExpansion() method. This is slightly less magic and you have to do the two imperative calls yourselve. It also allow to react in a useEffect if you are not using apiRef.selectItem to select the items but instead directly updating your model. But it would require to rework the params of setItemExpansion (which is planned regardless):
apiRef.current.setItemExpansion({
  event,
  itemId,
  isExpanded: true,
  shouldExpandAncestors: true,
});

I think it's better to have this kind of behavior built in inside the component because it's way easier to implement for the end user and the use case is far from being niche.
But, if you have some specific requirement that prevent you from using those built-in imperative methods and you need to keep controlling both the expansion and the selection, then you indeed need a way to build the path leading to an item to then update your model.
I think it would make sense to expose a getItemParentId method (or getItemMeta but I'm not a fan of making the whole TreeViewItemMeta method pubic. But the exact DX of this new methods could be discussed.

@yairEO
Copy link

yairEO commented Nov 20, 2024

what if I want to use setItemExpansion as a toggler without explicitly setting isExpanded: true, shouldExpandAncestors: true, but toggle these, on click somewhere, without caring what is the current state (expanded or collapsed).

if I understand correctly your use case, you are trying to expand a deeply nested item while making sure all it's parent are also expanded

For that I wrote a utility method which traverses the tree upwards from a certain given node id and I use that for the expandedItems prop.

I have a complex tree where I need need to programmatically expand and collapse multiple branches in the tree, to visually show only the portions of the tree which are relevant to certain different scenarios in my app.

setItemExpansion is singular and so cannot be easily used to toggle multiple nodes at once (and their ancestors)

Therefore for me working with a controlled expanded state gives me maximum freedom, with some utility methods which I've written.

For example, I've added a prop for my Tree wrapper for initialExpansionLevel and a utility method which use this prop for the initial value of the expanded prop, scanning the tree, generating an array of expanded tree nodes ids for a given desired level. Very handy for complex tree based on a lot of data where you want to show 10-30 items but not hundreds, and you do not care about the names, only how many levels to initially expand.

Maybe there should be a docs page with some recipes examples, like how to programmatically expand the whole tree. right now there is no easy one-method way to expand everything without the knowledge of all the ids of the nodes.

Here's a video showcasing how the tree is used in my SaaS app (mocked simplified data):

tree-demo.mp4

The tree is a tool to filter data in a large table component (DataGridPro) next to it, and it's bi-directional, so the table can also affect which node in the tree is selected and therefore the whole path to that node should be expanded, and after some timeout the expanded node is scrolled to:

const SCROLL_DELAY = 1000;

setTimeout(() => {
  if (selected) {
    const itemElement = apiRef.current?.getItemDOMElement(selected);

    itemElement?.scrollIntoView({
      behavior: "smooth",
      block: "nearest",
      inline: "nearest",
    });
  }
}, SCROLL_DELAY);

There's a ton of customization going on here, which might be beneficial for others. I assume many who are using this tree component must spend their time writing the exact same utility methods again and again. It might be beneficial to expose some common use cases to save people's time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: tree view TreeView, TreeItem. This is the name of the generic UI component, not the React module!
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[TreeView] Scroll to programmatically selected tree item
5 participants