Pointing people

Custom advanced search page

Advanced Search - Windows Internet Explorer_2011-02-24_19-33-50.png

Today, I finished my customized advanced search web part. As per customer's request the web part contains a textbox for entering search terms, a list with checkboxes for selecting scopes from a displaygroup and some metadata properties, as shown in the screenshot.

Building the form wasn't that big of a problem. Getting it to work was something else...

Basics

If you take a look at the reflected code of the AdvancedSearchBox and CoreResultsWebPart and the output of that AdvancedSearchBox webpart there are some things that come to mind:

  • CoreResultsWebPart does no real work, next to displaying results
  • The real work is being done by a member of the CoreResultsWebPart of the type SearchResultHiddenObject
  • Of course, this heavy lifting class cannot be used as it is marked internal (MS still manages to surprise me with that)
  • Combining the source from reflector and the POST parameters you can see how the input from the advanced search web part is parsed

Technical approach

I decided to build my own web part, instead of just dumping some html in a Content Editor Webpart. Mainly because I needed to have the user select values for the metadata properties, which are lookups and I want to dynamically populate those dropdowns.

When you look at the html for the relevant fields, you notice that there's a pattern in their names. For this post, I'll just go over the relevant ones for my solution. For starters there's the textbox for the keyword. This needs a name containing ASB_TQS_AndQ to be picked up by the SearchResultHiddenObject object. I borrowed a piece of code from the original AdvancedSearchWebPart like this:

private void CreateTextQuerySectionChildControls(Table parentTable) {
  this.AndQueryTextBox = CreateRowWithLabelAndTextBox("Zoek op al deze woorden", "", "ASB_TQS_AndQ_", parentTable, true);
}

private static TextBox CreateRowWithLabelAndTextBox(string encodedLabelText, string accessKey, string idPrefix, Table parentTable, bool visibility) {
  TextBox box;
  Label label;
  TableRow row;
  TableCell cell;
  TableCell cell2;
  row = new TableRow();
  cell2 = new TableCell();
  cell2.Attributes.Add("class", "ms-advsrchText");
  row.Cells.Add(cell2);
  label = new Label();
  label.Text = encodedLabelText;
  label.ID = idPrefix + "lb";
  cell2.Controls.Add(label);
  cell = new TableCell();
  cell.Attributes.Add("class", "ms-advsrchText");
  row.Cells.Add(cell);
  box = new TextBox();
  box.ID = idPrefix + "tb";
  box.MaxLength = 200;
  cell.Controls.Add(box);
  parentTable.Rows.Add(row);
  label.AssociatedControlID = box.ID;
  row.Visible = visibility;
  return box;
}

Next up was the selection for the scopes. The fields require a name constructed like this: ASB_SS_scb_[number]_[scopeid]. There is an additional hidden field here, not sure if it's required, with a name lke this: ASB_SS_shf_[defaultscopeid]. To construct this part, again, reflector to the rescue.

private void CreateScopingSectionChildControls(Table table) {
  if (ShowScopes)
    CreateScopeRows(table);
}
   
private void CreateScopeRows(Table parentTable) {
  CheckBox box;
  TableRow row;
  Scope information;
  Scope information2;
  TableCell cell;
  string str;
  TableCell cell2;
  int num;
  IEnumerator enumerator;
  Label label;

  SearchContext scontext;
  using (SPSite site = new SPSite(SPContext.Current.Site.ID)) {
    scontext = SearchContext.GetContext(site);
    Scopes scopes = new Scopes(scontext);
    if (DisplayGroupExists(scopes, this.DisplayGroup)) {
      ScopeDisplayGroup scopegroup = scopes.GetDisplayGroup(new Uri(site.Url), this.DisplayGroup);
      information2 = scopegroup.Default;
      enumerator = scopegroup.GetEnumerator();
    }
    else {
      throw new Exception("Scope display group bestaat niet.");
    }
  }

  if (enumerator == null) {
    return;
  }
  num = 0;
  this.ScopeCheckBoxAL = new ArrayList();
  while (enumerator.MoveNext()) {
    information = enumerator.Current as Scope;
    if (information.CompilationState != ScopeCompilationState.Compiled) {
      return;
    }
    row = new TableRow();
    parentTable.Rows.Add(row);
    cell2 = new TableCell();
    cell2.Attributes.Add("class", "ms-advsrchText");
    row.Cells.Add(cell2);
    str = string.Empty;
    if (num == 0) {
      if (!string.IsNullOrEmpty(this.DisplayGroup)) {
        str = SPHttpUtility.HtmlEncode(this.DisplayGroup);
      }
      label = new Label();
      label.Text = str;
      cell2.Controls.Add(label);
    }
    cell = new TableCell();
    cell.Attributes.Add("class", "ms-advsrchText");
    row.Cells.Add(cell);
    box = new CheckBox();
    box.Text = SPHttpUtility.HtmlEncode(information.Name);
    box.ID = "ASB_SS_scb_" + num.ToString(CultureInfo.InvariantCulture.NumberFormat) + "_" + information.ID.ToString(CultureInfo.InvariantCulture.NumberFormat);
    box.Checked = information == information2;
    cell.Controls.Add(box);
    this.ScopeCheckBoxAL.Add(box);
    num += 1;
    row.Visible = this.ShowScopes;
  }

  if (information2 == null || information2.CompilationState != ScopeCompilationState.Compiled) {
    return;
  }
  this.Page.ClientScript.RegisterHiddenField("ASB_SS_shf_" + information2.ID.ToString(CultureInfo.InvariantCulture.NumberFormat), "");
  return;
}

private bool DisplayGroupExists(Scopes scopes, string p) {
  foreach (ScopeDisplayGroup dg in scopes.AllDisplayGroups)
    if (dg.Name == p)
      return true;
  return false;
}

For the final part of the form I needed the user to provide values for metadata properties which are lookups, meaning that these values are somewhat fixed, but not hardcoded fixed. So instead of having them type it in a textbox I created some dropdowns.

The metadata properties require a couple more controls to get the job done. The OOB web part gives you 3 controls per property and 1 common one. The common one is where you select the operator for the metadata properties. This field is named ASB_PS_lolb_[somenumber]. The other 3 are the name of the metadata property, the operator for that property and the value for that property, respectively named ASB_PS_plb_[index], ASB_PS_olb_[index] and ASB_PS_pvtb_[index]. The values for these fields should be, respectively, the name of the metadata property, the operator (Contains, Does not contain, Equals, Does not equal, Later than, Greater than, Earlier than, Less than, Is true, Is false) and the value you want to compare to, in my case data from a list.

private void CreatePropertiesSectionChildControls(Table table) {
  row1.Cells.Add(cell_11);
  row1.Cells.Add(cell_12);
  row2.Cells.Add(cell_21);
  row2.Cells.Add(cell_22);
  row3.Cells.Add(cell_31);
  row3.Cells.Add(cell_32);
  row4.Cells.Add(cell_41);
  row4.Cells.Add(cell_42);
  row5.Cells.Add(cell_51);
  row5.Cells.Add(cell_52);
  row6.Cells.Add(cell_61);
  row6.Cells.Add(cell_62);

  hiddenfield1.ID = "ASB_PS_plb_0";
  hiddenfield2.ID = "ASB_PS_plb_1";
  hiddenfield3.ID = "ASB_PS_plb_2";
  hiddenfield4.ID = "ASB_PS_plb_3";
  hiddenfield5.ID = "ASB_PS_plb_4";
  hiddenfield6.ID = "ASB_PS_plb_5";
  hiddenfield1.Value = "CIPO1";
  hiddenfield2.Value = "CIPO2";
  hiddenfield3.Value = "CIPO3";
  hiddenfield4.Value = "CIPO4";
  hiddenfield5.Value = "MetaFolder1";
  hiddenfield6.Value = "MetaFolder2";

  hiddenoperator1.ID = "ASB_PS_olb_0";
  hiddenoperator2.ID = "ASB_PS_olb_1";
  hiddenoperator3.ID = "ASB_PS_olb_2";
  hiddenoperator4.ID = "ASB_PS_olb_3";
  hiddenoperator5.ID = "ASB_PS_olb_4";
  hiddenoperator6.ID = "ASB_PS_olb_5";
  hiddenoperator1.Value = "Equals";
  hiddenoperator2.Value = "Equals";
  hiddenoperator3.Value = "Equals";
  hiddenoperator4.Value = "Equals";
  hiddenoperator5.Value = "Equals";
  hiddenoperator6.Value = "Equals";

  hiddengoperator1.ID = "ASB_PS_lolb_0";
  hiddengoperator1.Value = "And";

  hiddentype.ID = "ASB_SS_rtlb";
  hiddentype.Value = "default";

  cipo1.ID = "ASB_PS_pvtb_0";
  cipo2.ID = "ASB_PS_pvtb_1";
  cipo3.ID = "ASB_PS_pvtb_2";
  cipo4.ID = "ASB_PS_pvtb_3";
  metafolder1.ID = "ASB_PS_pvtb_4";
  metafolder2.ID = "ASB_PS_pvtb_5";

  SPListItemCollection items1 = SPContext.Current.Site.RootWeb.Lists["CIPO Niveau 1"].Items;
  SPListItemCollection items2 = SPContext.Current.Site.RootWeb.Lists["CIPO Niveau 2"].Items;
  SPListItemCollection items3 = SPContext.Current.Site.RootWeb.Lists["CIPO Niveau 3"].Items;
  SPListItemCollection items4 = SPContext.Current.Site.RootWeb.Lists["CIPO Niveau 4"].Items;
  SPListItemCollection items5 = SPContext.Current.Site.RootWeb.Lists["MetaFolder1"].Items;
  SPListItemCollection items6 = SPContext.Current.Site.RootWeb.Lists["MetaFolder2"].Items;

  cipo1.DataSource = items1;
  cipo2.DataSource = items2;
  cipo3.DataSource = items3;
  cipo4.DataSource = items4;
  metafolder1.DataSource = items5;
  metafolder2.DataSource = items6;

  cipo1.DataValueField = "Title";
  cipo1.DataTextField = "Title";
  cipo2.DataValueField = "Title";
  cipo2.DataTextField = "Title";
  cipo3.DataValueField = "Title";
  cipo3.DataTextField = "Title";
  cipo4.DataValueField = "Title";
  cipo4.DataTextField = "Title";
  metafolder1.DataValueField = "Title";
  metafolder1.DataTextField = "Title";
  metafolder2.DataValueField = "Title";
  metafolder2.DataTextField = "Title";

  cipo1.DataBind();
  cipo2.DataBind();
  cipo3.DataBind();
  cipo4.DataBind();
  metafolder1.DataBind();
  metafolder2.DataBind();

  cipo1.Items.Add(new ListItem("--Kies CIPO 1--", "0"));
  cipo1.SelectedValue = "0";

  cipo2.Items.Add(new ListItem("--Kies CIPO 2--", "0"));
  cipo2.SelectedValue = "0";

  cipo3.Items.Add(new ListItem("--Kies CIPO 3--", "0"));
  cipo3.SelectedValue = "0";

  cipo4.Items.Add(new ListItem("--Kies CIPO 4--", "0"));
  cipo4.SelectedValue = "0";

  metafolder1.Items.Add(new ListItem("--Kies metafolder 1--", "0"));
  metafolder1.SelectedValue = "0";

  metafolder2.Items.Add(new ListItem("--Kies metafolder 2--", "0"));
  metafolder2.SelectedValue = "0";

  cell_11.Text = "CIPO Niveau 1";
  cell_21.Text = "CIPO Niveau 2";
  cell_31.Text = "CIPO Niveau 3";
  cell_41.Text = "CIPO Niveau 4";
  cell_51.Text = "MetaFolder 1";
  cell_61.Text = "MetaFolder 2";

  cell_12.Controls.Add(hiddenfield1);
  cell_12.Controls.Add(hiddenoperator1);
  cell_12.Controls.Add(cipo1);
  cell_12.Controls.Add(hiddengoperator1);
  cell_12.Controls.Add(hiddentype);
  cell_12.Controls.Add(hiddenrestype);
  cell_12.Controls.Add(hiddendatetimedt);
  cell_12.Controls.Add(hiddentextdt);

  cell_22.Controls.Add(hiddenfield2);
  cell_22.Controls.Add(hiddenoperator2);
  cell_22.Controls.Add(cipo2);

  cell_32.Controls.Add(hiddenfield3);
  cell_32.Controls.Add(hiddenoperator3);
  cell_32.Controls.Add(cipo3);

  cell_42.Controls.Add(hiddenfield4);
  cell_42.Controls.Add(hiddenoperator4);
  cell_42.Controls.Add(cipo4);

  cell_52.Controls.Add(hiddenfield5);
  cell_52.Controls.Add(hiddenoperator5);
  cell_52.Controls.Add(metafolder1);

  cell_62.Controls.Add(hiddenfield6);
  cell_62.Controls.Add(hiddenoperator6);
  cell_62.Controls.Add(metafolder2);

  table.Rows.Add(row1);
  table.Rows.Add(row2);
  table.Rows.Add(row3);
  table.Rows.Add(row4);
  table.Rows.Add(row5);
  table.Rows.Add(row6);
}

The last item on the form is the submit buttom. This should be named ASB_BS_SRCH_1 and have a PostBackUrl property set to the search results page.

private void CreateButtonSectionChildControls(Table parentTable) {
  TableRow row;
  TableCell cell;
  TableCell cell2;
  row = new TableRow();
  parentTable.Rows.Add(row);
  cell2 = new TableCell();
  row.Cells.Add(cell2);
  cell = new TableCell();
  row.Cells.Add(cell);
  this.SearchButton = new Button();
  this.SearchButton.Text = "Zoek";
  this.SearchButton.ID = "ASB_BS_SRCH_1";
  this.SearchButton.PostBackUrl = this.SearchResultPageURL;
  this.SearchButton.OnClientClick = "clearUnwantedFields()";
  cell.Controls.Add(this.SearchButton);
  return;
}

Finally there are some additional hidden fields required. ASB_TextDT_Props determines which metadata properties are of text datatype. ASB_DateTimeDT_Props determines which metadata properties are of datetime datatype. All other metadata properties are processed as numbers. This is why you get a weird Invalide parameter: [metadata property]. Expect a number. [value] is given instead error when these fields aren't there. Mind that you need to add them with exactly that name and not as a .NET control. That goes a little something like this:

private string GetScripts() {
  StringBuilder sb = new StringBuilder();
  sb.Append("<script type=\"text/javascript\">");
  sb.Append("function clearUnwantedFields() { ");
  sb.Append("if($('#" + cipo1.ClientID + "').val()=='0') { $('#" + hiddenfield1.ClientID + "').val('(Pick Property)'); } ");
  sb.Append("if($('#" + cipo2.ClientID + "').val()=='0') { $('#" + hiddenfield2.ClientID + "').val('(Pick Property)'); } ");
  sb.Append("if($('#" + cipo3.ClientID + "').val()=='0') { $('#" + hiddenfield3.ClientID + "').val('(Pick Property)'); } ");
  sb.Append("if($('#" + cipo4.ClientID + "').val()=='0') { $('#" + hiddenfield4.ClientID + "').val('(Pick Property)'); } ");
  sb.Append("if($('#" + metafolder1.ClientID + "').val()=='0') { $('#" + hiddenfield5.ClientID + "').val('(Pick Property)'); } ");
  sb.Append("if($('#" + metafolder2.ClientID + "').val()=='0') { $('#" + hiddenfield6.ClientID + "').val('(Pick Property)'); } ");
  sb.Append("}");
  sb.Append("</script>");
  return sb.ToString();
}

protected override void OnPreRender(EventArgs e) {
  try {
    string str = this.GetScripts();
    this.Page.ClientScript.RegisterClientScriptInclude(base.GetType(), "__JQUERY-1.5__", "/_layouts/jquery-1.5.min.js");
    this.Page.ClientScript.RegisterClientScriptBlock(base.GetType(), "__ASB_SCRIPTS__", str);
    this.Page.ClientScript.RegisterHiddenField("ASB_TextDT_Props", "CIPO1#;#CIPO2#;#CIPO3#;#CIPO4#;#MetaFolder1#;#MetaFolder1");
    this.Page.ClientScript.RegisterHiddenField("ASB_DateTimeDT_Props", "Write#;#Created");
    this.Page.ClientScript.RegisterHiddenField("ASB_ResType_Query", "");
  }
  catch (Exception ex) {
    this.errorMessage = ex.Message;
  }
}

So... This about wraps up the custom advanced search web part... There's some refactoring possible on this code, but I haven't got time to do it, nor do I feel like it. The thing does what it's built for! Next up: a glossary!

References

http://tqcblog.com/2007/10/26/creating-a-custom-advanced-search-box-in-moss-2007/
http://dattard.blogspot.com/2007/11/creating-custom-advanced-search-webpart.html