Fixed new object request node and allowed multiple classes with dot notation
All checks were successful
Build / build (push) Successful in 39s

This commit is contained in:
Ankitkumar Satapara
2026-04-29 11:50:20 +05:30
parent 77cd472f73
commit a235558db1
7 changed files with 270 additions and 184 deletions

View File

@@ -32,9 +32,6 @@ namespace Nodify.Calculator
// Dynamic Split node: populate outputs when a model connects to the Square input // Dynamic Split node: populate outputs when a model connects to the Square input
if (!IsLoading) HandleSplitNodeConnected(c); if (!IsLoading) HandleSplitNodeConnected(c);
// Dynamic New Object node: populate inputs when a model connects
if (!IsLoading) HandleNewObjectNodeConnected(c);
// Dynamic Copy node: adapt connectors when something connects // Dynamic Copy node: adapt connectors when something connects
if (!IsLoading) HandleCopyNodeConnected(c); if (!IsLoading) HandleCopyNodeConnected(c);
@@ -73,9 +70,6 @@ namespace Nodify.Calculator
// Dynamic Split node: clear outputs when model disconnects // Dynamic Split node: clear outputs when model disconnects
HandleSplitNodeDisconnected(c); HandleSplitNodeDisconnected(c);
// Dynamic New Object node: clear inputs when model disconnects
HandleNewObjectNodeDisconnected(c);
// Dynamic Copy node: reset on disconnect // Dynamic Copy node: reset on disconnect
HandleCopyNodeDisconnected(c); HandleCopyNodeDisconnected(c);
@@ -437,144 +431,6 @@ namespace Nodify.Calculator
sysVm.Title = "Split"; sysVm.Title = "Split";
} }
private void HandleNewObjectNodeConnected(ConnectionViewModel c)
{
// Find the New Object node's Square input connector
var squareInput = c.Input.Shape == ConnectorShape.Square ? c.Input : null;
if (squareInput == null) squareInput = c.Output.Shape == ConnectorShape.Square ? c.Output : null;
if (squareInput == null) return;
var newObjOp = squareInput.Operation;
if (newObjOp is not SystemOperationViewModel sysVm || sysVm.SystemOperationType != SystemOperations.NEW_OBJECT)
return;
// The other end is the source — find which model class it comes from
var sourceConnector = (c.Input == squareInput) ? c.Output : c.Input;
var sourceOp = sourceConnector.Operation;
string className = null;
if (sourceOp is SystemOperationViewModel srcSys)
{
var title = srcSys.Title ?? "";
if (title.StartsWith("GET ")) className = title.Substring(4).Trim();
else if (title.StartsWith("SET ")) className = title.Split(' ').ElementAtOrDefault(1);
}
else if (sourceOp is APIOperationViewModel apiVm)
{
className = apiVm.ResponseModelClassName;
if (!string.IsNullOrEmpty(className) && className.StartsWith("List<") && className.EndsWith(">"))
className = className.Substring(5, className.Length - 6);
}
if (string.IsNullOrEmpty(className) && !string.IsNullOrEmpty(sourceConnector.DataType))
{
var dt = sourceConnector.DataType;
if (dt.StartsWith("List<") && dt.EndsWith(">"))
dt = dt.Substring(5, dt.Length - 6);
if (!new[] { "string", "int", "double", "bool", "object" }.Contains(dt.ToLower()))
className = dt;
}
if (string.IsNullOrEmpty(className)) return;
var customModelDir = Path.Combine(ProjectManager.ProjectDirectory, "CustomModels");
var filePath = Path.Combine(customModelDir, $"{className}.cs");
if (!File.Exists(filePath)) return;
var fileContent = File.ReadAllText(filePath);
var properties = OperationFactory.GetPropertiesFromClassPublic(fileContent);
if (properties == null || properties.Count == 0) return;
// Remove existing dynamic inputs (non-triangle, non-square)
var toRemove = newObjOp.Input.Where(i => i.Shape != ConnectorShape.Triangle && i.Shape != ConnectorShape.Square).ToList();
toRemove.ForEach(i =>
{
DisconnectConnector(i);
newObjOp.Input.Remove(i);
});
// Add property inputs
foreach (var prop in properties)
{
var propType = prop.Type;
bool isList = propType.StartsWith("List<") && propType.EndsWith(">");
var innerType = isList ? propType.Substring(5, propType.Length - 6) : propType;
var primTypes = new[] { "string", "int", "double", "bool", "float", "decimal", "long", "object", "datetime" };
bool isModel = !primTypes.Contains(innerType.ToLower())
&& File.Exists(Path.Combine(customModelDir, $"{innerType}.cs"));
ConnectorShape shape;
System.Drawing.Color color;
if (isList)
{
shape = ConnectorShape.Grid;
color = System.Drawing.Color.MediumSpringGreen;
}
else if (isModel)
{
shape = ConnectorShape.Square;
color = System.Drawing.Color.MediumPurple;
}
else
{
shape = ConnectorShape.Circle;
color = ConnectorViewModel.GetColorForType(propType);
}
newObjOp.Input.Add(new ConnectorViewModel
{
Title = $"{prop.Name} ({propType})",
Shape = shape,
ConnectorColor = color,
DataType = isModel || isList ? propType : propType.ToLower()
});
}
// Update the output connector to reflect the model type
var modelOutput = newObjOp.Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square);
if (modelOutput != null)
{
modelOutput.Title = className;
modelOutput.DataType = className;
}
sysVm.Title = $"New {className}";
}
private void HandleNewObjectNodeDisconnected(ConnectionViewModel c)
{
var squareInput = c.Input.Shape == ConnectorShape.Square ? c.Input : null;
if (squareInput == null) squareInput = c.Output.Shape == ConnectorShape.Square ? c.Output : null;
if (squareInput == null) return;
var newObjOp = squareInput.Operation;
if (newObjOp is not SystemOperationViewModel sysVm || sysVm.SystemOperationType != SystemOperations.NEW_OBJECT)
return;
var stillConnected = Connections.Any(con => con.Input == squareInput || con.Output == squareInput);
if (stillConnected) return;
// Remove dynamic property inputs (keep triangle and square connectors)
var toRemove = newObjOp.Input.Where(i => i.Shape != ConnectorShape.Triangle && i.Shape != ConnectorShape.Square).ToList();
toRemove.ForEach(i =>
{
DisconnectConnector(i);
newObjOp.Input.Remove(i);
});
// Reset output
var modelOutput = newObjOp.Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square);
if (modelOutput != null)
{
modelOutput.Title = "Object";
modelOutput.DataType = "object";
}
sysVm.Title = "New Object";
}
private void HandleCopyNodeConnected(ConnectionViewModel c) private void HandleCopyNodeConnected(ConnectionViewModel c)
{ {
// Find the COPY node's input connector // Find the COPY node's input connector

View File

@@ -521,6 +521,25 @@
</nodify:Node> </nodify:Node>
</DataTemplate> </DataTemplate>
<DataTemplate DataType="{x:Type local:NewObjectOperationViewModel}">
<nodify:Node Header="{Binding Title}"
Content="{Binding}"
Input="{Binding Input}"
Output="{Binding Output}">
<nodify:Node.ContentTemplate>
<DataTemplate DataType="{x:Type local:NewObjectOperationViewModel}">
<StackPanel>
<TextBlock Text="Model" FontSize="10" Foreground="Gray" Margin="0,0,0,2" />
<ComboBox ItemsSource="{Binding AvailableModels}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
MinWidth="140"
Margin="0,0,0,2" />
</StackPanel>
</DataTemplate>
</nodify:Node.ContentTemplate>
</nodify:Node>
</DataTemplate>
<DataTemplate DataType="{x:Type local:StringConcatOperationViewModel}"> <DataTemplate DataType="{x:Type local:StringConcatOperationViewModel}">
<nodify:Node Header="{Binding Title}" <nodify:Node Header="{Binding Title}"
Content="{Binding}" Content="{Binding}"

View File

@@ -75,41 +75,70 @@ namespace Nodify.Calculator.Execution.Handlers
} }
} }
internal sealed class NewObjectHandler : INodeExecutionHandler internal sealed class NewObjectExecHandler : INodeExecutionHandler
{ {
public bool CanHandle(OperationViewModel node, ExecutionContext ctx) public bool CanHandle(OperationViewModel node, ExecutionContext ctx)
=> node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.NEW_OBJECT; => node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.NEW_OBJECT;
public void Execute(OperationViewModel node, ExecutionContext ctx) public void Execute(OperationViewModel node, ExecutionContext ctx)
{ {
ctx.Log($"Constructing new object: {node.Title}"); ctx.Log($"[NEW OBJECT] Constructing: {node.Title}");
var jObj = new JObject(); var root = new JObject();
foreach (var inp in node.Input) foreach (var inp in node.Input)
{ {
if (inp.Shape == ConnectorShape.Triangle || inp.Shape == ConnectorShape.Square) continue; if (inp.Shape == ConnectorShape.Triangle) continue;
var propName = inp.Title ?? ""; // Extract property path from title: "Address.Street (string)" → "Address.Street"
var parenIdx = propName.IndexOf(" (", StringComparison.Ordinal); var title = inp.Title ?? "";
if (parenIdx > 0) propName = propName.Substring(0, parenIdx); var parenIdx = title.IndexOf(" (", StringComparison.Ordinal);
var propPath = parenIdx > 0 ? title.Substring(0, parenIdx).Trim() : title.Trim();
if (string.IsNullOrEmpty(propPath)) continue;
var val = ctx.ReadInput(inp); var val = ctx.ReadInput(inp);
ctx.Log($"[NEW OBJECT] {propPath} = {(val?.Length > 80 ? val.Substring(0, 80) + "..." : val ?? "null")}");
SetNestedValue(root, propPath, val);
}
var json = root.ToString(Formatting.None);
ctx.Outputs[node.NodeId] = json;
ctx.Variables[node.NodeId] = json;
ctx.Log($"[NEW OBJECT] Result: {(json.Length > 200 ? json.Substring(0, 200) + "..." : json)}");
}
/// <summary>
/// Sets a value at a dot-notation path in a JObject, creating intermediate objects as needed.
/// e.g., "Address.Street" with value "Main St" → { "Address": { "Street": "Main St" } }
/// </summary>
private static void SetNestedValue(JObject root, string path, string val)
{
var parts = path.Split('.');
var current = root;
for (int i = 0; i < parts.Length - 1; i++)
{
if (current[parts[i]] is JObject existing)
current = existing;
else
{
var child = new JObject();
current[parts[i]] = child;
current = child;
}
}
var leaf = parts[parts.Length - 1];
if (val != null) if (val != null)
{ {
try { jObj[propName] = JToken.Parse(val); } try { current[leaf] = JToken.Parse(val); }
catch { jObj[propName] = val; } catch { current[leaf] = val; }
} }
else else
{ {
jObj[propName] = null; current[leaf] = null;
} }
} }
var json = jObj.ToString(Formatting.None);
ctx.Outputs[node.NodeId] = json;
ctx.Variables[node.NodeId] = json;
ctx.Log($"Object constructed: {json}");
}
} }
internal sealed class AssertHandler : INodeExecutionHandler internal sealed class AssertHandler : INodeExecutionHandler

View File

@@ -55,7 +55,7 @@ namespace Nodify.Calculator
new AuthHandler(), new AuthHandler(),
new KnotHandler(), new KnotHandler(),
new DebugHandler(), new DebugHandler(),
new NewObjectHandler(), new NewObjectExecHandler(),
new AssertHandler(), new AssertHandler(),
new ForEachHandler(), new ForEachHandler(),
new Execution.Handlers.DeserializeHandler(), new Execution.Handlers.DeserializeHandler(),

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.IO;
using System.Linq;
namespace Nodify.Calculator
{
public class NewObjectOperationViewModel : SystemOperationViewModel
{
private string _selectedModel = "";
public ObservableCollection<string> AvailableModels { get; } = new ObservableCollection<string>();
public string SelectedModel
{
get => _selectedModel;
set
{
if (SetProperty(ref _selectedModel, value))
{
RebuildInputs();
}
}
}
public NewObjectOperationViewModel()
{
Title = "New Object";
SystemOperationType = SystemOperations.NEW_OBJECT;
RefreshAvailableModels();
}
public void RefreshAvailableModels()
{
var current = _selectedModel;
AvailableModels.Clear();
AvailableModels.Add("");
var customModelDir = Path.Combine(ProjectManager.ProjectDirectory, "CustomModels");
if (Directory.Exists(customModelDir))
{
var modelFiles = Directory.GetFiles(customModelDir, "*.cs");
foreach (var file in modelFiles)
{
AvailableModels.Add(Path.GetFileNameWithoutExtension(file));
}
}
if (!string.IsNullOrEmpty(current) && AvailableModels.Contains(current))
_selectedModel = current;
else
_selectedModel = "";
}
public void RebuildInputs()
{
// Keep only Triangle (flow) connectors
var toRemove = Input.Where(i => i.Shape != ConnectorShape.Triangle).ToList();
foreach (var c in toRemove) Input.Remove(c);
// Update output connector
var modelOutput = Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square);
if (modelOutput != null)
{
modelOutput.Title = string.IsNullOrEmpty(_selectedModel) ? "Object" : _selectedModel;
modelOutput.DataType = string.IsNullOrEmpty(_selectedModel) ? "object" : _selectedModel;
}
if (string.IsNullOrEmpty(_selectedModel))
{
Title = "New Object";
return;
}
Title = $"New {_selectedModel}";
// Recursively flatten model properties with dot notation
var customModelDir = Path.Combine(ProjectManager.ProjectDirectory, "CustomModels");
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var flatProps = new List<System.ValueTuple<string, string, bool, bool>>();
FlattenModel(_selectedModel, "", customModelDir, visited, flatProps);
foreach (var entry in flatProps)
{
var path = entry.Item1;
var type = entry.Item2;
var isModel = entry.Item3;
var isList = entry.Item4;
ConnectorShape shape;
Color color;
if (isList)
{
shape = ConnectorShape.Grid;
color = NodeColors.List;
}
else if (isModel)
{
// For nested model types we still use Circle with a label since
// the user will type/connect primitives via dot notation
shape = ConnectorShape.Square;
color = NodeColors.Model;
}
else
{
shape = ConnectorShape.Circle;
color = ConnectorViewModel.GetColorForType(type);
}
Input.Add(new ConnectorViewModel
{
Title = $"{path} ({type})",
Shape = shape,
ConnectorColor = color,
DataType = isModel || isList ? type : type.ToLower()
});
}
}
/// <summary>
/// Recursively flattens a model's properties. Nested model properties are expanded
/// with dot notation (e.g., "Address.Street (string)"). Primitives and lists are leaves.
/// </summary>
private static void FlattenModel(string modelName, string prefix, string modelDir,
HashSet<string> visited, List<System.ValueTuple<string, string, bool, bool>> result)
{
if (visited.Contains(modelName)) return; // prevent circular references
visited.Add(modelName);
var filePath = Path.Combine(modelDir, $"{modelName}.cs");
if (!File.Exists(filePath)) return;
var content = File.ReadAllText(filePath);
var properties = OperationFactory.GetPropertiesFromClassPublic(content);
if (properties == null) return;
var primTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"string", "int", "double", "bool", "float", "decimal", "long",
"object", "datetime", "byte", "short", "char", "guid"
};
foreach (var prop in properties)
{
var propType = prop.Type;
var fullPath = string.IsNullOrEmpty(prefix) ? prop.Name : $"{prefix}.{prop.Name}";
bool isList = propType.StartsWith("List<") && propType.EndsWith(">");
var innerType = isList ? propType.Substring(5, propType.Length - 6) : propType;
bool isNestedModel = !primTypes.Contains(innerType)
&& File.Exists(Path.Combine(modelDir, $"{innerType}.cs"));
if (isList)
{
// List properties are always leaf inputs (Grid connector)
result.Add(new System.ValueTuple<string, string, bool, bool>(fullPath, propType, false, true));
}
else if (isNestedModel)
{
// Recurse into nested model with dot notation prefix
FlattenModel(innerType, fullPath, modelDir, visited, result);
}
else
{
// Primitive leaf
result.Add(new System.ValueTuple<string, string, bool, bool>(fullPath, propType, false, false));
}
}
visited.Remove(modelName); // allow same model in different branches
}
}
}

View File

@@ -97,6 +97,7 @@ namespace Nodify.Calculator.NodeHandlers
TakeOperationViewModel => _handlers.OfType<TakeHandler>().FirstOrDefault(), TakeOperationViewModel => _handlers.OfType<TakeHandler>().FirstOrDefault(),
ForEachOperationViewModel => _handlers.OfType<ForEachHandler>().FirstOrDefault(), ForEachOperationViewModel => _handlers.OfType<ForEachHandler>().FirstOrDefault(),
AssertOperationViewModel => _handlers.OfType<AssertHandler>().FirstOrDefault(), AssertOperationViewModel => _handlers.OfType<AssertHandler>().FirstOrDefault(),
NewObjectOperationViewModel => _handlers.OfType<NewObjectHandler>().FirstOrDefault(),
APIOperationViewModel => _handlers.OfType<ApiHandler>().FirstOrDefault(), APIOperationViewModel => _handlers.OfType<ApiHandler>().FirstOrDefault(),
ExpandoOperationViewModel => _handlers.OfType<ExpandoHandler>().FirstOrDefault(), ExpandoOperationViewModel => _handlers.OfType<ExpandoHandler>().FirstOrDefault(),
CalculatorOperationViewModel => _handlers.OfType<NormalHandler>().FirstOrDefault(), CalculatorOperationViewModel => _handlers.OfType<NormalHandler>().FirstOrDefault(),

View File

@@ -82,33 +82,33 @@ namespace Nodify.Calculator.NodeHandlers
public OperationViewModel Create(OperationInfoViewModel info) public OperationViewModel Create(OperationInfoViewModel info)
{ {
var op = new SystemOperationViewModel var op = new NewObjectOperationViewModel();
{
Title = info.Title ?? "New Object",
SystemOperationType = SystemOperations.NEW_OBJECT
};
op.Input.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Triangle }); op.Input.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Triangle });
op.Output.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Triangle, IsInput = false }); op.Output.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Triangle, IsInput = false });
op.Input.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Square, ConnectorColor = Color.MediumPurple });
op.Output.Add(new ConnectorViewModel { Title = "Object", IsInput = false, Shape = ConnectorShape.Square, ConnectorColor = Color.MediumPurple, DataType = "object" }); op.Output.Add(new ConnectorViewModel { Title = "Object", IsInput = false, Shape = ConnectorShape.Square, ConnectorColor = Color.MediumPurple, DataType = "object" });
return op; return op;
} }
public OperationViewModel Restore(NodeData data) public OperationViewModel Restore(NodeData data)
{ {
var op = (SystemOperationViewModel)Create(new OperationInfoViewModel { Title = data.Title, Type = OperationType.System, sysOp = SystemOperations.NEW_OBJECT }); var op = new NewObjectOperationViewModel();
op.Input.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Triangle });
op.Output.Add(new ConnectorViewModel { Title = "", Shape = ConnectorShape.Triangle, IsInput = false });
op.Output.Add(new ConnectorViewModel { Title = "Object", IsInput = false, Shape = ConnectorShape.Square, ConnectorColor = Color.MediumPurple, DataType = "object" });
// Restore dynamic property inputs (keep triangle + square, replace the rest) // Restore selected model (triggers RebuildInputs via property setter)
var dynamicInputs = op.Input.Where(i => i.Shape != ConnectorShape.Triangle && i.Shape != ConnectorShape.Square).ToList(); if (!string.IsNullOrEmpty(data.ClassName))
foreach (var d in dynamicInputs) op.Input.Remove(d);
foreach (var ic in data.InputConnectors)
{ {
if (ic.Shape == "Triangle" || ic.Shape == "Square") continue; op.RefreshAvailableModels();
op.Input.Add(NodeHandlerRegistry.DeserializeConnector(ic, true)); op.SelectedModel = data.ClassName;
} }
else
{
// Legacy: restore from saved input connectors
var dynamicInputs = data.InputConnectors.Where(ic => ic.Shape != "Triangle").ToList();
foreach (var ic in dynamicInputs)
op.Input.Add(NodeHandlerRegistry.DeserializeConnector(ic, true));
// Restore output connector metadata (Square model output)
var savedModelOutput = data.OutputConnectors.FirstOrDefault(o => o.Shape == "Square"); var savedModelOutput = data.OutputConnectors.FirstOrDefault(o => o.Shape == "Square");
var modelOutput = op.Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square); var modelOutput = op.Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square);
if (savedModelOutput != null && modelOutput != null) if (savedModelOutput != null && modelOutput != null)
@@ -117,6 +117,7 @@ namespace Nodify.Calculator.NodeHandlers
modelOutput.DataType = savedModelOutput.DataType; modelOutput.DataType = savedModelOutput.DataType;
modelOutput.ConnectorColor = Color.FromArgb(savedModelOutput.ColorArgb); modelOutput.ConnectorColor = Color.FromArgb(savedModelOutput.ColorArgb);
} }
}
op.Title = data.Title; op.Title = data.Title;
return op; return op;
@@ -126,6 +127,10 @@ namespace Nodify.Calculator.NodeHandlers
{ {
data.NodeType = "System"; data.NodeType = "System";
data.SystemOp = nameof(SystemOperations.NEW_OBJECT); data.SystemOp = nameof(SystemOperations.NEW_OBJECT);
if (vm is NewObjectOperationViewModel newObjVm)
{
data.ClassName = newObjVm.SelectedModel ?? "";
}
} }
} }