The Need for Smart Search
Unlike plug-in scripts, standalone apps don't get a ScriptContext with an opened patient. Instead, standalone apps must open the patient by its ID or by a PatientSummary object. The PatientSummaries property in the Application class provides a list of PatientSummary objects for the patients in the system.
For batch applications, the patients to be opened may be specified in a file. But for apps with a graphical interface, users must somehow specify the patient to be opened.
One possible way to do this is for the app's UI to show a text box where the user can enter the patient's ID. The problem is that the user may not know or remember the patient's ID. Perhaps the user only knows the patient's name.
Alternatively, the UI may show a list of patients (via PatientSummaries) where the user chooses the patient to open. The problem is that this list may be very long, which not only makes it difficult for the user to find the patient but may slow down the program.
A better solution is a "smart search" box. As you type in some patient information, like the ID, first name, or last name, you are shown a list of possible matches. The Eclipse application already has this functionality, but it is not available in ESAPI.
In this blog post, I'm going to show you how to build such a smart search box using WPF and some functions provided by ESAPI. I've integrated this functionality into my EclipsePlugInRunner tool, which I described in Run and Test Plug-In Scripts from Visual Studio. Briefly, it allows you to run binary plug-in scripts from Visual Studio without having to open Eclipse.
The SmartSearch Class
I'm going to encapsulate the smart search functionality into a class called SmartSearch. It has a single method called GetMatches that takes the search text (for example, "anders"), and returns a list of patients (PatientSummary objects) that best match the search text.
The algorithm for whether a patient matches the search text is as follows. (Note that I came up with this algorithm—it is not necessarily the one used by Eclipse.) If the search text is a single word, then it is a match if either the patient's ID, first name, or last name contain the search word.
If the search text is two words, for example, "Smith, John", it is a match if either the patient's first name or last name contain any of the search words. Any additional words in the search text are ignored.
The match results are sorted by the creation date of the patient, and only the first few are kept. The list is cut off because there could be thousands of results when the user is first typing in the search text (for example, "a").
Here's the full implementation of the SmartSearch class:
public class SmartSearch
{
private const int MaximumResults = 20;
private readonly IEnumerable<PatientSummary> _patients;
public SmartSearch(IEnumerable<PatientSummary> patients)
{
_patients = patients;
}
public IEnumerable<PatientSummary> GetMatches(string searchText)
{
return !string.IsNullOrEmpty(searchText)
? _patients
.Where(p => IsMatch(p, searchText))
.OrderByDescending(p => p.CreationDateTime)
.Take(MaximumResults)
: new PatientSummary[0];
}
private bool IsMatch(PatientSummary p, string searchText)
{
var searchTerms = GetSearchTerms(searchText);
if (searchTerms.Length == 0) // Nothing typed
{
return false;
}
else if (searchTerms.Length == 1) // One word
{
return IsMatch(p.Id, searchTerms[0]) ||
IsMatch(p.LastName, searchTerms[0]) ||
IsMatch(p.FirstName, searchTerms[0]);
}
else // Two or more words
{
return IsMatchWithLastThenFirstName(p, searchTerms) ||
IsMatchWithFirstThenLastName(p, searchTerms);
}
}
private string[] GetSearchTerms(string searchText)
{
// Split by whitespace and remove any separators
return searchText.Split().Select(t => t.Trim(',', ';')).ToArray();
}
private bool IsMatch(string actual, string candidate)
{
return actual.ToUpper().Contains(candidate.ToUpper());
}
private bool IsMatchWithLastThenFirstName(PatientSummary p, string[] searchTerms)
{
return IsMatch(p.LastName, searchTerms[0]) &&
IsMatch(p.FirstName, searchTerms[1]);
}
private bool IsMatchWithFirstThenLastName(PatientSummary p, string[] searchTerms)
{
return IsMatch(p.FirstName, searchTerms[0]) &&
IsMatch(p.LastName, searchTerms[1]);
}
}
The Smart Search ComboBox
The final step is to create a ComboBox that uses the SmartSearch functionality. I'm going to describe this process as I integrate it into the EclipsePlugInRunner.
First, I need to create and initialize the SmartSearch class. I do this by calling the LoadPatientSummaries method (in the MainViewModel class), soon after I create the ESAPI Application object:
private void LoadPatientSummaries()
{
try
{
Mouse.OverrideCursor = Cursors.Wait;
_allPatientSummaries = _app.PatientSummaries.ToArray();
_smartSearch = new SmartSearch(_allPatientSummaries);
}
finally
{
Mouse.OverrideCursor = null;
}
}
The SmartSearch constructor requires the list of patients, so before creating the SmartSearch object, I obtain the PatientSummary objects from the ESAPI Application object. It's important to call the ToArray() method on the list so that all the patient summaries are loaded completely. Otherwise, the search will be very slow.
Loading all patients is itself a bit time-consuming, so I precede it by changing the cursor to a wait icon. I use a try/finally block to ensure that the cursor is set back to the default even if an exception occurs.
In the MainViewModel class, I've also added a PatientMatches property and a method to update it based on the latest search text:
private IEnumerable<PatientSummary> _patientMatches;
public IEnumerable<PatientSummary> PatientMatches
{
get { return _patientMatches; }
set { Set(ref _patientMatches, value); }
}
public void UpdatePatientMatches(string searchText)
{
PatientMatches = _smartSearch.GetMatches(searchText);
}
The UpdatePatientMatches method takes in the search text and uses the SmartSearch class to update the PatientMatches. The PatientMatches property is data-bound to the ComboBox.
If you're not familiar with data binding and MVVM (see Simple DVH Summary Script using WPF and MVVM), it means that when PatientMatches changes the ComboBox is automatically updated to show the new list.
If you're familiar with these concepts, I'm using the Set method in the MVVM Light Toolkit to send a PropertyChanged event when PatientMatches changes to update the ComboBox.
Speaking of the ComboBox, let's look at what it looks like in XAML:
<ComboBox
Name="PatientIdTextBox"
ItemsSource="{Binding PatientMatches}"
Text="{Binding PatientId}"
IsEditable="True"
PreviewTextInput="PatientIdTextBox_OnPreviewTextInput"
DropDownClosed="PatientIdTextBox_OnDropDownClosed"
>
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding Id, Mode=OneWay}"/>
(<Run Text="{Binding LastName, Mode=OneWay}"/>,
<Run Text="{Binding FirstName, Mode=OneWay}"/>)
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
Here you see that the ItemsSource property, which provides the list of items to display in the ComboBox, is bound to PatientMatches. The Text property is bound to the PatientId property, which represents the search text and the final patient ID once it's chosen.
Before I discuss the event handlers for PreviewTextInput and DropDownClosed, I want to point out what the items in the ComboBox look like. These items are the patient matches, so I'm using a TextBlock to show the patient's ID, followed by the last and first name in parenthesis.
The fact that the text box (showing the search text) and the drop down box (showing the formatted list of patients) show different things causes a problem. The ComboBox wasn't designed for this duality in uses, so I had to invent a workaround to make it work properly.
This workaround uses the the two event handlers for the PreviewTextInput and DropDownClosed events:
private void PatientIdTextBox_OnPreviewTextInput(object sender, TextCompositionEventArgs e)
{
_viewModel.UpdatePatientMatches(PatientIdTextBox.Text + e.Text);
PatientIdTextBox.IsDropDownOpen = true;
}
// Happens when user selects an item from the drop down
private void PatientIdTextBox_OnDropDownClosed(object sender, EventArgs e)
{
var patientSummary = PatientIdTextBox.SelectedItem as PatientSummary;
if (patientSummary != null)
{
PatientIdTextBox.Text = patientSummary.Id;
}
}
When text is entered into the ComboBox text box by the user, the PreviewTextInput event is fired. This event is fired right after something is typed but before it's added to the text box. In the event handler, I call the view model's UpdatePatientMatches, sending it the current contents of the text box plus the entered text. I then open the drop down box to show the list of patient matches.
The user can then select the patient to be opened. When the user selects a patient, the drop down box closes automatically. In the event handler for when the drop down closes, I replace the ComboBox text box with that of the selected patient ID. The user can then click on the Open button to open the patient.
Final Thoughts
Even though I implemented the "smart search" functionality to work with the EclipsePlugInRunner, you can integrate this functionality in any standalone app. Also, you don't need to use a ComboBox. I used a ComboBox in the EclipsePlugInRunner to save space, but you can create other kinds of graphical controls. For example, you could have a TextBox for the search text input and a separate ListBox to show the list of patients.
Comments