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?

How To Build An Interactive, Persistent Tree Editor with MongoDB, Node.js, and React

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.

image

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.

Tree Structure Organizational Titles

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.

image

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.

1 powerful reason a day nudging you to read
so that you can read more, and level up in life.

Sent throughout the year. Absolutely FREE.

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.