Tuesday, May 20, 2014

Using a converter to allow datagrids to sort properly

This post is for WPF 4.0.

I have a datagrid that lists reports. One column, a datetime called LastRun, tells the user when they last ran the report. Rather than simply display the date and time, I decided to display it in a more friendly way i.e. "32 minutes ago" or "Yesterday at 8:32 AM".

I wrote a utility function to do this and populated a new string column in the datagrid's ItemsSource called FriendlyLastRun. I bound the DataGridTextColumn to FriendlyLastRun and all looks good.

 
Reports sorted alphabetically

This works great until the user tries to sort on the Last Run column. It sorts as a string which means "0" is before "4" which is before "L". This is not what I want. There are two potential solutions.

The obvious solution is to specify a SortMemberPath on the column. This allows the designer to display the contents of one datacolumn but sort on the contents of another datacolumn. This works fine, but essentially requires the same data to be represented twice which is never good design and creates issues if the column is updatable. You would achieve this with the following XAML.

<DataGridTextColumn Header="Last Run" Binding="{Binding FriendlyLastRun}" SortMemberPath="LastRun"/>

A less obvious solution is to write a converter that calls the utility function. This allows me to get rid of the FriendlyLastRun datacolumn and bind to LastRun using a converter. The XAML looks like this.

<DataGridTextColumn Header="Last Run" Binding="{Binding LastRun, Converter={StaticResource DisplayAgeConverter}}"/>

Because datagrids sort on the contents of the ItemsSource and not on the displayed value, this works correctly and does not require me to hold the same information in two places. Furthermore, if Last Run was editable, the ConvertBack method of the converter would update the ItemsSource correctly.

Reports sorted most recent first

Thursday, May 15, 2014

Asynchronous WCF calls

I'm working on hooking my WPF Purchasing system to an existing, home-grown reporting system that was never intended to be called from a WPF application. The report generation is too slow to be done synchronously so I have to figure out how to make asynchronous WCF calls. Fortunately it's not too difficult.

I have already written the WCF endpoint called ExecuteCannedReport which takes a report id and returns a byte array containing the pdf representation of the report. Everything works except the call blocks the main program while the report is being created. The endpoint itself does not need to know if it is being called synchronously or asynchronously so nothing has to change there.

I started by modifying the service reference in the client so that the "Generate asynchronous operations" checkbox is checked ...


This makes Begin<service> and End<service> methods available. I can now invoke the service asynchronously like this...


    Private PurSvc As New PurchasingServiceReference.PurchasingClient
    -----------------------------------------------------------------

    PurSvc.BeginExecuteCannedReport(ReportID, AddressOf CannedReportFinished, Nothing)

Notice that the service reference (PurSvc) must be global because it must be reachable by the CannedReportFinished method that is called when the service completes.

The Begin<service> method adds two new parameters to the synchronous <service> method. The first new parameter is the address of the method that will be called when the service completes or errors. The second new parameter is an optional AsyncState which I'm not using. This line of code launches a new WCF request and immediately continues - thus it is non-blocking.

When the service completes or errors the CannedReportFinished method will be called with an IAsyncResult parameter. It might look something like this...

    Public Sub CannedReportFinished(Result As IAsyncResult)

        Dim CannedReport As PurchasingServiceReference.CannedReport = Nothing
        Try
            CannedReport = PurSvc.EndExecuteCannedReport(Result)
        Catch ex As Exception
            MessageBox.Show("Failed to create report: " & ex.Message)
        End Try

    End Sub

Calling End<service> with the correct IAsyncResult will block until that particular Begin<service> completes. The AsyncResult parameter allows you to specify which particular call you are waiting for. This allows you to architect your asynchronous calls is different ways. For example, you don't have to use a callback if you don't want to

If the endpoint raised an exception, the same exception will get raised again by the End<service> call, which can be trapped and processed as desired.

You might be tempted to update the UI in the callback. If you do so you will raise an exception that states that the UI object is owned by a different thread. Try this instead...

    Dispatcher.Invoke(Sub() UpdateWithResults())

    Public Sub UpdateWithResults()
        RTFButton.Visibility = Windows.Visibility.Visible
        PDFButton.Visibility = Windows.Visibility.Visible
        CSVButton.Visibility = Windows.Visibility.Visible
    End Sub

Thursday, May 8, 2014

Intersoft

Intersoft is a third party WPF control creator. We recently purchased their WPF Client UI package, primarily for their masked input, numeric, and currency input controls. Despite a flashy website and worldwide presence they seem to be a very rinky-dink operation.

I'm having problems registering the license key they sent me. I added it to their license manager but I still get a popup when I run the application that says "Evaluation Version". So I sent an email to their tech support a week ago - no response. Then I sent an email to their sales support three days ago - no response.

I logged onto their website (I'm not going to link to it) and signed on with the account they created for me when I bought the license. I clicked on the Live Chat button and get this total failure of a web page. Notice how the panes are truncated - page resize is disabled. This is complete garbage.



Eventually I figured out how to move forward to the real live-chat screen - which no-one ever responds to. It looks like this for ten minutes then times out.

 

The registration email also includes a contact phone number 888.548.7685. Try it - no-one picks up.

Stay away from this company - it's some kind of fly-by-night cowboy outfit. There's no support in the USA as far as I can tell.

Monday, May 5, 2014

Researching ClickOnce deployment issues

I have just installed Windows 8.1 on a spare computer at work and I'm trying to deploy my WPF application on it using ClickOnce. The application fails with a meaningless generic error message "Purchasing failed to run" with no [Details] option, which implies the problem is in my code despite the fact that I have an unhandled exception handler defined. I wanted to be sure so I started investigating what log files might be available.

If you want to control the ClickOnce log files you need to run the registry editor and browse to HKCU/Software/Classes/Software/Microsoft/Windows/CurrentVersion/Deployment. You can add/modify a string key called LogFilePath which allows you to override the path that ClickOnce uses to place its log files. You can also specify verbose log files by setting a string key called LogVerbosityLevel = 1.

By default the ClickOnce application creates log files in the Temporary Internet Files folder which is at C:\Users\<user name>\AppData\Local\Microsoft\Windows\INetCache. The log files are named System_Deployment_Log_xxxxxxx where xxxxxxxx is random obfustication. You cannot open them directly because they're actually inside a Cache. To view the contents right-click one and note the Cache Name. You can click and drag the file to another folder but it will be copied as the cache name - not the file name. You can then double click the copy and open it with notepad.

In the end it turned out that the problem was with our Publish configuration. I had recently changed some Intersoft dlls in the Application Files setup to be in a new group. These dlls were not getting downloaded to the new client so ClickOnce thought it had done it's job but the application could not launch.

Friday, May 2, 2014

DataGrid vertical scrolling issues

This post is for WPF 4.0 and 4.5

If you have a DataGrid that displays parent and child data or very tall rows and the DataGrid's scrolling area is not very high you may find yourself unable to view some of the data. This is because, by default, the DataGrid scrolling snaps to top level items. Look at the XAML and code example below. You are unable to see the last of the child rows (band one).


<Window x:Class="DataGridScrolling.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" SizeToContent="WidthAndHeight"
        DataContext="{Binding RelativeSource={RelativeSource Self}}">
    <Grid>
        <DataGrid Name="DG" ItemsSource="{Binding B0}" AutoGenerateColumns="False" IsReadOnly="true" RowDetailsVisibilityMode="Visible" 
                  Width="200" Height="100">
            <DataGrid.RowDetailsTemplate>
                <DataTemplate>
                    <DataGrid Name="RD" HeadersVisibility="None" AutoGenerateColumns="false" IsReadOnly="true" ItemsSource="{Binding B1}">
                        <DataGrid.Columns>
                            <DataGridTextColumn Width="20"/>
                            <DataGridTextColumn Binding="{Binding}"/>
                        </DataGrid.Columns>
                    </DataGrid>
                </DataTemplate>
            </DataGrid.RowDetailsTemplate>
            <DataGrid.Columns>
                <DataGridTextColumn Header="Text" Binding="{Binding Text}"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>



using System;
using System.Windows;
using System.Collections.ObjectModel;

namespace DataGridScrolling
{
    public partial class MainWindow : Window
    {
        public class cText
        {
            public string Text { get; set; }
            public ObservableCollection<string> B1 { get; set;} 

            public cText(string s)
            {
                this.Text=s;
                this.B1 = new ObservableCollection<string>();
            }
        }

        private ObservableCollection<cText> _B0 = new ObservableCollection<cText>();
        public ObservableCollection<cText> B0 { get { return _B0; } }

        public MainWindow()
        {
            B0.Add(new cText("Band 0 Row 0"));
            B0.Add(new cText("Band 0 Row 1"));

            B0[0].B1.Add("Band 1 Row 0");
            B0[0].B1.Add("Band 1 Row 1");
            B0[0].B1.Add("Band 1 Row 2");
            B0[0].B1.Add("Band 1 Row 3");
            B0[0].B1.Add("Band 1 Row 4");

            B0[1].B1.Add("Band 1 Row 0");

            InitializeComponent();
        }
    }
}

If you run this project and try to scroll down to the lower children of the first row you will see how the grid snaps to the second parent row before showing all the children of the first row. There are several ways to change this behavior


By default we scroll directly from this...

...to this

If you are targeting Framework 4.5 there is a new dependency object on the DataGrid's internal VirtualizingPanel called ScrollUnit that can be set to Item (the default) or Pixel. If we modify the XAML a little we can see how it works.

<DataGrid Name="DG" ItemsSource="{Binding B0}" AutoGenerateColumns="False" IsReadOnly="true" RowDetailsVisibilityMode="Visible" Width="200" 
          Height="100" VirtualizingPanel.ScrollUnit="Pixel">

If you're targeting Framework 4.0 (perhaps you still need to support Windows XP) you have two options. The easiest one is to set the DataGrid's ScrollViewer.CanContentScroll to false. This has the same effect but turns off virtualization of the DataGrid.

<DataGrid Name="DG" ItemsSource="{Binding B0}" AutoGenerateColumns="False" IsReadOnly="true" RowDetailsVisibilityMode="Visible" Width="200" 
          Height="100" ScrollViewer.CanContentScroll="False">

If you have a lot of rows in your DataGrid this will impact performance. However, showing a DataGrid with a lot of rows in a small viewport is not going to provide the best user experience so hopefully your DataGrid is small. If so, this is an acceptable solution.

If you must have a virtualized DataGrid you will have to work a lot harder. I won't reproduce the solution in this blog but you can find it here.

Now we can scroll halfway and see all the child rows

ClickOnce deployment error

Many WPF applications will use ClickOnce to deploy to the user's desktop. ClickOnce is fairly stable once you have it configured properly but can be obtuse when problems occur.

I recently asked a tester to download the latest version of my application and she received an error that the application could not be installed. There was nothing useful in the error she saw, but there was an [OK] and a [Details] button. I told her to click the [Details] button and a very useful looking window opened up with all sorts of interesting information.

I looked at the report and saw the following errors...

OPERATION PROGRESS STATUS
 * [5/2/2014 10:36:39 AM] : Activation of http://it86174/Purchasing/Purchasing.application?IsLogging=true&UserName=adminps&Password=MGIGCSsGAQQBgjdYA6BVMFMGCisGAQQBgjdYAwGgRTBDAgMCAAECAmYCAgIAgAQI%0d%0awKA0Mv71ERgEENxnHBqsX0ogs6bxtYoAIb4EGN0uXW%2bdLanne%2fQgo5hvEgbld1kb%0d%0anQX%2fmg%3d%3d&District=86&FiscalYear=2014&SessionID=ucd1fyqw5yidrnafufoy2ek2 has started.
 * [5/2/2014 10:36:43 AM] : Processing of deployment manifest has successfully completed.
 * [5/2/2014 10:36:43 AM] : Installation of the application has started.
 * [5/2/2014 10:36:43 AM] : Processing of application manifest has successfully completed.
 * [5/2/2014 10:36:43 AM] : Found compatible runtime version 4.0.30319.
 * [5/2/2014 10:36:43 AM] : Request of trust and detection of platform is complete.
 * [5/2/2014 10:36:45 AM] : Downloading of subscription dependencies is complete.
 * [5/2/2014 10:36:45 AM] : Commit of the downloaded application has started.

ERROR DETAILS
 Following errors were detected during this operation.
 * [5/2/2014 10:36:47 AM] System.Runtime.InteropServices.COMException
  - A device attached to the system is not functioning. (Exception from HRESULT: 0x8007001F)
  - Source: System.Deployment
  - Stack trace:
   at System.Deployment.Internal.Isolation.IStateManager.Scavenge(UInt32 Flags, UInt32& Disposition)
   at System.Deployment.Application.ComponentStore.SubmitStoreTransaction(StoreTransactionContext storeTxn, SubscriptionState subState)
   at System.Deployment.Application.ComponentStore.SubmitStoreTransactionCheckQuota(StoreTransactionContext storeTxn, SubscriptionState subState)
   at System.Deployment.Application.ComponentStore.CommitApplication(SubscriptionState subState, CommitApplicationParams commitParams)
   at System.Deployment.Application.SubscriptionStore.CommitApplication(SubscriptionState& subState, CommitApplicationParams commitParams)
   at System.Deployment.Application.ApplicationActivator.InstallApplication(SubscriptionState& subState, ActivationDescription actDesc)
   at System.Deployment.Application.ApplicationActivator.PerformDeploymentActivation(Uri activationUri, Boolean isShortcut, String textualSubId, String deploymentProviderUrlFromExtension, BrowserSettings browserSettings, String& errorPageUrl)
   at System.Deployment.Application.ApplicationActivator.ActivateDeploymentWorker(Object state)

This told me that the manifest had been processed and the problem was in the download of the payload. The important line is "A device attached to the system is not functioning."

Googling this error results in a lot of results but did not reveal the actual solution for me. I've actually seen the same error before on web sites. The solution in the past has always been to restart IIS. As the content of my ClickOnce application is hosted by IIS I gave it a try. It worked.

I went back to the tester and had her try again. Problem solved.

Interestingly I had no problem downloading the application on the same machine that was hosting it. This suggests that at least part of IIS is not involved in local installations.

Anyhow, if you see this error during a ClickOnce installation try restarting IIS on the hosting machine.