feat: add startup config validation and document ConfigManager pipeline editor

Add ConfigurationValidationRunner with IConfigurationValidator interface for
validating required settings at startup. Includes SecureStore and LDAP validators.
Expand ConfigManager with pipeline editing UI, dialogs, and step editors.
Update documentation with config validation guidance.
This commit is contained in:
Joseph Doherty
2026-01-21 17:47:15 -05:00
parent ceb63bfefb
commit e5fe2f06e9
88 changed files with 4995 additions and 201 deletions
@@ -59,11 +59,21 @@ public class PipelineModel
/// </summary>
public PipelineSchedules Schedules { get; set; } = new();
/// <summary>
/// Gets or sets optional data transformers applied between source and destination.
/// </summary>
public List<TransformerModel>? Transformers { get; set; }
/// <summary>
/// Gets or sets the destination configuration for data loading.
/// </summary>
public PipelineDestination Destination { get; set; } = new();
/// <summary>
/// Gets or sets optional scripts to execute before pipeline starts.
/// </summary>
public string[]? PreScripts { get; set; }
/// <summary>
/// Gets or sets optional scripts to execute after pipeline completion.
/// </summary>
@@ -74,23 +84,33 @@ public class PipelineSource
{
/// <summary>
/// Gets or sets the source database connection name.
/// Used for database sources.
/// </summary>
public string Connection { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the query to extract data from the source.
/// Used for database sources.
/// </summary>
public string Query { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional mass query for full data extraction.
/// Used for database sources.
/// </summary>
public string? MassQuery { get; set; }
/// <summary>
/// Gets or sets the query parameters and their definitions.
/// Used for database sources.
/// </summary>
public Dictionary<string, ParameterDefinition> Parameters { get; set; } = new();
/// <summary>
/// Gets or sets the file name for file-based sources.
/// Used for Protobuf+Zstd files.
/// </summary>
public string? FileName { get; set; }
}
public class ParameterDefinition
@@ -131,6 +151,12 @@ public class PipelineSchedules
public class PipelineDestination
{
/// <summary>
/// Gets or sets the destination type (BulkImport or BulkMerge).
/// BulkImport truncates and loads; BulkMerge matches and updates.
/// </summary>
public string Type { get; set; } = "BulkMerge";
/// <summary>
/// Gets or sets the destination table name.
/// </summary>
@@ -138,11 +164,52 @@ public class PipelineDestination
/// <summary>
/// Gets or sets the columns used to match existing records for updates.
/// Only used for BulkMerge destination type.
/// </summary>
public string[] MatchColumns { get; set; } = [];
/// <summary>
/// Gets or sets the columns to exclude from update operations.
/// Only used for BulkMerge destination type.
/// </summary>
public string[] ExcludeFromUpdate { get; set; } = [];
}
/// <summary>
/// Represents a data transformer applied between source and destination.
/// </summary>
public class TransformerModel
{
/// <summary>
/// Gets or sets the transformer type.
/// Supported types: ColumnDrop, ColumnRename, JdeDate.
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the columns affected by this transformer.
/// Used by ColumnDrop (columns to remove) and JdeDate (date/time columns).
/// </summary>
public List<string>? Columns { get; set; }
/// <summary>
/// Gets or sets the column mappings for rename operations.
/// Used by ColumnRename: OldName → NewName.
/// </summary>
public Dictionary<string, string>? Mappings { get; set; }
/// <summary>
/// Gets or sets the date column name for JdeDate transformer.
/// </summary>
public string? DateColumn { get; set; }
/// <summary>
/// Gets or sets the time column name for JdeDate transformer.
/// </summary>
public string? TimeColumn { get; set; }
/// <summary>
/// Gets or sets the output column name for JdeDate transformer.
/// </summary>
public string? OutputColumn { get; set; }
}
@@ -59,7 +59,26 @@ public class AutoDiscoveryService : IAutoDiscoveryService
return Task.FromResult<string?>(hostDir);
}
// 4. Check user config directory
// 4. Check project structure paths (for development)
// When running from bin/Debug/net10.0, go up to find src/JdeScoping.Host
var projectHostPaths = new[]
{
// From bin/Debug/net10.0 -> src/JdeScoping.Host
_fileSystem.Combine(exeDir, "..", "..", "..", "..", "..", "JdeScoping.Host"),
// Absolute fallback for development
"/Users/dohertj2/Desktop/JdeScopingTool/NEW/src/JdeScoping.Host"
};
foreach (var projectPath in projectHostPaths)
{
if (IsValidConfigFolder(projectPath))
{
_logger?.LogInformation("Found config folder in project directory: {Path}", projectPath);
return Task.FromResult<string?>(Path.GetFullPath(projectPath));
}
}
// 5. Check user config directory
var userConfigDir = GetUserConfigDirectory();
if (userConfigDir != null && IsValidConfigFolder(userConfigDir))
{
@@ -1,6 +1,7 @@
using System.Text;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using JdeScoping.ConfigManager.Views.Dialogs;
using MsBox.Avalonia;
using MsBox.Avalonia.Enums;
@@ -146,4 +147,22 @@ public class AvaloniaDialogService : IDialogService
await box.ShowAsync();
}
}
/// <inheritdoc />
public async Task<string?> ShowInputDialogAsync(string title, string prompt, string? defaultValue = null)
{
var window = _getMainWindow();
if (window == null)
return null;
var dialog = new InputDialog(title, prompt, defaultValue);
var result = await dialog.ShowDialog<bool?>(window);
if (result == true)
{
return dialog.InputText;
}
return null;
}
}
@@ -42,4 +42,13 @@ public interface IDialogService
/// <param name="appSettingsResult">Validation result for appsettings.json.</param>
/// <param name="pipelinesResult">Validation result for pipelines.json.</param>
Task ShowValidationResultsAsync(ValidationResult appSettingsResult, ValidationResult pipelinesResult);
/// <summary>
/// Shows an input dialog to collect text from the user.
/// </summary>
/// <param name="title">The dialog title.</param>
/// <param name="prompt">The prompt message to display.</param>
/// <param name="defaultValue">Optional default value for the input field.</param>
/// <returns>The text entered by the user, or null if cancelled.</returns>
Task<string?> ShowInputDialogAsync(string title, string prompt, string? defaultValue = null);
}
@@ -0,0 +1,443 @@
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.ViewModels.PipelineSteps;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// ViewModel for the visual pipeline editor with flow diagram.
/// </summary>
public class PipelineEditorViewModel : ViewModelBase
{
private readonly PipelineModel _model;
private readonly Action _onChanged;
private PipelineStepViewModelBase? _selectedStep;
private object? _selectedStepEditor;
public PipelineEditorViewModel(string name, PipelineModel model, IReadOnlyList<string> availableConnections, Action onChanged)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
_model = model ?? throw new ArgumentNullException(nameof(model));
AvailableConnections = availableConnections ?? [];
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
// Initialize collections
PreScripts = [];
Transformers = [];
PostScripts = [];
// 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);
// Build the pipeline steps from the model
BuildPipelineSteps();
// Initialize commands
AddPreScriptCommand = new RelayCommand(AddPreScript);
AddTransformerCommand = new RelayCommand(AddTransformer);
AddPostScriptCommand = new RelayCommand(AddPostScript);
RemoveStepCommand = new RelayCommand<PipelineStepViewModelBase>(RemoveStep);
MoveStepUpCommand = new RelayCommand<PipelineStepViewModelBase>(MoveStepUp, CanMoveStepUp);
MoveStepDownCommand = new RelayCommand<PipelineStepViewModelBase>(MoveStepDown, CanMoveStepDown);
}
/// <summary>
/// Gets the pipeline name.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the available connection names from configuration.
/// </summary>
public IReadOnlyList<string> AvailableConnections { get; }
/// <summary>
/// Gets the pre-script steps.
/// </summary>
public ObservableCollection<PreScriptStepViewModel> PreScripts { get; }
/// <summary>
/// Gets the source step.
/// </summary>
public SourceStepViewModel Source { get; private set; } = null!;
/// <summary>
/// Gets the transformer steps.
/// </summary>
public ObservableCollection<TransformerStepViewModelBase> Transformers { get; }
/// <summary>
/// Gets the destination step.
/// </summary>
public DestinationStepViewModel Destination { get; private set; } = null!;
/// <summary>
/// Gets the post-script steps.
/// </summary>
public ObservableCollection<PostScriptStepViewModel> PostScripts { get; }
/// <summary>
/// Gets all pipeline steps in flow order for display.
/// </summary>
public IEnumerable<PipelineStepViewModelBase> AllSteps
{
get
{
foreach (var step in PreScripts)
yield return step;
yield return Source;
foreach (var step in Transformers)
yield return step;
yield return Destination;
foreach (var step in PostScripts)
yield return step;
}
}
/// <summary>
/// Gets or sets the currently selected pipeline step.
/// </summary>
public PipelineStepViewModelBase? SelectedStep
{
get => _selectedStep;
set
{
if (_selectedStep != value)
{
// Deselect previous step
if (_selectedStep != null)
_selectedStep.IsSelected = false;
_selectedStep = value;
// Select new step
if (_selectedStep != null)
_selectedStep.IsSelected = true;
OnPropertyChanged();
UpdateSelectedStepEditor();
}
}
}
/// <summary>
/// Gets the editor view model for the currently selected step.
/// </summary>
public object? SelectedStepEditor
{
get => _selectedStepEditor;
private set => SetProperty(ref _selectedStepEditor, value);
}
/// <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; }
// Commands
public ICommand AddPreScriptCommand { get; }
public ICommand AddTransformerCommand { get; }
public ICommand AddPostScriptCommand { get; }
public ICommand RemoveStepCommand { get; }
public ICommand MoveStepUpCommand { get; }
public ICommand MoveStepDownCommand { get; }
/// <summary>
/// Gets the list of available transformer types for the add dialog.
/// </summary>
public IReadOnlyList<string> AvailableTransformerTypes => TransformerFactory.AvailableTypes;
/// <summary>
/// Property to track selected transformer type for adding.
/// </summary>
private string? _selectedTransformerType;
public string? SelectedTransformerType
{
get => _selectedTransformerType;
set => SetProperty(ref _selectedTransformerType, value);
}
private void BuildPipelineSteps()
{
// Pre-scripts
PreScripts.Clear();
if (_model.PreScripts != null)
{
foreach (var script in _model.PreScripts)
{
PreScripts.Add(new PreScriptStepViewModel(script, () =>
{
SyncPreScriptsToModel();
_onChanged();
}));
}
}
// Source
Source = new SourceStepViewModel(_model.Source, () =>
{
_onChanged();
});
// Transformers
Transformers.Clear();
if (_model.Transformers != null)
{
foreach (var transformer in _model.Transformers)
{
var vm = TransformerFactory.Create(transformer, () =>
{
SyncTransformersToModel();
_onChanged();
});
if (vm != null)
Transformers.Add(vm);
}
}
// Destination
Destination = new DestinationStepViewModel(_model.Destination, () =>
{
_onChanged();
});
// Post-scripts
PostScripts.Clear();
if (_model.PostScripts != null)
{
foreach (var script in _model.PostScripts)
{
PostScripts.Add(new PostScriptStepViewModel(script, () =>
{
SyncPostScriptsToModel();
_onChanged();
}));
}
}
OnPropertyChanged(nameof(AllSteps));
}
private void UpdateSelectedStepEditor()
{
// The selected step IS the editor - we use the step VM directly
// The view will use DataTemplates to show the appropriate editor
SelectedStepEditor = _selectedStep;
}
private void AddPreScript()
{
var step = new PreScriptStepViewModel(string.Empty, () =>
{
SyncPreScriptsToModel();
_onChanged();
});
PreScripts.Add(step);
SyncPreScriptsToModel();
_onChanged();
OnPropertyChanged(nameof(AllSteps));
SelectedStep = step;
}
private void AddTransformer()
{
// Default to ColumnDrop if nothing selected
var type = SelectedTransformerType ?? "ColumnDrop";
var vm = TransformerFactory.CreateNew(type, () =>
{
SyncTransformersToModel();
_onChanged();
});
if (vm != null)
{
Transformers.Add(vm);
SyncTransformersToModel();
_onChanged();
OnPropertyChanged(nameof(AllSteps));
SelectedStep = vm;
}
}
/// <summary>
/// Adds a specific transformer type.
/// </summary>
public void AddTransformerOfType(string typeName)
{
var vm = TransformerFactory.CreateNew(typeName, () =>
{
SyncTransformersToModel();
_onChanged();
});
if (vm != null)
{
Transformers.Add(vm);
SyncTransformersToModel();
_onChanged();
OnPropertyChanged(nameof(AllSteps));
SelectedStep = vm;
}
}
private void AddPostScript()
{
var step = new PostScriptStepViewModel(string.Empty, () =>
{
SyncPostScriptsToModel();
_onChanged();
});
PostScripts.Add(step);
SyncPostScriptsToModel();
_onChanged();
OnPropertyChanged(nameof(AllSteps));
SelectedStep = step;
}
private void RemoveStep(PipelineStepViewModelBase? step)
{
if (step == null) return;
switch (step)
{
case PreScriptStepViewModel preScript:
PreScripts.Remove(preScript);
SyncPreScriptsToModel();
break;
case TransformerStepViewModelBase transformer:
Transformers.Remove(transformer);
SyncTransformersToModel();
break;
case PostScriptStepViewModel postScript:
PostScripts.Remove(postScript);
SyncPostScriptsToModel();
break;
// Source and Destination cannot be removed
}
_onChanged();
OnPropertyChanged(nameof(AllSteps));
if (SelectedStep == step)
SelectedStep = null;
}
private bool CanMoveStepUp(PipelineStepViewModelBase? step)
{
if (step == null) return false;
return step switch
{
PreScriptStepViewModel preScript => PreScripts.IndexOf(preScript) > 0,
TransformerStepViewModelBase transformer => Transformers.IndexOf(transformer) > 0,
PostScriptStepViewModel postScript => PostScripts.IndexOf(postScript) > 0,
_ => false
};
}
private void MoveStepUp(PipelineStepViewModelBase? step)
{
if (step == null || !CanMoveStepUp(step)) return;
switch (step)
{
case PreScriptStepViewModel preScript:
MoveInCollection(PreScripts, preScript, -1);
SyncPreScriptsToModel();
break;
case TransformerStepViewModelBase transformer:
MoveInCollection(Transformers, transformer, -1);
SyncTransformersToModel();
break;
case PostScriptStepViewModel postScript:
MoveInCollection(PostScripts, postScript, -1);
SyncPostScriptsToModel();
break;
}
_onChanged();
OnPropertyChanged(nameof(AllSteps));
}
private bool CanMoveStepDown(PipelineStepViewModelBase? step)
{
if (step == null) return false;
return step switch
{
PreScriptStepViewModel preScript => PreScripts.IndexOf(preScript) < PreScripts.Count - 1,
TransformerStepViewModelBase transformer => Transformers.IndexOf(transformer) < Transformers.Count - 1,
PostScriptStepViewModel postScript => PostScripts.IndexOf(postScript) < PostScripts.Count - 1,
_ => false
};
}
private void MoveStepDown(PipelineStepViewModelBase? step)
{
if (step == null || !CanMoveStepDown(step)) return;
switch (step)
{
case PreScriptStepViewModel preScript:
MoveInCollection(PreScripts, preScript, 1);
SyncPreScriptsToModel();
break;
case TransformerStepViewModelBase transformer:
MoveInCollection(Transformers, transformer, 1);
SyncTransformersToModel();
break;
case PostScriptStepViewModel postScript:
MoveInCollection(PostScripts, postScript, 1);
SyncPostScriptsToModel();
break;
}
_onChanged();
OnPropertyChanged(nameof(AllSteps));
}
private static void MoveInCollection<T>(ObservableCollection<T> collection, T item, int offset)
{
var index = collection.IndexOf(item);
if (index < 0) return;
var newIndex = index + offset;
if (newIndex < 0 || newIndex >= collection.Count) return;
collection.Move(index, newIndex);
}
private void SyncPreScriptsToModel()
{
_model.PreScripts = PreScripts.Count > 0
? PreScripts.Select(s => s.Script).ToArray()
: null;
}
private void SyncTransformersToModel()
{
_model.Transformers = Transformers.Count > 0
? Transformers.Select(t => t.ToModel()).ToList()
: null;
}
private void SyncPostScriptsToModel()
{
_model.PostScripts = PostScripts.Count > 0
? PostScripts.Select(s => s.Script).ToArray()
: null;
}
}
@@ -183,6 +183,16 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
public ICommand DeleteSecretCommand { get; }
/// <summary>
/// Gets the command for adding a new pipeline.
/// </summary>
public ICommand AddPipelineCommand { get; }
/// <summary>
/// Gets the command for deleting the selected pipeline.
/// </summary>
public ICommand DeletePipelineCommand { get; }
/// <summary>
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
/// </summary>
@@ -235,6 +245,10 @@ public class MainWindowViewModel : ViewModelBase
AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret);
DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret);
// Pipeline commands
AddPipelineCommand = new AsyncRelayCommand(AddPipelineAsync, CanAddPipeline);
DeletePipelineCommand = new AsyncRelayCommand(DeletePipelineAsync, CanDeletePipeline);
_ = InitializeAsync();
}
@@ -271,9 +285,67 @@ public class MainWindowViewModel : ViewModelBase
if (folder != null)
{
await LoadConfigAsync(folder);
await EnsureDefaultSecureStoreAsync(folder);
}
}
/// <summary>
/// Ensures a default secure store exists and is loaded.
/// Creates one if it doesn't exist.
/// </summary>
private async Task EnsureDefaultSecureStoreAsync(string configFolder)
{
var defaultStorePath = Path.Combine(configFolder, "default.secrets.json");
var defaultKeyPath = Path.Combine(configFolder, "default.secrets.key");
try
{
// Create default store if it doesn't exist
if (!File.Exists(defaultStorePath))
{
_logger?.LogInformation("Creating default secure store at {Path}", defaultStorePath);
_secureStoreManager.CreateStore(defaultStorePath, defaultKeyPath);
// Add some example secrets
_secureStoreManager.SetSecret("jde-password", "");
_secureStoreManager.SetSecret("cms-password", "");
_secureStoreManager.SetSecret("lotfinder-password", "");
_secureStoreManager.Save();
_secureStoreManager.CloseStore();
// Rebuild tree to show the new store
BuildTreeNodes();
}
// Auto-unlock the default store if key file exists
if (File.Exists(defaultStorePath) && File.Exists(defaultKeyPath))
{
// Find the default store node in the tree
var secureStoresFolder = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStoresFolder);
var defaultStoreNode = secureStoresFolder?.Children.FirstOrDefault(n =>
n.StorePath != null && n.StorePath.EndsWith("default.secrets.json"));
if (defaultStoreNode != null)
{
_secureStoreManager.OpenStore(defaultStorePath, defaultKeyPath);
defaultStoreNode.IsUnlocked = true;
_openStores[defaultStorePath] = defaultStoreNode;
RefreshStoreChildren(defaultStoreNode);
defaultStoreNode.IsExpanded = true;
_logger?.LogInformation("Auto-unlocked default secure store");
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to initialize default secure store");
}
await Task.CompletedTask;
}
/// <summary>
/// Opens a folder picker dialog to select a configuration folder.
/// </summary>
@@ -331,28 +403,28 @@ public class MainWindowViewModel : ViewModelBase
TreeNodes.Clear();
// Settings folder
var settingsFolder = new TreeNodeViewModel("Settings", "gear", TreeNodeType.Folder) { IsExpanded = true };
settingsFolder.Children.Add(new TreeNodeViewModel("DataSync", "sync", TreeNodeType.SettingsSection) { SectionKey = "DataSync" });
settingsFolder.Children.Add(new TreeNodeViewModel("DataAccess", "database", TreeNodeType.SettingsSection) { SectionKey = "DataAccess" });
settingsFolder.Children.Add(new TreeNodeViewModel("Auth", "lock", TreeNodeType.SettingsSection) { SectionKey = "Auth" });
settingsFolder.Children.Add(new TreeNodeViewModel("Ldap", "users", TreeNodeType.SettingsSection) { SectionKey = "Ldap" });
settingsFolder.Children.Add(new TreeNodeViewModel("Search", "search", TreeNodeType.SettingsSection) { SectionKey = "Search" });
settingsFolder.Children.Add(new TreeNodeViewModel("ExcelExport", "file-spreadsheet", TreeNodeType.SettingsSection) { SectionKey = "ExcelExport" });
var settingsFolder = new TreeNodeViewModel("Settings", "⚙️", TreeNodeType.Folder) { IsExpanded = true };
settingsFolder.Children.Add(new TreeNodeViewModel("DataSync", "🔄", TreeNodeType.SettingsSection) { SectionKey = "DataSync" });
settingsFolder.Children.Add(new TreeNodeViewModel("DataAccess", "🗄️", TreeNodeType.SettingsSection) { SectionKey = "DataAccess" });
settingsFolder.Children.Add(new TreeNodeViewModel("Auth", "🔐", TreeNodeType.SettingsSection) { SectionKey = "Auth" });
settingsFolder.Children.Add(new TreeNodeViewModel("Ldap", "👥", TreeNodeType.SettingsSection) { SectionKey = "Ldap" });
settingsFolder.Children.Add(new TreeNodeViewModel("Search", "🔍", TreeNodeType.SettingsSection) { SectionKey = "Search" });
settingsFolder.Children.Add(new TreeNodeViewModel("ExcelExport", "📊", TreeNodeType.SettingsSection) { SectionKey = "ExcelExport" });
TreeNodes.Add(settingsFolder);
// Pipelines folder
var pipelinesFolder = new TreeNodeViewModel("Pipelines", "workflow", TreeNodeType.Folder) { IsExpanded = true };
var pipelinesFolder = new TreeNodeViewModel("Pipelines", "", TreeNodeType.Folder) { IsExpanded = true };
if (_pipelines != null)
{
foreach (var (name, _) in _pipelines.Pipelines)
{
pipelinesFolder.Children.Add(new TreeNodeViewModel(name, "zap", TreeNodeType.Pipeline) { SectionKey = name });
pipelinesFolder.Children.Add(new TreeNodeViewModel(name, "📦", TreeNodeType.Pipeline) { SectionKey = name });
}
}
TreeNodes.Add(pipelinesFolder);
// Secure Stores folder
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "key", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
DiscoverSecureStores(secureStoresFolder);
TreeNodes.Add(secureStoresFolder);
}
@@ -378,7 +450,7 @@ public class MainWindowViewModel : ViewModelBase
if (storeName.EndsWith(".secrets"))
storeName = storeName[..^8]; // Remove ".secrets" suffix for display
var storeNode = new TreeNodeViewModel(storeName, "lock", TreeNodeType.SecureStore)
var storeNode = new TreeNodeViewModel(storeName, "🔒", TreeNodeType.SecureStore)
{
StorePath = filePath,
SectionKey = filePath,
@@ -400,6 +472,8 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
private void OnSelectedNodeChanged()
{
RaisePipelineCommandsCanExecuteChanged();
if (_selectedNode == null)
{
SelectedFormViewModel = null;
@@ -455,7 +529,7 @@ public class MainWindowViewModel : ViewModelBase
"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)
? new PipelineEditorViewModel(_selectedNode.SectionKey!, pipeline, GetAvailableConnections(), MarkAsChanged)
: null,
_ => null
};
@@ -682,6 +756,140 @@ public class MainWindowViewModel : ViewModelBase
await Task.CompletedTask;
}
/// <summary>
/// Gets the list of available connection names from the configuration.
/// </summary>
private IReadOnlyList<string> GetAvailableConnections()
{
// Return well-known connection names for the JDE Scoping Tool
// These match the connection string names in appsettings.json
return new List<string>
{
"jde",
"cms",
"giw",
"lotfinder"
}.AsReadOnly();
}
#region Pipeline Commands
/// <summary>
/// Determines whether a new pipeline can be added.
/// </summary>
private bool CanAddPipeline()
{
return _pipelines != null;
}
/// <summary>
/// Determines whether the selected pipeline can be deleted.
/// </summary>
private bool CanDeletePipeline()
{
return _selectedNode?.NodeType == TreeNodeType.Pipeline
&& _pipelines != null;
}
/// <summary>
/// Adds a new pipeline to the configuration.
/// </summary>
private async Task AddPipelineAsync()
{
if (_pipelines == null || _dialogService == null)
return;
// Get pipeline name from user
var name = await _dialogService.ShowInputDialogAsync(
"New Pipeline",
"Enter pipeline name:");
if (string.IsNullOrWhiteSpace(name))
return;
// Check for duplicate
if (_pipelines.Pipelines.ContainsKey(name))
{
await _dialogService.ShowMessageAsync("Error",
$"Pipeline '{name}' already exists.");
return;
}
// Create default pipeline model
var pipeline = new PipelineModel
{
Source = new PipelineSource { Connection = "lotfinder", Query = "" },
Destination = new PipelineDestination { Table = name },
Schedules = new PipelineSchedules()
};
_pipelines.Pipelines[name] = pipeline;
// Add tree node
var pipelinesFolder = TreeNodes.FirstOrDefault(n =>
n.Name == "Pipelines" && n.NodeType == TreeNodeType.Folder);
if (pipelinesFolder != null)
{
var node = new TreeNodeViewModel(name, "📦", TreeNodeType.Pipeline)
{ SectionKey = name };
pipelinesFolder.Children.Add(node);
SelectedNode = node;
}
MarkAsChanged();
RaisePipelineCommandsCanExecuteChanged();
_logger?.LogInformation("Pipeline created: {Name}", name);
}
/// <summary>
/// Deletes the selected pipeline from the configuration.
/// </summary>
private async Task DeletePipelineAsync()
{
if (_selectedNode?.NodeType != TreeNodeType.Pipeline ||
_pipelines == null ||
_dialogService == null)
return;
var name = _selectedNode.SectionKey!;
var confirmed = await _dialogService.ShowConfirmationAsync(
"Delete Pipeline",
$"Are you sure you want to delete pipeline '{name}'?");
if (!confirmed)
return;
// Remove from model
_pipelines.Pipelines.Remove(name);
// Remove tree node
var pipelinesFolder = TreeNodes.FirstOrDefault(n =>
n.Name == "Pipelines" && n.NodeType == TreeNodeType.Folder);
pipelinesFolder?.Children.Remove(_selectedNode);
// Clear selection
SelectedNode = pipelinesFolder;
SelectedFormViewModel = null;
MarkAsChanged();
RaisePipelineCommandsCanExecuteChanged();
_logger?.LogInformation("Pipeline deleted: {Name}", name);
}
/// <summary>
/// Raises CanExecuteChanged for all Pipeline commands.
/// </summary>
private void RaisePipelineCommandsCanExecuteChanged()
{
(AddPipelineCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(DeletePipelineCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
#endregion
#region SecureStore Commands
/// <summary>
@@ -1014,7 +1222,7 @@ public class MainWindowViewModel : ViewModelBase
var keys = _secureStoreManager.GetKeys();
foreach (var key in keys.OrderBy(k => k))
{
var secretNode = new TreeNodeViewModel(key, "key", TreeNodeType.Secret)
var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
{
SecretKey = key,
SectionKey = key
@@ -0,0 +1,120 @@
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.ViewModels.PipelineSteps;
/// <summary>
/// Destination type for the pipeline.
/// </summary>
public enum DestinationType
{
BulkImport,
BulkMerge
}
/// <summary>
/// View model for the destination step in a pipeline.
/// </summary>
public class DestinationStepViewModel : PipelineStepViewModelBase
{
private readonly PipelineDestination _model;
public DestinationStepViewModel(PipelineDestination model, Action onChanged) : base(onChanged)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
}
public override PipelineStepType StepType => PipelineStepType.Destination;
public override string DisplayName => "Destination";
public override string Icon => "󰆼"; // mdi-database
public override string Summary => !string.IsNullOrEmpty(Table) ? $"→ {Table}" : "(no table)";
/// <summary>
/// Gets or sets the destination type (BulkImport or BulkMerge).
/// </summary>
public DestinationType Type
{
get => _model.Type?.Equals("BulkImport", StringComparison.OrdinalIgnoreCase) == true
? DestinationType.BulkImport
: DestinationType.BulkMerge;
set
{
var typeStr = value == DestinationType.BulkImport ? "BulkImport" : "BulkMerge";
if (_model.Type != typeStr)
{
_model.Type = typeStr;
OnPropertyChanged();
OnPropertyChanged(nameof(IsBulkMerge));
OnPropertyChanged(nameof(TypeDescription));
NotifyChanged();
}
}
}
/// <summary>
/// Gets whether the destination type is BulkMerge (shows match columns).
/// </summary>
public bool IsBulkMerge => Type == DestinationType.BulkMerge;
/// <summary>
/// Gets a description of the current type.
/// </summary>
public string TypeDescription => Type == DestinationType.BulkImport
? "Truncate table and bulk load all data"
: "Merge data using match columns (upsert)";
/// <summary>
/// Gets or sets the destination table name.
/// </summary>
public string Table
{
get => _model.Table;
set
{
if (_model.Table != value)
{
_model.Table = value ?? string.Empty;
OnPropertyChanged();
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the match columns as newline-separated text.
/// Only used for BulkMerge type.
/// </summary>
public string MatchColumnsText
{
get => string.Join("\n", _model.MatchColumns);
set
{
var columns = (value ?? string.Empty).Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (!_model.MatchColumns.SequenceEqual(columns))
{
_model.MatchColumns = columns;
OnPropertyChanged();
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the columns to exclude from updates as newline-separated text.
/// Only used for BulkMerge type.
/// </summary>
public string ExcludeFromUpdateText
{
get => string.Join("\n", _model.ExcludeFromUpdate);
set
{
var columns = (value ?? string.Empty).Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (!_model.ExcludeFromUpdate.SequenceEqual(columns))
{
_model.ExcludeFromUpdate = columns;
OnPropertyChanged();
NotifyChanged();
}
}
}
}
@@ -0,0 +1,148 @@
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels.PipelineSteps;
/// <summary>
/// Type of pipeline step in the flow diagram.
/// </summary>
public enum PipelineStepType
{
PreScript,
Source,
Transformer,
Destination,
PostScript
}
/// <summary>
/// Base class for all pipeline step view models in the visual flow diagram.
/// </summary>
public abstract class PipelineStepViewModelBase : ViewModelBase
{
private bool _isSelected;
private readonly Action _onChanged;
protected PipelineStepViewModelBase(Action onChanged)
{
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
}
/// <summary>
/// Gets the type of this pipeline step.
/// </summary>
public abstract PipelineStepType StepType { get; }
/// <summary>
/// Gets the display name for this step.
/// </summary>
public abstract string DisplayName { get; }
/// <summary>
/// Gets the icon character for this step (using Material Design Icons).
/// </summary>
public abstract string Icon { get; }
/// <summary>
/// Gets a short description for this step shown in the flow diagram.
/// </summary>
public abstract string Summary { get; }
/// <summary>
/// Gets or sets whether this step is currently selected.
/// </summary>
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
/// <summary>
/// Notifies that the step has changed.
/// </summary>
protected void NotifyChanged()
{
_onChanged();
}
}
/// <summary>
/// View model for a pre-script step.
/// </summary>
public class PreScriptStepViewModel : PipelineStepViewModelBase
{
private string _script;
public PreScriptStepViewModel(string script, Action onChanged) : base(onChanged)
{
_script = script ?? string.Empty;
}
public override PipelineStepType StepType => PipelineStepType.PreScript;
public override string DisplayName => "Pre-Script";
public override string Icon => "󰯂"; // mdi-script-text
public override string Summary => TruncateScript(_script);
/// <summary>
/// Gets or sets the SQL script content.
/// </summary>
public string Script
{
get => _script;
set
{
if (SetProperty(ref _script, value ?? string.Empty))
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
private static string TruncateScript(string script)
{
if (string.IsNullOrWhiteSpace(script)) return "(empty)";
var firstLine = script.Split('\n')[0].Trim();
return firstLine.Length > 30 ? firstLine[..27] + "..." : firstLine;
}
}
/// <summary>
/// View model for a post-script step.
/// </summary>
public class PostScriptStepViewModel : PipelineStepViewModelBase
{
private string _script;
public PostScriptStepViewModel(string script, Action onChanged) : base(onChanged)
{
_script = script ?? string.Empty;
}
public override PipelineStepType StepType => PipelineStepType.PostScript;
public override string DisplayName => "Post-Script";
public override string Icon => "󰯂"; // mdi-script-text
public override string Summary => TruncateScript(_script);
/// <summary>
/// Gets or sets the SQL script content.
/// </summary>
public string Script
{
get => _script;
set
{
if (SetProperty(ref _script, value ?? string.Empty))
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
private static string TruncateScript(string script)
{
if (string.IsNullOrWhiteSpace(script)) return "(empty)";
var firstLine = script.Split('\n')[0].Trim();
return firstLine.Length > 30 ? firstLine[..27] + "..." : firstLine;
}
}
@@ -0,0 +1,298 @@
using JdeScoping.ConfigManager.Models;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels.PipelineSteps;
/// <summary>
/// Source type for the pipeline.
/// </summary>
public enum SourceType
{
Database,
File
}
/// <summary>
/// View model for the source step in a pipeline.
/// </summary>
public class SourceStepViewModel : PipelineStepViewModelBase
{
private readonly PipelineSource _model;
public SourceStepViewModel(PipelineSource model, Action onChanged) : base(onChanged)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
// Initialize parameters collection
Parameters = new ObservableCollection<ParameterViewModel>(
_model.Parameters.Select(kvp => new ParameterViewModel(kvp.Key, kvp.Value, () =>
{
SyncParametersToModel();
NotifyChanged();
})));
// Initialize commands
AddParameterCommand = new RelayCommand(AddParameter);
}
public override PipelineStepType StepType => PipelineStepType.Source;
public override string DisplayName => "Source";
public override string Icon => IsFileSource ? "󰈔" : "󰆼"; // mdi-file vs mdi-database
public override string Summary => IsFileSource
? $"File: {System.IO.Path.GetFileName(FileName) ?? "(none)"}"
: $"{Connection}: {TruncateQuery(Query)}";
/// <summary>
/// Gets whether this is a file-based source.
/// </summary>
public bool IsFileSource => !string.IsNullOrEmpty(_model.FileName);
/// <summary>
/// Gets whether this is a database source.
/// </summary>
public bool IsDatabaseSource => !IsFileSource;
/// <summary>
/// Gets or sets the source type (Database or File).
/// </summary>
public SourceType SourceType
{
get => IsFileSource ? SourceType.File : SourceType.Database;
set
{
if (value == SourceType.File && !IsFileSource)
{
// Switching to file source
_model.FileName = string.Empty;
_model.Connection = string.Empty;
_model.Query = string.Empty;
_model.MassQuery = null;
OnPropertyChanged();
OnPropertyChanged(nameof(IsFileSource));
OnPropertyChanged(nameof(IsDatabaseSource));
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
else if (value == SourceType.Database && IsFileSource)
{
// Switching to database source
_model.FileName = null;
OnPropertyChanged();
OnPropertyChanged(nameof(IsFileSource));
OnPropertyChanged(nameof(IsDatabaseSource));
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the source database connection name.
/// </summary>
public string Connection
{
get => _model.Connection;
set
{
if (_model.Connection != value)
{
_model.Connection = value ?? string.Empty;
OnPropertyChanged();
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the query to extract data from the source.
/// </summary>
public string Query
{
get => _model.Query;
set
{
if (_model.Query != value)
{
_model.Query = value ?? string.Empty;
OnPropertyChanged();
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the optional mass query for full data extraction.
/// </summary>
public string? MassQuery
{
get => _model.MassQuery;
set
{
if (_model.MassQuery != value)
{
_model.MassQuery = value;
OnPropertyChanged();
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the file name for file-based sources.
/// </summary>
public string? FileName
{
get => _model.FileName;
set
{
if (_model.FileName != value)
{
_model.FileName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets the collection of query parameters.
/// </summary>
public ObservableCollection<ParameterViewModel> Parameters { get; }
/// <summary>
/// Gets the command to add a new parameter.
/// </summary>
public ICommand AddParameterCommand { get; }
/// <summary>
/// Adds a new parameter.
/// </summary>
public void AddParameter()
{
var key = $"param{Parameters.Count + 1}";
var param = new ParameterDefinition { Name = key };
var vm = new ParameterViewModel(key, param, () =>
{
SyncParametersToModel();
NotifyChanged();
});
Parameters.Add(vm);
SyncParametersToModel();
NotifyChanged();
}
/// <summary>
/// Removes a parameter.
/// </summary>
public void RemoveParameter(ParameterViewModel parameter)
{
if (Parameters.Remove(parameter))
{
SyncParametersToModel();
NotifyChanged();
}
}
private void SyncParametersToModel()
{
_model.Parameters.Clear();
foreach (var p in Parameters)
{
_model.Parameters[p.Key] = p.ToModel();
}
}
private static string TruncateQuery(string query)
{
if (string.IsNullOrWhiteSpace(query)) return "(no query)";
var trimmed = query.Trim().Replace("\n", " ").Replace("\r", "");
return trimmed.Length > 25 ? trimmed[..22] + "..." : trimmed;
}
}
/// <summary>
/// View model for a query parameter.
/// </summary>
public class ParameterViewModel : ViewModelBase
{
private string _key;
private string _name;
private string? _format;
private string? _source;
private readonly Action _onChanged;
public ParameterViewModel(string key, ParameterDefinition model, Action onChanged)
{
_key = key;
_name = model.Name;
_format = model.Format;
_source = model.Source;
_onChanged = onChanged;
}
/// <summary>
/// Gets or sets the parameter key (used in the dictionary).
/// </summary>
public string Key
{
get => _key;
set
{
if (SetProperty(ref _key, value ?? string.Empty))
_onChanged();
}
}
/// <summary>
/// Gets or sets the parameter name.
/// </summary>
public string Name
{
get => _name;
set
{
if (SetProperty(ref _name, value ?? string.Empty))
_onChanged();
}
}
/// <summary>
/// Gets or sets the parameter format (e.g., jdeJulian, jdeTime).
/// </summary>
public string? Format
{
get => _format;
set
{
if (SetProperty(ref _format, value))
_onChanged();
}
}
/// <summary>
/// Gets or sets the parameter source (e.g., offset, static).
/// </summary>
public string? Source
{
get => _source;
set
{
if (SetProperty(ref _source, value))
_onChanged();
}
}
/// <summary>
/// Converts this view model back to a model.
/// </summary>
public ParameterDefinition ToModel() => new()
{
Name = _name,
Format = _format,
Source = _source
};
}
@@ -0,0 +1,318 @@
using JdeScoping.ConfigManager.Models;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace JdeScoping.ConfigManager.ViewModels.PipelineSteps;
/// <summary>
/// Base class for transformer step view models.
/// </summary>
public abstract class TransformerStepViewModelBase : PipelineStepViewModelBase
{
protected TransformerStepViewModelBase(Action onChanged) : base(onChanged)
{
}
public override PipelineStepType StepType => PipelineStepType.Transformer;
public override string Icon => "󰁖"; // mdi-cog-transfer
/// <summary>
/// Gets the transformer type name.
/// </summary>
public abstract string TransformerType { get; }
/// <summary>
/// Converts this view model back to a model.
/// </summary>
public abstract TransformerModel ToModel();
}
/// <summary>
/// View model for ColumnDrop transformer.
/// </summary>
public class ColumnDropTransformerViewModel : TransformerStepViewModelBase
{
private string _columnsText;
public ColumnDropTransformerViewModel(TransformerModel model, Action onChanged) : base(onChanged)
{
_columnsText = model.Columns != null ? string.Join("\n", model.Columns) : string.Empty;
}
public ColumnDropTransformerViewModel(Action onChanged) : base(onChanged)
{
_columnsText = string.Empty;
}
public override string TransformerType => "ColumnDrop";
public override string DisplayName => "Column Drop";
public override string Summary => GetColumnCount() > 0 ? $"Drop {GetColumnCount()} columns" : "No columns";
/// <summary>
/// Gets or sets the columns to drop as newline-separated text.
/// </summary>
public string ColumnsText
{
get => _columnsText;
set
{
if (SetProperty(ref _columnsText, value ?? string.Empty))
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets the columns as a list.
/// </summary>
public List<string> GetColumns()
{
return _columnsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
private int GetColumnCount()
{
return _columnsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length;
}
public override TransformerModel ToModel() => new()
{
Type = TransformerType,
Columns = GetColumns()
};
}
/// <summary>
/// View model for ColumnRename transformer.
/// </summary>
public class ColumnRenameTransformerViewModel : TransformerStepViewModelBase
{
public ColumnRenameTransformerViewModel(TransformerModel model, Action onChanged) : base(onChanged)
{
Mappings = new ObservableCollection<ColumnMappingViewModel>(
model.Mappings?.Select(kvp => new ColumnMappingViewModel(kvp.Key, kvp.Value, () =>
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
})) ?? []);
AddMappingCommand = new RelayCommand(AddMapping);
}
public ColumnRenameTransformerViewModel(Action onChanged) : base(onChanged)
{
Mappings = [];
AddMappingCommand = new RelayCommand(AddMapping);
}
public override string TransformerType => "ColumnRename";
public override string DisplayName => "Column Rename";
public override string Summary => Mappings.Count > 0 ? $"Rename {Mappings.Count} columns" : "No mappings";
/// <summary>
/// Gets the collection of column mappings (old name -> new name).
/// </summary>
public ObservableCollection<ColumnMappingViewModel> Mappings { get; }
/// <summary>
/// Gets the command to add a new mapping.
/// </summary>
public ICommand AddMappingCommand { get; }
/// <summary>
/// Adds a new mapping.
/// </summary>
public void AddMapping()
{
Mappings.Add(new ColumnMappingViewModel("", "", () =>
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}));
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
/// <summary>
/// Removes a mapping.
/// </summary>
public void RemoveMapping(ColumnMappingViewModel mapping)
{
if (Mappings.Remove(mapping))
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
public override TransformerModel ToModel() => new()
{
Type = TransformerType,
Mappings = Mappings.ToDictionary(m => m.OldName, m => m.NewName)
};
}
/// <summary>
/// View model for a column rename mapping.
/// </summary>
public class ColumnMappingViewModel : ViewModelBase
{
private string _oldName;
private string _newName;
private readonly Action _onChanged;
public ColumnMappingViewModel(string oldName, string newName, Action onChanged)
{
_oldName = oldName;
_newName = newName;
_onChanged = onChanged;
}
/// <summary>
/// Gets or sets the original column name.
/// </summary>
public string OldName
{
get => _oldName;
set
{
if (SetProperty(ref _oldName, value ?? string.Empty))
_onChanged();
}
}
/// <summary>
/// Gets or sets the new column name.
/// </summary>
public string NewName
{
get => _newName;
set
{
if (SetProperty(ref _newName, value ?? string.Empty))
_onChanged();
}
}
}
/// <summary>
/// View model for JdeDate transformer (converts JDE Julian date/time to DateTime).
/// </summary>
public class JdeDateTransformerViewModel : TransformerStepViewModelBase
{
private string? _dateColumn;
private string? _timeColumn;
private string? _outputColumn;
public JdeDateTransformerViewModel(TransformerModel model, Action onChanged) : base(onChanged)
{
_dateColumn = model.DateColumn;
_timeColumn = model.TimeColumn;
_outputColumn = model.OutputColumn;
}
public JdeDateTransformerViewModel(Action onChanged) : base(onChanged)
{
_dateColumn = null;
_timeColumn = null;
_outputColumn = null;
}
public override string TransformerType => "JdeDate";
public override string DisplayName => "JDE Date Convert";
public override string Icon => "󰃭"; // mdi-calendar
public override string Summary => !string.IsNullOrEmpty(_outputColumn) ? $"→ {_outputColumn}" : "Configure...";
/// <summary>
/// Gets or sets the date column name.
/// </summary>
public string? DateColumn
{
get => _dateColumn;
set
{
if (SetProperty(ref _dateColumn, value))
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
/// <summary>
/// Gets or sets the time column name.
/// </summary>
public string? TimeColumn
{
get => _timeColumn;
set
{
if (SetProperty(ref _timeColumn, value))
NotifyChanged();
}
}
/// <summary>
/// Gets or sets the output column name.
/// </summary>
public string? OutputColumn
{
get => _outputColumn;
set
{
if (SetProperty(ref _outputColumn, value))
{
OnPropertyChanged(nameof(Summary));
NotifyChanged();
}
}
}
public override TransformerModel ToModel() => new()
{
Type = TransformerType,
DateColumn = _dateColumn,
TimeColumn = _timeColumn,
OutputColumn = _outputColumn
};
}
/// <summary>
/// Factory methods for creating transformer view models.
/// </summary>
public static class TransformerFactory
{
/// <summary>
/// Creates a transformer view model from a model.
/// </summary>
public static TransformerStepViewModelBase? Create(TransformerModel model, Action onChanged)
{
return model.Type?.ToLowerInvariant() switch
{
"columndrop" => new ColumnDropTransformerViewModel(model, onChanged),
"columnrename" => new ColumnRenameTransformerViewModel(model, onChanged),
"jdedate" => new JdeDateTransformerViewModel(model, onChanged),
_ => null // Unknown transformer type
};
}
/// <summary>
/// Creates a new transformer view model by type name.
/// </summary>
public static TransformerStepViewModelBase? CreateNew(string typeName, Action onChanged)
{
return typeName?.ToLowerInvariant() switch
{
"columndrop" => new ColumnDropTransformerViewModel(onChanged),
"columnrename" => new ColumnRenameTransformerViewModel(onChanged),
"jdedate" => new JdeDateTransformerViewModel(onChanged),
_ => null
};
}
/// <summary>
/// Gets the list of available transformer type names.
/// </summary>
public static IReadOnlyList<string> AvailableTypes => ["ColumnDrop", "ColumnRename", "JdeDate"];
}
@@ -60,3 +60,68 @@ public class RelayCommand : ICommand
/// </summary>
public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// A strongly-typed command implementation that delegates to action methods.
/// </summary>
/// <typeparam name="T">The type of the command parameter.</typeparam>
public class RelayCommand<T> : ICommand
{
private readonly Action<T?> _execute;
private readonly Predicate<T?>? _canExecute;
private EventHandler? _canExecuteChanged;
/// <summary>
/// Occurs when the result of <see cref="CanExecute"/> has changed.
/// </summary>
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
/// <summary>
/// Initializes a new instance of the <see cref="RelayCommand{T}"/> class.
/// </summary>
/// <param name="execute">The action to execute when the command is invoked.</param>
/// <param name="canExecute">An optional predicate to determine if the command can execute.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="execute"/> is null.</exception>
public RelayCommand(Action<T?> execute, Predicate<T?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
/// <summary>
/// Determines whether the command can execute in its current state.
/// </summary>
/// <param name="parameter">The parameter to pass to the canExecute predicate.</param>
/// <returns>True if the command can execute; otherwise, false.</returns>
public bool CanExecute(object? parameter)
{
if (parameter is T typedParam)
return _canExecute?.Invoke(typedParam) ?? true;
if (parameter is null && !typeof(T).IsValueType)
return _canExecute?.Invoke(default) ?? true;
return _canExecute?.Invoke(default) ?? true;
}
/// <summary>
/// Executes the command with the specified parameter.
/// </summary>
/// <param name="parameter">The parameter to pass to the execute action.</param>
public void Execute(object? parameter)
{
if (parameter is T typedParam)
_execute(typedParam);
else if (parameter is null && !typeof(T).IsValueType)
_execute(default);
else
_execute(default);
}
/// <summary>
/// Raises the <see cref="CanExecuteChanged"/> event to notify command bindings of state changes.
/// </summary>
public void RaiseCanExecuteChanged() => _canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -0,0 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="JdeScoping.ConfigManager.Views.Controls.FlowArrow"
Width="200"
Height="24">
<Canvas HorizontalAlignment="Center" Width="20" Height="24">
<!-- Vertical line -->
<Line StartPoint="10,0" EndPoint="10,16"
Stroke="#3D4550" StrokeThickness="2"/>
<!-- Arrow head (pointing down) -->
<Polygon Points="10,24 4,16 16,16"
Fill="#3D4550"/>
</Canvas>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Controls;
public partial class FlowArrow : UserControl
{
public FlowArrow()
{
InitializeComponent();
}
}
@@ -0,0 +1,53 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Controls.PipelineStepCard"
x:DataType="steps:PipelineStepViewModelBase"
Width="200">
<UserControl.Styles>
<!-- Selected state style -->
<Style Selector="Border.card">
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="#3D4550"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Border.card:pointerover">
<Setter Property="BorderBrush" Value="#5C6A7A"/>
</Style>
</UserControl.Styles>
<Border Classes="card"
Background="#1A1F26"
CornerRadius="8"
Padding="12"
MinHeight="60"
Name="CardBorder">
<Grid ColumnDefinitions="Auto,12,*">
<!-- Icon with colored background -->
<Border Grid.Column="0"
Width="36" Height="36"
CornerRadius="6"
Name="IconBackground"
Background="#3B82F6">
<TextBlock Text="{Binding Icon}"
FontSize="18"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="White"/>
</Border>
<!-- Step info -->
<StackPanel Grid.Column="2" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding DisplayName}"
Foreground="#E6EDF5"
FontWeight="SemiBold"
FontSize="13"/>
<TextBlock Text="{Binding Summary}"
Foreground="#5C6A7A"
FontSize="11"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,106 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using JdeScoping.ConfigManager.ViewModels.Forms;
using JdeScoping.ConfigManager.ViewModels.PipelineSteps;
namespace JdeScoping.ConfigManager.Views.Controls;
public partial class PipelineStepCard : UserControl
{
public static readonly StyledProperty<string> StepColorProperty =
AvaloniaProperty.Register<PipelineStepCard, string>(nameof(StepColor), "#3B82F6");
public PipelineStepCard()
{
InitializeComponent();
PointerPressed += OnPointerPressed;
PropertyChanged += OnPropertyChangedHandler;
DataContextChanged += OnDataContextChanged;
}
/// <summary>
/// Gets or sets the step color for the icon background.
/// </summary>
public string StepColor
{
get => GetValue(StepColorProperty);
set => SetValue(StepColorProperty, value);
}
private void OnPropertyChangedHandler(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == StepColorProperty)
{
UpdateIconColor();
}
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
UpdateIconColor();
UpdateSelectionState();
if (DataContext is PipelineStepViewModelBase vm)
{
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(PipelineStepViewModelBase.IsSelected))
{
UpdateSelectionState();
}
};
}
}
private void UpdateIconColor()
{
var iconBg = this.FindControl<Border>("IconBackground");
if (iconBg != null && !string.IsNullOrEmpty(StepColor))
{
if (Color.TryParse(StepColor, out var color))
{
iconBg.Background = new SolidColorBrush(color);
}
}
}
private void UpdateSelectionState()
{
var cardBorder = this.FindControl<Border>("CardBorder");
if (cardBorder != null && DataContext is PipelineStepViewModelBase vm)
{
if (vm.IsSelected)
{
cardBorder.BorderBrush = new SolidColorBrush(Color.Parse("#3B82F6"));
cardBorder.Background = new SolidColorBrush(Color.Parse("#1E2A3A"));
}
else
{
cardBorder.BorderBrush = new SolidColorBrush(Color.Parse("#3D4550"));
cardBorder.Background = new SolidColorBrush(Color.Parse("#1A1F26"));
}
}
}
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
// Find the PipelineEditorViewModel from the visual tree
var parent = this.Parent;
while (parent != null)
{
if (parent is UserControl uc && uc.DataContext is PipelineEditorViewModel editorVm)
{
if (DataContext is PipelineStepViewModelBase stepVm)
{
editorVm.SelectedStep = stepVm;
}
break;
}
parent = parent.Parent;
}
e.Handled = true;
}
}
@@ -0,0 +1,53 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="JdeScoping.ConfigManager.Views.Dialogs.InputDialog"
Title="Input"
Width="450" Height="200"
MinWidth="350" MinHeight="180"
Background="#151920"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<DockPanel>
<!-- Header -->
<Border DockPanel.Dock="Top" Background="#1C2128" Padding="24,16"
BorderBrush="#2D3540" BorderThickness="0,0,0,1">
<TextBlock x:Name="TitleText"
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
</Border>
<!-- Footer -->
<Border DockPanel.Dock="Bottom" Background="#1C2128" Padding="24,16"
BorderBrush="#2D3540" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Cancel" Click="CancelButton_Click"
Background="Transparent" BorderBrush="#3D4550"
Foreground="#9BA8B8" Padding="16,8" MinWidth="80"/>
<Button Content="OK" Click="OkButton_Click"
Background="#5C9AFF" Foreground="#0D0F12"
Padding="16,8" FontWeight="Medium" MinWidth="80"/>
</StackPanel>
</Border>
<!-- Content -->
<Grid Background="#151920" Margin="24,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Prompt -->
<TextBlock Grid.Row="0" x:Name="PromptText"
Foreground="#9BA8B8" FontSize="14"
Margin="0,0,0,12" TextWrapping="Wrap"/>
<!-- Input -->
<TextBox Grid.Row="1" x:Name="InputTextBox"
Background="#0D0F12" Foreground="#E6EDF5"
BorderBrush="#3D4550" Padding="12"
VerticalContentAlignment="Center"
FontFamily="JetBrains Mono" FontSize="14"/>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,59 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace JdeScoping.ConfigManager.Views.Dialogs;
/// <summary>
/// A simple input dialog for collecting text from the user.
/// </summary>
public partial class InputDialog : Window
{
/// <summary>
/// Gets the text entered by the user.
/// </summary>
public string? InputText => InputTextBox.Text;
/// <summary>
/// Design-time constructor for XAML previewer.
/// </summary>
public InputDialog() : this("Input", "Enter value:")
{
}
/// <summary>
/// Initializes a new instance of the <see cref="InputDialog"/> class.
/// </summary>
/// <param name="title">The dialog title.</param>
/// <param name="prompt">The prompt message to display.</param>
/// <param name="defaultValue">Optional default value for the input field.</param>
public InputDialog(string title, string prompt, string? defaultValue = null)
{
InitializeComponent();
TitleText.Text = title;
Title = title;
PromptText.Text = prompt;
if (!string.IsNullOrEmpty(defaultValue))
{
InputTextBox.Text = defaultValue;
}
// Focus the input when loaded
Loaded += (_, _) =>
{
InputTextBox.Focus();
InputTextBox.SelectAll();
};
}
private void OkButton_Click(object? sender, RoutedEventArgs e)
{
Close(true);
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Close(false);
}
}
@@ -0,0 +1,35 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.ColumnDropEditorView"
x:DataType="steps:ColumnDropTransformerViewModel">
<StackPanel Spacing="16">
<!-- Header -->
<StackPanel>
<TextBlock Text="Column Drop Transformer"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="Remove columns from the data stream"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Columns to Drop -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Columns to Drop (one per line)"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding ColumnsText}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="150"
Watermark="TempColumn1&#x0a;TempColumn2&#x0a;..."/>
<TextBlock Text="Enter column names to remove from the data, one per line"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class ColumnDropEditorView : UserControl
{
public ColumnDropEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,61 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.ColumnRenameEditorView"
x:DataType="steps:ColumnRenameTransformerViewModel">
<StackPanel Spacing="16">
<!-- Header -->
<StackPanel>
<TextBlock Text="Column Rename Transformer"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="Rename columns in the data stream"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Mappings List -->
<StackPanel Spacing="8">
<TextBlock Text="Column Mappings"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<ItemsControl ItemsSource="{Binding Mappings}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="steps:ColumnMappingViewModel">
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="*,Auto,*,8,Auto">
<TextBox Grid.Column="0"
Text="{Binding OldName}"
Background="#232A35" Height="32" FontSize="11"
FontFamily="JetBrains Mono"
Watermark="Old Name"/>
<TextBlock Grid.Column="1"
Text="→" Foreground="#5C6A7A"
FontSize="14" Margin="8,0"
VerticalAlignment="Center"/>
<TextBox Grid.Column="2"
Text="{Binding NewName}"
Background="#232A35" Height="32" FontSize="11"
FontFamily="JetBrains Mono"
Watermark="New Name"/>
<Button Grid.Column="4" Content="X"
Background="Transparent" Foreground="#FF6B6B"
BorderThickness="0" FontSize="11" Width="28"
VerticalAlignment="Center"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add Mapping"
Background="#232A35" Foreground="#9BA8B8"
BorderBrush="#3D4550" Height="32"
HorizontalAlignment="Left" Padding="12,0"
Command="{Binding AddMappingCommand}"/>
<TextBlock Text="Map original column names to new names"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class ColumnRenameEditorView : UserControl
{
public ColumnRenameEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,102 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.DestinationEditorView"
x:DataType="steps:DestinationStepViewModel">
<StackPanel Spacing="16">
<!-- Header -->
<StackPanel>
<TextBlock Text="Destination Configuration"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="Configure how data is loaded into the destination table"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Destination Type Toggle -->
<StackPanel Spacing="4">
<TextBlock Text="Load Type" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<StackPanel Orientation="Horizontal" Spacing="12">
<RadioButton GroupName="DestType"
IsChecked="{Binding IsBulkMerge}"
Foreground="#E6EDF5">
<TextBlock Text="Bulk Merge (Upsert)" FontSize="12"/>
</RadioButton>
<RadioButton GroupName="DestType"
IsChecked="{Binding !IsBulkMerge}"
Foreground="#E6EDF5">
<TextBlock Text="Bulk Import (Truncate+Load)" FontSize="12"/>
</RadioButton>
</StackPanel>
<TextBlock Text="{Binding TypeDescription}"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Destination Table -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Destination Table"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding Table}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="dbo.WorkOrder_Curr"/>
<TextBlock Text="Target table in SQL Server (include schema prefix)"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- BulkMerge-specific fields -->
<StackPanel Spacing="16" IsVisible="{Binding IsBulkMerge}">
<!-- Match Columns -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Match Columns (one per line)"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding MatchColumnsText}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="80"
Watermark="OrderNumber&#x0a;OrderType"/>
<TextBlock Text="Columns to match source rows with existing destination rows"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Exclude From Update -->
<StackPanel Spacing="4">
<TextBlock Text="Exclude From Update (one per line)"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding ExcludeFromUpdateText}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="60"
Watermark="CreatedDate&#x0a;CreatedBy"/>
<TextBlock Text="Columns to skip when updating existing rows"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
<!-- BulkImport info box -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,8,0,0"
IsVisible="{Binding !IsBulkMerge}">
<StackPanel Spacing="4">
<TextBlock Text="Bulk Import Mode" Foreground="#F59E0B" FontSize="11" FontWeight="Medium"/>
<TextBlock Text="All existing data will be deleted before loading new data."
Foreground="#5C6A7A" FontSize="10"/>
<TextBlock Text="Use this for full table refreshes during mass sync operations."
Foreground="#5C6A7A" FontSize="10"/>
</StackPanel>
</Border>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class DestinationEditorView : UserControl
{
public DestinationEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,73 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.JdeDateEditorView"
x:DataType="steps:JdeDateTransformerViewModel">
<StackPanel Spacing="16">
<!-- Header -->
<StackPanel>
<TextBlock Text="JDE Date Transformer"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="Convert JDE Julian date/time columns to DateTime"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Date Column -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Date Column"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding DateColumn}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="TRDJ"/>
<TextBlock Text="Column containing JDE Julian date (e.g., TRDJ)"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Time Column -->
<StackPanel Spacing="4">
<TextBlock Text="Time Column (Optional)"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding TimeColumn}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="TRTM"/>
<TextBlock Text="Column containing JDE time value (e.g., TRTM)"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Output Column -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Output Column"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding OutputColumn}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="TransactionDateTime"/>
<TextBlock Text="Name for the new DateTime column"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Info Box -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Text="JDE Date Format" Foreground="#9BA8B8" FontSize="11" FontWeight="Medium"/>
<TextBlock Text="JDE Julian dates are in CYYDDD format where:" Foreground="#5C6A7A" FontSize="10"/>
<TextBlock Text=" C = Century (1=20th, 2=21st)" Foreground="#5C6A7A" FontSize="10"/>
<TextBlock Text=" YY = Year" Foreground="#5C6A7A" FontSize="10"/>
<TextBlock Text=" DDD = Day of year (001-366)" Foreground="#5C6A7A" FontSize="10"/>
</StackPanel>
</Border>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class JdeDateEditorView : UserControl
{
public JdeDateEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,51 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.PostScriptEditorView"
x:DataType="steps:PostScriptStepViewModel">
<StackPanel Spacing="16">
<!-- Header (dynamic based on script type) -->
<StackPanel>
<TextBlock Text="{Binding DisplayName, StringFormat='{}{0} Configuration'}"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="SQL script to execute"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Script Content -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Script"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
</StackPanel>
<TextBox Text="{Binding Script}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="200"
Watermark="-- SQL Script&#x0a;EXEC dbo.MyProcedure @Param1"/>
<TextBlock Text="SQL script or stored procedure call to execute"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Info Box -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Script Execution" Foreground="#9BA8B8" FontSize="11" FontWeight="Medium"/>
<TextBlock Foreground="#5C6A7A" FontSize="10">
<Run Text="Pre-scripts run before data extraction."/>
</TextBlock>
<TextBlock Foreground="#5C6A7A" FontSize="10">
<Run Text="Post-scripts run after data loading completes."/>
</TextBlock>
<TextBlock Foreground="#5C6A7A" FontSize="10">
<Run Text="Use for index management, statistics updates, or cleanup tasks."/>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class PostScriptEditorView : UserControl
{
public PostScriptEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,51 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.ScriptEditorView"
x:DataType="steps:PreScriptStepViewModel">
<StackPanel Spacing="16">
<!-- Header (dynamic based on script type) -->
<StackPanel>
<TextBlock Text="{Binding DisplayName, StringFormat='{}{0} Configuration'}"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="SQL script to execute"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Script Content -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Script"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
</StackPanel>
<TextBox Text="{Binding Script}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="200"
Watermark="-- SQL Script&#x0a;EXEC dbo.MyProcedure @Param1"/>
<TextBlock Text="SQL script or stored procedure call to execute"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Info Box -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Script Execution" Foreground="#9BA8B8" FontSize="11" FontWeight="Medium"/>
<TextBlock Foreground="#5C6A7A" FontSize="10">
<Run Text="Pre-scripts run before data extraction."/>
</TextBlock>
<TextBlock Foreground="#5C6A7A" FontSize="10">
<Run Text="Post-scripts run after data loading completes."/>
</TextBlock>
<TextBlock Foreground="#5C6A7A" FontSize="10">
<Run Text="Use for index management, statistics updates, or cleanup tasks."/>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class ScriptEditorView : UserControl
{
public ScriptEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,156 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
x:Class="JdeScoping.ConfigManager.Views.Editors.SourceEditorView"
x:DataType="steps:SourceStepViewModel">
<StackPanel Spacing="16">
<!-- Header -->
<StackPanel>
<TextBlock Text="Source Configuration"
Foreground="#E6EDF5" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="Configure the data source for this pipeline"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Source Type Toggle -->
<StackPanel Spacing="4">
<TextBlock Text="Source Type" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<StackPanel Orientation="Horizontal" Spacing="12">
<RadioButton GroupName="SourceType"
IsChecked="{Binding IsDatabaseSource}"
Foreground="#E6EDF5">
<TextBlock Text="Database" FontSize="12"/>
</RadioButton>
<RadioButton GroupName="SourceType"
IsChecked="{Binding IsFileSource}"
Foreground="#E6EDF5">
<TextBlock Text="File" FontSize="12"/>
</RadioButton>
</StackPanel>
</StackPanel>
<!-- Database Source Fields -->
<StackPanel Spacing="16" IsVisible="{Binding IsDatabaseSource}">
<!-- Connection -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Connection"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding Connection}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="jde"/>
<TextBlock Text="Connection string name (e.g., jde, cms, giw)"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Query -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="Query"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding Query}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="100"
Watermark="SELECT ... FROM ... WHERE ..."/>
<TextBlock Text="SQL query for incremental updates (use @LastSync parameter)"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Mass Query -->
<StackPanel Spacing="4">
<TextBlock Text="Mass Query (Optional)"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding MassQuery}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="80"
Watermark="SELECT ... FROM ... (no date filter)"/>
<TextBlock Text="Query for full table reload during mass sync"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- Parameters Section -->
<Expander IsExpanded="False">
<Expander.Header>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Parameters" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="{Binding Parameters.Count, StringFormat='({0})'}"
Foreground="#5C6A7A" FontSize="12"/>
</StackPanel>
</Expander.Header>
<StackPanel Spacing="8" Margin="0,8,0,0">
<ItemsControl ItemsSource="{Binding Parameters}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="steps:ParameterViewModel">
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="*,8,*,8,*,8,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="Key" Foreground="#5C6A7A" FontSize="10"/>
<TextBox Text="{Binding Key}"
Background="#232A35" Height="28" FontSize="11"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock Text="Format" Foreground="#5C6A7A" FontSize="10"/>
<TextBox Text="{Binding Format}"
Background="#232A35" Height="28" FontSize="11"
Watermark="jdeJulian"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="2">
<TextBlock Text="Source" Foreground="#5C6A7A" FontSize="10"/>
<TextBox Text="{Binding Source}"
Background="#232A35" Height="28" FontSize="11"
Watermark="offset"/>
</StackPanel>
<Button Grid.Column="6" Content="X"
Background="Transparent" Foreground="#FF6B6B"
BorderThickness="0" FontSize="11" Width="24"
VerticalAlignment="Bottom"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add Parameter"
Background="#232A35" Foreground="#9BA8B8"
BorderBrush="#3D4550" Height="32"
HorizontalAlignment="Left" Padding="12,0"
Command="{Binding AddParameterCommand}"/>
</StackPanel>
</Expander>
</StackPanel>
<!-- File Source Fields -->
<StackPanel Spacing="16" IsVisible="{Binding IsFileSource}">
<!-- File Name -->
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="2">
<TextBlock Text="File Name"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBlock Text="*" Foreground="#FF6B6B" FontSize="12"/>
</StackPanel>
<TextBox Text="{Binding FileName}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="data.pb.zstd"/>
<TextBlock Text="Protobuf+Zstd compressed file name"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Editors;
public partial class SourceEditorView : UserControl
{
public SourceEditorView()
{
InitializeComponent();
}
}
@@ -0,0 +1,210 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
xmlns:controls="using:JdeScoping.ConfigManager.Views.Controls"
xmlns:editors="using:JdeScoping.ConfigManager.Views.Editors"
x:Class="JdeScoping.ConfigManager.Views.Forms.PipelineEditorView"
x:DataType="vm:PipelineEditorViewModel">
<UserControl.Resources>
<!-- Step card colors by type -->
<SolidColorBrush x:Key="PreScriptBrush" Color="#8B5CF6"/>
<SolidColorBrush x:Key="SourceBrush" Color="#3B82F6"/>
<SolidColorBrush x:Key="TransformerBrush" Color="#F59E0B"/>
<SolidColorBrush x:Key="DestinationBrush" Color="#10B981"/>
<SolidColorBrush x:Key="PostScriptBrush" Color="#EC4899"/>
</UserControl.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" MinWidth="180"/>
<ColumnDefinition Width="1"/>
<ColumnDefinition Width="*" MinWidth="300"/>
<ColumnDefinition Width="1"/>
<ColumnDefinition Width="350" MinWidth="280"/>
</Grid.ColumnDefinitions>
<!-- Left Panel: Toolbar and Add Buttons -->
<Border Grid.Column="0" Background="#0D0F12" Padding="12">
<StackPanel Spacing="16">
<!-- Header -->
<TextBlock Text="{Binding Name, StringFormat='{}{0}'}"
Foreground="#E6EDF5" FontSize="16" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<Border Height="1" Background="#2D3540"/>
<!-- Add Steps Section -->
<TextBlock Text="ADD STEPS" Foreground="#5C6A7A" FontSize="11" FontWeight="Medium"/>
<!-- Add Pre-Script Button -->
<Button Command="{Binding AddPreScriptCommand}"
Background="#151920" Foreground="#E6EDF5"
BorderBrush="#2D3540" BorderThickness="1"
HorizontalAlignment="Stretch" Height="36"
HorizontalContentAlignment="Left" Padding="12,0">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="+" Foreground="#8B5CF6" FontWeight="Bold" FontSize="14"/>
<TextBlock Text="Pre-Script" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- Add Transformer Dropdown -->
<StackPanel Spacing="4">
<ComboBox ItemsSource="{Binding AvailableTransformerTypes}"
SelectedItem="{Binding SelectedTransformerType}"
Background="#151920" Foreground="#E6EDF5"
BorderBrush="#2D3540" BorderThickness="1"
HorizontalAlignment="Stretch" Height="32"
PlaceholderText="Select Transformer..."/>
<Button Command="{Binding AddTransformerCommand}"
Background="#151920" Foreground="#E6EDF5"
BorderBrush="#2D3540" BorderThickness="1"
HorizontalAlignment="Stretch" Height="36"
HorizontalContentAlignment="Left" Padding="12,0">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="+" Foreground="#F59E0B" FontWeight="Bold" FontSize="14"/>
<TextBlock Text="Transformer" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
<!-- Add Post-Script Button -->
<Button Command="{Binding AddPostScriptCommand}"
Background="#151920" Foreground="#E6EDF5"
BorderBrush="#2D3540" BorderThickness="1"
HorizontalAlignment="Stretch" Height="36"
HorizontalContentAlignment="Left" Padding="12,0">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="+" Foreground="#EC4899" FontWeight="Bold" FontSize="14"/>
<TextBlock Text="Post-Script" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Border Height="1" Background="#2D3540" Margin="0,8,0,0"/>
<!-- Schedules Section -->
<Expander IsExpanded="False">
<Expander.Header>
<TextBlock Text="Schedules" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
</Expander.Header>
<StackPanel Spacing="8" Margin="0,8,0,0">
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox IsChecked="{Binding MassSchedule.Enabled}"/>
<TextBlock Text="Mass" Foreground="#9BA8B8" FontSize="12"/>
<NumericUpDown Value="{Binding MassSchedule.IntervalMinutes}"
Minimum="1" Width="80" Height="28"
Background="#232A35" FontSize="11"
IsEnabled="{Binding MassSchedule.Enabled}"/>
<TextBlock Text="min" Foreground="#5C6A7A" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox IsChecked="{Binding DailySchedule.Enabled}"/>
<TextBlock Text="Daily" Foreground="#9BA8B8" FontSize="12"/>
<NumericUpDown Value="{Binding DailySchedule.IntervalMinutes}"
Minimum="1" Width="80" Height="28"
Background="#232A35" FontSize="11"
IsEnabled="{Binding DailySchedule.Enabled}"/>
<TextBlock Text="min" Foreground="#5C6A7A" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox IsChecked="{Binding HourlySchedule.Enabled}"/>
<TextBlock Text="Hourly" Foreground="#9BA8B8" FontSize="12"/>
<NumericUpDown Value="{Binding HourlySchedule.IntervalMinutes}"
Minimum="1" Width="80" Height="28"
Background="#232A35" FontSize="11"
IsEnabled="{Binding HourlySchedule.Enabled}"/>
<TextBlock Text="min" Foreground="#5C6A7A" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
</Expander>
</StackPanel>
</Border>
<!-- Divider -->
<Border Grid.Column="1" Background="#2D3540"/>
<!-- Center Panel: Visual Pipeline Flow -->
<Border Grid.Column="2" Background="#151920" Padding="16">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="0" HorizontalAlignment="Center">
<!-- Pre-Scripts -->
<ItemsControl ItemsSource="{Binding PreScripts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel>
<controls:PipelineStepCard DataContext="{Binding}"
StepColor="#8B5CF6"/>
<controls:FlowArrow/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Source Step (always present) -->
<controls:PipelineStepCard DataContext="{Binding Source}"
StepColor="#3B82F6"/>
<controls:FlowArrow/>
<!-- Transformers -->
<ItemsControl ItemsSource="{Binding Transformers}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel>
<controls:PipelineStepCard DataContext="{Binding}"
StepColor="#F59E0B"/>
<controls:FlowArrow/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Destination Step (always present) -->
<controls:PipelineStepCard DataContext="{Binding Destination}"
StepColor="#10B981"/>
<!-- Post-Scripts -->
<ItemsControl ItemsSource="{Binding PostScripts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel>
<controls:FlowArrow/>
<controls:PipelineStepCard DataContext="{Binding}"
StepColor="#EC4899"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Divider -->
<Border Grid.Column="3" Background="#2D3540"/>
<!-- Right Panel: Properties Editor -->
<Border Grid.Column="4" Background="#0D0F12" Padding="16">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="16">
<!-- Properties Header -->
<StackPanel>
<TextBlock Text="PROPERTIES"
Foreground="#5C6A7A" FontSize="11" FontWeight="Medium"/>
<Border Height="1" Background="#2D3540" Margin="0,8,0,0"/>
</StackPanel>
<!-- Step Editor Content (changes based on selection) -->
<!-- Shows placeholder text when nothing selected, otherwise uses DataTemplates from MainWindow -->
<TextBlock Text="Select a step to edit its properties"
Foreground="#5C6A7A" FontSize="12"
FontStyle="Italic"
IsVisible="{Binding SelectedStepEditor, Converter={x:Static ObjectConverters.IsNull}}"/>
<ContentControl Content="{Binding SelectedStepEditor}"
IsVisible="{Binding SelectedStepEditor, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Forms;
public partial class PipelineEditorView : UserControl
{
public PipelineEditorView()
{
InitializeComponent();
}
}
@@ -2,7 +2,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels"
xmlns:forms="using:JdeScoping.ConfigManager.ViewModels.Forms"
xmlns:steps="using:JdeScoping.ConfigManager.ViewModels.PipelineSteps"
xmlns:views="using:JdeScoping.ConfigManager.Views.Forms"
xmlns:editors="using:JdeScoping.ConfigManager.Views.Editors"
x:Class="JdeScoping.ConfigManager.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="JdeScoping ConfigManager"
@@ -15,6 +17,7 @@
</Design.DataContext>
<Window.DataTemplates>
<!-- Settings Form ViewModels -->
<DataTemplate DataType="{x:Type forms:DataSyncFormViewModel}">
<views:DataSyncFormView/>
</DataTemplate>
@@ -33,9 +36,36 @@
<DataTemplate DataType="{x:Type forms:ExcelExportFormViewModel}">
<views:ExcelExportFormView/>
</DataTemplate>
<DataTemplate DataType="{x:Type forms:PipelineFormViewModel}">
<views:PipelineFormView/>
<!-- Pipeline Editor (replaces PipelineFormViewModel) -->
<DataTemplate DataType="{x:Type forms:PipelineEditorViewModel}">
<views:PipelineEditorView/>
</DataTemplate>
<!-- Pipeline Step Editors (for the properties panel) -->
<DataTemplate DataType="{x:Type steps:SourceStepViewModel}">
<editors:SourceEditorView/>
</DataTemplate>
<DataTemplate DataType="{x:Type steps:DestinationStepViewModel}">
<editors:DestinationEditorView/>
</DataTemplate>
<DataTemplate DataType="{x:Type steps:ColumnDropTransformerViewModel}">
<editors:ColumnDropEditorView/>
</DataTemplate>
<DataTemplate DataType="{x:Type steps:ColumnRenameTransformerViewModel}">
<editors:ColumnRenameEditorView/>
</DataTemplate>
<DataTemplate DataType="{x:Type steps:JdeDateTransformerViewModel}">
<editors:JdeDateEditorView/>
</DataTemplate>
<DataTemplate DataType="{x:Type steps:PreScriptStepViewModel}">
<editors:ScriptEditorView/>
</DataTemplate>
<DataTemplate DataType="{x:Type steps:PostScriptStepViewModel}">
<editors:PostScriptEditorView/>
</DataTemplate>
<!-- SecureStore ViewModels -->
<DataTemplate DataType="{x:Type forms:SecureStoreLockedFormViewModel}">
<views:SecureStoreLockedFormView/>
</DataTemplate>
@@ -66,6 +96,18 @@
<Separator/>
<MenuItem Header="View _Backups..."/>
</MenuItem>
<MenuItem Header="_Pipelines">
<MenuItem Header="_New Pipeline..." Command="{Binding AddPipelineCommand}" InputGesture="Ctrl+Shift+P">
<MenuItem.Icon>
<TextBlock Text="+" FontSize="14" FontWeight="Bold"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="_Delete Pipeline" Command="{Binding DeletePipelineCommand}">
<MenuItem.Icon>
<TextBlock Text="X" FontSize="12" FontWeight="Bold"/>
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="_Secure Stores">
<MenuItem Header="_New Store..." Command="{Binding NewStoreCommand}" InputGesture="Ctrl+Shift+N">
<MenuItem.Icon>
@@ -113,6 +155,8 @@
<Button Content="Test" Command="{Binding TestConnectionCommand}" Classes="toolbar"/>
<Button Content="Validate" Command="{Binding ValidateCommand}" Classes="toolbar"/>
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
<Button Content="+ Pipeline" Command="{Binding AddPipelineCommand}" ToolTip.Tip="Add Pipeline" Classes="toolbar"/>
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
<Button Content="Unlock" Command="{Binding UnlockStoreCommand}" ToolTip.Tip="Unlock/Lock Store" Classes="toolbar"/>
<Button Content="+ Secret" Command="{Binding AddSecretCommand}" ToolTip.Tip="Add Secret" Classes="toolbar"/>
</StackPanel>
@@ -166,6 +210,9 @@
Margin="8">
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem Header="New Pipeline..." Command="{Binding AddPipelineCommand}"/>
<MenuItem Header="Delete Pipeline" Command="{Binding DeletePipelineCommand}"/>
<Separator/>
<MenuItem Header="Unlock Store..." Command="{Binding UnlockStoreCommand}"/>
<MenuItem Header="Lock Store" Command="{Binding LockStoreCommand}"/>
<Separator/>