diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs
new file mode 100644
index 0000000..824e980
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs
@@ -0,0 +1,172 @@
+using JdeScoping.ConfigManager.Models;
+
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// ViewModel for editing a pipeline configuration.
+///
+public class PipelineFormViewModel : ViewModelBase
+{
+ private readonly PipelineModel _model;
+ private readonly Action _onChanged;
+
+ public PipelineFormViewModel(string name, PipelineModel model, Action onChanged)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ _model = model ?? throw new ArgumentNullException(nameof(model));
+ _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
+
+ // Initialize schedule view models
+ _model.Schedules.Mass ??= new ScheduleModel();
+ _model.Schedules.Daily ??= new ScheduleModel();
+ _model.Schedules.Hourly ??= new ScheduleModel();
+
+ MassSchedule = new ScheduleFormViewModel(_model.Schedules.Mass, _onChanged);
+ DailySchedule = new ScheduleFormViewModel(_model.Schedules.Daily, _onChanged);
+ HourlySchedule = new ScheduleFormViewModel(_model.Schedules.Hourly, _onChanged);
+ }
+
+ ///
+ /// Gets the pipeline name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets or sets the source connection name.
+ ///
+ public string Connection
+ {
+ get => _model.Source.Connection;
+ set
+ {
+ if (_model.Source.Connection != value)
+ {
+ _model.Source.Connection = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the source query.
+ ///
+ public string Query
+ {
+ get => _model.Source.Query;
+ set
+ {
+ if (_model.Source.Query != value)
+ {
+ _model.Source.Query = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the optional mass query.
+ ///
+ public string? MassQuery
+ {
+ get => _model.Source.MassQuery;
+ set
+ {
+ if (_model.Source.MassQuery != value)
+ {
+ _model.Source.MassQuery = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the destination table name.
+ ///
+ public string DestinationTable
+ {
+ get => _model.Destination.Table;
+ set
+ {
+ if (_model.Destination.Table != value)
+ {
+ _model.Destination.Table = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the match columns as newline-separated text.
+ ///
+ public string MatchColumnsText
+ {
+ get => string.Join("\n", _model.Destination.MatchColumns);
+ set
+ {
+ var columns = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (!_model.Destination.MatchColumns.SequenceEqual(columns))
+ {
+ _model.Destination.MatchColumns = columns;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the exclude from update columns as newline-separated text.
+ ///
+ public string ExcludeFromUpdateText
+ {
+ get => string.Join("\n", _model.Destination.ExcludeFromUpdate);
+ set
+ {
+ var columns = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (!_model.Destination.ExcludeFromUpdate.SequenceEqual(columns))
+ {
+ _model.Destination.ExcludeFromUpdate = columns;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the post scripts as newline-separated text.
+ ///
+ public string PostScriptsText
+ {
+ get => _model.PostScripts != null ? string.Join("\n", _model.PostScripts) : string.Empty;
+ set
+ {
+ var scripts = string.IsNullOrWhiteSpace(value)
+ ? null
+ : value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (_model.PostScripts?.SequenceEqual(scripts ?? []) != true)
+ {
+ _model.PostScripts = scripts;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets the mass schedule view model.
+ ///
+ public ScheduleFormViewModel MassSchedule { get; }
+
+ ///
+ /// Gets the daily schedule view model.
+ ///
+ public ScheduleFormViewModel DailySchedule { get; }
+
+ ///
+ /// Gets the hourly schedule view model.
+ ///
+ public ScheduleFormViewModel HourlySchedule { get; }
+}
diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs
new file mode 100644
index 0000000..a88e8d1
--- /dev/null
+++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs
@@ -0,0 +1,86 @@
+using JdeScoping.ConfigManager.Models;
+
+namespace JdeScoping.ConfigManager.ViewModels.Forms;
+
+///
+/// ViewModel for editing a schedule configuration.
+///
+public class ScheduleFormViewModel : ViewModelBase
+{
+ private readonly ScheduleModel _model;
+ private readonly Action _onChanged;
+
+ public ScheduleFormViewModel(ScheduleModel model, Action onChanged)
+ {
+ _model = model ?? throw new ArgumentNullException(nameof(model));
+ _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
+ }
+
+ ///
+ /// Gets or sets whether this schedule is enabled.
+ ///
+ public bool Enabled
+ {
+ get => _model.Enabled;
+ set
+ {
+ if (_model.Enabled != value)
+ {
+ _model.Enabled = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the interval in minutes.
+ ///
+ public int IntervalMinutes
+ {
+ get => _model.IntervalMinutes;
+ set
+ {
+ if (_model.IntervalMinutes != value)
+ {
+ _model.IntervalMinutes = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets whether to purge before sync.
+ ///
+ public bool PrePurge
+ {
+ get => _model.PrePurge;
+ set
+ {
+ if (_model.PrePurge != value)
+ {
+ _model.PrePurge = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets whether to reindex after sync.
+ ///
+ public bool ReIndex
+ {
+ get => _model.ReIndex;
+ set
+ {
+ if (_model.ReIndex != value)
+ {
+ _model.ReIndex = value;
+ OnPropertyChanged();
+ _onChanged();
+ }
+ }
+ }
+}
diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs
new file mode 100644
index 0000000..285fba9
--- /dev/null
+++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs
@@ -0,0 +1,287 @@
+using JdeScoping.ConfigManager.Models;
+using JdeScoping.ConfigManager.ViewModels.Forms;
+
+namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
+
+public class PipelineFormViewModelTests
+{
+ [Fact]
+ public void Constructor_InitializesFromModel()
+ {
+ // Arrange
+ var model = new PipelineModel
+ {
+ Source = new PipelineSource
+ {
+ Connection = "jde",
+ Query = "SELECT * FROM Test"
+ },
+ Destination = new PipelineDestination
+ {
+ Table = "TestTable",
+ MatchColumns = ["Id", "Name"]
+ }
+ };
+
+ // Act
+ var sut = new PipelineFormViewModel("TestPipeline", model, () => { });
+
+ // Assert
+ sut.Name.ShouldBe("TestPipeline");
+ sut.Connection.ShouldBe("jde");
+ sut.Query.ShouldBe("SELECT * FROM Test");
+ sut.DestinationTable.ShouldBe("TestTable");
+ sut.MatchColumnsText.ShouldBe("Id\nName");
+ }
+
+ [Fact]
+ public void PropertyChange_UpdatesModelAndInvokesOnChanged()
+ {
+ // Arrange
+ var model = new PipelineModel();
+ var changedInvoked = false;
+ var sut = new PipelineFormViewModel("Test", model, () => changedInvoked = true);
+
+ // Act
+ sut.Connection = "cms";
+
+ // Assert
+ model.Source.Connection.ShouldBe("cms");
+ changedInvoked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MatchColumnsText_SplitsIntoArray()
+ {
+ // Arrange
+ var model = new PipelineModel();
+ var sut = new PipelineFormViewModel("Test", model, () => { });
+
+ // Act
+ sut.MatchColumnsText = "Col1\nCol2\nCol3";
+
+ // Assert
+ model.Destination.MatchColumns.Length.ShouldBe(3);
+ model.Destination.MatchColumns[0].ShouldBe("Col1");
+ }
+
+ [Fact]
+ public void Schedules_AreInitialized()
+ {
+ // Arrange
+ var model = new PipelineModel
+ {
+ Schedules = new PipelineSchedules
+ {
+ Mass = new ScheduleModel { Enabled = true, IntervalMinutes = 10080 }
+ }
+ };
+
+ // Act
+ var sut = new PipelineFormViewModel("Test", model, () => { });
+
+ // Assert
+ sut.MassSchedule.ShouldNotBeNull();
+ sut.MassSchedule.Enabled.ShouldBeTrue();
+ sut.MassSchedule.IntervalMinutes.ShouldBe(10080);
+ }
+
+ [Fact]
+ public void NullSchedules_AreInitializedToDefault()
+ {
+ // Arrange
+ var model = new PipelineModel
+ {
+ Schedules = new PipelineSchedules
+ {
+ Mass = null,
+ Daily = null,
+ Hourly = null
+ }
+ };
+
+ // Act
+ var sut = new PipelineFormViewModel("Test", model, () => { });
+
+ // Assert
+ sut.MassSchedule.ShouldNotBeNull();
+ sut.DailySchedule.ShouldNotBeNull();
+ sut.HourlySchedule.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void ExcludeFromUpdateText_JoinsAndSplits()
+ {
+ // Arrange
+ var model = new PipelineModel
+ {
+ Destination = new PipelineDestination
+ {
+ ExcludeFromUpdate = ["CreatedDate", "ModifiedDate"]
+ }
+ };
+ var sut = new PipelineFormViewModel("Test", model, () => { });
+
+ // Assert - verify getter joins correctly
+ sut.ExcludeFromUpdateText.ShouldBe("CreatedDate\nModifiedDate");
+
+ // Act - verify setter splits correctly
+ sut.ExcludeFromUpdateText = "Col1\nCol2";
+ model.Destination.ExcludeFromUpdate.Length.ShouldBe(2);
+ model.Destination.ExcludeFromUpdate[0].ShouldBe("Col1");
+ }
+
+ [Fact]
+ public void PostScriptsText_HandlesNullable()
+ {
+ // Arrange
+ var model = new PipelineModel { PostScripts = null };
+ var sut = new PipelineFormViewModel("Test", model, () => { });
+
+ // Assert - null should return empty string
+ sut.PostScriptsText.ShouldBe(string.Empty);
+
+ // Act - set some scripts
+ sut.PostScriptsText = "script1.sql\nscript2.sql";
+ model.PostScripts.ShouldNotBeNull();
+ model.PostScripts!.Length.ShouldBe(2);
+
+ // Act - clear scripts by setting empty
+ sut.PostScriptsText = "";
+ model.PostScripts.ShouldBeNull();
+ }
+
+ [Fact]
+ public void MassQuery_Property_ReadsAndWrites()
+ {
+ // Arrange
+ var model = new PipelineModel
+ {
+ Source = new PipelineSource
+ {
+ MassQuery = "SELECT * FROM Test WHERE All = 1"
+ }
+ };
+ var changedInvoked = false;
+ var sut = new PipelineFormViewModel("Test", model, () => changedInvoked = true);
+
+ // Assert - verify getter
+ sut.MassQuery.ShouldBe("SELECT * FROM Test WHERE All = 1");
+
+ // Act - verify setter
+ sut.MassQuery = "SELECT * FROM NewTable";
+ model.Source.MassQuery.ShouldBe("SELECT * FROM NewTable");
+ changedInvoked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void PropertyChange_RaisesPropertyChanged()
+ {
+ // Arrange
+ var model = new PipelineModel();
+ var sut = new PipelineFormViewModel("Test", model, () => { });
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(PipelineFormViewModel.Query))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.Query = "SELECT * FROM NewQuery";
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ScheduleChange_InvokesOnChanged()
+ {
+ // Arrange
+ var model = new PipelineModel();
+ var changedInvoked = false;
+ var sut = new PipelineFormViewModel("Test", model, () => changedInvoked = true);
+
+ // Act - change schedule property (Enabled defaults to true, so set to false)
+ sut.MassSchedule.Enabled = false;
+
+ // Assert
+ changedInvoked.ShouldBeTrue();
+ }
+}
+
+public class ScheduleFormViewModelTests
+{
+ [Fact]
+ public void Constructor_InitializesFromModel()
+ {
+ // Arrange
+ var model = new ScheduleModel
+ {
+ Enabled = true,
+ IntervalMinutes = 1440,
+ PrePurge = true,
+ ReIndex = false
+ };
+
+ // Act
+ var sut = new ScheduleFormViewModel(model, () => { });
+
+ // Assert
+ sut.Enabled.ShouldBeTrue();
+ sut.IntervalMinutes.ShouldBe(1440);
+ sut.PrePurge.ShouldBeTrue();
+ sut.ReIndex.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void PropertyChange_UpdatesModelAndInvokesOnChanged()
+ {
+ // Arrange
+ var model = new ScheduleModel();
+ var changedInvoked = false;
+ var sut = new ScheduleFormViewModel(model, () => changedInvoked = true);
+
+ // Act
+ sut.IntervalMinutes = 120;
+
+ // Assert
+ model.IntervalMinutes.ShouldBe(120);
+ changedInvoked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void PropertyChange_RaisesPropertyChanged()
+ {
+ // Arrange
+ var model = new ScheduleModel();
+ var sut = new ScheduleFormViewModel(model, () => { });
+ var propertyChangedRaised = false;
+ sut.PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == nameof(ScheduleFormViewModel.PrePurge))
+ propertyChangedRaised = true;
+ };
+
+ // Act
+ sut.PrePurge = true;
+
+ // Assert
+ propertyChangedRaised.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SameValue_DoesNotInvokeOnChanged()
+ {
+ // Arrange
+ var model = new ScheduleModel { Enabled = true };
+ var changedInvoked = false;
+ var sut = new ScheduleFormViewModel(model, () => changedInvoked = true);
+
+ // Act - set same value
+ sut.Enabled = true;
+
+ // Assert
+ changedInvoked.ShouldBeFalse();
+ }
+}