# ConfigManager Implementation Plan: Phases 7-9 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Complete the ConfigManager UI by implementing form ViewModels, dialogs, and polish features. **Architecture:** Each settings section gets a dedicated FormViewModel that wraps the model and provides two-way binding with change tracking. Dialogs follow the AvaloniaDialogService pattern from SecureStoreManager. **Tech Stack:** Avalonia 11.2, MVVM, TDD with xUnit/NSubstitute/Shouldly **Prerequisites:** Phases 1-6 complete (13 tasks, all services and basic UI shell implemented) --- ## Phase 7: Form ViewModels ### Task 14: Create DataSyncFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/DataSyncFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/DataSyncFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.ViewModels.Forms; namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; public class DataSyncFormViewModelTests { [Fact] public void Constructor_InitializesFromModel() { // Arrange var model = new DataSyncSection { Enabled = true, MaxDegreeOfParallelism = 8, BatchSize = 25000 }; // Act var sut = new DataSyncFormViewModel(model, () => { }); // Assert sut.Enabled.ShouldBeTrue(); sut.MaxDegreeOfParallelism.ShouldBe(8); sut.BatchSize.ShouldBe(25000); } [Fact] public void PropertyChange_UpdatesModel() { // Arrange var model = new DataSyncSection { MaxDegreeOfParallelism = 4 }; var sut = new DataSyncFormViewModel(model, () => { }); // Act sut.MaxDegreeOfParallelism = 16; // Assert model.MaxDegreeOfParallelism.ShouldBe(16); } [Fact] public void PropertyChange_InvokesOnChanged() { // Arrange var model = new DataSyncSection(); var changedInvoked = false; var sut = new DataSyncFormViewModel(model, () => changedInvoked = true); // Act sut.BatchSize = 10000; // Assert changedInvoked.ShouldBeTrue(); } [Fact] public void PropertyChange_RaisesPropertyChanged() { // Arrange var model = new DataSyncSection(); var sut = new DataSyncFormViewModel(model, () => { }); var propertyChangedRaised = false; sut.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(DataSyncFormViewModel.LookbackMultiplier)) propertyChangedRaised = true; }; // Act sut.LookbackMultiplier = 2.5; // Assert propertyChangedRaised.ShouldBeTrue(); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "DataSyncFormViewModelTests"` Expected: FAIL with "DataSyncFormViewModel not found" **Step 3: Create DataSyncFormViewModel** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.ViewModels.Forms; /// /// ViewModel for editing DataSync configuration section. /// public class DataSyncFormViewModel : ViewModelBase { private readonly DataSyncSection _model; private readonly Action _onChanged; public DataSyncFormViewModel(DataSyncSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); } /// /// Gets or sets whether data synchronization is enabled. /// public bool Enabled { get => _model.Enabled; set { if (_model.Enabled != value) { _model.Enabled = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the check interval in minutes. /// public int CheckIntervalMinutes { get => (int)_model.CheckInterval.TotalMinutes; set { var newValue = TimeSpan.FromMinutes(value); if (_model.CheckInterval != newValue) { _model.CheckInterval = newValue; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the maximum degree of parallelism. /// public int MaxDegreeOfParallelism { get => _model.MaxDegreeOfParallelism; set { if (_model.MaxDegreeOfParallelism != value) { _model.MaxDegreeOfParallelism = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the batch size for sync operations. /// public int BatchSize { get => _model.BatchSize; set { if (_model.BatchSize != value) { _model.BatchSize = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the bulk copy batch size. /// public int BulkCopyBatchSize { get => _model.BulkCopyBatchSize; set { if (_model.BulkCopyBatchSize != value) { _model.BulkCopyBatchSize = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the lookback multiplier. /// public double LookbackMultiplier { get => _model.LookbackMultiplier; set { if (Math.Abs(_model.LookbackMultiplier - value) > 0.001) { _model.LookbackMultiplier = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the purge retention days. /// public int PurgeRetentionDays { get => _model.PurgeRetentionDays; set { if (_model.PurgeRetentionDays != value) { _model.PurgeRetentionDays = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the sync timeout in seconds. /// public int SyncTimeoutSeconds { get => _model.SyncTimeoutSeconds; set { if (_model.SyncTimeoutSeconds != value) { _model.SyncTimeoutSeconds = value; OnPropertyChanged(); _onChanged(); } } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "DataSyncFormViewModelTests"` Expected: PASS **Step 5: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add DataSyncFormViewModel" ``` --- ### Task 15: Create DataAccessFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/DataAccessFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/DataAccessFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.ViewModels.Forms; namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; public class DataAccessFormViewModelTests { [Fact] public void Constructor_InitializesFromModel() { // Arrange var model = new DataAccessSection { DefaultTimeoutSeconds = 60, ProductionSchema = "dbo", EnableDetailedLogging = true }; // Act var sut = new DataAccessFormViewModel(model, () => { }); // Assert sut.DefaultTimeoutSeconds.ShouldBe(60); sut.ProductionSchema.ShouldBe("dbo"); sut.EnableDetailedLogging.ShouldBeTrue(); } [Fact] public void PropertyChange_UpdatesModelAndInvokesOnChanged() { // Arrange var model = new DataAccessSection(); var changedInvoked = false; var sut = new DataAccessFormViewModel(model, () => changedInvoked = true); // Act sut.ArchiveSchema = "hist"; // Assert model.ArchiveSchema.ShouldBe("hist"); changedInvoked.ShouldBeTrue(); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "DataAccessFormViewModelTests"` Expected: FAIL with "DataAccessFormViewModel not found" **Step 3: Create DataAccessFormViewModel** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.ViewModels.Forms; /// /// ViewModel for editing DataAccess configuration section. /// public class DataAccessFormViewModel : ViewModelBase { private readonly DataAccessSection _model; private readonly Action _onChanged; public DataAccessFormViewModel(DataAccessSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); } /// /// Gets or sets the default query timeout in seconds. /// public int DefaultTimeoutSeconds { get => _model.DefaultTimeoutSeconds; set { if (_model.DefaultTimeoutSeconds != value) { _model.DefaultTimeoutSeconds = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the lot usage query timeout in seconds. /// public int LotUsageTimeoutSeconds { get => _model.LotUsageTimeoutSeconds; set { if (_model.LotUsageTimeoutSeconds != value) { _model.LotUsageTimeoutSeconds = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the MIS data query timeout in seconds. /// public int MisDataTimeoutSeconds { get => _model.MisDataTimeoutSeconds; set { if (_model.MisDataTimeoutSeconds != value) { _model.MisDataTimeoutSeconds = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the production schema name. /// public string ProductionSchema { get => _model.ProductionSchema; set { if (_model.ProductionSchema != value) { _model.ProductionSchema = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the archive schema name. /// public string ArchiveSchema { get => _model.ArchiveSchema; set { if (_model.ArchiveSchema != value) { _model.ArchiveSchema = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the stage schema name. /// public string StageSchema { get => _model.StageSchema; set { if (_model.StageSchema != value) { _model.StageSchema = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets whether detailed logging is enabled. /// public bool EnableDetailedLogging { get => _model.EnableDetailedLogging; set { if (_model.EnableDetailedLogging != value) { _model.EnableDetailedLogging = value; OnPropertyChanged(); _onChanged(); } } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "DataAccessFormViewModelTests"` Expected: PASS **Step 5: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add DataAccessFormViewModel" ``` --- ### Task 16: Create AuthFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/AuthFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/AuthFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.ViewModels.Forms; namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; public class AuthFormViewModelTests { [Fact] public void Constructor_InitializesFromModel() { // Arrange var model = new AuthSection { CookieName = ".TestAuth", CookieExpirationMinutes = 120 }; // Act var sut = new AuthFormViewModel(model, () => { }); // Assert sut.CookieName.ShouldBe(".TestAuth"); sut.CookieExpirationMinutes.ShouldBe(120); } [Fact] public void PropertyChange_UpdatesModelAndInvokesOnChanged() { // Arrange var model = new AuthSection(); var changedInvoked = false; var sut = new AuthFormViewModel(model, () => changedInvoked = true); // Act sut.CookieExpirationMinutes = 240; // Assert model.CookieExpirationMinutes.ShouldBe(240); changedInvoked.ShouldBeTrue(); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "AuthFormViewModelTests"` Expected: FAIL with "AuthFormViewModel not found" **Step 3: Create AuthFormViewModel** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.ViewModels.Forms; /// /// ViewModel for editing Auth configuration section. /// public class AuthFormViewModel : ViewModelBase { private readonly AuthSection _model; private readonly Action _onChanged; public AuthFormViewModel(AuthSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); } /// /// Gets or sets the authentication cookie name. /// public string CookieName { get => _model.CookieName; set { if (_model.CookieName != value) { _model.CookieName = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the cookie expiration time in minutes. /// public int CookieExpirationMinutes { get => _model.CookieExpirationMinutes; set { if (_model.CookieExpirationMinutes != value) { _model.CookieExpirationMinutes = value; OnPropertyChanged(); _onChanged(); } } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "AuthFormViewModelTests"` Expected: PASS **Step 5: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add AuthFormViewModel" ``` --- ### Task 17: Create LdapFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/LdapFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/LdapFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.ViewModels.Forms; namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; public class LdapFormViewModelTests { [Fact] public void Constructor_InitializesFromModel() { // Arrange var model = new LdapSection { ServerUrls = ["ldap://server1.local", "ldap://server2.local"], GroupDn = "CN=Admins,DC=corp", SearchBase = "DC=corp,DC=local", UseFakeAuth = true }; // Act var sut = new LdapFormViewModel(model, () => { }); // Assert sut.ServerUrlsText.ShouldBe("ldap://server1.local\nldap://server2.local"); sut.GroupDn.ShouldBe("CN=Admins,DC=corp"); sut.SearchBase.ShouldBe("DC=corp,DC=local"); sut.UseFakeAuth.ShouldBeTrue(); } [Fact] public void ServerUrlsText_SplitsIntoArray() { // Arrange var model = new LdapSection(); var sut = new LdapFormViewModel(model, () => { }); // Act sut.ServerUrlsText = "ldap://a.local\nldap://b.local\nldap://c.local"; // Assert model.ServerUrls.Length.ShouldBe(3); model.ServerUrls[0].ShouldBe("ldap://a.local"); model.ServerUrls[2].ShouldBe("ldap://c.local"); } [Fact] public void AdminBypassUsersText_SplitsIntoArray() { // Arrange var model = new LdapSection(); var sut = new LdapFormViewModel(model, () => { }); // Act sut.AdminBypassUsersText = "admin\nservice_account"; // Assert model.AdminBypassUsers.Length.ShouldBe(2); model.AdminBypassUsers[0].ShouldBe("admin"); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "LdapFormViewModelTests"` Expected: FAIL with "LdapFormViewModel not found" **Step 3: Create LdapFormViewModel** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.ViewModels.Forms; /// /// ViewModel for editing LDAP configuration section. /// public class LdapFormViewModel : ViewModelBase { private readonly LdapSection _model; private readonly Action _onChanged; public LdapFormViewModel(LdapSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); } /// /// Gets or sets the server URLs as newline-separated text. /// public string ServerUrlsText { get => string.Join("\n", _model.ServerUrls); set { var urls = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (!_model.ServerUrls.SequenceEqual(urls)) { _model.ServerUrls = urls; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the group distinguished name. /// public string GroupDn { get => _model.GroupDn; set { if (_model.GroupDn != value) { _model.GroupDn = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the search base distinguished name. /// public string SearchBase { get => _model.SearchBase; set { if (_model.SearchBase != value) { _model.SearchBase = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the connection timeout in seconds. /// public int ConnectionTimeoutSeconds { get => _model.ConnectionTimeoutSeconds; set { if (_model.ConnectionTimeoutSeconds != value) { _model.ConnectionTimeoutSeconds = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets whether to use fake authentication. /// public bool UseFakeAuth { get => _model.UseFakeAuth; set { if (_model.UseFakeAuth != value) { _model.UseFakeAuth = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the admin bypass users as newline-separated text. /// public string AdminBypassUsersText { get => string.Join("\n", _model.AdminBypassUsers); set { var users = value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (!_model.AdminBypassUsers.SequenceEqual(users)) { _model.AdminBypassUsers = users; OnPropertyChanged(); _onChanged(); } } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "LdapFormViewModelTests"` Expected: PASS **Step 5: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add LdapFormViewModel" ``` --- ### Task 18: Create SearchFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SearchFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SearchFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.ViewModels.Forms; namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; public class SearchFormViewModelTests { [Fact] public void Constructor_InitializesFromModel() { // Arrange var model = new SearchSection { MaxResultRows = 50000, TimeoutSeconds = 600, MaxConcurrentSearches = 10 }; // Act var sut = new SearchFormViewModel(model, () => { }); // Assert sut.MaxResultRows.ShouldBe(50000); sut.TimeoutSeconds.ShouldBe(600); sut.MaxConcurrentSearches.ShouldBe(10); } [Fact] public void PropertyChange_UpdatesModelAndInvokesOnChanged() { // Arrange var model = new SearchSection(); var changedInvoked = false; var sut = new SearchFormViewModel(model, () => changedInvoked = true); // Act sut.MaxResultRows = 25000; // Assert model.MaxResultRows.ShouldBe(25000); changedInvoked.ShouldBeTrue(); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "SearchFormViewModelTests"` Expected: FAIL with "SearchFormViewModel not found" **Step 3: Create SearchFormViewModel** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.ViewModels.Forms; /// /// ViewModel for editing Search configuration section. /// public class SearchFormViewModel : ViewModelBase { private readonly SearchSection _model; private readonly Action _onChanged; public SearchFormViewModel(SearchSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); } /// /// Gets or sets the maximum number of result rows. /// public int MaxResultRows { get => _model.MaxResultRows; set { if (_model.MaxResultRows != value) { _model.MaxResultRows = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the search timeout in seconds. /// public int TimeoutSeconds { get => _model.TimeoutSeconds; set { if (_model.TimeoutSeconds != value) { _model.TimeoutSeconds = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the maximum number of concurrent searches. /// public int MaxConcurrentSearches { get => _model.MaxConcurrentSearches; set { if (_model.MaxConcurrentSearches != value) { _model.MaxConcurrentSearches = value; OnPropertyChanged(); _onChanged(); } } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "SearchFormViewModelTests"` Expected: PASS **Step 5: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add SearchFormViewModel" ``` --- ### Task 19: Create ExcelExportFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp using JdeScoping.ConfigManager.Models; using JdeScoping.ConfigManager.ViewModels.Forms; namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; public class ExcelExportFormViewModelTests { [Fact] public void Constructor_InitializesFromModel() { // Arrange var model = new ExcelExportSection { MaxRowsPerSheet = 500000, DefaultDateFormat = "MM/dd/yyyy", TimezoneId = "America/New_York", DebugWriteToFile = true }; // Act var sut = new ExcelExportFormViewModel(model, () => { }); // Assert sut.MaxRowsPerSheet.ShouldBe(500000); sut.DefaultDateFormat.ShouldBe("MM/dd/yyyy"); sut.TimezoneId.ShouldBe("America/New_York"); sut.DebugWriteToFile.ShouldBeTrue(); } [Fact] public void PropertyChange_UpdatesModelAndInvokesOnChanged() { // Arrange var model = new ExcelExportSection(); var changedInvoked = false; var sut = new ExcelExportFormViewModel(model, () => changedInvoked = true); // Act sut.TimezoneAbbreviation = "ET"; // Assert model.TimezoneAbbreviation.ShouldBe("ET"); changedInvoked.ShouldBeTrue(); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "ExcelExportFormViewModelTests"` Expected: FAIL with "ExcelExportFormViewModel not found" **Step 3: Create ExcelExportFormViewModel** ```csharp using JdeScoping.ConfigManager.Models; namespace JdeScoping.ConfigManager.ViewModels.Forms; /// /// ViewModel for editing ExcelExport configuration section. /// public class ExcelExportFormViewModel : ViewModelBase { private readonly ExcelExportSection _model; private readonly Action _onChanged; public ExcelExportFormViewModel(ExcelExportSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); } /// /// Gets or sets the criteria sheet password. /// public string CriteriaSheetPassword { get => _model.CriteriaSheetPassword; set { if (_model.CriteriaSheetPassword != value) { _model.CriteriaSheetPassword = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the data sheet password. /// public string DataSheetPassword { get => _model.DataSheetPassword; set { if (_model.DataSheetPassword != value) { _model.DataSheetPassword = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the maximum rows per sheet. /// public int MaxRowsPerSheet { get => _model.MaxRowsPerSheet; set { if (_model.MaxRowsPerSheet != value) { _model.MaxRowsPerSheet = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the default date format. /// public string DefaultDateFormat { get => _model.DefaultDateFormat; set { if (_model.DefaultDateFormat != value) { _model.DefaultDateFormat = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets whether to write debug output to file. /// public bool DebugWriteToFile { get => _model.DebugWriteToFile; set { if (_model.DebugWriteToFile != value) { _model.DebugWriteToFile = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the debug output directory. /// public string DebugOutputDirectory { get => _model.DebugOutputDirectory; set { if (_model.DebugOutputDirectory != value) { _model.DebugOutputDirectory = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the timezone identifier. /// public string TimezoneId { get => _model.TimezoneId; set { if (_model.TimezoneId != value) { _model.TimezoneId = value; OnPropertyChanged(); _onChanged(); } } } /// /// Gets or sets the timezone abbreviation. /// public string TimezoneAbbreviation { get => _model.TimezoneAbbreviation; set { if (_model.TimezoneAbbreviation != value) { _model.TimezoneAbbreviation = value; OnPropertyChanged(); _onChanged(); } } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "ExcelExportFormViewModelTests"` Expected: PASS **Step 5: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add ExcelExportFormViewModel" ``` --- ### Task 20: Create PipelineFormViewModel **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/PipelineFormViewModel.cs` - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ScheduleFormViewModel.cs` - Create: `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/PipelineFormViewModelTests.cs` **Step 1: Write the failing test** ```csharp 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); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "PipelineFormViewModelTests"` Expected: FAIL with "PipelineFormViewModel not found" **Step 3: Create ScheduleFormViewModel** ```csharp 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(); } } } } ``` **Step 4: Create PipelineFormViewModel** ```csharp 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; } } ``` **Step 5: Run test to verify it passes** Run: `dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "PipelineFormViewModelTests"` Expected: PASS **Step 6: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ git add NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ git commit -m "feat(configmanager): add PipelineFormViewModel and ScheduleFormViewModel" ``` --- ## Phase 8: Dialogs ### Task 21: Create Platform Dialog Service **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs` - Create: `NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs` **Step 1: Create IDialogService interface** ```csharp namespace JdeScoping.ConfigManager.Services; /// /// Service for showing platform dialogs. /// public interface IDialogService { Task ShowFolderPickerAsync(string? title = null); Task ShowMessageAsync(string title, string message); Task ShowConfirmationAsync(string title, string message); Task ShowDiffPreviewAsync(string title, DiffResult diff); Task ShowValidationResultsAsync(ValidationResult appSettingsResult, ValidationResult pipelinesResult); } ``` **Step 2: Create AvaloniaDialogService** ```csharp using Avalonia.Controls; using Avalonia.Platform.Storage; namespace JdeScoping.ConfigManager.Services; /// /// Avalonia implementation of the dialog service. /// public class AvaloniaDialogService : IDialogService { private readonly Func _getMainWindow; public AvaloniaDialogService(Func getMainWindow) { _getMainWindow = getMainWindow ?? throw new ArgumentNullException(nameof(getMainWindow)); } public async Task ShowFolderPickerAsync(string? title = null) { var window = _getMainWindow(); if (window == null) return null; var result = await window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = title ?? "Select Configuration Folder", AllowMultiple = false }); return result.FirstOrDefault()?.Path.LocalPath; } public async Task ShowMessageAsync(string title, string message) { var window = _getMainWindow(); if (window == null) return; var dialog = new Window { Title = title, Width = 400, Height = 200, WindowStartupLocation = WindowStartupLocation.CenterOwner, Content = new StackPanel { Margin = new Avalonia.Thickness(24), Children = { new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Margin = new Avalonia.Thickness(0, 16, 0, 0) } } } }; await dialog.ShowDialog(window); } public async Task ShowConfirmationAsync(string title, string message) { var window = _getMainWindow(); if (window == null) return false; var result = false; var dialog = new Window { Title = title, Width = 400, Height = 200, WindowStartupLocation = WindowStartupLocation.CenterOwner }; var okButton = new Button { Content = "OK" }; var cancelButton = new Button { Content = "Cancel" }; okButton.Click += (s, e) => { result = true; dialog.Close(); }; cancelButton.Click += (s, e) => { result = false; dialog.Close(); }; dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Children = { new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Margin = new Avalonia.Thickness(0, 16, 0, 0), Spacing = 8, Children = { cancelButton, okButton } } } }; await dialog.ShowDialog(window); return result; } public async Task ShowDiffPreviewAsync(string title, DiffResult diff) { var window = _getMainWindow(); if (window == null) return false; // TODO: Implement rich diff preview dialog return await ShowConfirmationAsync(title, $"Changes detected: {diff.Insertions} insertions, {diff.Deletions} deletions. Save changes?"); } public async Task ShowValidationResultsAsync(ValidationResult appSettingsResult, ValidationResult pipelinesResult) { var window = _getMainWindow(); if (window == null) return; var allErrors = appSettingsResult.Errors.Concat(pipelinesResult.Errors).ToList(); var allWarnings = appSettingsResult.Warnings.Concat(pipelinesResult.Warnings).ToList(); var message = $"Errors: {allErrors.Count}\nWarnings: {allWarnings.Count}\n\n"; if (allErrors.Count > 0) message += "Errors:\n" + string.Join("\n", allErrors.Select(e => "• " + e)) + "\n\n"; if (allWarnings.Count > 0) message += "Warnings:\n" + string.Join("\n", allWarnings.Select(w => "• " + w)); await ShowMessageAsync("Validation Results", message); } } ``` **Step 3: Verify build** Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/` Expected: Build succeeded **Step 4: Commit** ```bash git add NEW/src/Utils/JdeScoping.ConfigManager/Services/ git commit -m "feat(configmanager): add IDialogService and AvaloniaDialogService" ``` --- ### Task 22: Create DiffPreviewDialog **Files:** - Create: `NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/DiffPreviewDialog.axaml` - Create: `NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/DiffPreviewDialog.axaml.cs` - Create: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/DiffPreviewDialogViewModel.cs` **Step 1: Create DiffPreviewDialogViewModel** ```csharp using System.Collections.ObjectModel; using System.Windows.Input; using JdeScoping.ConfigManager.Services; namespace JdeScoping.ConfigManager.ViewModels.Dialogs; /// /// ViewModel for the diff preview dialog. /// public class DiffPreviewDialogViewModel : ViewModelBase { private bool _result; public DiffPreviewDialogViewModel(DiffResult diff) { ArgumentNullException.ThrowIfNull(diff); Lines = new ObservableCollection( diff.Lines.Select(l => new DiffLineViewModel(l))); Insertions = diff.Insertions; Deletions = diff.Deletions; HasChanges = diff.HasChanges; SaveCommand = new RelayCommand(() => { Result = true; RequestClose?.Invoke(); }); CancelCommand = new RelayCommand(() => { Result = false; RequestClose?.Invoke(); }); } public ObservableCollection Lines { get; } public int Insertions { get; } public int Deletions { get; } public bool HasChanges { get; } public bool Result { get => _result; private set => SetProperty(ref _result, value); } public ICommand SaveCommand { get; } public ICommand CancelCommand { get; } public Action? RequestClose { get; set; } } /// /// ViewModel for a single diff line. /// public class DiffLineViewModel { public DiffLineViewModel(DiffLine line) { OldLineNumber = line.OldLineNumber?.ToString() ?? ""; NewLineNumber = line.NewLineNumber?.ToString() ?? ""; Text = line.Text; Type = line.Type; Background = line.Type switch { DiffLineType.Added => "#1A3DD68C", DiffLineType.Removed => "#1AFF6B6B", _ => "Transparent" }; BorderColor = line.Type switch { DiffLineType.Added => "#3DD68C", DiffLineType.Removed => "#FF6B6B", _ => "Transparent" }; } public string OldLineNumber { get; } public string NewLineNumber { get; } public string Text { get; } public DiffLineType Type { get; } public string Background { get; } public string BorderColor { get; } } ``` **Step 2: Create DiffPreviewDialog.axaml** ```xml insertions, deletions