Visual Studio Extension – Part 3

Visual Studio Extension – Part 3

Last time I covered the internals of my Visual Studio extension, CG.Ruby, for generating simple CRUD repository classes. This time I’ll cover the visual part of that extension, which is the CG.Ruby.UI project.

The project’s source code is available HERE

The compiled (but unsigned) VSIX project is available HERE

The whole purpose of the CG.Ruby.UI library is to present a simple wizard UI. Because I chose to use a wizard UI, that means I needed to go find a WPF wizard control. Now, I have an old WPF wizard class, somewhere, that I was going to use for this purpose. Of course, as soon as I started looking for it, I couldn’t find it. For that reason, I chose to use my Syncfusion developer license – which includes access to their WPF control library.


Let me stop and say, the folks at Syncfusion were ridiculously generous when they gave me a community license, last year. You can get one too, if you like. HERE is the where to go for that. This model is perfect for small companies, like CODEGATOR. I get to use professional controls in my projects, that I otherwise wouldn’t be able to afford. Syncfusion gets exposure into projects that it otherwise wouldn’t have. For instance, I know Syncfusion controls for various platforms because of that community license. When I do consulting gigs, or any kind of side work, really, I bring that knowledge with me, to those projects. That means when I get asked for an opinion about a control package, Syncfusion is one of the companies I routinely bring up. I don’t bring up companies whose products I’ve never had the opportunity to use (Infragistics comes to mind). Now, I realize the reality of business is such that not every company feels like they can give a license away to a small business. But, by not doing that, those companies also give up the opportunity to have a presence in certain projects. Now, I’m sure those companies would point to a trial period in response. But, we all know that 30 days (or whatever the trial period is) isn’t long enough to learn an entire control package. Anyway, the point is, I can’t recommend a product I don’t know, and I can’t learn a product I don’t have access to. In my opinion, companies who don’t work to put their products into developer’s hands are making a mistake, and costing themselves money in the long term. End of rant …


I’ll start with a high level overview of the view-model class. I won’t do a code dump here though, because the view-model is just too large to deal with. In the future, I want to go back and break the view-model up into multiple classes. I’m thinking one view-model per wizard page. But, for now, it’s a bloated class with tons of boring to look at properties and no significant business logic.

If you want to see the code for the view-model, the project can be downloaded HERE.

The WizardWindow class’ XAML is shown here:

<syncfusion:ChromelessWindow
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:CG.Ruby.UI"
    xmlns:vm="clr-namespace:CG.Ruby.UI.ViewModels"
    xmlns:inf="clr-namespace:CG.Ruby.UI.Infrastructure"
    xmlns:ctrl="clr-namespace:System.Windows.Controls;assembly=PresentationFramework"
    xmlns:syncfusion="http://schemas.syncfusion.com/wpf" 
    x:Class="CG.Ruby.UI.WizardWindow"
    mc:Ignorable="d"
    Title="New CodeGator Repository" 
    Height="520" 
    Width="680"
    ResizeMode="CanResizeWithGrip"
    UseNativeChrome="True"
    WindowStartupLocation="CenterScreen">

    <syncfusion:ChromelessWindow.Resources>
        <ctrl:BooleanToVisibilityConverter x:Key="VisibleIfTrueConverter" />
    </syncfusion:ChromelessWindow.Resources>
    
    <syncfusion:ChromelessWindow.DataContext>
        <vm:WizardViewModel />
    </syncfusion:ChromelessWindow.DataContext>

    <Grid>
        <syncfusion:WizardControl Grid.Row="0" 
                                  SelectedPageChanged="WizardControl_SelectedPageChanged"
                                  BackEnabled="True" 
                                  FinishEnabled="False" 
                                  HelpVisible="True"
                                  VerticalAlignment="Stretch" 
                                  HorizontalAlignment="Stretch"
                                  Cancel="WizardControl_Cancel"
                                  Finish="WizardControl_Finish"
                                  Help="WizardControl_Help">
            <syncfusion:WizardPage Name="welcomePage"
                                   Title="Welcome!"
                                   Description="CodeGator repository wizard"
                                   BannerImage="/CG.Ruby.UI;component/Assets/WizardControl.png"                                
                                   BackVisible="False"
                                   CancelVisible="True"
                                   FinishVisible="False"
                                   NextPage="{Binding ElementName=step1Page}"
                                   PageType="Exterior" >
                <StackPanel Orientation="Vertical">
                    <TextBlock Margin="0,10"
                               Text="This wizard creates a new repository in your project." />
                    <TextBlock Margin="0,10" 
                               Text="You'll need to have an existing model class to use the wizard."
                               TextWrapping="Wrap"/>
                    <TextBlock Margin="0,10" 
                               Text="Press the Next button to get started!"
                               TextWrapping="Wrap"/>
                </StackPanel>
            </syncfusion:WizardPage>
            <syncfusion:WizardPage Name="step1Page"
                                   Title="Step 1"
                                   Description="Choose your repository type"
                                   BannerImage="/CG.Ruby.UI;component/Assets/WizardControl.png"                                
                                   BackVisible="True"
                                   CancelVisible="True"
                                   FinishVisible="False"
                                   NextPage="{Binding ElementName=step2Page}"
                                   NextEnabled="{Binding FormIsValid}"
                                   PageType="Exterior">
                <StackPanel Orientation="Vertical">
                    <TextBlock Margin="0,10" 
                               Text="What type of repository do you want to create?"
                               FontWeight="Medium"
                               TextWrapping="Wrap"/>
                    <TextBlock Text="Repository Types" />
                    <ComboBox x:Name="repoTypes"
                          HorizontalAlignment="Stretch"  
                          VerticalAlignment="Center" 
                          SelectedValue="{Binding SelectedRepoType, ValidatesOnDataErrors=True}"
                          ItemsSource="{Binding Source={inf:EnumBindingSource {x:Type vm:RepositoryTypes}}}" />
                    <TextBlock Text="{Binding SelectedRepoTypeError}" Foreground="Red" />
                    <StackPanel Orientation="Vertical"
                                Visibility="{Binding Path=EFCoreTypeChosen, Converter={StaticResource VisibleIfTrueConverter}}">
                        <TextBlock Margin="0,6" 
                               Text="You chose an EFCORE repository type so we need to find the data-context now."
                               TextWrapping="Wrap"/>
                        <TextBlock Text="EFCORE Context Project" />
                        <ComboBox ItemsSource="{Binding EfCoreContextProjects, Mode=TwoWay}"
                                  SelectedValue="{Binding SelectedEfCoreContextProject, Mode=TwoWay, ValidatesOnDataErrors=True}" 
                                  x:Name="efCoreProjects" />
                        <TextBlock Text="{Binding SelectedEfCoreContextProjectError}" Foreground="Red" />
                        <TextBlock Text="EFCORE Context Namespace" />
                        <ComboBox ItemsSource="{Binding EfCoreContextNamespaces, Mode=TwoWay}"
                                  SelectedValue="{Binding SelectedEfCoreContextNamespace, Mode=TwoWay, ValidatesOnDataErrors=True}" 
                                  x:Name="efCoreNamespace" />
                        <TextBlock Text="{Binding SelectedEfCoreContextNamespaceError}" Foreground="Red" />
                        <TextBlock Text="EFCORE Context Class" />
                        <ComboBox ItemsSource="{Binding EfCoreContextClasses, Mode=TwoWay}"
                                  SelectedValue="{Binding SelectedEfCoreContextClass, Mode=TwoWay, ValidatesOnDataErrors=True}" 
                                  x:Name="efCoreClass" />
                        <TextBlock Text="{Binding SelectedEfCoreContextClassError}" Foreground="Red" />
                        <CheckBox IsChecked="{Binding UseDataContextFactory}">Use Data-Context Factory</CheckBox>
                    </StackPanel>                    
                </StackPanel>                
            </syncfusion:WizardPage>
            <syncfusion:WizardPage Name="step2Page"
                                   Title="Step 2"
                                   Description="Choose a repository model"
                                   BannerImage="/CG.Ruby.UI;component/Assets/WizardControl.png"                                
                                   BackVisible="True"
                                   CancelVisible="True"
                                   FinishVisible="False"
                                   NextPage="{Binding ElementName=step3Page}"
                                   NextEnabled="{Binding FormIsValid}"
                                   PageType="Exterior" >
                <StackPanel Orientation="Vertical">
                    <TextBlock Margin="0,10" 
                               Text="We need to find the model for the repository now."
                               FontWeight="Medium"
                               TextWrapping="Wrap"/>
                    <StackPanel Orientation="Vertical">
                        <TextBlock Text="Model Project" />
                        <ComboBox ItemsSource="{Binding ModelProjects, Mode=TwoWay}"
                                  SelectedValue="{Binding SelectedModelProject, Mode=TwoWay, ValidatesOnDataErrors=True}" 
                                  x:Name="modelProjects" />
                        <TextBlock Text="{Binding SelectedModelProjectError}" Foreground="Red" />
                        <TextBlock Text="Model Namespace" />
                        <ComboBox ItemsSource="{Binding ModelNamespaces, Mode=TwoWay}"
                                  SelectedValue="{Binding SelectedModelNamespace, Mode=TwoWay, ValidatesOnDataErrors=True}" 
                                  x:Name="modelNamespace" />
                        <TextBlock Text="{Binding SelectedModelNamespaceError}" Foreground="Red" />
                        <TextBlock Text="Model Class" />
                        <ComboBox ItemsSource="{Binding ModelClasses, Mode=TwoWay}"
                                  SelectedValue="{Binding SelectedModelClass, Mode=TwoWay, ValidatesOnDataErrors=True}" 
                                  x:Name="modelClass" />
                        <TextBlock Text="{Binding SelectedModelClassError}" Foreground="Red" />
                    </StackPanel>
                </StackPanel>
            </syncfusion:WizardPage>
            <syncfusion:WizardPage Name="step3Page"
                                   Title="Step 3"
                                   Description="Choose a repository name."
                                   BannerImage="/CG.Ruby.UI;component/Assets/WizardControl.png"                                
                                   BackVisible="True"
                                   CancelVisible="True"
                                   FinishVisible="False"
                                   NextPage="{Binding ElementName=step4Page}"
                                   NextEnabled="{Binding FormIsValid}"
                                   PageType="Exterior">
            <StackPanel Orientation="Vertical">
                    <TextBlock Margin="0,10" 
                               Text="What should we call your repository?"
                               FontWeight="Medium"
                               TextWrapping="Wrap"/>
                    <TextBlock Text="Namespace" />
                <TextBox Text="{Binding NameSpace, ValidatesOnDataErrors=True}" />
                <TextBlock Text="{Binding NameSpaceError}" Foreground="Red" />
                <TextBlock Text="ClassName" />
                <TextBox Text="{Binding ClassName, ValidatesOnDataErrors=True}" />
                <TextBlock Text="{Binding ClassNameError}" Foreground="Red" />
                <TextBlock Text="IFaceName" />
                <TextBox Text="{Binding IFaceName, ValidatesOnDataErrors=True}" />
                <TextBlock Text="{Binding IFaceNameError}" Foreground="Red" />
            </StackPanel>
        </syncfusion:WizardPage>
        <syncfusion:WizardPage Name="step4Page"
                               Title="Step 4"
                               Description="Choose the repository methods."
                               BannerImage="/CG.Ruby.UI;component/Assets/WizardControl.png"                                
                               BackVisible="True"
                               CancelVisible="True"
                               FinishVisible="False"
                               NextPage="{Binding ElementName=finishPage}"
                               NextEnabled="{Binding FormIsValid}"
                               PageType="Exterior">
                <StackPanel Orientation="Vertical">
                    <TextBlock Margin="0,10" 
                               Text="What methods (if any) should we add to the repository?"
                               FontWeight="Medium"
                               TextWrapping="Wrap"/>
                    <StackPanel Orientation="Vertical" CanVerticallyScroll="True">
                        <CheckBox IsChecked="{Binding AddAnyAsync}">AnyAsync</CheckBox>
                        <CheckBox IsChecked="{Binding AddCreateAsync}">CreateAsync</CheckBox>
                        <CheckBox IsChecked="{Binding AddFindAsync}">FindAsync</CheckBox>
                        <CheckBox IsChecked="{Binding AddFindSingleAsync}">FindSingleAsync</CheckBox>
                        <CheckBox IsChecked="{Binding AddDeleteAsync}">DeleteAsync</CheckBox>
                        <CheckBox IsChecked="{Binding AddUpdateAsync}">UpdateAsync</CheckBox>
                    </StackPanel>
                </StackPanel>
            </syncfusion:WizardPage>
            <syncfusion:WizardPage Name="finishPage"
                               Title="Finished!"
                               Description="Let's review ..."
                               BannerImage="/CG.Ruby.UI;component/Assets/WizardControl.png"                                
                               BackVisible="True"
                               CancelVisible="True"
                               NextVisible="False"
                               FinishVisible="True"
                               FinishEnabled="{Binding FormIsValid}"
                               PageType="Exterior">
                <StackPanel Orientation="Vertical">
                    <DataGrid x:Name="summaryGrid"
                              ItemsSource="{Binding SummaryList}" 
                              AutoGenerateColumns="False" 
                              FontWeight="Medium"
                              VerticalAlignment="Stretch"
                              HorizontalAlignment="Stretch"
                              MaxHeight="380"
                              GridLinesVisibility="None"
                              HeadersVisibility="None">
                        <DataGrid.RowStyle>
                            <Style TargetType="DataGridRow">
                                <Setter Property="IsHitTestVisible" Value="False"/>
                            </Style>
                        </DataGrid.RowStyle>
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Value" Binding="{Binding}" IsReadOnly="True" Width="*" />
                        </DataGrid.Columns>
                    </DataGrid>
                    <TextBlock Margin="0,5"
                               Text="Press Finish to generate your repository, with these options, or press Back to make changes."
                               FontWeight="Medium"
                               TextWrapping="Wrap"/>
                </StackPanel>
            </syncfusion:WizardPage>
        </syncfusion:WizardControl>
    </Grid>
</syncfusion:ChromelessWindow>

So, the window itself derives from the Syncfusion ChromelessWindow class, which produces a nice visual for a wizard form. I didn’t have to derive from ChromelessWindow but it really does look better, that way.

The window defines a single value converter, BooleanToVisibilityConverter, which is part of the System.Windows.Controls namespace, and so, wasn’t something I had to write. This converter is used in the Visibility binding for the StackPanel that holds the controls we see popup when a user selects an EFCORE repository type. That’s how that section of controls disappears whenever the user selects a default repository type.

Next we see a bit of XAML to define the view-model for the window, in this case the WizardViewModel class. There are several ways to create a view-model instance for a WPF window, but I like to do it with XAML whenever possible.

Next we see a Grid containing a single Syncfusion WizardControl control. The wizard control contains several WizardPage controls, one for each page.

I won’t walk though each element of the XAML. It’s all pretty standard WPF controls and binding.

The code-behind for the WizardWindow form look like this:

#region Local using statements
using CG.Ruby.UI.ViewModels;
using Syncfusion.SfSkinManager;
using Syncfusion.Windows.Tools.Controls;
#endregion

namespace CG.Ruby.UI
{
    /// <summary>
    /// This class is the code-behind for the <see cref="WizardWindow"/> window.
    /// </summary>
    public partial class WizardWindow 
    {
        // *******************************************************************
        // Constructors.
        // *******************************************************************

        #region Constructors

        /// <summary>
        /// This constructor creates a new instance of the <see cref="WizardWindow"/>
        /// class.
        /// </summary>
        public WizardWindow()
        {
            // Set the Syncfusion theme.
            SfSkinManager.SetTheme(this, new Theme() { ThemeName = "MaterialDark" });

            // Make the designer happy.
            InitializeComponent();
        }

        #endregion

        // *******************************************************************
        // Private methods.
        // *******************************************************************

        #region Private methods
        
        /// <summary>
        /// This method is called when the user presses the cancel button on
        /// the wizard.
        /// </summary>
        /// <param name="sender">The event sender.</param>
        /// <param name="e">The event arguments.</param>
        private void WizardControl_Cancel(
            object sender, 
            System.Windows.RoutedEventArgs e
            )
        {
            // Cancel the dialog and close it.
            DialogResult = false;
            Close();
        }

        // *******************************************************************

        /// <summary>
        /// This method is called when the user presses the finish button on
        /// the wizard.
        /// </summary>
        /// <param name="sender">The event sender.</param>
        /// <param name="e">The event arguments.</param>
        private void WizardControl_Finish(
            object sender, 
            System.Windows.RoutedEventArgs e
            )
        {
            // Accept the dialog and close it.
            DialogResult = true;
            Close();
        }

        // *******************************************************************

        /// <summary>
        /// This method is called when the user switches pages on the wizard.
        /// </summary>
        /// <param name="sender">The event sender.</param>
        /// <param name="e">The event arguments.</param>
        private void WizardControl_SelectedPageChanged(
            object sender, 
            System.Windows.RoutedEventArgs e
            )
        {
            // Get a reference to the wizard control.
            var wizardControl = sender as WizardControl;
            if (null != wizardControl)
            {
                // Get a reference to the view-model.
                var viewModel = DataContext as WizardViewModel;
                if (null != viewModel)
                {
                    // Update the selected page property and validate.
                    viewModel.SelectedPageName = wizardControl.SelectedWizardPage.Name;
                    viewModel.OnValidate("");
                }
            }
        }

        // *******************************************************************

        /// <summary>
        /// This method is called when the user presses the help button.
        /// </summary>
        /// <param name="sender">The event sender.</param>
        /// <param name="e">The event arguments.</param>
        private void WizardControl_Help(
            object sender, 
            System.Windows.RoutedEventArgs e
            )
        {
            // Open the help site.
            System.Diagnostics.Process.Start("https://github.com/CodeGator/CG.Ruby");
        }

        #endregion
    }
}

The handlers at the bottom of the class could all have been done through XAML and WPF binding, I just didn’t know that at the time and I was pressed for time. In the future, I’ll pull that code out and do it the right way, which is through XAML.

The only handler that should be here is the one for help.

The constructor for the class includes a call to SfsSkinManager.SetTheme, which is used to ensure the overall UI theme, for my wizard window, generally matches the theme I use for Visual Studio.

There is a custom WPF markup extension class, called EnumBindingSourceExtension, that I got from HERE. Thanks again Brian for sharing that. EnumBindingSourceExtension makes it easier to use a C# enumeration as the binding source for a dropdown. I use that class in the binding for my repository type dropdown list.

That’s pretty much it. There’s a view-model base class, that I derive from for my WizardViewModel view-model class. Here is what that looks like:

#region Local using statements
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
#endregion

namespace CG.Ruby.UI.ViewModels
{
    /// <summary>
    /// This class is a base implementation of a view-model.
    /// </summary>
    public class ViewModelBase : INotifyPropertyChanged, IDataErrorInfo
    {
        // *******************************************************************
        // Fields.
        // *******************************************************************

        #region Fields

        /// <summary>
        /// This field backs the <see cref="Error"/> property.
        /// </summary>
        private string _error;

        #endregion

        // *******************************************************************
        // Events.
        // *******************************************************************

        #region Events

        /// <summary>
        /// This event is raised whenever a property value changes on the view-model.
        /// </summary>
        public event PropertyChangedEventHandler PropertyChanged;

        #endregion

        // *******************************************************************
        // Properties.
        // *******************************************************************

        #region Properties

        /// <summary>
        /// This property contains the error message for the given property.
        /// </summary>
        /// <param name="columnName">The columnn name to use for the operation.</param>
        /// <returns></returns>
        public string this[string columnName] => OnValidate(columnName);

        // *******************************************************************

        /// <summary>
        /// This property contains the error for the view-model.
        /// </summary>
        public string Error
        {
            get => _error;
            set => SetValue(ref _error, value);
        }

        #endregion

        // *******************************************************************
        // Public methods.
        // *******************************************************************

        #region Public methods

        /// <summary>
        /// This method performs a validation for the given column (property).
        /// </summary>
        /// <param name="columnName">the column name to use for the operation.</param>
        /// <returns>The validation results for the given column.</returns>
        public virtual string OnValidate(string columnName)
        {
            return "";
        }

        #endregion

        // *******************************************************************
        // Protected methods.
        // *******************************************************************

        #region Protected methods

        /// <summary>
        /// This method raises the <see cref="PropertyChanged"/> event.
        /// </summary>
        /// <param name="propertyName">The name of the property.</param>
        protected virtual void OnPropertyChanged(
            [CallerMemberName] string propertyName = ""
            )
        {
            // Raise the event.
            PropertyChanged?.Invoke(
                this,
                new PropertyChangedEventArgs(propertyName)
                );
        }

        // *******************************************************************

        /// <summary>
        /// This method sets the value of the specified property's backing 
        /// field, then calls <see cref="OnPropertyChanged(string)"/> on behalf
        /// of the property.
        /// </summary>
        /// <typeparam name="T">The type of the property.</typeparam>
        /// <param name="backingField">The backing field associated with the property.</param>
        /// <param name="value">The value to set in the property.</param>
        /// <param name="propertyName">The name of the property.</param>
        protected virtual void SetValue<T>(
            ref T backingField,
            T value,
            [CallerMemberName] string propertyName = null
            )
        {
            // Is the new value same as the old value?
            if (EqualityComparer<T>.Default.Equals(backingField, value))
            {
                return; // Nothing to do!
            }

            // Set the value of the backing field.
            backingField = value;

            // Tell the world what we did.
            OnPropertyChanged(
                propertyName
                );
        }
                
        #endregion
    }
}

The class implements the INotifyPropertyChanged and IDataErrorInfo interfaces. Both of those are a standard part of any WPF application. The only thing of any note, in this class, is the SetValue method, which is called whenever a view-model property is updated. The method updates the referenced backing field and then calls the OnPropertyChanged event to notify WPF that the binding should be refreshed. Of course, you could do all that yourself but this makes updating properties and refreshing bindings a little easier.


So there you have it. That’s the CG.Ruby Visual Studio extension for generating simple CRUD repository classes. I hope you enjoy the tool. I hope you enjoyed the article.

Photo by Dubhe Zhang on Unsplash