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
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
if (!IsLoading) HandleCopyNodeConnected(c);
@@ -73,9 +70,6 @@ namespace Nodify.Calculator
// Dynamic Split node: clear outputs when model disconnects
HandleSplitNodeDisconnected(c);
// Dynamic New Object node: clear inputs when model disconnects
HandleNewObjectNodeDisconnected(c);
// Dynamic Copy node: reset on disconnect
HandleCopyNodeDisconnected(c);
@@ -437,144 +431,6 @@ namespace Nodify.Calculator
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)
{
// Find the COPY node's input connector

View File

@@ -521,6 +521,25 @@
</nodify:Node>
</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}">
<nodify:Node Header="{Binding Title}"
Content="{Binding}"

View File

@@ -75,40 +75,69 @@ namespace Nodify.Calculator.Execution.Handlers
}
}
internal sealed class NewObjectHandler : INodeExecutionHandler
internal sealed class NewObjectExecHandler : INodeExecutionHandler
{
public bool CanHandle(OperationViewModel node, ExecutionContext ctx)
=> node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.NEW_OBJECT;
public void Execute(OperationViewModel node, ExecutionContext ctx)
{
ctx.Log($"Constructing new object: {node.Title}");
var jObj = new JObject();
ctx.Log($"[NEW OBJECT] Constructing: {node.Title}");
var root = new JObject();
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 ?? "";
var parenIdx = propName.IndexOf(" (", StringComparison.Ordinal);
if (parenIdx > 0) propName = propName.Substring(0, parenIdx);
// Extract property path from title: "Address.Street (string)" → "Address.Street"
var title = inp.Title ?? "";
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);
if (val != null)
{
try { jObj[propName] = JToken.Parse(val); }
catch { jObj[propName] = val; }
}
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
{
jObj[propName] = null;
var child = new JObject();
current[parts[i]] = child;
current = child;
}
}
var json = jObj.ToString(Formatting.None);
ctx.Outputs[node.NodeId] = json;
ctx.Variables[node.NodeId] = json;
ctx.Log($"Object constructed: {json}");
var leaf = parts[parts.Length - 1];
if (val != null)
{
try { current[leaf] = JToken.Parse(val); }
catch { current[leaf] = val; }
}
else
{
current[leaf] = null;
}
}
}

View File

@@ -55,7 +55,7 @@ namespace Nodify.Calculator
new AuthHandler(),
new KnotHandler(),
new DebugHandler(),
new NewObjectHandler(),
new NewObjectExecHandler(),
new AssertHandler(),
new ForEachHandler(),
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(),
ForEachOperationViewModel => _handlers.OfType<ForEachHandler>().FirstOrDefault(),
AssertOperationViewModel => _handlers.OfType<AssertHandler>().FirstOrDefault(),
NewObjectOperationViewModel => _handlers.OfType<NewObjectHandler>().FirstOrDefault(),
APIOperationViewModel => _handlers.OfType<ApiHandler>().FirstOrDefault(),
ExpandoOperationViewModel => _handlers.OfType<ExpandoHandler>().FirstOrDefault(),
CalculatorOperationViewModel => _handlers.OfType<NormalHandler>().FirstOrDefault(),

View File

@@ -82,40 +82,41 @@ namespace Nodify.Calculator.NodeHandlers
public OperationViewModel Create(OperationInfoViewModel info)
{
var op = new SystemOperationViewModel
{
Title = info.Title ?? "New Object",
SystemOperationType = 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.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" });
return op;
}
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)
var dynamicInputs = op.Input.Where(i => i.Shape != ConnectorShape.Triangle && i.Shape != ConnectorShape.Square).ToList();
foreach (var d in dynamicInputs) op.Input.Remove(d);
foreach (var ic in data.InputConnectors)
// Restore selected model (triggers RebuildInputs via property setter)
if (!string.IsNullOrEmpty(data.ClassName))
{
if (ic.Shape == "Triangle" || ic.Shape == "Square") continue;
op.Input.Add(NodeHandlerRegistry.DeserializeConnector(ic, true));
op.RefreshAvailableModels();
op.SelectedModel = data.ClassName;
}
// Restore output connector metadata (Square model output)
var savedModelOutput = data.OutputConnectors.FirstOrDefault(o => o.Shape == "Square");
var modelOutput = op.Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square);
if (savedModelOutput != null && modelOutput != null)
else
{
modelOutput.Title = savedModelOutput.Title;
modelOutput.DataType = savedModelOutput.DataType;
modelOutput.ConnectorColor = Color.FromArgb(savedModelOutput.ColorArgb);
// 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));
var savedModelOutput = data.OutputConnectors.FirstOrDefault(o => o.Shape == "Square");
var modelOutput = op.Output.FirstOrDefault(o => o.Shape == ConnectorShape.Square);
if (savedModelOutput != null && modelOutput != null)
{
modelOutput.Title = savedModelOutput.Title;
modelOutput.DataType = savedModelOutput.DataType;
modelOutput.ConnectorColor = Color.FromArgb(savedModelOutput.ColorArgb);
}
}
op.Title = data.Title;
@@ -126,6 +127,10 @@ namespace Nodify.Calculator.NodeHandlers
{
data.NodeType = "System";
data.SystemOp = nameof(SystemOperations.NEW_OBJECT);
if (vm is NewObjectOperationViewModel newObjVm)
{
data.ClassName = newObjVm.SelectedModel ?? "";
}
}
}