DEV Community

Cover image for Building AI Low-Code Platform in Angular — Part 3: Creating Custom Nodes and a Node Palette
Siarhei Huzarevich
Siarhei Huzarevich

Posted on • Edited on • Originally published at Medium

Building AI Low-Code Platform in Angular — Part 3: Creating Custom Nodes and a Node Palette

Updated for Foblex Flow v19.1.6 (July 2026). The examples now use the unified fConnector API and the preferred v19 event property names.

In the previous article, we built a basic editor with two nodes and a single connection. Now, let’s take the next step — we’ll add custom Angular node components, interactive connectors, and a simple node palette.

It’s not a full low-code platform just yet, but we’re steadily laying the groundwork — from visual customization to dynamic interaction between elements.

In this part, we’ll implement:

  • 🎨 A node palette with icons and labels
  • 🧩 Custom Angular components for each node
  • 🧵 Connectors (input/output) with interactive hover effects
  • 🔌 Logic to create nodes and connections using Foblex Flow events

🔧 Historical tutorial source — Foblex Flow 17.5.2

🔧 Explore the full application

The linked tutorial repository is the historical 17.5.2 implementation. The snippets below are updated for 19.1.6. For maintained v19 code, use the AI Low-Code Platform example.

Component Architecture

Our visual flow will be based on three main components:

  • Flow — the main Angular component containing f-flow and managing all nodes and connections
  • Node — a custom Angular component that represents a single node, linked to Foblex Flow using the fNode directive
  • FlowPalette — a sidebar panel containing draggable elements for the canvas

Continuing from the Previous Project

We’ll continue building on the project from the previous article.

Add Icons and Reset Styles

First, we need to load the required fonts and icons. Add the following to your index.html:

<link href="https://fonts.googleapis.com/css2?family=Inter&family=Material+Symbols+Outlined" rel="stylesheet"/>
Enter fullscreen mode Exit fullscreen mode

Then, reset default styles and apply the base font by adding this to your styles.scss:

html, body {
  margin: 0;
  padding: 0;
  width: 100%;
  height: 100%;
  font-family: 'Inter', sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

Defining Node Models and Initial Data

Let’s begin by defining the models for nodes and connections. Below are the TypeScript interfaces and an example set of 8 node types, which you can expand or customize later as needed:

// i-connection.ts
export interface IConnection {
  id: string;
  from: string;
  to: string;
}
Enter fullscreen mode Exit fullscreen mode
// i-storage-node.ts
export interface IStorageNode {
  name: string;
  icon: string;
  shortDescription: string;
  inputs: string[];
  outputs: string[];
}
Enter fullscreen mode Exit fullscreen mode
// i-node.ts
import { IStorageNode } from './i-storage-node';

export interface INode extends IStorageNode {
  id: string;
  position: { x: number; y: number };
}
Enter fullscreen mode Exit fullscreen mode

Now let’s define a list of available nodes:

// data.ts
export const DATA: IStorageNode[] = [
  {
    name: "Start",
    icon: "link",
    shortDescription: "Start: incoming request",
    inputs: [],
    outputs: ["any"]
  },
  {
    name: "AI Parser",
    icon: "code_blocks",
    shortDescription: "Extract JSON fields",
    inputs: ["any"],
    outputs: ["success", "failure"]
  },
  {
    name: "Retry Loop",
    icon: "repeat",
    shortDescription: "Repeat for each item",
    inputs: ["any"],
    outputs: ["any"]
  },
  {
    name: "AI Validator",
    icon: "check_circle",
    shortDescription: "AI data checker",
    inputs: ["parsed", "retry"],
    outputs: ["valid", "invalid"]
  },
  {
    name: "AI Executor",
    icon: "psychology",
    shortDescription: "AI-powered action",
    inputs: ["valid"],
    outputs: ["done"]
  },
  {
    name: "If-Else",
    icon: "fork_right",
    shortDescription: "Conditional branching",
    inputs: ["any"],
    outputs: ["true", "false"]
  },
  {
    name: "Error Handler",
    icon: "error",
    shortDescription: "Error management",
    inputs: ["any"],
    outputs: []
  },
  {
    name: "Logger",
    icon: "terminal",
    shortDescription: "Flow logging",
    inputs: ["any"],
    outputs: []
  }
];
Enter fullscreen mode Exit fullscreen mode

Creating the Node Palette

Next, we’ll create the flow-palette component — a vertical sidebar from which we can drag nodes into the canvas.

Each palette item is a div using the fExternalItem directive, which enables external drag-and-drop support with Foblex Flow.

Template (flow-palette.html)

@for (node of nodes; track node.name) {
  <div class="palette-node" fExternalItem [fData]="node" [fPreviewMatchSize]="true">
    <span class="icon">{{ node.icon }}</span>
    <span>{{ node.name }}</span>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Styles (flow-palette.scss)

:host {
  position: absolute;
  display: flex;
  flex-direction: column;
  width: fit-content;
  gap: 8px;
  left: 20px;
  top: 50%;
  transform: translateY(-50%);
}

::ng-deep .palette-node {
  display: flex;
  align-items: center;
  gap: 4px;
  border: 1px solid rgba(0, 0, 0, 0.06);
  border-radius: 4px;
  padding: 5px;
  background-color: white;

  .icon {
    font-family: 'Material Symbols Outlined';
    font-size: 20px;
    background: linear-gradient(to right, #4b91f1, #5c2ae8);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}
Enter fullscreen mode Exit fullscreen mode

We use ::ng-deep here because drag preview elements are appended to the document.body. Without it, styles wouldn’t apply to them.

Component (flow-palette.ts)

import { Component, ChangeDetectionStrategy } from '@angular/core';
import { FFlowModule } from '@foblex/flow';
import { DATA } from './data';

@Component({
  selector: 'flow-palette',
  imports: [FFlowModule],
  templateUrl: './flow-palette.html',
  styleUrl: './flow-palette.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
})
export class FlowPalette {
  protected nodes = DATA;
}
Enter fullscreen mode Exit fullscreen mode

Now we can add nodes from the palette directly into the flow by handling the following event:

<f-flow fDraggable (fCreateNode)="createNode($event)">
</f-flow>
Enter fullscreen mode Exit fullscreen mode

📦 Custom Nodes

Each node in the editor will be represented by a dedicated Angular component:

@Component({
  selector: 'node',
  ...
})
export class Node {
  public data = input.required<INode>();
}
Enter fullscreen mode Exit fullscreen mode

Node Template:

<div class="connectors inputs">
  @for (connector of data().inputs; track $index) {
    <connector
      fConnector
      fConnectorType="target"
      [fConnectorId]="data().id + ':input:' + connector"
      fConnectorConnectableSide="left"
    />
  }
</div>
<div class="connectors outputs">
  @for (connector of data().outputs; track $index) {
    <connector
      fConnector
      fConnectorType="source"
      [fConnectorId]="data().id + ':output:' + connector"
      fConnectorConnectableSide="right"
      [fConnectorMultiple]="false"
    />
  }
</div>

<div class="header">
  <span class="icon">{{ data().icon }}</span>
  <span>{{ data().name }}</span>
</div>

<div class="description">{{ data().shortDescription }}</div>
Enter fullscreen mode Exit fullscreen mode

Styles (node.scss):

:host {
  position: relative;
  background: #ffffff;
  border-radius: 6px;
  padding: 20px 24px;
  min-width: 180px;
  max-width: 180px;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.04);
  border: 1px solid rgba(0, 0, 0, 0.06);
  display: flex;
  flex-direction: column;
  gap: 8px;
  cursor: default;

  &:hover {
    box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08);
  }

  &.f-selected {
    outline: 2px solid #4b91f1;
    outline-offset: -2px;
    box-shadow: 0 0 0 4px rgba(75, 145, 241, 0.12);
  }

  .header {
    display: flex;
    align-items: center;
    gap: 10px;
    font-weight: 600;
    font-size: 15px;
  }

  .icon {
    font-family: 'Material Symbols Outlined';
    font-size: 20px;
    background: linear-gradient(to right, #4b91f1, #5c2ae8);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }

  .description {
    font-size: 13px;
    opacity: 0.7;
    white-space: nowrap;
    text-overflow: ellipsis;
    overflow: hidden;
    width: fit-content;
  }

  .connectors {
    position: absolute;
    display: flex;
    flex-direction: column;
    gap: 10px;
    z-index: 1;

    &.inputs {
      left: -6px;
      top: 8px;
      bottom: 8px;
      justify-content: start;
    }

    &.outputs {
      right: -6px;
      top: 8px;
      bottom: 8px;
      justify-content: end;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Each node has target and source fConnector directives — small circular components with hover animation and styles for the connected state. The legacy fNodeInput and fNodeOutput directives remain available in v19 but are deprecated.

🔘 Connector Component

While we could have kept the connector inline inside the node, we’re moving it to a standalone component for better style encapsulation and code separation.

Component (connector.ts):

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FFlowModule } from '@foblex/flow';

@Component({
  selector: 'connector',
  imports: [FFlowModule],
  template: '',
  styleUrl: './connector.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
})
export class Connector {}
Enter fullscreen mode Exit fullscreen mode

Styles (connector.scss):

:host {
  position: relative;
  width: 12px;
  height: 12px;
  min-height: 12px;
  border-radius: 50%;
  background-color: #fff;
  box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
  cursor: pointer;

  &.f-connector-connected {
    background-color: #4b91f1;
    box-shadow: 0 0 0 2px rgba(75, 145, 241, 0.2);
  }

  &:hover {
    transform: scale(1.4);
    box-shadow: 0 0 6px rgba(75, 145, 241, 0.4);
  }
}
Enter fullscreen mode Exit fullscreen mode

🚀 Dynamically Adding Nodes from the Palette

Up to this point, we’ve been rendering nodes manually — statically defined in the template. Now it’s time to take the next step: we’ll enable users to dynamically add nodes by dragging them from the side palette into the canvas.
Here’s how the flow works:

  1. The user drags a node from the palette.
  2. The fCreateNode event is triggered, giving us information about what type of node was added and where it should appear.
  3. We push the new node into a reactive array — and thanks to @for, it immediately appears on the canvas.

1. Storing nodes in a signal

We start by creating a signal to hold the list of all current nodes:

protected nodes = signal<INode[]>([]);
Enter fullscreen mode Exit fullscreen mode

Signals make the UI reactive — whenever the list updates, the DOM reflects the changes.

2. Handling the fCreateNode event

Now let’s define the method that will run when a new node is added:

protected createNode(event: FCreateNodeEvent<IStorageNode>): void {
  this.nodes.update((nodes) => {
    const newNode: INode = {
      ...event.data,
      id: generateGuid(),
      position: {
        x: event.externalItemRect.x,
        y: event.externalItemRect.y
      }
    };
    return [...nodes, newNode];
  });
}
Enter fullscreen mode Exit fullscreen mode

Here’s what’s happening:

  • We extract the node’s metadata from event.data.
  • We generate a unique id using generateGuid().
  • We take the dropped item’s top-left flow coordinates from event.externalItemRect.
  • We return a new list of nodes with the new one added.

3. Updating the template

Let’s replace the static nodes with a dynamic list rendered using @for:

<f-flow fDraggable
        (fCreateNode)="createNode($event)">
  <f-canvas>
    @for (node of nodes(); track node.id) {
      <node
        fNode
        fDragHandle
        [fNodePosition]="node.position"
        [fNodeId]="node.id"
        [data]="node">
      </node>
    }
  </f-canvas>
</f-flow>

<flow-palette />

Enter fullscreen mode Exit fullscreen mode

Key notes:

  • fDraggable enables node movement inside the canvas.
  • fNode links the element to Foblex Flow.
  • fDragHandle defines the draggable area inside the node.
  • [fNodePosition], [fNodeId], and [data] pass in the necessary parameters for rendering.

🔁 How the flow comes together

When a user drags a node from flow-palette, the fExternalItem directive fires an fCreateNode event. We handle that event in the createNode method, construct a new node object, and append it to the list. Thanks to @for, the UI instantly reflects the change.

🛠 Generating Unique IDs

To ensure each node has a unique identifier, we use a small utility function:

export function generateGuid(): string {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
Enter fullscreen mode Exit fullscreen mode

⚙️ Logic for Creating and Rendering Connections

Now that we can add nodes from the palette, it’s time to implement the next key step: connecting nodes together — and rendering those connections inside the canvas.

🧩 What <f-connection-for-create> Does

This component goes inside <f-canvas> and renders the temporary line during a connection gesture. The fDraggable directive on <f-flow> emits fCreateConnection when the gesture ends — either over a valid target connector or in empty space.

📌 Listening to fCreateConnection and Displaying Connections

Let’s update our Flow component template to handle the connection event and also render all created connections:

<f-flow fDraggable
        (fCreateNode)="createNode($event)"
        (fCreateConnection)="createConnection($event)">
  <f-canvas>
    <!-- Visual feedback during dragging -->
    <f-connection-for-create></f-connection-for-create>

    <!-- Render all established connections -->
    @for (connection of connections(); track connection.id) {
      <f-connection [fConnectionId]="connection.id"
                    [fSourceId]="connection.from"
                    [fTargetId]="connection.to" />
    }

    <!-- Render all nodes -->
    @for (node of nodes(); track node.id) {
      <node
        fNode
        fDragHandle
        [fNodePosition]="node.position"
        [fNodeId]="node.id"
        [data]="node">
      </node>
    }
  </f-canvas>
</f-flow>

<flow-palette />
Enter fullscreen mode Exit fullscreen mode

🔁 Handling the Connection Event

Here’s the method that handles fCreateConnection and stores the connection in a reactive signal:

protected connections = signal<IConnection[]>([]);

protected createConnection(event: FCreateConnectionEvent): void {
  if (!event.targetId) return;

  this.connections.update((connections) => [
    ...connections,
    {
      id: generateGuid(),
      from: event.sourceId,
      to: event.targetId
    }
  ]);
}
Enter fullscreen mode Exit fullscreen mode

Here’s what’s happening:

  • event.sourceId is the ID of the source connector the user dragged from.
  • event.targetId is the ID of the target connector the user dropped onto.
  • If targetId is missing (the user released the pointer in empty space), we ignore the event.

🖇 Putting It All Together

Now we have a complete cycle in place:

  1. Nodes are added from the palette.
  2. The user connects outputs to inputs.
  3. All valid connections are stored in connections() and automatically rendered in the flow via .

🧩 Conclusion

At this stage, our visual editor is starting to look much more like a real low-code platform. We’ve learned how to:

  • create custom Angular-based nodes;
  • render them inside the canvas;
  • drag items from a palette;
  • connect nodes visually using interactive connectors;
  • store and display the list of all connections.

This tutorial uses classic mode: the application owns its records and updates them from events. Since v19.1, provideFFlow(withFlowState()) can optionally maintain typed graph records, supported gestures, snapshots, and undo/redo; domain fields, validation, and persistence remain application concerns.

In the next part, we’ll focus on styling and connection behavior. We’ll add curved lines, color schemes, arrows, custom states, and interactions like hover, click, and delete. These enhancements will give your flow editor a polished and professional feel.

🧠 Series: Building AI Low-Code Platform in Angular
Part 1: Introduction to Foblex Flow
Part 2: Creating Your First Flow
✅ Part 3: Creating Custom Nodes and a Node Palette
🔄 Part 4: Styling and Handling Connections (soon)
🔄 Part 5: Creating a Node Configuration Panel (soon)
🔄 Part 6: Flow Execution and Playback Animation (soon)
🔄 Part 7: Minimap, Multi-Select and Smart Alignment (soon)

Top comments (0)