feat(configmanager): wire up form selection in MainWindowViewModel

- Add IFileSystem and IDialogService dependencies to constructor
- Implement OnSelectedNodeChanged to create appropriate form ViewModels
- Add LoadConfigForTesting helper for unit testing
- Add MarkAsChanged helper to track unsaved changes
- Update OpenFolderAsync to use IDialogService
- Add comprehensive unit tests for form selection
This commit is contained in:
Joseph Doherty
2026-01-19 19:57:27 -05:00
parent 46e94539cd
commit 042e036c35
2 changed files with 436 additions and 5 deletions
@@ -3,6 +3,7 @@ using System.Windows.Input;
using Avalonia.Media;
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
using JdeScoping.ConfigManager.ViewModels.Forms;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.ViewModels;
@@ -12,10 +13,12 @@ namespace JdeScoping.ConfigManager.ViewModels;
/// </summary>
public class MainWindowViewModel : ViewModelBase
{
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;
private string _configFolderPath = "No folder selected";
@@ -28,30 +31,45 @@ public class MainWindowViewModel : ViewModelBase
private ConfigModel? _appSettings;
private PipelinesConfigModel? _pipelines;
/// <summary>
/// Gets or sets the currently loaded configuration folder path.
/// </summary>
public string ConfigFolderPath
{
get => _configFolderPath;
set => SetProperty(ref _configFolderPath, value);
}
/// <summary>
/// Gets or sets a value indicating whether there are unsaved configuration changes.
/// </summary>
public bool HasUnsavedChanges
{
get => _hasUnsavedChanges;
set => SetProperty(ref _hasUnsavedChanges, value);
}
/// <summary>
/// Gets or sets the validation status message displayed to the user.
/// </summary>
public string ValidationStatus
{
get => _validationStatus;
set => SetProperty(ref _validationStatus, value);
}
/// <summary>
/// Gets or sets the brush color for the validation status indicator.
/// </summary>
public IBrush ValidationStatusColor
{
get => _validationStatusColor;
set => SetProperty(ref _validationStatusColor, value);
}
/// <summary>
/// Gets or sets the currently selected tree node in the configuration tree view.
/// </summary>
public TreeNodeViewModel? SelectedNode
{
get => _selectedNode;
@@ -62,33 +80,80 @@ public class MainWindowViewModel : ViewModelBase
}
}
/// <summary>
/// Gets or sets the view model for the form displayed when a node is selected.
/// </summary>
public object? SelectedFormViewModel
{
get => _selectedFormViewModel;
set => SetProperty(ref _selectedFormViewModel, value);
}
/// <summary>
/// Gets the collection of tree nodes representing the configuration structure.
/// </summary>
public ObservableCollection<TreeNodeViewModel> TreeNodes { get; } = [];
/// <summary>
/// Gets the command for opening a configuration folder.
/// </summary>
public ICommand OpenFolderCommand { get; }
/// <summary>
/// Gets the command for saving configuration changes.
/// </summary>
public ICommand SaveCommand { get; }
/// <summary>
/// Gets the command for exiting the application.
/// </summary>
public ICommand ExitCommand { get; }
/// <summary>
/// Gets the command for undoing the last configuration change.
/// </summary>
public ICommand UndoCommand { get; }
/// <summary>
/// Gets the command for redoing the last undone configuration change.
/// </summary>
public ICommand RedoCommand { get; }
/// <summary>
/// Gets the command for validating the current configuration.
/// </summary>
public ICommand ValidateCommand { get; }
/// <summary>
/// Gets the command for testing database connections.
/// </summary>
public ICommand TestConnectionCommand { get; }
/// <summary>
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
/// </summary>
/// <param name="fileSystem">File system abstraction for I/O operations.</param>
/// <param name="configFileService">Service for loading and saving configuration files.</param>
/// <param name="validationService">Service for validating configuration settings.</param>
/// <param name="backupService">Service for creating configuration backups.</param>
/// <param name="autoDiscoveryService">Service for discovering configuration folder locations.</param>
/// <param name="dialogService">Service for showing platform dialogs.</param>
/// <param name="logger">Optional logger for recording view model activities.</param>
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;
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
@@ -106,10 +171,12 @@ public class MainWindowViewModel : ViewModelBase
/// Design-time constructor for XAML previewer.
/// </summary>
public MainWindowViewModel() : this(
new FileSystem(),
new ConfigFileService(new FileSystem()),
new ValidationService(),
new BackupService(new FileSystem()),
new AutoDiscoveryService(new FileSystem()),
null,
null)
{
}
@@ -131,9 +198,17 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
private async Task OpenFolderAsync()
{
// TODO: Show folder picker dialog
_logger?.LogInformation("Open folder requested");
await Task.CompletedTask;
if (_dialogService == null)
{
_logger?.LogWarning("Dialog service is not available");
return;
}
var folder = await _dialogService.ShowFolderPickerAsync("Select Configuration Folder");
if (folder != null)
{
await LoadConfigAsync(folder);
}
}
/// <summary>
@@ -201,8 +276,48 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
private void OnSelectedNodeChanged()
{
// TODO: Load appropriate form ViewModel based on selected node
SelectedFormViewModel = null;
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
};
}
/// <summary>
/// Marks the configuration as having unsaved changes.
/// </summary>
private void MarkAsChanged()
{
HasUnsavedChanges = true;
if (_selectedNode != null)
_selectedNode.IsModified = true;
}
/// <summary>
/// Loads configuration for testing purposes.
/// </summary>
/// <param name="appSettings">The application settings configuration model.</param>
/// <param name="pipelines">The pipelines configuration model.</param>
public void LoadConfigForTesting(ConfigModel? appSettings, PipelinesConfigModel? pipelines)
{
_appSettings = appSettings;
_pipelines = pipelines;
BuildTreeNodes();
}
/// <summary>
@@ -0,0 +1,316 @@
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
using JdeScoping.ConfigManager.ViewModels;
using JdeScoping.ConfigManager.ViewModels.Forms;
using Microsoft.Extensions.Logging;
using NSubstitute;
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 SelectingDataAccessNode_LoadsDataAccessFormViewModel()
{
// Arrange
var config = new ConfigModel { DataAccess = new DataAccessSection { ProductionSchema = "custom" } };
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, null);
var dataAccessNode = sut.TreeNodes
.SelectMany(n => n.Children)
.First(n => n.SectionKey == "DataAccess");
// Act
sut.SelectedNode = dataAccessNode;
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<DataAccessFormViewModel>();
((DataAccessFormViewModel)sut.SelectedFormViewModel!).ProductionSchema.ShouldBe("custom");
}
[Fact]
public void SelectingAuthNode_LoadsAuthFormViewModel()
{
// Arrange
var config = new ConfigModel { Auth = new AuthSection { CookieName = "TestCookie" } };
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, null);
var authNode = sut.TreeNodes
.SelectMany(n => n.Children)
.First(n => n.SectionKey == "Auth");
// Act
sut.SelectedNode = authNode;
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<AuthFormViewModel>();
((AuthFormViewModel)sut.SelectedFormViewModel!).CookieName.ShouldBe("TestCookie");
}
[Fact]
public void SelectingLdapNode_LoadsLdapFormViewModel()
{
// Arrange
var config = new ConfigModel { Ldap = new LdapSection { GroupDn = "CN=TestGroup" } };
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, null);
var ldapNode = sut.TreeNodes
.SelectMany(n => n.Children)
.First(n => n.SectionKey == "Ldap");
// Act
sut.SelectedNode = ldapNode;
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<LdapFormViewModel>();
((LdapFormViewModel)sut.SelectedFormViewModel!).GroupDn.ShouldBe("CN=TestGroup");
}
[Fact]
public void SelectingSearchNode_LoadsSearchFormViewModel()
{
// Arrange
var config = new ConfigModel { Search = new SearchSection { MaxResultRows = 50000 } };
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, null);
var searchNode = sut.TreeNodes
.SelectMany(n => n.Children)
.First(n => n.SectionKey == "Search");
// Act
sut.SelectedNode = searchNode;
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<SearchFormViewModel>();
((SearchFormViewModel)sut.SelectedFormViewModel!).MaxResultRows.ShouldBe(50000);
}
[Fact]
public void SelectingExcelExportNode_LoadsExcelExportFormViewModel()
{
// Arrange
var config = new ConfigModel { ExcelExport = new ExcelExportSection { TimezoneId = "America/New_York" } };
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, null);
var excelNode = sut.TreeNodes
.SelectMany(n => n.Children)
.First(n => n.SectionKey == "ExcelExport");
// Act
sut.SelectedNode = excelNode;
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<ExcelExportFormViewModel>();
((ExcelExportFormViewModel)sut.SelectedFormViewModel!).TimezoneId.ShouldBe("America/New_York");
}
[Fact]
public void SelectingPipelineNode_LoadsPipelineFormViewModel()
{
// Arrange
var config = new ConfigModel();
var pipelines = new PipelinesConfigModel
{
Pipelines = new Dictionary<string, PipelineModel>
{
["WorkOrders"] = new PipelineModel
{
Source = new PipelineSource { Connection = "jde", Query = "SELECT * FROM WO" },
Destination = new PipelineDestination { Table = "WorkOrder_Curr" }
}
}
};
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, pipelines);
var pipelineNode = sut.TreeNodes
.SelectMany(n => n.Children)
.First(n => n.SectionKey == "WorkOrders");
// Act
sut.SelectedNode = pipelineNode;
// Assert
sut.SelectedFormViewModel.ShouldBeOfType<PipelineFormViewModel>();
var pipelineForm = (PipelineFormViewModel)sut.SelectedFormViewModel!;
pipelineForm.Name.ShouldBe("WorkOrders");
pipelineForm.Connection.ShouldBe("jde");
}
[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();
}
[Fact]
public void ModifyingFormProperty_MarksNodeAsModified()
{
// 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!).MaxDegreeOfParallelism = 16;
// Assert
dataSyncNode.IsModified.ShouldBeTrue();
}
[Fact]
public void SelectingFolderNode_SetsSelectedFormViewModelToNull()
{
// Arrange
var config = new ConfigModel();
var sut = CreateViewModel();
sut.LoadConfigForTesting(config, null);
var folderNode = sut.TreeNodes.First(); // Settings folder
// Act
sut.SelectedNode = folderNode;
// Assert
sut.SelectedFormViewModel.ShouldBeNull();
}
[Fact]
public void SelectingNull_SetsSelectedFormViewModelToNull()
{
// 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
sut.SelectedNode = null;
// Assert
sut.SelectedFormViewModel.ShouldBeNull();
}
[Fact]
public void LoadConfigForTesting_BuildsTreeNodes()
{
// Arrange
var config = new ConfigModel();
var sut = CreateViewModel();
// Act
sut.LoadConfigForTesting(config, null);
// Assert
sut.TreeNodes.Count.ShouldBe(2); // Settings and Pipelines folders
sut.TreeNodes[0].Name.ShouldBe("Settings");
sut.TreeNodes[0].Children.Count.ShouldBe(6); // DataSync, DataAccess, Auth, Ldap, Search, ExcelExport
}
[Fact]
public void LoadConfigForTesting_WithPipelines_BuildsPipelineNodes()
{
// Arrange
var config = new ConfigModel();
var pipelines = new PipelinesConfigModel
{
Pipelines = new Dictionary<string, PipelineModel>
{
["Pipeline1"] = new PipelineModel(),
["Pipeline2"] = new PipelineModel()
}
};
var sut = CreateViewModel();
// Act
sut.LoadConfigForTesting(config, pipelines);
// Assert
sut.TreeNodes[1].Name.ShouldBe("Pipelines");
sut.TreeNodes[1].Children.Count.ShouldBe(2);
}
private MainWindowViewModel CreateViewModel()
{
return new MainWindowViewModel(
_fileSystem,
_configFileService,
_validationService,
_backupService,
_autoDiscoveryService,
_dialogService,
_logger);
}
}