# 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
```
**Step 3: Create DiffPreviewDialog.axaml.cs**
```csharp
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**
```bash
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**
```csharp
using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
///
/// ViewModel for the validation results dialog.
///
public class ValidationResultsDialogViewModel : ViewModelBase
{
public ValidationResultsDialogViewModel(ValidationResult appSettingsResult, ValidationResult pipelinesResult)
{
var items = new List();
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(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 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 }
///
/// ViewModel for a single validation item.
///
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**
```xml
errors
warnings
```
**Step 3: Create ValidationResultsDialog.axaml.cs**
```csharp
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**
```bash
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**
```csharp
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 _logger;
public MainWindowViewModelTests()
{
_fileSystem = Substitute.For();
_configFileService = Substitute.For();
_validationService = Substitute.For();
_backupService = Substitute.For();
_autoDiscoveryService = Substitute.For();
_dialogService = Substitute.For();
_logger = Substitute.For>();
_validationService.ValidateAppSettings(Arg.Any())
.Returns(new ValidationResult());
_validationService.ValidatePipelines(Arg.Any())
.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)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:
```csharp
// 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 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**
```bash
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**
```xml
```
**Step 2: Create DataSyncFormView.axaml.cs**
```csharp
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**
```bash
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:
```xml
```
Add namespaces:
```xml
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**
```bash
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**
```csharp
private void ConfigureServices(IServiceCollection services)
{
// Logging
services.AddLogging(builder => builder
.AddConsole()
.SetMinimumLevel(LogLevel.Debug));
// Services
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddScoped();
// Platform Services
services.AddSingleton(sp =>
new AvaloniaDialogService(GetMainWindow));
// ViewModels
services.AddTransient();
}
private Window? GetMainWindow()
{
return (ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
}
```
**Step 2: Update MainWindow creation**
```csharp
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new Views.MainWindow
{
DataContext = Services.GetRequiredService()
};
}
```
**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**
```bash
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**
```bash
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