Files
jdescopingtool/docs/plans/2026-01-19-configmanager-phases7-9.md
T
Joseph Doherty d49330e697 docs: add XML documentation and ConfigManager implementation plans
Add comprehensive XML documentation (param/returns tags) across 132 source
files to improve IntelliSense and API discoverability. Include ConfigManager
design documents and implementation plans for phases 1-9.
2026-01-20 02:26:26 -05:00

82 KiB

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing DataSync configuration section.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets whether data synchronization is enabled.
    /// </summary>
    public bool Enabled
    {
        get => _model.Enabled;
        set
        {
            if (_model.Enabled != value)
            {
                _model.Enabled = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the check interval in minutes.
    /// </summary>
    public int CheckIntervalMinutes
    {
        get => (int)_model.CheckInterval.TotalMinutes;
        set
        {
            var newValue = TimeSpan.FromMinutes(value);
            if (_model.CheckInterval != newValue)
            {
                _model.CheckInterval = newValue;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the maximum degree of parallelism.
    /// </summary>
    public int MaxDegreeOfParallelism
    {
        get => _model.MaxDegreeOfParallelism;
        set
        {
            if (_model.MaxDegreeOfParallelism != value)
            {
                _model.MaxDegreeOfParallelism = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the batch size for sync operations.
    /// </summary>
    public int BatchSize
    {
        get => _model.BatchSize;
        set
        {
            if (_model.BatchSize != value)
            {
                _model.BatchSize = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the bulk copy batch size.
    /// </summary>
    public int BulkCopyBatchSize
    {
        get => _model.BulkCopyBatchSize;
        set
        {
            if (_model.BulkCopyBatchSize != value)
            {
                _model.BulkCopyBatchSize = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the lookback multiplier.
    /// </summary>
    public double LookbackMultiplier
    {
        get => _model.LookbackMultiplier;
        set
        {
            if (Math.Abs(_model.LookbackMultiplier - value) > 0.001)
            {
                _model.LookbackMultiplier = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the purge retention days.
    /// </summary>
    public int PurgeRetentionDays
    {
        get => _model.PurgeRetentionDays;
        set
        {
            if (_model.PurgeRetentionDays != value)
            {
                _model.PurgeRetentionDays = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the sync timeout in seconds.
    /// </summary>
    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

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing DataAccess configuration section.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets the default query timeout in seconds.
    /// </summary>
    public int DefaultTimeoutSeconds
    {
        get => _model.DefaultTimeoutSeconds;
        set
        {
            if (_model.DefaultTimeoutSeconds != value)
            {
                _model.DefaultTimeoutSeconds = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the lot usage query timeout in seconds.
    /// </summary>
    public int LotUsageTimeoutSeconds
    {
        get => _model.LotUsageTimeoutSeconds;
        set
        {
            if (_model.LotUsageTimeoutSeconds != value)
            {
                _model.LotUsageTimeoutSeconds = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the MIS data query timeout in seconds.
    /// </summary>
    public int MisDataTimeoutSeconds
    {
        get => _model.MisDataTimeoutSeconds;
        set
        {
            if (_model.MisDataTimeoutSeconds != value)
            {
                _model.MisDataTimeoutSeconds = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the production schema name.
    /// </summary>
    public string ProductionSchema
    {
        get => _model.ProductionSchema;
        set
        {
            if (_model.ProductionSchema != value)
            {
                _model.ProductionSchema = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the archive schema name.
    /// </summary>
    public string ArchiveSchema
    {
        get => _model.ArchiveSchema;
        set
        {
            if (_model.ArchiveSchema != value)
            {
                _model.ArchiveSchema = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the stage schema name.
    /// </summary>
    public string StageSchema
    {
        get => _model.StageSchema;
        set
        {
            if (_model.StageSchema != value)
            {
                _model.StageSchema = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets whether detailed logging is enabled.
    /// </summary>
    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

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing Auth configuration section.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets the authentication cookie name.
    /// </summary>
    public string CookieName
    {
        get => _model.CookieName;
        set
        {
            if (_model.CookieName != value)
            {
                _model.CookieName = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the cookie expiration time in minutes.
    /// </summary>
    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

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing LDAP configuration section.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets the server URLs as newline-separated text.
    /// </summary>
    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();
            }
        }
    }

    /// <summary>
    /// Gets or sets the group distinguished name.
    /// </summary>
    public string GroupDn
    {
        get => _model.GroupDn;
        set
        {
            if (_model.GroupDn != value)
            {
                _model.GroupDn = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the search base distinguished name.
    /// </summary>
    public string SearchBase
    {
        get => _model.SearchBase;
        set
        {
            if (_model.SearchBase != value)
            {
                _model.SearchBase = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the connection timeout in seconds.
    /// </summary>
    public int ConnectionTimeoutSeconds
    {
        get => _model.ConnectionTimeoutSeconds;
        set
        {
            if (_model.ConnectionTimeoutSeconds != value)
            {
                _model.ConnectionTimeoutSeconds = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets whether to use fake authentication.
    /// </summary>
    public bool UseFakeAuth
    {
        get => _model.UseFakeAuth;
        set
        {
            if (_model.UseFakeAuth != value)
            {
                _model.UseFakeAuth = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the admin bypass users as newline-separated text.
    /// </summary>
    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

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing Search configuration section.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets the maximum number of result rows.
    /// </summary>
    public int MaxResultRows
    {
        get => _model.MaxResultRows;
        set
        {
            if (_model.MaxResultRows != value)
            {
                _model.MaxResultRows = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the search timeout in seconds.
    /// </summary>
    public int TimeoutSeconds
    {
        get => _model.TimeoutSeconds;
        set
        {
            if (_model.TimeoutSeconds != value)
            {
                _model.TimeoutSeconds = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the maximum number of concurrent searches.
    /// </summary>
    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

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing ExcelExport configuration section.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets the criteria sheet password.
    /// </summary>
    public string CriteriaSheetPassword
    {
        get => _model.CriteriaSheetPassword;
        set
        {
            if (_model.CriteriaSheetPassword != value)
            {
                _model.CriteriaSheetPassword = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the data sheet password.
    /// </summary>
    public string DataSheetPassword
    {
        get => _model.DataSheetPassword;
        set
        {
            if (_model.DataSheetPassword != value)
            {
                _model.DataSheetPassword = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the maximum rows per sheet.
    /// </summary>
    public int MaxRowsPerSheet
    {
        get => _model.MaxRowsPerSheet;
        set
        {
            if (_model.MaxRowsPerSheet != value)
            {
                _model.MaxRowsPerSheet = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the default date format.
    /// </summary>
    public string DefaultDateFormat
    {
        get => _model.DefaultDateFormat;
        set
        {
            if (_model.DefaultDateFormat != value)
            {
                _model.DefaultDateFormat = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets whether to write debug output to file.
    /// </summary>
    public bool DebugWriteToFile
    {
        get => _model.DebugWriteToFile;
        set
        {
            if (_model.DebugWriteToFile != value)
            {
                _model.DebugWriteToFile = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the debug output directory.
    /// </summary>
    public string DebugOutputDirectory
    {
        get => _model.DebugOutputDirectory;
        set
        {
            if (_model.DebugOutputDirectory != value)
            {
                _model.DebugOutputDirectory = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the timezone identifier.
    /// </summary>
    public string TimezoneId
    {
        get => _model.TimezoneId;
        set
        {
            if (_model.TimezoneId != value)
            {
                _model.TimezoneId = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the timezone abbreviation.
    /// </summary>
    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

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

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

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing a schedule configuration.
/// </summary>
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));
    }

    /// <summary>
    /// Gets or sets whether this schedule is enabled.
    /// </summary>
    public bool Enabled
    {
        get => _model.Enabled;
        set
        {
            if (_model.Enabled != value)
            {
                _model.Enabled = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the interval in minutes.
    /// </summary>
    public int IntervalMinutes
    {
        get => _model.IntervalMinutes;
        set
        {
            if (_model.IntervalMinutes != value)
            {
                _model.IntervalMinutes = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets whether to purge before sync.
    /// </summary>
    public bool PrePurge
    {
        get => _model.PrePurge;
        set
        {
            if (_model.PrePurge != value)
            {
                _model.PrePurge = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets whether to reindex after sync.
    /// </summary>
    public bool ReIndex
    {
        get => _model.ReIndex;
        set
        {
            if (_model.ReIndex != value)
            {
                _model.ReIndex = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }
}

Step 4: Create PipelineFormViewModel

using JdeScoping.ConfigManager.Models;

namespace JdeScoping.ConfigManager.ViewModels.Forms;

/// <summary>
/// ViewModel for editing a pipeline configuration.
/// </summary>
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);
    }

    /// <summary>
    /// Gets the pipeline name.
    /// </summary>
    public string Name { get; }

    /// <summary>
    /// Gets or sets the source connection name.
    /// </summary>
    public string Connection
    {
        get => _model.Source.Connection;
        set
        {
            if (_model.Source.Connection != value)
            {
                _model.Source.Connection = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the source query.
    /// </summary>
    public string Query
    {
        get => _model.Source.Query;
        set
        {
            if (_model.Source.Query != value)
            {
                _model.Source.Query = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the optional mass query.
    /// </summary>
    public string? MassQuery
    {
        get => _model.Source.MassQuery;
        set
        {
            if (_model.Source.MassQuery != value)
            {
                _model.Source.MassQuery = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the destination table name.
    /// </summary>
    public string DestinationTable
    {
        get => _model.Destination.Table;
        set
        {
            if (_model.Destination.Table != value)
            {
                _model.Destination.Table = value;
                OnPropertyChanged();
                _onChanged();
            }
        }
    }

    /// <summary>
    /// Gets or sets the match columns as newline-separated text.
    /// </summary>
    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();
            }
        }
    }

    /// <summary>
    /// Gets or sets the exclude from update columns as newline-separated text.
    /// </summary>
    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();
            }
        }
    }

    /// <summary>
    /// Gets or sets the post scripts as newline-separated text.
    /// </summary>
    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();
            }
        }
    }

    /// <summary>
    /// Gets the mass schedule view model.
    /// </summary>
    public ScheduleFormViewModel MassSchedule { get; }

    /// <summary>
    /// Gets the daily schedule view model.
    /// </summary>
    public ScheduleFormViewModel DailySchedule { get; }

    /// <summary>
    /// Gets the hourly schedule view model.
    /// </summary>
    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

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

namespace JdeScoping.ConfigManager.Services;

/// <summary>
/// Service for showing platform dialogs.
/// </summary>
public interface IDialogService
{
    Task<string?> ShowFolderPickerAsync(string? title = null);
    Task ShowMessageAsync(string title, string message);
    Task<bool> ShowConfirmationAsync(string title, string message);
    Task<bool> ShowDiffPreviewAsync(string title, DiffResult diff);
    Task ShowValidationResultsAsync(ValidationResult appSettingsResult, ValidationResult pipelinesResult);
}

Step 2: Create AvaloniaDialogService

using Avalonia.Controls;
using Avalonia.Platform.Storage;

namespace JdeScoping.ConfigManager.Services;

/// <summary>
/// Avalonia implementation of the dialog service.
/// </summary>
public class AvaloniaDialogService : IDialogService
{
    private readonly Func<Window?> _getMainWindow;

    public AvaloniaDialogService(Func<Window?> getMainWindow)
    {
        _getMainWindow = getMainWindow ?? throw new ArgumentNullException(nameof(getMainWindow));
    }

    public async Task<string?> 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<bool> 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<bool> 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

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

using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.ConfigManager.Services;

namespace JdeScoping.ConfigManager.ViewModels.Dialogs;

/// <summary>
/// ViewModel for the diff preview dialog.
/// </summary>
public class DiffPreviewDialogViewModel : ViewModelBase
{
    private bool _result;

    public DiffPreviewDialogViewModel(DiffResult diff)
    {
        ArgumentNullException.ThrowIfNull(diff);

        Lines = new ObservableCollection<DiffLineViewModel>(
            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<DiffLineViewModel> 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; }
}

/// <summary>
/// ViewModel for a single diff line.
/// </summary>
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

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Dialogs"
        x:Class="JdeScoping.ConfigManager.Views.Dialogs.DiffPreviewDialog"
        x:DataType="vm:DiffPreviewDialogViewModel"
        Title="Preview Changes"
        Width="800" Height="600"
        MinWidth="600" MinHeight="400"
        Background="#151920"
        WindowStartupLocation="CenterOwner">

    <DockPanel>
        <!-- Header -->
        <Border DockPanel.Dock="Top" Background="#1C2128" Padding="24,16"
                BorderBrush="#2D3540" BorderThickness="0,0,0,1">
            <TextBlock Text="Preview Changes"
                       Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
        </Border>

        <!-- Footer -->
        <Border DockPanel.Dock="Bottom" Background="#1C2128" Padding="24,16"
                BorderBrush="#2D3540" BorderThickness="0,1,0,0">
            <Grid>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
                    <TextBlock Foreground="#9BA8B8" FontFamily="JetBrains Mono" FontSize="12">
                        <Run Text="{Binding Insertions}"/> insertions, <Run Text="{Binding Deletions}"/> deletions
                    </TextBlock>
                </StackPanel>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
                    <Button Content="Cancel" Command="{Binding CancelCommand}"
                            Background="Transparent" BorderBrush="#3D4550"
                            Foreground="#9BA8B8" Padding="16,8"/>
                    <Button Content="💾 Save Changes" Command="{Binding SaveCommand}"
                            Background="#5C9AFF" Foreground="#0D0F12"
                            Padding="16,8" FontWeight="Medium"/>
                </StackPanel>
            </Grid>
        </Border>

        <!-- Diff Content -->
        <ScrollViewer Background="#0D0F12" Padding="0">
            <ItemsControl ItemsSource="{Binding Lines}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Border Background="{Binding Background}"
                                BorderBrush="{Binding BorderColor}"
                                BorderThickness="3,0,0,0"
                                Padding="0,2">
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition Width="50"/>
                                    <ColumnDefinition Width="50"/>
                                    <ColumnDefinition Width="*"/>
                                </Grid.ColumnDefinitions>

                                <TextBlock Grid.Column="0" Text="{Binding OldLineNumber}"
                                           Foreground="#5C6A7A" FontFamily="JetBrains Mono" FontSize="12"
                                           HorizontalAlignment="Right" Padding="0,0,8,0"/>
                                <TextBlock Grid.Column="1" Text="{Binding NewLineNumber}"
                                           Foreground="#5C6A7A" FontFamily="JetBrains Mono" FontSize="12"
                                           HorizontalAlignment="Right" Padding="0,0,8,0"/>
                                <Border Grid.Column="1" Width="1" Background="#2D3540" HorizontalAlignment="Right"/>
                                <TextBlock Grid.Column="2" Text="{Binding Text}"
                                           Foreground="#E6EDF5" FontFamily="JetBrains Mono" FontSize="12"
                                           Padding="12,0,0,0"/>
                            </Grid>
                        </Border>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </ScrollViewer>
    </DockPanel>
</Window>

Step 3: Create DiffPreviewDialog.axaml.cs

using Avalonia.Controls;
using JdeScoping.ConfigManager.ViewModels.Dialogs;

namespace JdeScoping.ConfigManager.Views.Dialogs;

public partial class DiffPreviewDialog : Window
{
    public DiffPreviewDialog()
    {
        InitializeComponent();
    }

    public DiffPreviewDialog(DiffPreviewDialogViewModel viewModel) : this()
    {
        DataContext = viewModel;
        viewModel.RequestClose = Close;
    }
}

Step 4: Verify build

Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/ Expected: Build succeeded

Step 5: Commit

git add NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/
git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/
git commit -m "feat(configmanager): add DiffPreviewDialog"

Task 23: Create ValidationResultsDialog

Files:

  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/ValidationResultsDialog.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/ValidationResultsDialog.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/ValidationResultsDialogViewModel.cs

Step 1: Create ValidationResultsDialogViewModel

using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.ConfigManager.Services;

namespace JdeScoping.ConfigManager.ViewModels.Dialogs;

/// <summary>
/// ViewModel for the validation results dialog.
/// </summary>
public class ValidationResultsDialogViewModel : ViewModelBase
{
    public ValidationResultsDialogViewModel(ValidationResult appSettingsResult, ValidationResult pipelinesResult)
    {
        var items = new List<ValidationItemViewModel>();

        foreach (var error in appSettingsResult.Errors)
            items.Add(new ValidationItemViewModel(error, "appsettings.json", ValidationItemType.Error));
        foreach (var warning in appSettingsResult.Warnings)
            items.Add(new ValidationItemViewModel(warning, "appsettings.json", ValidationItemType.Warning));
        foreach (var error in pipelinesResult.Errors)
            items.Add(new ValidationItemViewModel(error, "pipelines.json", ValidationItemType.Error));
        foreach (var warning in pipelinesResult.Warnings)
            items.Add(new ValidationItemViewModel(warning, "pipelines.json", ValidationItemType.Warning));

        Items = new ObservableCollection<ValidationItemViewModel>(items);
        ErrorCount = appSettingsResult.Errors.Count + pipelinesResult.Errors.Count;
        WarningCount = appSettingsResult.Warnings.Count + pipelinesResult.Warnings.Count;
        IsValid = ErrorCount == 0 && WarningCount == 0;

        CloseCommand = new RelayCommand(() => RequestClose?.Invoke());
    }

    public ObservableCollection<ValidationItemViewModel> Items { get; }
    public int ErrorCount { get; }
    public int WarningCount { get; }
    public bool IsValid { get; }
    public ICommand CloseCommand { get; }
    public Action? RequestClose { get; set; }
}

public enum ValidationItemType { Error, Warning }

/// <summary>
/// ViewModel for a single validation item.
/// </summary>
public class ValidationItemViewModel
{
    public ValidationItemViewModel(string message, string source, ValidationItemType type)
    {
        Message = message;
        Source = source;
        Type = type;
        Icon = type == ValidationItemType.Error ? "✗" : "⚠";
        IconColor = type == ValidationItemType.Error ? "#FF6B6B" : "#FFB84D";
        Background = type == ValidationItemType.Error ? "#1AFF6B6B" : "#1AFFB84D";
        BorderColor = type == ValidationItemType.Error ? "#FF6B6B" : "#FFB84D";
    }

    public string Message { get; }
    public string Source { get; }
    public ValidationItemType Type { get; }
    public string Icon { get; }
    public string IconColor { get; }
    public string Background { get; }
    public string BorderColor { get; }
}

Step 2: Create ValidationResultsDialog.axaml

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Dialogs"
        x:Class="JdeScoping.ConfigManager.Views.Dialogs.ValidationResultsDialog"
        x:DataType="vm:ValidationResultsDialogViewModel"
        Title="Validation Results"
        Width="600" Height="500"
        MinWidth="400" MinHeight="300"
        Background="#151920"
        WindowStartupLocation="CenterOwner">

    <DockPanel>
        <!-- Header -->
        <Border DockPanel.Dock="Top" Background="#1C2128" Padding="24,16"
                BorderBrush="#2D3540" BorderThickness="0,0,0,1">
            <StackPanel>
                <TextBlock Text="Validation Results"
                           Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
                <StackPanel Orientation="Horizontal" Margin="0,8,0,0" Spacing="16">
                    <TextBlock Foreground="#FF6B6B" FontSize="12">
                        <Run Text="{Binding ErrorCount}"/> errors
                    </TextBlock>
                    <TextBlock Foreground="#FFB84D" FontSize="12">
                        <Run Text="{Binding WarningCount}"/> warnings
                    </TextBlock>
                </StackPanel>
            </StackPanel>
        </Border>

        <!-- Footer -->
        <Border DockPanel.Dock="Bottom" Background="#1C2128" Padding="24,16"
                BorderBrush="#2D3540" BorderThickness="0,1,0,0">
            <Button Content="Close" Command="{Binding CloseCommand}"
                    HorizontalAlignment="Right"
                    Background="#5C9AFF" Foreground="#0D0F12"
                    Padding="16,8" FontWeight="Medium"/>
        </Border>

        <!-- Content -->
        <ScrollViewer Background="#0D0F12" Padding="16">
            <ItemsControl ItemsSource="{Binding Items}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Border Background="{Binding Background}"
                                BorderBrush="{Binding BorderColor}"
                                BorderThickness="3,0,0,0"
                                Margin="0,0,0,8"
                                Padding="12">
                            <StackPanel>
                                <StackPanel Orientation="Horizontal" Spacing="8">
                                    <TextBlock Text="{Binding Icon}"
                                               Foreground="{Binding IconColor}"
                                               FontSize="14"/>
                                    <TextBlock Text="{Binding Source}"
                                               Foreground="#5C6A7A"
                                               FontFamily="JetBrains Mono" FontSize="11"/>
                                </StackPanel>
                                <TextBlock Text="{Binding Message}"
                                           Foreground="#E6EDF5"
                                           TextWrapping="Wrap"
                                           Margin="22,4,0,0"/>
                            </StackPanel>
                        </Border>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </ScrollViewer>
    </DockPanel>
</Window>

Step 3: Create ValidationResultsDialog.axaml.cs

using Avalonia.Controls;
using JdeScoping.ConfigManager.ViewModels.Dialogs;

namespace JdeScoping.ConfigManager.Views.Dialogs;

public partial class ValidationResultsDialog : Window
{
    public ValidationResultsDialog()
    {
        InitializeComponent();
    }

    public ValidationResultsDialog(ValidationResultsDialogViewModel viewModel) : this()
    {
        DataContext = viewModel;
        viewModel.RequestClose = Close;
    }
}

Step 4: Verify build

Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/ Expected: Build succeeded

Step 5: Commit

git add NEW/src/Utils/JdeScoping.ConfigManager/Views/Dialogs/
git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Dialogs/
git commit -m "feat(configmanager): add ValidationResultsDialog"

Phase 9: Polish & Integration

Task 24: Wire Up Form Selection in MainWindowViewModel

Files:

  • Modify: NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
  • Create: NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs

Step 1: Write the failing test

using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
using JdeScoping.ConfigManager.ViewModels;
using JdeScoping.ConfigManager.ViewModels.Forms;
using Microsoft.Extensions.Logging;

namespace JdeScoping.ConfigManager.Tests.ViewModels;

public class MainWindowViewModelTests
{
    private readonly IFileSystem _fileSystem;
    private readonly IConfigFileService _configFileService;
    private readonly IValidationService _validationService;
    private readonly IBackupService _backupService;
    private readonly IAutoDiscoveryService _autoDiscoveryService;
    private readonly IDialogService _dialogService;
    private readonly ILogger<MainWindowViewModel> _logger;

    public MainWindowViewModelTests()
    {
        _fileSystem = Substitute.For<IFileSystem>();
        _configFileService = Substitute.For<IConfigFileService>();
        _validationService = Substitute.For<IValidationService>();
        _backupService = Substitute.For<IBackupService>();
        _autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
        _dialogService = Substitute.For<IDialogService>();
        _logger = Substitute.For<ILogger<MainWindowViewModel>>();

        _validationService.ValidateAppSettings(Arg.Any<ConfigModel>())
            .Returns(new ValidationResult());
        _validationService.ValidatePipelines(Arg.Any<PipelinesConfigModel>())
            .Returns(new ValidationResult());
    }

    [Fact]
    public void SelectingDataSyncNode_LoadsDataSyncFormViewModel()
    {
        // Arrange
        var config = new ConfigModel { DataSync = new DataSyncSection { MaxDegreeOfParallelism = 8 } };
        var sut = CreateViewModel();
        sut.LoadConfigForTesting(config, null);

        var dataSyncNode = sut.TreeNodes
            .SelectMany(n => n.Children)
            .First(n => n.SectionKey == "DataSync");

        // Act
        sut.SelectedNode = dataSyncNode;

        // Assert
        sut.SelectedFormViewModel.ShouldBeOfType<DataSyncFormViewModel>();
        ((DataSyncFormViewModel)sut.SelectedFormViewModel!).MaxDegreeOfParallelism.ShouldBe(8);
    }

    [Fact]
    public void ModifyingFormProperty_SetsHasUnsavedChanges()
    {
        // Arrange
        var config = new ConfigModel();
        var sut = CreateViewModel();
        sut.LoadConfigForTesting(config, null);

        var dataSyncNode = sut.TreeNodes
            .SelectMany(n => n.Children)
            .First(n => n.SectionKey == "DataSync");
        sut.SelectedNode = dataSyncNode;

        // Act
        ((DataSyncFormViewModel)sut.SelectedFormViewModel!).BatchSize = 10000;

        // Assert
        sut.HasUnsavedChanges.ShouldBeTrue();
    }

    private MainWindowViewModel CreateViewModel()
    {
        return new MainWindowViewModel(
            _fileSystem,
            _configFileService,
            _validationService,
            _backupService,
            _autoDiscoveryService,
            _dialogService,
            _logger);
    }
}

Step 2: Run test to verify it fails

Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "MainWindowViewModelTests" Expected: FAIL - methods not implemented

Step 3: Update MainWindowViewModel with form selection logic

Add to MainWindowViewModel.cs:

// Add field
private readonly IDialogService _dialogService;
private readonly IFileSystem _fileSystem;

// Update constructor to include new services
public MainWindowViewModel(
    IFileSystem fileSystem,
    IConfigFileService configFileService,
    IValidationService validationService,
    IBackupService backupService,
    IAutoDiscoveryService autoDiscoveryService,
    IDialogService dialogService,
    ILogger<MainWindowViewModel> logger)
{
    _fileSystem = fileSystem;
    _configFileService = configFileService;
    _validationService = validationService;
    _backupService = backupService;
    _autoDiscoveryService = autoDiscoveryService;
    _dialogService = dialogService;
    _logger = logger;

    // ... existing command initialization
}

// Add test helper method
public void LoadConfigForTesting(ConfigModel? appSettings, PipelinesConfigModel? pipelines)
{
    _appSettings = appSettings;
    _pipelines = pipelines;
    BuildTreeNodes();
}

// Update OnSelectedNodeChanged
private void OnSelectedNodeChanged()
{
    if (_selectedNode == null || _appSettings == null)
    {
        SelectedFormViewModel = null;
        return;
    }

    SelectedFormViewModel = _selectedNode.SectionKey switch
    {
        "DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged),
        "DataAccess" => new DataAccessFormViewModel(_appSettings.DataAccess, MarkAsChanged),
        "Auth" => new AuthFormViewModel(_appSettings.Auth, MarkAsChanged),
        "Ldap" => new LdapFormViewModel(_appSettings.Ldap, MarkAsChanged),
        "Search" => new SearchFormViewModel(_appSettings.Search, MarkAsChanged),
        "ExcelExport" => new ExcelExportFormViewModel(_appSettings.ExcelExport, MarkAsChanged),
        _ when _selectedNode.NodeType == TreeNodeType.Pipeline && _pipelines != null
            => _pipelines.Pipelines.TryGetValue(_selectedNode.SectionKey!, out var pipeline)
                ? new PipelineFormViewModel(_selectedNode.SectionKey!, pipeline, MarkAsChanged)
                : null,
        _ => null
    };
}

private void MarkAsChanged()
{
    HasUnsavedChanges = true;
    if (_selectedNode != null)
        _selectedNode.IsModified = true;
}

// Update OpenFolderAsync
private async Task OpenFolderAsync()
{
    var folder = await _dialogService.ShowFolderPickerAsync("Select Configuration Folder");
    if (folder != null)
    {
        await LoadConfigAsync(folder);
    }
}

Step 4: Run test to verify it passes

Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ --filter "MainWindowViewModelTests" Expected: PASS

Step 5: Commit

git add NEW/src/Utils/JdeScoping.ConfigManager/
git add NEW/tests/JdeScoping.ConfigManager.Tests/
git commit -m "feat(configmanager): wire up form selection in MainWindowViewModel"

Task 25: Create Form Views (XAML)

Files:

  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/DataSyncFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/DataSyncFormView.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/DataAccessFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/DataAccessFormView.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/AuthFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/AuthFormView.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/LdapFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/LdapFormView.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SearchFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SearchFormView.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml.cs
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/PipelineFormView.axaml
  • Create: NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/PipelineFormView.axaml.cs

Step 1: Create DataSyncFormView.axaml

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
             x:Class="JdeScoping.ConfigManager.Views.Forms.DataSyncFormView"
             x:DataType="vm:DataSyncFormViewModel">

    <StackPanel Spacing="16" MaxWidth="600">
        <!-- Header -->
        <TextBlock Text="Data Sync Options" FontSize="18" FontWeight="SemiBold"
                   Foreground="#E6EDF5" Margin="0,0,0,8"/>
        <Border Height="1" Background="#2D3540" Margin="0,0,0,16"/>

        <!-- Enabled Toggle -->
        <StackPanel>
            <CheckBox IsChecked="{Binding Enabled}" Content="Enable Data Synchronization"
                      Foreground="#E6EDF5"/>
        </StackPanel>

        <!-- Timing Section -->
        <Border Background="#0D0F12" CornerRadius="6" Padding="16" BorderBrush="#2D3540" BorderThickness="1">
            <StackPanel Spacing="12">
                <TextBlock Text="Timing" FontWeight="SemiBold" Foreground="#E6EDF5"/>

                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" RowSpacing="12" ColumnSpacing="16">
                    <StackPanel Grid.Row="0" Grid.Column="0">
                        <TextBlock Text="Check Interval (minutes)" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding CheckIntervalMinutes}" Minimum="1" Maximum="60"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                    </StackPanel>
                    <StackPanel Grid.Row="0" Grid.Column="1">
                        <TextBlock Text="Sync Timeout (seconds)" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding SyncTimeoutSeconds}" Minimum="60" Maximum="86400"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                    </StackPanel>
                </Grid>
            </StackPanel>
        </Border>

        <!-- Performance Section -->
        <Border Background="#0D0F12" CornerRadius="6" Padding="16" BorderBrush="#2D3540" BorderThickness="1">
            <StackPanel Spacing="12">
                <TextBlock Text="Performance" FontWeight="SemiBold" Foreground="#E6EDF5"/>

                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" RowSpacing="12" ColumnSpacing="16">
                    <StackPanel Grid.Row="0" Grid.Column="0">
                        <TextBlock Text="Max Parallelism" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding MaxDegreeOfParallelism}" Minimum="1" Maximum="32"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                        <TextBlock Text="1-32" Foreground="#5C6A7A" FontSize="11" Margin="0,4,0,0"/>
                    </StackPanel>
                    <StackPanel Grid.Row="0" Grid.Column="1">
                        <TextBlock Text="Batch Size" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding BatchSize}" Minimum="1000" Maximum="10000000"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                    </StackPanel>
                    <StackPanel Grid.Row="1" Grid.Column="0">
                        <TextBlock Text="Bulk Copy Batch Size" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding BulkCopyBatchSize}" Minimum="100" Maximum="100000"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                    </StackPanel>
                </Grid>
            </StackPanel>
        </Border>

        <!-- Data Retention Section -->
        <Border Background="#0D0F12" CornerRadius="6" Padding="16" BorderBrush="#2D3540" BorderThickness="1">
            <StackPanel Spacing="12">
                <TextBlock Text="Data Retention" FontWeight="SemiBold" Foreground="#E6EDF5"/>

                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto" ColumnSpacing="16">
                    <StackPanel Grid.Column="0">
                        <TextBlock Text="Lookback Multiplier" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding LookbackMultiplier}" Minimum="1" Maximum="10" Increment="0.1"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                    </StackPanel>
                    <StackPanel Grid.Column="1">
                        <TextBlock Text="Purge Retention (days)" Foreground="#9BA8B8" FontSize="12" Margin="0,0,0,4"/>
                        <NumericUpDown Value="{Binding PurgeRetentionDays}" Minimum="1" Maximum="365"
                                       Background="#232A35" Foreground="#E6EDF5"/>
                    </StackPanel>
                </Grid>
            </StackPanel>
        </Border>
    </StackPanel>
</UserControl>

Step 2: Create DataSyncFormView.axaml.cs

using Avalonia.Controls;

namespace JdeScoping.ConfigManager.Views.Forms;

public partial class DataSyncFormView : UserControl
{
    public DataSyncFormView()
    {
        InitializeComponent();
    }
}

Step 3: Create remaining form views following the same pattern

(Repeat for DataAccessFormView, AuthFormView, LdapFormView, SearchFormView, ExcelExportFormView, PipelineFormView - each with appropriate fields from the corresponding FormViewModel)

Step 4: Verify build

Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/ Expected: Build succeeded

Step 5: Commit

git add NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/
git commit -m "feat(configmanager): add form views for all configuration sections"

Task 26: Add DataTemplates for Form Selection

Files:

  • Modify: NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml

Step 1: Add DataTemplates to MainWindow.axaml

Add inside the Window before DockPanel:

<Window.DataTemplates>
    <DataTemplate DataType="{x:Type forms:DataSyncFormViewModel}">
        <views:DataSyncFormView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type forms:DataAccessFormViewModel}">
        <views:DataAccessFormView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type forms:AuthFormViewModel}">
        <views:AuthFormView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type forms:LdapFormViewModel}">
        <views:LdapFormView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type forms:SearchFormViewModel}">
        <views:SearchFormView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type forms:ExcelExportFormViewModel}">
        <views:ExcelExportFormView/>
    </DataTemplate>
    <DataTemplate DataType="{x:Type forms:PipelineFormViewModel}">
        <views:PipelineFormView/>
    </DataTemplate>
</Window.DataTemplates>

Add namespaces:

xmlns:forms="using:JdeScoping.ConfigManager.ViewModels.Forms"
xmlns:views="using:JdeScoping.ConfigManager.Views.Forms"

Step 2: Verify build

Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/ Expected: Build succeeded

Step 3: Commit

git add NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml
git commit -m "feat(configmanager): add DataTemplates for form view selection"

Task 27: Update App.axaml.cs with All Service Registrations

Files:

  • Modify: NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs

Step 1: Update ConfigureServices

private void ConfigureServices(IServiceCollection services)
{
    // Logging
    services.AddLogging(builder => builder
        .AddConsole()
        .SetMinimumLevel(LogLevel.Debug));

    // Services
    services.AddSingleton<IFileSystem, FileSystem>();
    services.AddSingleton<IAutoDiscoveryService, AutoDiscoveryService>();
    services.AddSingleton<IBackupService, BackupService>();
    services.AddSingleton<IDiffService, DiffService>();
    services.AddSingleton<IValidationService, ValidationService>();
    services.AddScoped<IConfigFileService, ConfigFileService>();

    // Platform Services
    services.AddSingleton<IDialogService>(sp =>
        new AvaloniaDialogService(GetMainWindow));

    // ViewModels
    services.AddTransient<MainWindowViewModel>();
}

private Window? GetMainWindow()
{
    return (ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
}

Step 2: Update MainWindow creation

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
    desktop.MainWindow = new Views.MainWindow
    {
        DataContext = Services.GetRequiredService<MainWindowViewModel>()
    };
}

Step 3: Verify build

Run: dotnet build NEW/src/Utils/JdeScoping.ConfigManager/ Expected: Build succeeded

Step 4: Run all tests

Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ Expected: All tests pass

Step 5: Commit

git add NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs
git commit -m "feat(configmanager): complete service registrations in App.axaml.cs"

Task 28: Final Integration Test

Step 1: Run the application

Run: dotnet run --project NEW/src/Utils/JdeScoping.ConfigManager/ Expected: Application launches with dark theme UI

Step 2: Verify tree view appears

Expected: Settings and Pipelines folders visible with section nodes

Step 3: Verify form selection works

Click on DataSync node Expected: DataSync form appears with numeric inputs

Step 4: Run full test suite

Run: dotnet test NEW/tests/JdeScoping.ConfigManager.Tests/ -v normal Expected: All tests pass

Step 5: Final commit

git add -A
git commit -m "feat(configmanager): complete Phases 7-9 implementation"

Summary

Phase 7 Tasks (14-20): 7 form ViewModels with TDD

  • DataSyncFormViewModel
  • DataAccessFormViewModel
  • AuthFormViewModel
  • LdapFormViewModel
  • SearchFormViewModel
  • ExcelExportFormViewModel
  • PipelineFormViewModel + ScheduleFormViewModel

Phase 8 Tasks (21-23): 3 dialog components

  • IDialogService + AvaloniaDialogService
  • DiffPreviewDialog
  • ValidationResultsDialog

Phase 9 Tasks (24-28): 5 integration tasks

  • Wire up form selection in MainWindowViewModel
  • Create XAML form views
  • Add DataTemplates
  • Update App.axaml.cs service registrations
  • Final integration test

Total Tasks: 15 Estimated Commits: 15+

Key Patterns:

  • TDD for all ViewModels
  • Two-way binding with change tracking
  • Action callback pattern for marking changes
  • DataTemplate-based view selection
  • Dialog service abstraction