Take the 2-minute tour ×
Programmers Stack Exchange is a question and answer site for professional programmers interested in conceptual questions about software development. It's 100% free, no registration required.

While creating a 'search' form I've been faced to an overwhelming complexity and I was wondering whether there is a well-known way to accomplish such task.

Fields of the form in question can be seen here: http://developer.echonest.com/docs/v4/artist.html#search

Here are a few issues I've been faced to and tried to solve with simple solutions though it led to a lot of copying and pasting in the end :

  • When the user can select multiple choices for a field or not, I systematically had to create a collection of them, adding a 'null' value for the ability of ignoring that field. Also, as these are enumeration values they are not user-friendly to read and therefore needed an extra description field that will be shown instead on the UI. And at the end all this user-friendly values need to be converted back to the original object.

  • Selection being made by the user, these choices need to be retrieved and for this, unfortunately in my case WPF data binding shown its limits. Example : when a user chooses items from a collection, there is no way to update the target object with these selections. (Obviously this is unrealistic as the object serves as a data source)

In the end it resumes to a lot of manual fetching from combo boxes, list boxes etc ... a middle-man object to receive all that and to this you have to add validation as well.

A strategy therefore becomes imperative as I will have to realise a couple of these complex forms.

I've been searching the web for solutions but beside extremely simple examples I found nothing relevant.

So the question is, is there an effective way that helps when creating complex user forms ?

share|improve this question
2  
Not everything has a pattern... especially not something as oddly specific as this. So no. There probably isn't one. –  Ampt Jul 3 '13 at 18:15
    
Yes that's what I thought initially but decided to ask anyway :) –  Aybe Jul 3 '13 at 18:28
    
I would edit the question to remove the reliance on a pattern and possibly try and ask about the best way to do this, or a strategy suggestion to make it easier if you want help. –  Ampt Jul 3 '13 at 18:29
    
Ok I've removed the 'pattern' word, do you think it's enough or should I reformulate the question ? –  Aybe Jul 3 '13 at 18:37
    
You could probably link the stuff that you found that didn't work as well. Showing that youve tried to solve it yourself gives others confidence they aren't wasting their time by answering the same thing over and over. –  Ampt Jul 3 '13 at 19:28

1 Answer 1

up vote 6 down vote accepted

I've sorted it out and in the end it went pretty well so I'll give here my recommendations according this painful experience.

(these tips apply for WPF)

  • Validation : use INotifyDataErrorInfo instead of rules in XAML, your validation takes place in the object instead and even it's sort of complicate to setup it is really worth it. This interface is appropriate for validation of a complex object. Here's an excellent article showing it : http://anthymecaillard.wordpress.com/2012/03/26/wpf-4-5-validation-asynchrone/ I'd suggest you to not copy/paste it but write it manually. (I did and it took me less time than copy/paste and trying to grasp what it does later)

  • Updating your object whenever user inputs values : it's clearly better to do nothing during controls events such as TextChanged but to stick to validation as mentioned above. Hook to Validation.Error events of your controls for managing the logic of your API (in my case when 'name' is set the user cannot search by 'familiarity' so I disabled appropriate controls).

  • That leds to a search button that gets enabled only when all values are correct and this is actually the place where you'd grab all params and your stuff, all in one place, not scattered anymore through events of controls.

  • Also, do not hesitate to use a middle-man object even though it won't contain all the fields of the original object it still proved to be helpful as that's where I implemented INotifyDataDataErrorInfo. There was absolutely no way that I modify the source object which is fine and part of a library anyway ! For multiple choices a user can do on a ListBox, I didn't use it, I just populated them and that's it. (they are all valid by nature, in my case)

Now a few screenshots and explanations.

Here's the API, not about 2 or 3 fields but 21, when I saw it first I knew it would be a pain to implement but still I underestimated it ...

enter image description here enter image description here

Here the search button gets enabled only when validation is correct,

enter image description here

Using an extremely simple method, luckily in my case only these 3 cases were needed.

    private void ToggleSearch()
    {
        var b1 = GetHasErrorPropertyValue(TextBoxArtistLocation);
        var b2 = GetHasErrorPropertyValue(TextBoxDescription);
        var b3 = GetHasErrorPropertyValue(TextBoxName);
        ButtonSearch.IsEnabled = !(b1 || b2 || b3);
    }

    private static bool GetHasErrorPropertyValue(DependencyObject dependencyObject)
    {
        if (dependencyObject == null) throw new ArgumentNullException("dependencyObject");
        return (bool)dependencyObject.GetValue(Validation.HasErrorProperty);
    }

    // Hooking to Validation.Error attached property, these boxes all point to here.
    private void TextBox_OnError(object sender, ValidationErrorEventArgs e)
    {
        ToggleSearch();
    }

Here's the output of the query, well the only bothering stuff compared to previous version is that for testing I have to search every time to see the result of the built query for which I assigned Alt-S as a time-saver. Previously I was fetching the result on each value changed in each control, while it looked simple as these were 5 lines methods it soon went to a nightmare. With the new method, I have one hand setting the fields with mouse the other pressing Alt-S and obviously a much simpler class to understand/use/debug.

enter image description here

Last screen : as you can see there isn't much in it, the 3 top methods each call one of the methods below, I think they are self-explanatory. The helper methods are used for setting/retrieving values from controls and to form the query.

enter image description here

Last two samples :

Populating data :

    private async Task PopulateData()
    {
        string apiKey = App.GetApiKey();

        var years = GetDescribedEnum<ArtistSearchYear>().ToList();
        ComboBoxArtistEndYearAfter.ItemsSource = years;
        ComboBoxArtistEndYearBefore.ItemsSource = years;
        ComboBoxArtistStartYearAfter.ItemsSource = years;
        ComboBoxArtistStartYearBefore.ItemsSource = years;

        var rankTypes = GetDescribedEnum<ArtistSearchRankType>().ToList();
        ComboBoxRankType.ItemsSource = rankTypes;

        var results = Enumerable.Range(0, 101).Select(s => s.ToString(CultureInfo.InvariantCulture)).ToList();
        results.Insert(0, string.Empty);
        ComboBoxResults.ItemsSource = results;

        var sort = GetDescribedEnum<ArtistSearchSort>().OrderBy(s => s.Description).ToList();
        ComboBoxSort.ItemsSource = sort;

        var start = Enumerable.Range(0, 3).Select(s => (s * 15).ToString(CultureInfo.InvariantCulture)).ToList();
        start.Insert(0, string.Empty);
        ComboBoxStart.ItemsSource = start;

        var buckets = GetDescribedEnum<ArtistSearchBucket>().OrderBy(s => s.Description).ToList();
        ListBoxBuckets.ItemsSource = buckets;

        var genres = await Queries.ArtistListGenres(apiKey);
        ListBoxGenre.ItemsSource = genres.Genres.Select(s => s.Name);

        var styles = await Queries.ArtistListTerms(apiKey, ArtistListTermsType.Style);
        ListBoxStyle.ItemsSource = styles.Terms.Select(s => s.Name);

        var moods = await Queries.ArtistListTerms(apiKey, ArtistListTermsType.Mood);
        ListBoxMood.ItemsSource = moods.Terms.Select(s => s.Name);
    }

Retrieving parameters :

    private void RunSearch()
    {
        var parameters = new ArtistSearchParameters();

        var year = (Func<ComboBox, ArtistSearchYear?>)((c) =>
            {
                if (c.SelectedValue == null) return null;
                var value = GetDescribedObjectValue<ArtistSearchYear>(c.SelectedValue);
                return value == ArtistSearchYear.Invalid ? (ArtistSearchYear?)null : value;
            });
        parameters.ArtistEndYearAfter = year(ComboBoxArtistEndYearAfter);
        parameters.ArtistEndYearBefore = year(ComboBoxArtistEndYearBefore);
        parameters.ArtistStartYearAfter = year(ComboBoxArtistStartYearAfter);
        parameters.ArtistStartYearBefore = year(ComboBoxArtistStartYearBefore);

        if (ComboBoxRankType.SelectedValue == null)
        {
            parameters.RankType = null;
        }
        else
        {
            var value = GetDescribedObjectValue<ArtistSearchRankType>(ComboBoxRankType.SelectedValue);
            parameters.RankType = value == ArtistSearchRankType.Invalid ? (ArtistSearchRankType?)null : value;
        }

        var selectedValue = ComboBoxSort.SelectedValue;
        if (selectedValue == null)
        {
            parameters.Sort = null;
        }
        else
        {
            var value = GetDescribedObjectValue<ArtistSearchSort>(selectedValue);
            parameters.Sort = value == ArtistSearchSort.Invalid ? (ArtistSearchSort?)null : value;
        }

        parameters.Start = StringToNullableInt(GetSelectorValue<string>(ComboBoxStart));
        parameters.Results = StringToNullableInt(GetSelectorValue<string>(ComboBoxResults));

        parameters.MaxFamiliarity = SliderMaxFamiliarity.Value < 1.0d ? (double?)SliderMaxFamiliarity.Value : null;
        parameters.MinFamiliarity = SliderMinFamiliarity.Value > 0.0d ? (double?)SliderMinFamiliarity.Value : null;
        parameters.MaxHotttnesss = SliderMaxHottness.Value < 1.0d ? (double?)SliderMaxHottness.Value : null;
        parameters.MinHotttnesss = SliderMinHottness.Value > 0.0d ? (double?)SliderMinHottness.Value : null;

        if (CheckBoxFuzzyMatch.IsChecked.HasValue)
            parameters.FuzzyMatch = CheckBoxFuzzyMatch.IsChecked.Value ? (bool?)true : null;
        if (CheckBoxLimit.IsChecked.HasValue)
            parameters.Limit = CheckBoxLimit.IsChecked.Value ? (bool?)true : null;

        var buckets =
            GetSelectedItems<DescribedObject<ArtistSearchBucket>>(ListBoxBuckets)
                          .OrderBy(s => s.Description)
                          .Select(s => s.Value)
                          .ToList();
        parameters.Buckets = buckets.Count > 0 ? buckets : null;
        var genres = GetSelectedItems<string>(ListBoxGenre).OrderBy(s => s).ToList();
        parameters.Genres = genres.Count > 0 ? genres : null;
        var moods = GetSelectedItems<string>(ListBoxMood).OrderBy(s => s).ToList();
        parameters.Moods = moods.Count > 0 ? moods : null;
        var style = GetSelectedItems<string>(ListBoxStyle).OrderBy(s => s).ToList();
        parameters.Styles = style.Count > 0 ? style : null;

        parameters.ArtistLocation = string.IsNullOrEmpty(TextBoxArtistLocation.Text)
                                        ? null
                                        : TextBoxArtistLocation.Text;
        parameters.Description = string.IsNullOrEmpty(TextBoxDescription.Text)
                                     ? null
                                     : Regex.Split(TextBoxDescription.Text, @", ?")
                                            .Where(s => !string.IsNullOrWhiteSpace(s))
                                            .ToList();
        if (string.IsNullOrEmpty(TextBoxName.Text))
        {
            parameters.Name = null;
        }
        else
        {
            parameters.Name = TextBoxName.Text;
            parameters.MaxFamiliarity = null;
            parameters.MaxHotttnesss = null;
            parameters.MinFamiliarity = null;
            parameters.MinHotttnesss = null;
        }

    }

In my case nullables were vital as they allowed me to discard values when building the query.

For building the query I cheated somehow using JSON :

    protected static string GetUrlParameters(string json)
    {
        if (json == null) throw new ArgumentNullException("json");
        JsonTextReader reader = new JsonTextReader(new StringReader(json));
        string path = string.Empty;
        List<string> list = new List<string>();
        while (reader.Read())
        {
            JsonToken type = reader.TokenType;
            if (type == JsonToken.PropertyName)
            {
                path = reader.Path;
            }
            else
            {
                bool b0 = type == JsonToken.Integer;
                bool b1 = type == JsonToken.Float;
                bool b2 = type == JsonToken.String;
                bool b3 = type == JsonToken.Boolean;
                if (b0 || b1 || b2 || b3)
                {
                    var value = reader.Value.ToString();
                    if (b3) value = value.ToLower();

                    string item = string.Format("{0}={1}", path, value);
                    list.Add(item);
                }
            }
        }
        return string.Join("&", list);
    }

DescribedObject is a middle-man object that holds the desired value and the description is fetched from a value [DescriptionAttribute]

public class DescribedObject 
{
    private readonly string _description;
    private readonly Object _value;

    public DescribedObject() // NOTE : for design-time
    {

    }
    protected DescribedObject(Object value, string description)
    {
        _value = value;
        _description = description;
    }

    public string Description
    {
        get { return _description; }
    }

    public Object Value
    {
        get { return _value; }
    }

    public override string ToString()
    {
        return string.Format("Value: {0}, Description: {1}", _value, _description);
    }
}

public class DescribedObject<T> : DescribedObject
{

    public DescribedObject(T value, string description)
        : base(value, description)
    {
    }
    public new T Value
    {
        get
        {
            return (T)base.Value;
        }
    }
}

A described object :

[JsonConverter(typeof(PropertyEnumConverter))]
public enum ArtistSearchYear
{
    [Description(null), JsonProperty(null)]
    Invalid = 0,
    [Description("1970"), JsonProperty("1970")]
    Year1970,
    [Description("1971"), JsonProperty("1971")]
    Year1971
}

(PropertyEnumConverter is a simple object that will convert these values according their JsonProperty attribute.)

So as you can see, I kept it stupidly simple but it took me some time as at first I had a more complex approach, again the simplicity proven really helpful though it sounds stupid sometimes.

I haven't talked about the XAML side, briefly there's absolutely nothing in it beside controls and templates, only bindings have these two properties set for them to validate. (also, the object implements INotifyPropertyChanged)

<TextBox x:Name="TextBoxArtistLocation"
            Style="{StaticResource TextBoxInError}"
            Validation.Error="TextBox_OnError"
            Text="{Binding ArtistLocation, NotifyOnValidationError=True, UpdateSourceTrigger=PropertyChanged}" />

I hope this answer will help someone, I've tried to be as concise as possible but if you have any question/comment, ask below.

share|improve this answer
1  
+1 for use of pictures and answering your own question so thoroughly for individuals in the future who might have this problem. –  Ampt Jul 5 '13 at 16:23
1  
Yes, I couldn't resist to post the solution :) thank you ! –  Aybe Jul 5 '13 at 17:01

Your Answer

 
discard

By posting your answer, you agree to the privacy policy and terms of service.

Not the answer you're looking for? Browse other questions tagged or ask your own question.