Monday, April 27, 2020

Using a user control to encapsulate custom context sensitive menu functionality

My last post https://wpfthoughts.blogspot.com/2020/04/dynamically-generated-and-formatted.html was a bit of a hack job. It was designed to be used only once. But my users love it so much they want to put it on a dozen different screens so I decided to encapsulate the functionality into a user control that can be dropped easily into any Infragistics XamDataGrid. It would  be fairly easy to tweak the Loaded event handler to work inside a Microsoft datagrid too.

Here are the requirements again.

  • Add a paperclip icon to a XamDataGrid row. It is only visible when the row's item has attachments.
  • When the user right-clicks, show a formatted context menu that lists the attachments.
  • When the user selects a menu item, display the attachment.

To do this, the control needs some information.

  • Which property contains the list of attachments
  • How do we want the menu items formatted
  • Which property of the attachment actually contains the attachment

So our user control needs three string dependency properties that I have called

  • AttachmentsPath
  • FormatString
  • AttachmentPath

In this example our XamDataGrid will contain a list of Payments. Each payment may have a list of Attachments which we will access using our new user control.

Consuming the user control is very easy.
<a:AttachmentControl AttachmentsPath="Attachments" FormatString="AttachmentName,200;Creator" AttachmentPath="Attachment"/>

Start by creating a new C# WPF  UserControl project in Visual Studio called "Payments". Rename the project to AttachmentsControl and rename Control1 to AttachmentsControl.

Add references to InfragisticsWPF4.DataPresenter and InfragisticsWPF4 so your references look like this.

Find a nice paperclip icon from Images.Google.com and add it to your project. I called mine paperclip.jpg. In our XAML we will define the command used to open the attachment and an Image with an empty context menu. We define a Loaded event handler which will find the attachments and decide whether the control is visible or not. We also define an Opened event handler for the context menu so we can dynamically generate its contents. The XAML looks like this.

<UserControl x:Class="AttachmentsControl.AttachmentControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:AttachmentsControl"
             mc:Ignorable="d"
             DataContext="{Binding RelativeSource={RelativeSource self}}"
             Loaded="UserControl_Loaded"
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.Resources>
        <RoutedCommand x:Key="OpenCommand"/>
    </UserControl.Resources>
    <UserControl.CommandBindings>
        <CommandBinding Command="{StaticResource OpenCommand}" Executed="Open_Executed"/>
    </UserControl.CommandBindings>
    <Image Source="paperclip.jpg" Height="16">
        <Image.ContextMenu>
            <ContextMenu Opened="ContextMenu_Opened"/>
        </Image.ContextMenu>
    </Image>
</UserControl>


Moving to the code behind let's start by defining those three dependency properties just after the class constructor.

        public static readonly DependencyProperty AttachmentsPathProperty = DependencyProperty.Register("AttachmentsPath", typeof(String), typeof(AttachmentControl), new PropertyMetadata("Attachments"));
        public static readonly DependencyProperty FormatStringProperty = DependencyProperty.Register("FormatString", typeof(String), typeof(AttachmentControl), new PropertyMetadata(""));
        public static readonly DependencyProperty AttachmentPathProperty = DependencyProperty.Register("AttachmentPath", typeof(String), typeof(AttachmentControl), new PropertyMetadata("Attachment"));

        public static string GetAttachmentsPath(DependencyObject obj)
        {
            return (string)obj.GetValue(AttachmentsPathProperty);
        }

        public static void SetAttachmentsPath(DependencyObject obj, string value)
        {
            obj.SetValue(AttachmentsPathProperty, value);
        }

        public static string GetFormatString(DependencyObject obj)
        {
            return (String)obj.GetValue(FormatStringProperty);
        }

        public static void SetFormatString(DependencyObject obj, string value)
        {
            obj.SetValue(FormatStringProperty, value);
        }

        public static String GetAttachmentPath(DependencyObject obj)
        {
            return (String)obj.GetValue(AttachmentPathProperty);
        }

        public static void SetAttachmentPath(DependencyObject obj, String value)
        {
            obj.SetValue(AttachmentPathProperty, value);
        }

The FormatString is a list of column definitions separated by semi-colons. Each column definition is a property name (in the attachment class) and an optional width separated by commas. We parse the FormatString into a list of cFormats which are defined thus. If we wanted to support alignment, font, etc. it would be done here.

      private class cFormat:IDisposable
      {
          public String MemberName;
          public Double Width;
         
          public void Dispose()
          {
          }
      }

We also need somewhere to hold a reference to the list of attachments that will populate our menu. Remember, we make no assumptions about what the contents of this list are - they are just objects. This is populated as the control is loaded and is used to populate the context menu as it opens.

IEnumerable<object> Attachments;

Let's look at the Loaded event handler. It has to find the DataRecord's attachments and decide if we are visible. We take the sender (which is a user control) and walk up the visual tree until we find an ancestor that has a DataContext that is an Infragistics DataRecord.

Using the AttachmentsPath property we get the attachments collection and store it in our Attachments field.


        private void UserControl_Loaded(object sender, RoutedEventArgs e)
        {
            string AttachmentsPath = (String)GetValue(AttachmentsPathProperty);
            object di;

            // Walk up the visual tree until I find something with a DataContext that is a DataRecord
            DependencyObject p = sender as DependencyObject;
            while (!((p as FrameworkElement).DataContext is DataRecord))
                p = VisualTreeHelper.GetParent(p);

            di = ((p as FrameworkElement).DataContext as DataRecord).DataItem;
            if (di != null) Attachments = di.GetType().GetProperty(AttachmentsPath).GetValue(di) as IEnumerable<object>;

            // am I visible?
            this.Visibility = (Attachments == null || Attachments.Count() == 0) ? Visibility.Hidden : Visibility.Visible;
        }

The meat of the code is in the ContextMenu opened event handler. If we don't have attachments or a FormatString we hide the menu, otherwise we parse the FormatString and populate the Format collection.

Then, for each attachment we create a new menu item containing a StackPanel and, for each Format, we add a TextBlock to the StackPanel. We use reflection to populate the TextBlocks. We also add the OpenCommand to each menu item and pass the attachment as the parameter. In real life, this would be a Byte(). It is important to explicitly assign the MenuItem.CommandTarget otherwise the menu item would not be selectable.

        private void ContextMenu_Opened(object sender, RoutedEventArgs e)
        {
            ContextMenu cm = (ContextMenu)sender;
            String FormatString = (String)GetValue(FormatStringProperty);
            List<cFormat> Formats = new List<cFormat>();
            RoutedCommand oa = this.FindResource("OpenCommand") as RoutedCommand;
            string AttachmentPath = (String)GetValue(AttachmentPathProperty);

            if (Attachments == null || Attachments.Count() == 0 || FormatString == "")
            {
                cm.Visibility = Visibility.Collapsed;
                return;
            }

            // Expecting property name and optional width. You can jazz this up if you want
            // eg TransmittalNumber,60;DocumentNumber,60;AttachmentName,200;Creator
            foreach (String format in FormatString.Split(';'))
            {
                List<String> FormatParts = format.Split(',').ToList();
                using (cFormat oFormat = new cFormat())
                {
                    oFormat.MemberName = FormatParts[0];
                    if (FormatParts.Count > 1)
                        Double.TryParse(FormatParts[1], out oFormat.Width);
                    else
                        oFormat.Width = double.NaN;

                    Formats.Add(oFormat);
                }
            }

            cm.Items.Clear();
            foreach (object o in Attachments)
            {
                Type t = o.GetType();
                MenuItem mi = new MenuItem();
                StackPanel sp = new StackPanel() { Orientation = Orientation.Horizontal };
                foreach (cFormat oFormat in Formats)
                {
                    TextBlock tb = new TextBlock();
                    PropertyInfo pi = t.GetProperty(oFormat.MemberName);
                    if (pi == null)
                        tb.Text = oFormat.MemberName;
                    else
                        tb.Text = (string)pi.GetValue(o);

                    tb.Width = oFormat.Width;
                    tb.Margin = new Thickness(5, 0, 0, 0);
                    tb.TextTrimming = TextTrimming.CharacterEllipsis;
                    sp.Children.Add(tb);
                }

                mi.Header = sp;
                mi.Command = oa;
                mi.CommandParameter = t.GetProperty(AttachmentPath).GetValue(o) as string;
                mi.CommandTarget = cm.PlacementTarget;
                cm.Items.Add(mi);
            }
        }

The last method we need is the OpenCommand_Executed. Normally you would use Process.Start or something to display the attachment. Ours are simple strings so I use a MessageBox.

        private void Open_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            // Replace this with a call to Process.Start
            MessageBox.Show(e.Parameter as string,"Attachment");
        }

So that's the user control. Let's see how we actually use it.

Add a WPF App called Payments to the solution. Make sure it's the Startup project. It will need references to InfragisticsWPF4.DataPresenter, InfragisticsWPF4.Editors, and InfragisticsWPF4 like this.


The XAML is a "simple" XAMDataGrid (is there really such a thing?). The user control goes into an unbound template field.

<Window x:Class="Payments.MainWindow"
        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:igDP="http://infragistics.com/DataPresenter"
        xmlns:a="clr-namespace:AttachmentsControl;assembly=AttachmentsControl"
        xmlns:local="clr-namespace:Payments"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource self}}"
        Title="Payments" Height="450" Width="800">
    <Grid>
        <igDP:XamDataGrid DataSource="{Binding Transmittals}">
            <igDP:XamDataGrid.FieldLayoutSettings>
                <igDP:FieldLayoutSettings AutoGenerateFields="False"/>
            </igDP:XamDataGrid.FieldLayoutSettings>
            <igDP:XamDataGrid.FieldSettings>
                <igDP:FieldSettings AllowSummaries="False" AllowEdit="False"/>
            </igDP:XamDataGrid.FieldSettings>
            <igDP:XamDataGrid.FieldLayouts>
                <igDP:FieldLayout Description="Transmittals" Key="Transmittals">
                    <igDP:FieldLayout.Fields>
                        <igDP:TemplateField Label="" BindingType="Unbound" Width="20">
                            <igDP:TemplateField.DisplayTemplate>
                                <DataTemplate>
                                    <a:AttachmentControl AttachmentsPath="Attachments"  FormatString="AttachmentName,200;Creator" AttachmentPath="Attachment"/>
                                </DataTemplate>
                            </igDP:TemplateField.DisplayTemplate>
                        </igDP:TemplateField>
                        <igDP:TextField Label="Payment Number" Name="PaymentNumber"/>
                        <igDP:TextField Label="Description" Name="Description" Width="auto"/>
                    </igDP:FieldLayout.Fields>
                </igDP:FieldLayout>
            </igDP:XamDataGrid.FieldLayouts>
        </igDP:XamDataGrid>
    </Grid>
</Window>

The code behind is very simple. It defines the cPayment and cAttachment classes and then populates a list of payments like so. You can see how the above AttachmentsPath property is set to the Attachments property in cPayment, the AttachmentPath property is set to the Attachment property in cAttachment, and the FormatString references properties in cAttachment.

using System.Collections.Generic;
using System.Windows;

namespace Payments
{
    public partial class MainWindow : Window
    {

        public class cPayment
        {
            public string PaymentNumber { get; set; }
            public string Description { get; set; }
            public List<cAttachment> Attachments { get; set; }
        }

        public class cAttachment
        {
            public string AttachmentName { get; set; }
            public string Attachment { get; set; }
            public string Creator { get; set; }
        }

        public List<cPayment> Transmittals
        {
            get
            {
                return new List<cPayment>()
            {
                new cPayment()
                {
                    PaymentNumber ="123456",
                    Description = "Payment to a vendor",
                    Attachments=new List<cAttachment>()
                    {
                        new cAttachment() {AttachmentName="County guidelines", Attachment="These are the detailed County Guidelines for doing things", Creator="rsnipper"},
                        new cAttachment() {AttachmentName="Vendor contract", Attachment="Here is the vendor contract covering the things we are doing", Creator="abaglady"},
                        new cAttachment() {AttachmentName="A ridiculously long attachment name that goes on forever", Attachment="Honestly, some of these attachments are way too long.", Creator="rsmith"}
                    }
                },
                new cPayment()
                {
                    PaymentNumber = "123478",
                    Description = "Payment to a different vendor",
                    Attachments=new List<cAttachment>()
                    {
                        new cAttachment() {AttachmentName="Other Vendor contract", Attachment="This is the contract for a different vendor that covers the kinds of things we're doing", Creator="sblotty"}
                    }
                },
                new cPayment()
                {
                    PaymentNumber="876543",
                    Description="Payment with no attachments"
                }
            };
            }
        }

        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

Here is the resulting context menu.