Repository: lightdash/lightdash
Pull request #14442: feat: add tree component in storybook
Author: login: IrakliJani
Labels: name: released
Created: 2025-04-16T14:57:58Z | Merged: 2025-04-17T14:20:38Z

<!-- Thanks so much for your PR, your contribution is appreciated! ❤️ -->

Closes: #14338


### Description:
<!-- Add a description of the changes proposed in the pull request. -->

<!-- Even better add a screenshot / gif / loom -->

### Reviewer actions

- [ ] I have manually tested the changes in the preview environment
- [ ] I have reviewed the code
- [ ] I understand that "request changes" will block this PR from merging



Diff:
diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js
index 1c7299f6dcc7a..22b633b71c08a 100644
--- a/packages/frontend/.eslintrc.js
+++ b/packages/frontend/.eslintrc.js
@@ -11,7 +11,7 @@ module.exports = {
         project: './tsconfig.json',
         createDefaultProgram: true,
     },
-    ignorePatterns: ['**/styles/*.css', '.eslintrc.js'],
+    ignorePatterns: ['**/*.css', '.eslintrc.js'],
     extends: [
         './../../.eslintrc.js',
         'plugin:@typescript-eslint/recommended',
diff --git a/packages/frontend/src/stories/Tree.module.css b/packages/frontend/src/stories/Tree.module.css
new file mode 100644
index 0000000000000..2761cd686eccd
--- /dev/null
+++ b/packages/frontend/src/stories/Tree.module.css
@@ -0,0 +1,23 @@
+.actionIcon {
+    opacity: 0.8;
+
+    &:hover {
+        opacity: 1;
+    }
+}
+
+.label {
+    &:where([data-selected]) {
+        background-color: transparent;
+    }
+
+    position: relative;
+
+    &::before {
+        content: '';
+        display: block;
+        width: 1px;
+        height: 100%;
+        background-color: red;
+    }
+}
diff --git a/packages/frontend/src/stories/Tree.stories.tsx b/packages/frontend/src/stories/Tree.stories.tsx
index b16eeb6a2a0ee..502fbdc9dbd7f 100644
--- a/packages/frontend/src/stories/Tree.stories.tsx
+++ b/packages/frontend/src/stories/Tree.stories.tsx
@@ -1,8 +1,7 @@
 import type { Meta, StoryObj } from '@storybook/react';
 
-const Tree = () => {
-    return <>tree</>;
-};
+import Tree from './Tree';
+import { response } from './test.json';
 
 const meta: Meta<typeof Tree> = {
     component: Tree,
@@ -13,5 +12,10 @@ export default meta;
 type Story = StoryObj<typeof Tree>;
 
 export const Primary: Story = {
-    args: {},
+    args: {
+        data: response.results,
+        onSelect: (selectedUuid) => {
+            console.log('Selected item UUID:', selectedUuid);
+        },
+    },
 };
diff --git a/packages/frontend/src/stories/Tree.tsx b/packages/frontend/src/stories/Tree.tsx
new file mode 100644
index 0000000000000..5b770d7731a7c
--- /dev/null
+++ b/packages/frontend/src/stories/Tree.tsx
@@ -0,0 +1,158 @@
+import {
+    ActionIcon,
+    Group,
+    MantineProvider,
+    Tree as MantineTree,
+    Paper,
+    ScrollArea,
+    Text,
+    useTree,
+    type RenderTreeNodePayload,
+    type TreeNodeData,
+} from '@lightdash/mantine-v7';
+import {
+    IconCheck,
+    IconChevronDown,
+    IconChevronRight,
+    IconFolder,
+} from '@tabler/icons-react';
+import React, { useEffect, useMemo } from 'react';
+import MantineIcon from '../components/common/MantineIcon';
+
+import '@lightdash/mantine-v7/style.css';
+import classes from './Tree.module.css';
+
+type NestableItem = {
+    uuid: string;
+    name: string;
+    path: string;
+};
+
+const convertNestableListToTree = (items: NestableItem[]): TreeNodeData[] => {
+    const buildTree = (
+        nodes: NestableItem[],
+        parentPath = '',
+    ): TreeNodeData[] => {
+        return nodes
+            .filter((item) => {
+                const pathParts = item.path.split('.');
+                const itemParentPath = pathParts.slice(0, -1).join('.');
+                return itemParentPath === parentPath;
+            })
+            .map((item) => {
+                const children = buildTree(nodes, item.path);
+                return {
+                    label: item.name,
+                    value: item.uuid,
+                    nodeProps: { uuid: item.uuid },
+                    ...(children.length > 0 ? { children } : {}),
+                };
+            });
+    };
+
+    return buildTree(items);
+};
+
+const renderTreeNode = ({
+    node,
+    selected,
+    expanded,
+    hasChildren,
+    elementProps,
+    tree,
+}: RenderTreeNodePayload) => {
+    return (
+        <div {...elementProps}>
+            <Paper
+                component={Group}
+                miw="200px"
+                maw="300px"
+                gap={5}
+                wrap="nowrap"
+                h="32"
+                px="sm"
+                radius="md"
+                bg={selected ? 'blue.0' : 'transparent'}
+                style={{ overflow: 'hidden' }}
+                onClick={() => tree.toggleSelected(node.value)}
+            >
+                {hasChildren && (
+                    <ActionIcon
+                        className={classes.actionIcon}
+                        onClick={(e) => {
+                            e.stopPropagation();
+                            tree.toggleExpanded(node.value);
+                        }}
+                        size="xs"
+                        variant="transparent"
+                    >
+                        <MantineIcon
+                            icon={expanded ? IconChevronDown : IconChevronRight}
+                            size="lg"
+                        />
+                    </ActionIcon>
+                )}
+
+                <MantineIcon
+                    icon={IconFolder}
+                    color="gray.7"
+                    size="lg"
+                    stroke={1.5}
+                    style={{ flexShrink: 0 }}
+                />
+
+                <Text inline truncate="end" style={{ flexGrow: 1 }}>
+                    {node.label}
+                </Text>
+
+                {selected && (
+                    <MantineIcon
+                        icon={IconCheck}
+                        size="lg"
+                        color="blue.6"
+                        style={{ flexShrink: 0 }}
+                    />
+                )}
+            </Paper>
+        </div>
+    );
+};
+
+type Props = {
+    data: NestableItem[];
+    onSelect: (selectedUuid: string | null) => void;
+};
+
+const Tree: React.FC<Props> = ({ data, onSelect }) => {
+    const tree = useTree();
+
+    const treeData = useMemo(() => convertNestableListToTree(data), [data]);
+
+    useEffect(() => {
+        onSelect(tree.selectedState[0] ?? null);
+    }, [tree.selectedState, onSelect]);
+
+    return (
+        <MantineProvider>
+            <Paper component={ScrollArea} w="300px" h="500px" withBorder p="sm">
+                <MantineTree
+                    data={treeData}
+                    tree={tree}
+                    levelOffset={23}
+                    renderNode={renderTreeNode}
+                    allowRangeSelection={false}
+                    checkOnSpace={false}
+                    clearSelectionOnOutsideClick={false}
+                    expandOnClick={false}
+                    expandOnSpace={false}
+                    selectOnClick={false}
+                    classNames={{
+                        label: classes.label,
+                    }}
+                />
+            </Paper>
+        </MantineProvider>
+    );
+};
+
+export default Tree;
diff --git a/packages/frontend/src/stories/test.json.ts b/packages/frontend/src/stories/test.json.ts
new file mode 100644
index 0000000000000..d987f183cdae6
--- /dev/null
+++ b/packages/frontend/src/stories/test.json.ts
@@ -0,0 +1,81 @@
+export const response = {
+    status: 'ok',
+    // mock data
+    results: [
+        {
+            uuid: 'fake0',
+            name: 'The most main space',
+            path: 'the_most_main_space',
+        },
+        {
+            uuid: 'fake1',
+            name: 'Main space',
+            path: 'main_space',
+        },
+        {
+            uuid: 'fake2',
+            name: 'Sub space',
+            path: 'main_space.sub',
+        },
+        {
+            uuid: 'fake3',
+            name: 'Sub space 2',
+            path: 'main_space.sub2',
+        },
+        {
+            uuid: 'fake4',
+            name: 'Sub space 3',
+            path: 'main_space.sub3',
+        },
+        {
+            uuid: 'fake5',
+            name: 'Sub of sub space',
+            path: 'main_space.sub.sub_of_sub',
+        },
+        {
+            uuid: 'fake6',
+            name: 'Sub of sub space 2',
+            path: 'main_space.sub.sub_of_sub2',
+        },
+        {
+            uuid: 'fake7',
+            name: 'Sub of sub space 3',
+            path: 'main_space.sub.sub_of_sub3',
+        },
+        {
+            uuid: 'fake10',
+            name: 'Sub of sub of sub space',
+            path: 'main_space.sub.sub_of_sub.sub_of_sub_of_sub',
+        },
+        {
+            uuid: 'fake11',
+            name: 'Sub of sub of sub space 2',
+            path: 'main_space.sub.sub_of_sub.sub_of_sub_of_sub2',
+        },
+        {
+            uuid: 'fake12',
+            name: 'Sub of sub of sub sub space',
+            path: 'main_space.sub.sub_of_sub.sub_of_sub_of_sub.sub_of_sub_of_sub_of_sub',
+        },
+        {
+            uuid: 'fake13',
+            name: 'Sub of sub of sub sub space 2',
+            path: 'main_space.sub.sub_of_sub.sub_of_sub_of_sub.sub_of_sub_of_sub_of_sub2',
+        },
+        {
+            uuid: 'fake14',
+            name: 'Kinda top level space',
+            path: 'kinda_top_level_space',
+        },
+        {
+            uuid: 'fake15',
+            name: 'Kinda top level space 2',
+            path: 'kinda_top_level_space2',
+        },
+        {
+            uuid: 'fake16',
+            name: 'Kinda top level space 3',
+            path: 'kinda_top_level_space3',
+        },
+    ],
+};


Reviews:
user: login: notgiorgi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:02:39Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:26:52Z
user: login: notgiorgi
body: Tested, looking great ✨
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T08:27:52Z

Review comments:
diff_hunk: @@ -0,0 +1,158 @@
+import {
+    ActionIcon,
+    Group,
+    MantineProvider,
+    Tree as MantineTree,
+    Paper,
+    ScrollArea,
+    Text,
+    useTree,
+    type RenderTreeNodePayload,
+    type TreeNodeData,
+} from '@lightdash/mantine-v7';
+import {
+    IconCheck,
+    IconChevronDown,
+    IconChevronRight,
+    IconFolder,
+} from '@tabler/icons-react';
+import React, { useEffect, useMemo } from 'react';
+import MantineIcon from '../components/common/MantineIcon';
+
+import '@lightdash/mantine-v7/style.css';
+import classes from './Tree.module.css';
+
+type NestableItem = {
+    uuid: string;
+    name: string;
+    path: string;
+};
+
+const convertNestableListToTree = (items: NestableItem[]): TreeNodeData[] => {
+    const buildTree = (
+        nodes: NestableItem[],
+        parentPath = '',
+    ): TreeNodeData[] => {
+        return nodes
+            .filter((item) => {
+                const pathParts = item.path.split('.');
+                const itemParentPath = pathParts.slice(0, -1).join('.');
+                return itemParentPath === parentPath;
+            })
+            .map((item) => {
+                const children = buildTree(nodes, item.path);
+                return {
+                    label: item.name,
+                    value: item.uuid,
+                    nodeProps: { uuid: item.uuid },
+                    ...(children.length > 0 ? { children } : {}),
+                };
+            });
+    };
+
+    return buildTree(items);
+};
+
+const renderTreeNode = ({
+    node,
+    selected,
+    expanded,
+    hasChildren,
+    elementProps,
+    tree,
+}: RenderTreeNodePayload) => {
+    return (
+        <div {...elementProps}>
+            <Paper
+                component={Group}
+                miw="200px"
+                maw="300px"
+                gap={5}
+                wrap="nowrap"
+                h="32"
+                px="sm"
+                radius="md"
+                bg={selected ? 'blue.0' : 'transparent'}
+                style={{ overflow: 'hidden' }}
+                onClick={() => tree.toggleSelected(node.value)}
+            >
+                {hasChildren && (
+                    <ActionIcon
+                        className={classes.actionIcon}
+                        onClick={(e) => {
+                            e.stopPropagation();
+                            tree.toggleExpanded(node.value);
+                        }}
+                        size="xs"
+                        variant="transparent"
+                    >
+                        <MantineIcon
+                            icon={expanded ? IconChevronDown : IconChevronRight}
+                            size="lg"
+                        />
+                    </ActionIcon>
+                )}
+
+                <MantineIcon
+                    icon={IconFolder}
+                    color="gray.7"
+                    size="lg"
+                    stroke={1.5}
+                    style={{ flexShrink: 0 }}
+                />
+
+                <Text inline truncate="end" style={{ flexGrow: 1 }}>
+                    {node.label}
+                </Text>
+
+                {selected && (
+                    <MantineIcon
+                        icon={IconCheck}
+                        size="lg"
+                        color="blue.6"
+                        style={{ flexShrink: 0 }}
+                    />
+                )}
+            </Paper>
+        </div>
+    );
+};
+
+type Props = {
+    data: NestableItem[];
+    onSelect: (selectedUuid: string | null) => void;
+};
path: packages/frontend/src/stories/Tree.tsx
user: login: notgiorgi
body: I think we want the tree to be controlled (`value/selected` prop) or at least have initial value (`initialValue/initialSelected` prop) (depending on how good `Tree/useTree` api is)

For example when moving chart/dash/space to a different space, when I click button and the tree comes up I want it to have my current location selected and scrolled to
created_at: 2025-04-17T08:02:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,158 @@
+import {
+    ActionIcon,
+    Group,
+    MantineProvider,
+    Tree as MantineTree,
+    Paper,
+    ScrollArea,
+    Text,
+    useTree,
+    type RenderTreeNodePayload,
+    type TreeNodeData,
+} from '@lightdash/mantine-v7';
+import {
+    IconCheck,
+    IconChevronDown,
+    IconChevronRight,
+    IconFolder,
+} from '@tabler/icons-react';
+import React, { useEffect, useMemo } from 'react';
+import MantineIcon from '../components/common/MantineIcon';
+
+import '@lightdash/mantine-v7/style.css';
+import classes from './Tree.module.css';
+
+type NestableItem = {
+    uuid: string;
+    name: string;
+    path: string;
+};
+
+const convertNestableListToTree = (items: NestableItem[]): TreeNodeData[] => {
+    const buildTree = (
+        nodes: NestableItem[],
+        parentPath = '',
+    ): TreeNodeData[] => {
+        return nodes
+            .filter((item) => {
+                const pathParts = item.path.split('.');
+                const itemParentPath = pathParts.slice(0, -1).join('.');
+                return itemParentPath === parentPath;
+            })
+            .map((item) => {
+                const children = buildTree(nodes, item.path);
+                return {
+                    label: item.name,
+                    value: item.uuid,
+                    nodeProps: { uuid: item.uuid },
+                    ...(children.length > 0 ? { children } : {}),
+                };
+            });
+    };
+
+    return buildTree(items);
+};
+
+const renderTreeNode = ({
+    node,
+    selected,
+    expanded,
+    hasChildren,
+    elementProps,
+    tree,
+}: RenderTreeNodePayload) => {
+    return (
+        <div {...elementProps}>
+            <Paper
+                component={Group}
+                miw="200px"
+                maw="300px"
+                gap={5}
+                wrap="nowrap"
+                h="32"
+                px="sm"
+                radius="md"
+                bg={selected ? 'blue.0' : 'transparent'}
+                style={{ overflow: 'hidden' }}
+                onClick={() => tree.toggleSelected(node.value)}
+            >
+                {hasChildren && (
+                    <ActionIcon
+                        className={classes.actionIcon}
+                        onClick={(e) => {
+                            e.stopPropagation();
+                            tree.toggleExpanded(node.value);
+                        }}
+                        size="xs"
+                        variant="transparent"
+                    >
+                        <MantineIcon
+                            icon={expanded ? IconChevronDown : IconChevronRight}
+                            size="lg"
+                        />
+                    </ActionIcon>
+                )}
+
+                <MantineIcon
+                    icon={IconFolder}
+                    color="gray.7"
+                    size="lg"
+                    stroke={1.5}
+                    style={{ flexShrink: 0 }}
+                />
+
+                <Text inline truncate="end" style={{ flexGrow: 1 }}>
+                    {node.label}
+                </Text>
+
+                {selected && (
+                    <MantineIcon
+                        icon={IconCheck}
+                        size="lg"
+                        color="blue.6"
+                        style={{ flexShrink: 0 }}
+                    />
+                )}
+            </Paper>
+        </div>
+    );
+};
+
+type Props = {
+    data: NestableItem[];
+    onSelect: (selectedUuid: string | null) => void;
+};
path: packages/frontend/src/stories/Tree.tsx
user: login: IrakliJani
body: ok I'll add that in the next iteration!
created_at: 2025-04-17T08:26:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: IrakliJani
created_at: 2025-04-16T14:58:13Z
body: * **#14476** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14476?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14456** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14456?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14454** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14454?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14451** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14451?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14442** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14442?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14442?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#14441** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14441?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14440** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14440?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: IrakliJani
created_at: 2025-04-17T14:20:25Z
body: ### Merge activity

* **Apr 17, 10:20 AM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/lightdash/lightdash/14442).
* **Apr 17, 10:20 AM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/lightdash/lightdash/14442).

author_association: CONTRIBUTOR
user: login: lightdash-bot
created_at: 2025-04-17T14:23:36Z
body: :tada: This PR is included in version 0.1587.0 :tada:

The release is available on:
- `0.1587.0`
- [GitHub release](https://github.com/lightdash/lightdash/releases/tag/0.1587.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR