How To Build An Interactive, Persistent Tree Editor with MongoDB, Node.js, and React
Ever wonder how tree data structures are used in real-world applications? How are they stored in a database, and how can you perform CRUD operations on them from the frontend?
The Tree data structure is a hierarchical structure used to represent and organize data in a way that is easy to navigate and search. It consists of nodes connected by edges, with a hierarchical relationship between the nodes.
The topmost node of the tree is called the root, and the nodes below it are referred to as child nodes. Each node can have multiple child nodes, which can, in turn, have their own child nodes, forming a recursive structure.
In this blog post, I'll take you through my journey of implementing a tree structure in Node.js using MongoDB. You'll find valuable insights and practical tips to help you navigate this complex task.
Additionally, I'll demonstrate how to implement a basic user interface for editing the tree structure directly from the UI.
Why I Implemented a Tree Structure
In Hexmos Feedback, we believe in keeping teams motivated by encouraging regular, meaningful feedback, improving team commitment, and tracking participation.
To represent user titles, we have an Organizational Title management system. It displays the hierarchy, facilitates navigation, and manages title relationships. Each employee is assigned a title when signing up for the organization.
The existing organizational title management system was not up to the mark.
Organizations could not edit the titles in any way; only the default titles provided by the product were available.
We wanted a Hierarchical Organizational Title Chart in our product.
A hierarchy is an Organizational Title structure where one group or person is at the top, with others beneath them, forming a pyramid based on accountability and reporting lines.
In a hierarchy, members typically communicate with the person they report to, as well as anyone who reports directly to them. For example, in the case of a leave application.
A tree structure was the perfect solution for implementing this.
Just like a real tree with branches and leaves, it organizes titles in a hierarchical manner.
This means each title can have smaller roles beneath it. This is a natural way to represent how organizations work, making it a great fit for displaying roles and titles in our system.
We can represent the hierarchy, facilitate navigation, and manage title relationships. This enhances team commitment and collaboration.
What were our use cases in UI and backend implementation?
Displaying Titles Similar to a Tree Structure
Tree structures are ideal for representing hierarchical data like organizational titles and folder structures.
They allow you to visually organize and display information in a clear and intuitive manner. This is particularly useful when dealing with complex hierarchies with many levels and subcategories.
Displaying the Hierarchy
One of the early challenges we encountered was finding an effective way to visualize the hierarchical structure of our titles in a clear, organized, and intuitive manner.
Our initial approach involved hard-coding the hierarchy, which provided a functional solution but lacked flexibility.
We aimed to create a more dynamic and user-friendly experience by allowing users to see how titles relate to each other, similar to a tree structure.
Our objective was to enable each title (or node) to be connected to a parent title and have multiple child titles beneath it.
The user interface needed to accurately reflect this tree-like structure, making it easy for users to understand and navigate through the various levels of titles.
Customization and User Interaction
Customization was a crucial aspect of our project.
We wanted to empower users to not only view the hierarchical structure but also actively interact with it. This involved providing functionalities such as:
- Creating New Titles: Users should be able to add new titles at any level of the hierarchy.
- Editing Titles: Titles may evolve over time, so we needed to enable easy editing within the user interface.
- Reassigning Titles: In the event of organizational changes, users should be able to move titles from one parent to another without disrupting the overall hierarchy.
This level of customization enhances flexibility and ensures that the hierarchy remains dynamic and adaptable to various organizational requirements.
Visualizing Organizational Titles in a Better Way
By using a tree structure, you can create a more visually appealing and interactive representation of your organizational titles. This can make it easier for users to understand the relationships between different titles and navigate the hierarchy.
Implementation Strategy
We wanted to keep things simple at first, so we could easily add more features later. We thought about building everything ourselves, but after talking to other developers and doing some research, we decided to see what was already available. It made more sense to use existing tools than to start from scratch – it would have taken way too much time.
For editing titles, we found a great tool called JSON Editor (link: JSON Editor). It lets users easily view and change the data structure. On the backend, we took an open-source project that stores data in a tree-like format and modified it to fit our specific needs. This way, we didn't have to build everything from scratch, but we still got a solution that worked perfectly for us.
Implementing Basic CRUD Operations
On the backend, the core functionality revolved around basic CRUD (Create, Read, Update, Delete) operations for our title hierarchy. Each node (representing a title) needed to be efficiently created, stored, modified, and deleted.
- Create: Adding a new node in the hierarchy required assigning it to a parent node, whether at the top (CEO-level, for example) or deeper in the structure.
- Read: This involved fetching the entire hierarchy from MongoDB, which stored each node and its relation to other nodes.
- Update: Updating nodes required the ability to edit the title itself and change its position within the hierarchy (i.e., assigning it a new parent).
- Delete: Deleting a node meant removing it from the hierarchy, while also considering how to handle its child nodes — whether they should be reassigned to a new parent or deleted as well.
MongoDB proved to be an excellent choice for this, as it allows for flexible storage of hierarchical data using tree structures.
Its JSON-like documents made it straightforward to store each node along with its relationships.
Storing the Title Hierarchy in Tree Structure
The final step in the backend implementation was to store the hierarchical structure in a way that MongoDB could efficiently handle. We employed a parent-child model, where each node in the tree stored a reference to its parent node. This allowed us to easily traverse the hierarchy, fetch child nodes as needed, and move nodes to different parts of the tree.
We also utilized recursive queries to retrieve the entire hierarchy in one request, enabling the front end to render the tree without performance issues. Each node in MongoDB stored fields such as:
title
: The name of the title (e.g., Manager, Director)parentId
: The ID of the parent node (for linking the hierarchy)children
: An array of child nodes (to facilitate traversal)
With these core components in place, we had a fully functional backend that supported the dynamic management of title hierarchies.
Node.js Backend Implementation
This file structure promotes maintainability, scalability, and clarity in your Node.js backend project.
It effectively separates concerns, making code easier to understand, modify, and collaborate on.
The config directory holds configuration files, the controllers handle API routing and business logic, the models define database schemas, the utils contain helper functions, and the services encapsulate complex business operations.
title/
├── config/
└── db.js
├── controllers/
└── title.controller.js
├── models/
├── node.models.js
└── title.models.js
├── utils/
└── title.js
└── services/
├── node.services.js
└── title.services.js
Use Mongoose Package for Connecting To MongoDB
I will be using Mongoose package to connect to MongoDB. It defines an asynchronous connectDB
function, which retrieves the MongoDB URI from environment variables.
If the URI is not defined, an error is thrown.
It then establishes a connection to the database using connect()
and logs a success message once connected.
Finally, the function returns the active MongoDB connection.
const { connect } = require("mongoose");
const { Connection } = require("mongoose");
export const connectDB = async (): Promise<typeof Connection> => {
if (!process.env.MONGODB_URI) {
throw new Error('MONGODB_URI is not defined in environment variables');
}
const connection = await connect(process.env.MONGODB_URI);
console.log('MongoDB connected');
return connection.connection;
};
Title Controller for API Calls
Router Setup and Services Import
This part of the code imports Express and two service files: titleService
and NodeService
. It initializes an Express router (router
) to handle API routes and interactions related to the title hierarchy.
Health Check Endpoint
The /health
route is a basic health check that responds with an HTTP 200 status and a simple JSON object { status: 'OK' }
. This route is used to ensure the service is running and responding properly.
// title.controller.js
const Express = require("express")
const titleService = require("../services/title.service")
const NodeService = require("../services/node.service")
const router = Express.Router()
// Health check endpoint
router.get('/health', (req, res) => {
res.status(200).json({ status: 'OK' })
})
Utility Function: Generate Unique ID
A helper function generateUniqueId
creates a random unique ID using Math.random()
and converts it to a base-36 string. This function is used to assign unique identifiers to new nodes in the title hierarchy.
// Utility function to generate unique IDs
const generateUniqueId = () => Math.random().toString(36).substr(2, 9)
Get All Titles by Organization ID
The GET route /:orgId
retrieves the organization's title hierarchy by orgId
. It queries the database via titleService.findByOrgId(orgId)
and returns the data if found, otherwise, it sends a 404 response indicating the organization was not found.
// Get all titles
router.get("/:orgId", async (req, res) => {
try {
const orgId = req.params.orgId;
// Fetch existing organization data from the database
let existingOrgData = await titleService.findByOrgId(orgId);
if (existingOrgData) {
// Organization data exists, return it or process further
return res.status(200).json(existingOrgData);
} else {
// No existing organization found for the provided orgId
return res.status(404).json({ message: "Organization not found" });
}
} catch (error) {
// Handle any errors
return res.status(500).json({ error: error.message });
}
});
Upsert (Update/Insert) Title Hierarchy
The PUT route /
accepts a request body with hierarchyJson
, objectId
, and orgID
. It processes the hierarchy, generating IDs for any new nodes, and recursively updating child nodes. The hierarchy is upserted into the database, and the updated structure along with the rootId
is returned as a response.
// Upsert a title
router.put("/", async (req, res) => {
try {
const { hierarchyJson, objectId, orgID } = req.body
const processNode = (node, parentId = null) => {
if (!node.id) {
node.id = generateUniqueId()
}
node.parentId = parentId
if (node.children && node.children.length > 0) {
node.children.forEach(child => processNode(child, node.id))
}
}
processNode(hierarchyJson)
const updatedTitle = await titleService.upsert(hierarchyJson, objectId, orgID)
const rootId = await NodeService.upsertBytitle(hierarchyJson)
res.status(200).json({ message: 'Updated organization structure', rootId })
} catch (error) {
console.error('Error in PUT /:', error)
res.status(500).json({ message: 'Server error', error: error.message })
}
})
Add a New Title under a Parent Node
The PUT route /newTitle/
allows adding a new title under an existing parent node in the organization structure. It first searches for the parent by parentId
, generates a new child node, updates the parent's children
array, and then saves the updated structure to the database.
// PUT to add new title
router.put('/newTitle/', async (req, res) => {
try {
const { orgId, parentId, label } = req.body
// Step 1: Find the organization structure by orgId
const orgData = await titleService.findByOrgId(orgId)
if (!orgData) {
return res.status(404).json({ message: 'Organization structure not found' })
}
const hierarchyJson = orgData.hierarchyJson
// Step 2: Recursively find the parent node with the given parentId
const findParentNode = (node, parentId) => {
if (node.id === parentId) {
return node
}
if (node.children && node.children.length > 0) {
for (let child of node.children) {
const found = findParentNode(child, parentId)
if (found) return found
}
}
return null
}
const parentNode = findParentNode(hierarchyJson, parentId)
if (!parentNode) {
return res.status(404).json({ message: 'Parent node not found' })
}
// Step 3: Generate a new id and calculate the level for the new child node
const newChild = {
id: generateUniqueId(), // Generate a unique id for the new child node
parentId: parentNode.id,
label: label,
level: parentNode.level + 1, // Set level based on parent node
children: [] // No children initially
}
// Step 4: Add the new child to the parent's children array
parentNode.children.push(newChild)
// Step 5: Save the updated organization structure back to the database
const updatedOrg = await titleService.upsert(hierarchyJson, orgData.objectId, orgId)
res.status(201).json({
success: true,
message: 'Successfully added new child under parent node',
updatedOrg
})
} catch (error) {
console.error('Error in PUT /newTitle/', error)
res.status(500).json({ message: 'Server error', error: error.message })
}
})
Delete Title with Child Node Restriction
The DELETE route /deletetitle/
prevents deletion of nodes that have children. It searches for the node by Id
, checks if it has children, and if not, removes the node and saves the updated structure back to the database.
// New DELETE title API that prevents deletion if the node has children
router.delete('/deletetitle/', async (req, res) => {
try {
const { orgId, Id } = req.body
// Step 1: Find the organization structure by orgId
const orgData = await titleService.findByOrgId(orgId)
if (!orgData) {
return res.status(404).json({ message: 'Organization structure not found' })
}
const hierarchyJson = orgData.hierarchyJson
// Step 2: Recursively find the node to delete
const findNodeById = (node, Id) => {
if (node.id === Id) {
return node
}
if (node.children && node.children.length > 0) {
for (let child of node.children) {
const found = findNodeById(child, Id)
if (found) return found
}
}
return null
}
const nodeToDelete = findNodeById(hierarchyJson, Id)
if (!nodeToDelete) {
return res.status(404).json({ message: 'Node not found' })
}
// Step 3: Check if the node has children
if (nodeToDelete.children && nodeToDelete.children.length > 0) {
return res.status(400).json({ message: 'Cannot delete title with children' })
}
// Step 4: Recursively find and remove the node from the parent's children array
const removeNodeById = (node, Id) => {
if (node.children && node.children.length > 0) {
node.children = node.children.filter(child => child.id !== Id)
node.children.forEach(child => removeNodeById(child, Id))
}
}
removeNodeById(hierarchyJson, Id)
// Step 5: Save the updated organization structure back to the database
const updatedOrg = await titleService.upsert(hierarchyJson, orgData.objectId, orgId)
res.status(200).json({
success: true,
message: 'Title deleted successfully',
updatedOrg
})
} catch (error) {
console.error('Error in DELETE /deletetitle', error)
res.status(500).json({ message: 'Server error', error: error.message })
}
})
List Titles with Metadata
The GET route /list/:orgId
retrieves the organizational titles for a given orgId
and returns them in a { id, name, createdAt, updatedAt }
format. It traverses the hierarchical structure to collect and format the necessary data.
// Get titles by orgId and return { id: name, createdAt, updatedAt } format
router.get("/list/:orgId", async (req, res) => {
try {
const { orgId } = req.params;
// Step 1: Find the organization structure by orgId
const orgData = await titleService.findByOrgId(orgId);
if (!orgData) {
return res.status(404).json({ message: 'Organization structure not found' });
}
const hierarchyJson = orgData.hierarchyJson;
// Step 2: Traverse the hierarchyJson tree to collect { id, name, createdAt, updatedAt }
const collectTitles = (node) => {
const titles = [];
const queue = [node];
while (queue.length > 0) {
const currentNode = queue.shift();
// Create the desired format: { id, name, createdAt, updatedAt }
const titleObject = {
id: currentNode.id,
name: currentNode.label,
createdAt: currentNode.createdAt,
updatedAt: currentNode.updatedAt
};
titles.push(titleObject);
if (currentNode.children && currentNode.children.length > 0) {
queue.push(...currentNode.children);
}
}
return titles;
};
const titles = collectTitles(hierarchyJson);
// Step 3: Respond with the titles
res.status(200).json(titles);
} catch (error) {
console.error('Error in GET /titles/:orgId:', error);
res.status(500).json({ message: 'Server error', error: error.message });
}
});
Edit Title Label
The PUT route /edittitle
allows users to update the label of a specific node by id
. It searches through the title hierarchy, updates the node's label if found, saves the updated structure, and returns a success response with the updated data.
// PUT request to update the title label
router.put("/edittitle", async (req, res) => {
const { orgId, id, label } = req.body;
try {
// Step 1: Find the organization structure by orgId
const titleData = await titleService.findByOrgId(orgId);
if (!titleData) {
return res.status(404).json({ message: 'Organization not found' });
}
// Step 2: Function to traverse and find the node by ID
const findAndUpdateNode = (node) => {
if (node.id === id) {
node.label = label; // Update only the label of the specific node
return true; // Indicate that the update was successful
}
// If there are children, recursively search in them
if (node.children && node.children.length > 0) {
for (let child of node.children) {
if (findAndUpdateNode(child)) {
return true; // Stop once the node is found and updated
}
}
}
return false; // Return false if the node with the given ID is not found
};
const isUpdated = findAndUpdateNode(titleData.hierarchyJson);
if (!isUpdated) {
return res.status(404).json({ message: 'Node with the specified ID not found' });
}
// Step 3: Save the updated organization structure
await titleData.save();
// Step 4: Respond with the updated structure
res.status(200).json({ message: 'Title updated successfully', data: titleData });
} catch (error) {
console.error("Error in updating title:", error);
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router
Tile and Node Models
Node Model
The nodeSchema
defines a tree structure node with unique id
, optional parentId
, level
, and label
.
Each node can have nested child nodes in the children
array. Timestamps are added automatically to track creation and updates.
The NodeModel
represents the node collection and is exported for use in the app.
Similarly, the titleSchema
imports nodeSchema
to store hierarchical data in a collection called TitleHierarchyGraph
, using custom timestamps for tracking.
//node.models.js
const mongoose = require("mongoose");
const { Schema } = mongoose;
const nodeSchema = new Schema({
id: { type: String, required: true, unique: true },
parentId: { type: String, default: null },
level: { type: Number, required: true },
label: { type: String, required: true },
children: [{ type: Schema.Types.Mixed, default: [] }]
}, {
timestamps: true // This automatically adds createdAt and updatedAt fields
});
const NodeModel = mongoose.model("Node", nodeSchema);
module.exports = { nodeSchema, NodeModel };
Title Models
The nodeSchema
defines nodes with id
, parentId
, level
, label
, and an array of child nodes, including automatic timestamps for creation and updates.
The titleSchema
references nodeSchema
to store hierarchical title data, with fields like objectId
and orgID
for organizational reference. It uses custom timestamps (_created_at
, _updated_at
) and explicitly saves in a TitleHierarchyGraph
collection.
Both schemas are used to create Mongoose models (NodeModel
and titleModel
), which are exported for use in managing organization and title data in the app.
const { Schema, model } = require("mongoose");
const { nodeSchema } = require("./node.model"); // Import the correct schema
const titleSchema = new Schema(
{
objectId: { type: String, required: true }, // Keep this as a reference to the Organization's id
orgID: { type: String, required: true }, // Storing the organization ID
hierarchyJson: { type: nodeSchema, required: true },
_p_organizationPointer: { type: String, required: true }, // Add this field for the pointer
},
{
timestamps: { createdAt: '_created_at', updatedAt: '_updated_at' }, // Custom field names for timestamps
collection: 'TitleHierarchyGraph' // Specify the collection name explicitly
}
);
const titleModel = model("TitleHierarchyGraph", titleSchema);
module.exports = { titleModel };
Service function for DB Interaction
Title Services
The title.service.js
provides several database interaction functions for the TitleHierarchyGraph
.
The upsert
function inserts or updates a title with a unique organization ID pointer.
remove
deletes a document based on a root node ID, while findWithRoot
retrieves all documents.
findOneWithNodes
fetches a specific document by node ID, and updatetitleById
updates a title document by its ID.
The findByOrgId
function retrieves titles associated with a specific organization using its ID pointer.
All functions handle Mongoose database operations and ensure data consistency.
//title.service.js
const mongoose = require("mongoose");
const { titleModel } = require("cloud/userManagement/feedbackUserManagement/title/models/title.model");
const { SendTestNotificationResponseValidator } = require("@apple/app-store-server-library/dist/models/SendTestNotificationResponse");
const generateOrgIdPointer = (orgId) => {
let orgIdPointer = `Organization$${orgId}`
return orgIdPointer
}
async function listCollections() {
const collections = await mongoose.connection.db.collection('TitleHierarchyGraph').find({}).toArray();
console.log('Collections:', collections);
}
const upsert = async (hierarchyJson, objectId, orgID) => {
// Check if orgID is defined
if (!orgID) {
throw new Error("orgID is required");
}
// Generate organization pointer
const orgIdPointer = generateOrgIdPointer(orgID);
console.log("Generated Org ID Pointer:", orgIdPointer);
// If hierarchyJson doesn't have an id, generate one
if (!hierarchyJson.id) {
hierarchyJson.id = generateUniqueId(); // Set an ID if it's missing
}
// Check if the title document already exists
const titleDocument = await titleModel.findOne({ objectId });
if (!titleDocument) {
// Create a new document
const newTitle = new titleModel({
objectId,
_p_organizationPointer: orgIdPointer,
orgID, // Make sure to pass orgID correctly
hierarchyJson
});
return await newTitle.save();
}
// Update the existing document
titleDocument.hierarchyJson = hierarchyJson;
titleDocument._p_organizationPointer = orgIdPointer; // Ensure it's set correctly
titleDocument.orgID = orgID; // Ensure it's set correctly
titleDocument.updatedAt = new Date(); // Set updatedAt timestamp
return await titleDocument.save();
};
const remove = async (rootId) => {
const result = await titleModel.deleteOne({ "hierarchyJson.id": rootId })
return result.deletedCount > 0 ? result : null
}
const findWithRoot = async () => {
return await titleModel.find({})
}
const findOneWithNodes = async (rootId) => {
const result = await titleModel.findOne({ "hierarchyJson.id": rootId })
return result
}
const updatetitleById = async (id, updateData) => {
try {
const updatedTitle = await titleModel.findByIdAndUpdate(id, updateData, { new: true })
return updatedTitle
} catch (error) {
throw new Error(error.message)
}
}
// Add this new function to the service
const findByOrgId = async (orgId) => {
try {
// Fetch the organization by orgID from the Organization collection
const result = await titleModel.find({ _p_organizationPointer: generateOrgIdPointer(orgId) });
return result;
} catch (error) {
throw new Error(`Error finding titles by organization ID: ${error.message}`);
}
}
module.exports = {
upsert, remove,
findWithRoot,
findOneWithNodes,
updatetitleById,
findByOrgId
};
Node Services
The node.service.js
file manages node-related database operations.
It provides methods to interact with the NodeModel
in MongoDB.
The findBytitle
function retrieves nodes and their children using a graph lookup, starting with the root node.
The upsertBytitle
function updates or inserts nodes, comparing them to the previous state, and performs bulk database operations accordingly.
The removeBytitle
method deletes nodes by root ID, and deleteNodeById
allows for deletion of individual nodes, updating the parent node’s children if applicable.
All functions handle errors appropriately and ensure efficient data management.
//node.service.js
const { NodeModel } = require("../models/node.model")
const { traverseNodes } = require("../util/title")
const findBytitle = async (rootId) => {
const result = await NodeModel.aggregate([
{ $match: { id: rootId } },
{
$graphLookup: {
from: "nodes",
startWith: "$id",
connectFromField: "id",
connectToField: "parentId",
as: "children",
},
},
])
const rootWithNodes = result[0]
return rootWithNodes ? [rootWithNodes, ...(rootWithNodes.children ?? [])] : []
}
const upsertBytitle = async (titleDto) => {
const newNodes = traverseNodes(titleDto);
const prevNodes = await findBytitle(titleDto.id);
const createdNodes = newNodes.filter((newNode) => !prevNodes.some((prevNode) => newNode.id === prevNode.id));
const updatedNodes = newNodes.filter((newNode) => prevNodes.some((prevNode) => newNode.id === prevNode.id));
const deletedNodes = prevNodes.filter((prevNode) => !newNodes.some((newNode) => newNode.id === prevNode.id));
const bulkJobs = [
...createdNodes.map((node) => ({
insertOne: { document: node },
})),
...updatedNodes.map((node) => ({
updateOne: {
filter: { id: node.id },
update: {
$set: {
parentId: node.parentId,
level: node.level,
label: node.label,
},
},
},
})),
{
deleteMany: {
filter: { id: { $in: deletedNodes.map((node) => node.id) } },
},
},
];
try {
const result = await NodeModel.bulkWrite(bulkJobs);
// Check for any errors in the bulk operation
if (result.hasWriteErrors()) {
console.error('Some operations failed during bulkWrite:', result.getWriteErrors());
throw new Error('Some operations failed during the bulk update.');
}
return titleDto.id; // Return the root id
} catch (error) {
console.error('Error during bulkWrite operation:', error.message);
throw new Error(`Failed to upsert title: ${error.message}`);
}
};
const removeBytitle = async (rootId) => {
const nodes = await findBytitle(rootId)
await NodeModel.bulkWrite([
{
deleteMany: {
filter: { id: { $in: nodes.map((node) => node.id) } },
},
},
])
}
const deleteNodeById = async (id) => {
console.log("Deleting node with ID:", id)
const nodeToDelete = await NodeModel.findOne({ id })
if (!nodeToDelete) {
console.log("Node not found with ID:", id)
return false
}
const parentNode = await NodeModel.findOne({ id: nodeToDelete.parentId })
if (parentNode && parentNode.children) {
parentNode.children = parentNode.children.filter((child) => child.id !== id)
await NodeModel.updateOne({ id: parentNode.id }, { $set: { children: parentNode.children } })
}
await NodeModel.deleteOne({ id })
console.log(`Node with ID ${id} deleted successfully`)
return true
}
module.exports = {
findBytitle,
upsertBytitle,
removeBytitle,
deleteNodeById,
}
Utils
The traverseNodes
function performs a breadth-first traversal of a node tree.
It starts from the root, processes each node and its children using a queue, and returns all nodes in the hierarchy in a flat array. The function is used for bulk operations like updating or deleting nodes.
//title.js
const { NodeDto } = require("cloud/userManagement/feedbackUserManagement/title/models/node.model")
const traverseNodes = (root) => {
let currentNode = root
const queue = []
const nodes = []
queue.push(currentNode)
while (queue.length) {
currentNode = queue.shift()
nodes.push(currentNode)
if (currentNode.children && currentNode.children.length > 0) {
currentNode.children.forEach((child) => queue.push(child))
}
}
return nodes
}
module.exports = { traverseNodes }
React Json Editor Implementation
This implementation features a JSON editor that fetches and transforms organizational title data for editing.
It supports CRUD operations through API calls, enabling users to add and delete nodes.
Custom buttons facilitate adding nodes under a selected parent title, with input collected via a modal.
Custom rendering logic enhances the display of node information.
React hooks manage the component's state and handle user interactions effectively.
import { callAPI } from "api/apiWrapper";
import { ALERT_TYPE } from "components/AlertStack";
import { urls, ALERT_DISMISS_TIMEOUT } from "config";
import { JsonEditor } from "json-edit-react";
import React, { useCallback, useEffect, useState, useRef } from "react";
import { Button } from "react-bootstrap";
import { ErrorAlert, SuccessAlert } from "Utils/AlertStackHelper";
interface TitleEditRenderProps {
alertStackRef: React.RefObject<any>;
}
const TitleEditRender: React.FC<TitleEditRenderProps> = ({ alertStackRef }) => {
const [updatedTitle, setUpdatedTitle] = useState([]);
const [fetchedTitle, setFetchedTitle] = useState([]);
const [orgID, setOrgID] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [selectedTitle, setSelectedTitle] = useState(null);
const [parentId, setParentId] = useState(null);
const getTitleData = useCallback(async () => {
try {
const response = await callAPI({
url: `${node.js.urls}/title/`,
method: "GET",
body: null,
queryParameters: {},
includeCreds: false,
validateResponse: false,
});
console.log("Fetched Data:", response);
const simplifiedData = transformData(response);
setUpdatedTitle(simplifiedData);
// Extract orgID and store it
const firstTitle = response.titles[0];
setOrgID(firstTitle.orgID);
} catch (error) {
console.error("Error fetching data:", error);
ErrorAlert("Failed to fetch data. Please try again.", alertStackRef, ALERT_DISMISS_TIMEOUT);
}
}, []);
useEffect(() => {
getTitleData();
}, [getTitleData]);
const transformData = (data) => {
if (!data || !data.titles || data.titles.length === 0) {
return [];
}
const traverse = (node) => {
return {
title: { label: node.label, id: node.id },
children: node.children?.map(traverse) || [],
};
};
return [traverse(data.titles[0].org)];
};
const addNodeToDatabase = async (label) => {
if (orgID && parentId) {
const body = {
orgId: orgID,
parentId: parentId,
label: label,
};
try {
const addTitleUrl = `${node.js.urls}/title/newTitle`;
const response = await callAPI({
url: addTitleUrl,
method: "PUT",
body: JSON.stringify(body),
queryParameters: {},
includeCreds: false,
validateResponse: false,
});
if (response.success) {
console.log("Node added successfully:", response.message);
SuccessAlert("Node added successfully", "success");
getTitleData(); // Refresh data after successful addition
} else {
console.error("Error adding node:", response.message || "Unknown error occurred");
ErrorAlert(`Failed to add node. Please try again.${response.message}`, alertStackRef);
}
} catch (error) {
console.error("Error in addNodeToDatabase:", error.message || error);
ErrorAlert(`An error occurred while adding the node. ${error}`, alertStackRef);
}
} else {
console.log("orgID or parentId is not defined.");
ErrorAlert(`Unable to add node. Missing organization or parent information.`, alertStackRef);
}
};
const handleDelete = async (node) => {
const nodeId = node.currentValue?.id; // Use the internal _id
console.log("Node received in handleDelete:", nodeId);
if (orgID && nodeId) {
const body = {
orgId: orgID,
Id: nodeId,
};
try {
const response = await callAPI({
url: `${node.js.urls}/title/deletetitle/`,
method: "DELETE",
body: JSON.stringify(body),
queryParameters: {},
includeCreds: false,
validateResponse: false,
});
if (response.success) {
SuccessAlert("Node deleted successfully", "success");
getTitleData();
} else {
console.error("Error deleting node:", response.message || "Unknown error occurred");
ErrorAlert("Failed to delete node. Please try again.", alertStackRef, ALERT_DISMISS_TIMEOUT);
}
} catch (error) {
console.error("Error in handleDelete:", error);
ErrorAlert("An error occurred while deleting the node.", alertStackRef, ALERT_DISMISS_TIMEOUT);
}
} else {
console.log("orgID or nodeId is not defined.");
ErrorAlert(
"Unable to delete node. Missing organization or node information.",
alertStackRef,
ALERT_DISMISS_TIMEOUT
);
}
};
const handleEditorChange = (updatedData) => {
setFetchedTitle(updatedTitle);
setUpdatedTitle(updatedData);
};
const handleCustomButtonClick = (nodeData) => {
setSelectedTitle(nodeData);
setIsModalOpen(true);
};
const handleInputSubmit = () => {
console.log("Input Value:", inputValue);
addNodeToDatabase(inputValue);
setIsModalOpen(false);
};
const customJSONEditorAddButtons = [
{
Element: () => (
<svg viewBox="0 0 24 24" height="1em" width="1em">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"
/>
</svg>
),
onClick: (nodeData, e) => {
console.log("Custom Button Clicked:", nodeData);
const parentId = nodeData?.parentData?.title?.id || undefined;
console.log("Custom Button Clicked:", nodeData);
console.log("Parent ID:", parentId);
if (parentId) {
console.log("Extracted parentId (key):", parentId);
setParentId(parentId);
} else {
console.error("parentId not found");
ErrorAlert("Unable to add node. Parent information not found.");
}
handleCustomButtonClick(nodeData);
},
condition: ({ key }) => key === "label",
},
];
const customNodeDefinitions = [
{
condition: ({ key }) => key === "title",
element: ({ value }) => <span>Title : {value.label}</span>,
hideKey: true,
},
];
const restrictDelete = ({ key }) => key === "id" || key === "label";
return (
<div style={{ padding: "20px" }}>
<JsonEditor
data={updatedTitle}
// onChange={handleEditorChange}
// onDelete={handleDelete}
// restrictDelete={restrictDelete}
restrictDelete={true}
searchText=""
restrictAdd={true}
restrictEdit={true}
enableClipboard={false}
theme="default"
restrictDrag={false}
customButtons={customJSONEditorAddButtons}
customNodeDefinitions={customNodeDefinitions}
showCollectionCount={false}
/>
{isModalOpen && (
<div>
{/* Dimmed background */}
<div className="fixed inset-0 bg-black bg-opacity-50 z-50" />
{/* Modal */}
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-lg z-50">
<h2 className="text-xl font-bold mb-4">Enter New Value</h2>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter new value"
className="w-full p-2 mb-4 border border-gray-300 rounded"
/>
<div className="flex justify-end space-x-2">
<Button variant="contained" color="primary" onClick={handleInputSubmit}>
Submit
</Button>
<Button variant="outlined" color="secondary" onClick={() => setIsModalOpen(false)}>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default TitleEditRender;
Conclusion
Creating a tree editor using MongoDB, Node.js, and React is a great way to build interactive and organized systems.
It's especially useful for apps like Hexmos Feedback, where you need to show and manage complex hierarchies where there are many levels and subcategories.
This approach makes it easy for users to work with the data and helps ensure it is user friendly to visualise the organizaional titles and their relationships.
By following these steps, developers can create a reliable and user-friendly tool that encourages people to get involved and interact with the system.
LiveAPI: Interactive API Docs that Convert
Static API docs often lose the customer's attention before they try your APIs. With LiveAPI, developers can instantly try your APIs right from the browser, capturing their attention within the first 30 seconds.
FeedZap: Read 2X Books This Year
FeedZap helps you consume your books through a healthy, snackable feed, so that you can read more with less time, effort and energy.