I am using the treeview component, but when I select a node, despite selecting correctly, all the nodes collapse, can you help me?
// CategoryTreeView.tsx (correção final e funcional)
import {
NodeSelectEventArgs,
TreeViewComponent,
} from "@syncfusion/ej2-react-navigations";
import { useCallback, useEffect, useRef, useMemo } from "react";
export interface TreeNodeData {
id: number | string;
text: string;
icone?: string;
cor?: string;
ativo?: boolean;
children?: TreeNodeData[];
[key: string]: any;
}
interface Props {
treeData: TreeNodeData[];
onNodeSelect?: (node: TreeNodeData) => void;
initialSelectedNodeId?: string | number | null;
}
// Função corrigida e com retorno correto
export function transformCategory(cat: any): TreeNodeData {
const sortedSubs = cat.subcategorias
? cat.subcategorias.slice().sort((a: any, b: any) =>
a.nome.localeCompare(b.nome)
)
: [];
return {
id: cat.id.toString(),
text: cat.nome,
icone: cat.icone,
cor: cat.cor,
ativo: cat.ativo,
children: sortedSubs.map(transformCategory),
};
}
// Função nodeTemplate movida para fora corretamente
function nodeTemplate(data: any) {
const nodeData = data.data || data;
return (
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
{nodeData.icone && (
<i
className={`fa fa-${nodeData.icone}`}
style={{ color: nodeData.cor || "inherit" }}
/>
)}
<span>{nodeData.text}</span>
</div>
);
}
export function CategoryTreeView({
treeData,
onNodeSelect,
initialSelectedNodeId = null,
}: Props) {
const treeRef = useRef<TreeViewComponent>(null);
const handleNodeSelected = useCallback(
(args: NodeSelectEventArgs) => {
const nodeData = (args.nodeData as any)?.data || args.nodeData;
console.log("Nó selecionado no CategoryTreeView:", nodeData);
onNodeSelect?.(nodeData);
},
[onNodeSelect]
);
const memoizedTreeData = useMemo(() => treeData, [treeData]);
useEffect(() => {
if (initialSelectedNodeId && treeRef.current) {
treeRef.current.selectedNodes = [initialSelectedNodeId.toString()];
treeRef.current.ensureVisible(initialSelectedNodeId.toString());
}
}, [initialSelectedNodeId]);
return (
<div style={{ maxWidth: "300px" }}>
<TreeViewComponent
ref={treeRef}
fields={{
dataSource: memoizedTreeData,
id: "id",
text: "text",
child: "children",
}}
nodeSelected={handleNodeSelected}
nodeTemplate={nodeTemplate}
/>
</div>
);
}
It would definitely be a great improvement when using the toolbar to work.
Hi Jorge,
Greetings from Syncfusion support.
With the provided code details, we have prepared and validated the issue you mentioned regarding the React TreeView component. However, when we select a tree node (both parent and child), the selection is properly maintained, and the parent nodes are not collapsed. We have verified this issue by manually and dynamically selecting the nodes across different browsers, and it works correctly.
For your reference, we have included the validated sample:
Sample: https://stackblitz.com/edit/react-ts-koqcczzr?file=App.tsx,style.css,index.html,package.json
Please check the sample on your end. If you are still facing any issues, kindly share a sample code snippet or a replication example along with the steps to reproduce the issue. Additionally, let us know if you have made any customizations to the node selection, the number of tree nodes rendered in your application, and the type of data being used (local data or remote data). This information will help us validate the problem and provide a prompt solution.
Regards,
Leo Lavanya Dhanaraj
greetings for helping me, I really need it, LOL.
I will share with you all the cycle of this component from treeview to toolbar
CategoryTreeView
// CategoryTreeView.tsx (Simple Version)
import {NodeSelectEventArgs, TreeViewComponent} from "@syncfusion/ej2-react-navigations";
import {useCallback, useRef, useState} from "react";
import './treeview.css';
export interface TreeNodeData {
id: number | string;
nome: string;
icone?: string;
cor?: string;
ativo?: boolean;
children?: TreeNodeData[];
parentId?: number | string;
hasChildren?: boolean;
expanded?: boolean;
[key: string]: any;
}
interface Props {
treeData: TreeNodeData[];
onNodeSelect?: (node: TreeNodeData) => void;
initialSelectedNodeId?: string | number | null;
}
export function transformCategory(cat: any, isFirst: boolean = false): TreeNodeData {
const sortedSubs = cat.subcategorias
? cat.subcategorias
.slice()
.sort((a: any, b: any) => a.nome.localeCompare(b.nome))
: [];
return {
id: cat.id.toString(),
nome: cat.nome,
icone: cat.icone,
cor: cat.cor,
ativo: cat.ativo,
children: sortedSubs.map((sub: any) => transformCategory(sub, false)),
hasChildren: sortedSubs.length > 0,
parentId: cat.categoriaPaiId?.toString() || null,
expanded: isFirst,
};
}
// Função nodeTemplate movida para fora corretamente
function nodeTemplate(data: any) {
const nodeData = data.data || data;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{nodeData.icone && (
<i
className={`fa fa-${nodeData.icone}`}
style={{ color: nodeData.cor || 'inherit' }}
/>
)}
<span>{nodeData.nome}</span>
</div>
);
}
export function CategoryTreeView({
treeData,
onNodeSelect,
initialSelectedNodeId,
}: Props) {
// Rastrear o último nó selecionado para evitar chamadas duplicadas
const lastSelectedNodeRef = useRef<string | null>(null);
const [selectedNodes, setSelectedNodes] = useState<string[]>(
initialSelectedNodeId ? [initialSelectedNodeId.toString()] : []
);
const handleNodeSelected = useCallback((args: NodeSelectEventArgs) => {
if (!args.nodeData || !args.nodeData.id) return;
const nodeId = args.nodeData.id.toString();
// Prevenir chamadas duplicadas para o mesmo nó
if (lastSelectedNodeRef.current === nodeId) return;
lastSelectedNodeRef.current = nodeId;
const nodeData = (args.nodeData as any)?.data || args.nodeData;
// Notificar o componente pai
if (onNodeSelect) {
onNodeSelect(nodeData);
}
}, [onNodeSelect]);
return (
<div style={{maxWidth: "500px"}}>
<TreeViewComponent
fields={{
dataSource: treeData,
id: 'id',
text: 'nome',
child: "children",
expanded: 'false',
hasChildren: 'hasChildren',
parentID: 'parentId',
selectable: 'true',
}}
expandOn="Click"
nodeTemplate={nodeTemplate}
nodeSelected={handleNodeSelected}
selectedNodes={selectedNodes}
/>
</div>
);
}
your Parent
CategoryTreeCrud
//
import {CategoryTreeView, transformCategory, TreeNodeData} from "../CategoryTreeView/CategoryTreeView.tsx";
import {useCallback, useEffect, useState} from "react";
import {Category} from "../../types/Category.ts";
interface Props {
categories: Category[];
onNodeSelect?: (node: TreeNodeData) => void;
}
export function CategoryTreeCrud({
categories,
onNodeSelect,
}: Props) {
const [treeData, setTreeData] = useState<TreeNodeData[]>([]);
// Transformar categorias apenas quando elas mudarema
useEffect(() => {
// Filtrar para obter apenas categorias raiz
const rootCategories = categories.filter(cat => !cat.categoriaPaiId);
// Transformar em formato de árvore
const newTreeData = rootCategories
.slice()
.sort((a, b) => a.nome.localeCompare(b.nome))
.map((c) => transformCategory(c, true)); // true = expandir primeiro nível
setTreeData(newTreeData);
}, [categories]);
// Memoize o callback de seleção
const handleNodeSelect = useCallback((node: TreeNodeData) => {
if (onNodeSelect) {
onNodeSelect(node);
}
}, [onNodeSelect]);
return (
<div>
<CategoryTreeView
treeData={treeData}
onNodeSelect={handleNodeSelect}
initialSelectedNodeId={null}
/>
</div>
);
}
your parent : CategoryTabs
// CategoryTabs.tsx (CORRETO)
import {
TabComponent,
TabItemDirective,
TabItemsDirective,
SelectEventArgs,
} from '@syncfusion/ej2-react-navigations';
import { CategoryTreeCrud } from "../CategoryCrudToolBar/CategoryTreeCrud"; // Caminho correto
import { TreeNodeData } from "../CategoryTreeView/CategoryTreeView.tsx"; // Caminho correto
import { useMemo } from 'react';
import {Category} from "../../types/Category.ts";
interface CategoryTabsProps {
categories: Category[];
activeType: string;
onTabChange: (newType: string) => void;
onNodeSelect: (node: TreeNodeData) => void;
initialSelectedNodeId?: string | number | null;
}
export function CategoryTabs({
categories,
activeType,
onTabChange,
onNodeSelect,
}: CategoryTabsProps) {
const grouped = useMemo(() => {
const groupByType = (cats: Category[]): Record<string, Category[]> => {
const result: Record<string, Category[]> = {};
cats.forEach((cat) => {
const type = cat.tipo || "OUTRO";
if (!result[type]) result[type] = [];
result[type].push(cat);
});
return result;
};
return groupByType(categories);
}, [categories]);
const sortedTypes = useMemo(() => Object.keys(grouped).sort(), [grouped]);
const tabItems = useMemo(() => sortedTypes.map((type) => ({
header: { text: type },
content: () => (
<div style={{ padding: '1rem' }}>
<CategoryTreeCrud
categories={grouped[type]}
onNodeSelect={onNodeSelect}
/>
</div>
),
})), [sortedTypes, onNodeSelect, grouped]);
const activeIndex = sortedTypes.indexOf(activeType);
const handleTabChange = (args: SelectEventArgs) => {
const newIndex = args.selectedIndex;
const newType = sortedTypes[newIndex];
onTabChange(newType);
};
return (
<div style={{ width: 'calc(100vw - 30px)', margin: '0 auto' }}>
<TabComponent
selectedItem={activeIndex >= 0 ? activeIndex : 0}
selected={handleTabChange}
>
<TabItemsDirective>
{tabItems.map((item) => (
<TabItemDirective key={item.header.text} header={item.header} content={item.content} />
))}
</TabItemsDirective>
</TabComponent>
</div>
);
}
and finally the page that link to toolbar
// Continuação de CategoriesPage.tsx (Código Completo e Corrigido)
import './CategoriesPage.css';
import {CategoryTabs} from "../components/CategoryTab/CategoryTabs";
import {CustomToolbar} from "../components/CustomToolbar/CustomToolbar";
import {useEffect, useState} from "react";
import {Category} from "../types/Category"; // Caminho correto
import {TreeNodeData} from "../components/CategoryTreeView/CategoryTreeView.tsx"; // Caminho correto
export function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | number | null>(null);
const [activeType, setActiveType] = useState<string>("DESPESA");
useEffect(() => {
const fetchData = async () => {
try {
const initialCategories: Category[] = [
{
id: 1,
nome: "Moradia",
descricao: "Despesas relacionadas à habitação e residência",
ativo: true,
tipo: "DESPESA",
cor: "#4B89DC",
icone: "home",
nivel: 1,
subcategorias: [
{
id: 3,
nome: "Aluguel",
descricao: "Pagamentos mensais de aluguel da residência",
ativo: true,
tipo: "DESPESA",
cor: "#5D9CEC",
icone: "key",
nivel: 2,
categoriaPaiId: 1,
categoriaPaiNome: "Moradia",
subcategorias: [
{
id: 5,
nome: "Condomínio",
descricao: "Taxa mensal de condomínio do imóvel alugado",
ativo: true,
tipo: "DESPESA",
cor: "#AC92EC",
icone: "building",
nivel: 3,
categoriaPaiId: 3,
categoriaPaiNome: "Aluguel"
},
{
id: 6,
nome: "IPTU",
descricao: "Imposto Predial Territorial Urbano",
ativo: true,
tipo: "DESPESA",
cor: "#AC92EC",
icone: "building",
nivel: 3,
categoriaPaiId: 3,
categoriaPaiNome: "Aluguel"
},
{
id: 7,
nome: "Tx Incêndio",
descricao: "Taxa Anual de Combate à Incêndios",
ativo: true,
tipo: "DESPESA",
cor: "#AC92EC",
icone: "building",
nivel: 3,
categoriaPaiId: 3,
categoriaPaiNome: "Aluguel"
}
]
}
]
},
{
id: 2,
nome: "Renda",
descricao: "Fontes de entrada de dinheiro",
ativo: true,
tipo: "RECEITA",
cor: "#37BC9B",
icone: "briefcase",
nivel: 1,
subcategorias: [
{
id: 4,
nome: "Salário",
descricao: "Remuneração mensal do trabalho principal",
ativo: true,
tipo: "RECEITA",
cor: "#48CFAD",
icone: "briefcase",
nivel: 2,
categoriaPaiId: 2,
categoriaPaiNome: "Renda",
subcategorias: []
}
]
}
];
await new Promise((resolve) => setTimeout(resolve, 500)); // Simula atraso
setCategories(initialCategories);
setLoading(false);
} catch (err: any) {
setError(err);
setLoading(false);
}
};
fetchData();
}, []);
const handleNodeSelect = (node: TreeNodeData) => {
console.log("Nó selecionado em CategoriesPage:", node);
setSelectedNodeId(node.id);
};
const handleTabChange = (newActiveType: string) => {
setActiveType(newActiveType);
setSelectedNodeId(null);
};
const handleToolbarAction = (action: string) => {
const selectedNode = categories.find((cat) => cat.id === selectedNodeId) || categories.flatMap(cat => cat.subcategorias || []).find(cat => cat.id === selectedNodeId)
if (!selectedNode) {
alert("Nenhum nó selecionado.");
return;
}
const nodeId = selectedNode?.id;
const nodeText = selectedNode?.nome;
if (action === 'delete') {
if (nodeId === undefined) return;
const newCategories = removeCategoryById(categories, nodeId);
setCategories(newCategories);
setSelectedNodeId(null);
alert(`Nó "${nodeText}" removido.`);
} else if (action === 'new') {
alert(`Adicionar novo item a partir de "${nodeText}"`);
} else if (action === 'edit') {
alert(`Editar "${nodeText}" (ID: ${nodeId})`);
} else if (action === 'activate') {
if (selectedNode.ativo) {
alert(`Inativar "${nodeText}"`);
} else {
alert(`Ativar "${nodeText}"`);
}
} else if (action === 'move') {
alert(`Mover "${nodeText}"`);
}
};
// Função auxiliar recursiva para remover um nó (CORRETA)
function removeCategoryById(cats: Category[], id: number | string): Category[] {
return cats
.filter(cat => cat.id !== id)
.map(cat => {
const updatedCat: Category = { ...cat };
if (updatedCat.subcategorias) {
updatedCat.subcategorias = removeCategoryById(updatedCat.subcategorias, id);
}
return updatedCat;
});
}
if (loading) {
return <div>Carregando...</div>;
}
if (error) {
return <div>Erro: {error.message}</div>;
}
return (
<div className="categories-page">
<div className="page-header">
<h1>Categorias</h1>
<p>Gerencie suas categorias de transações</p>
</div>
<div className="grid-container">
<CustomToolbar onAction={handleToolbarAction} /> {/* Passe selectedNodeId aqui */}
<CategoryTabs
categories={categories}
activeType={activeType}
onTabChange={handleTabChange}
onNodeSelect={handleNodeSelect}
/>
</div>
</div>
);
}
Hi Jorge,
Thanks for the shared code snippets.
We were able to reproduce the mentioned issue using the provided code snippets and found that the issue occurs because the expanded property in the TreeView component fields is directly set as a boolean value. The expanded property should be a string value that specifies the mapping field for the expand state of the TreeView node in the datasource. Additionally, in the transformCategory method, you passed the value `false` for the subcategories.
To resolve the issue, we changed the expanded property value to a string and passed the value `true` for the subcategories in the transformCategory method. Check out the modified sample and code snippets below for further assistance.
|
[transformCategory.tsx] export function transformCategory( cat: any, isFirst: boolean = false ): TreeNodeData { const sortedSubs = cat.subcategorias ? cat.subcategorias .slice() .sort((a: any, b: any) => a.nome.localeCompare(b.nome)) : [];
return { id: cat.id.toString(), nome: cat.nome, icone: cat.icone, cor: cat.cor, ativo: cat.ativo, children: sortedSubs.map((sub: any) => transformCategory(sub, true)), hasChildren: sortedSubs.length > 0, parentId: cat.categoriaPaiId?.toString() || null, expanded: isFirst, }; }
<TreeViewComponent fields={{ dataSource: treeData, id: 'id', text: 'nome', child: 'children', // expanded: 'false', expanded: 'expanded', hasChildren: 'hasChildren', parentID: 'parentId', selectable: 'true', }} expandOn="Click" nodeTemplate={nodeTemplate} nodeSelected={handleNodeSelected} selectedNodes={selectedNodes} /> |