Facebook Style Search Feature using Twitter Typeahead.js and PHP

Search is one of the interesting feature Facebook has invented. It is way to search the various connections inside of Facebook for a user. The style of how the Facebook search works was really good that i wanted to create a front-end system like that. Fortunately i found Twitter Typeahead, which is a suggestion system that is very likely to be the best candidate to implement this system.


Twitter Typeahead.js

Typeahead is the core of this front-end system. It is what provides us the features that makes this possible.

Bloodhound

Bloodhound is a suggestion engine for Twitter typeahead which super flexible to configure the various ways data could be provided to the suggestion system. It does a good job in showing suggestions from both local storage when applicable and from remote server.

Database Design

Since this is just the front-end part of the system the Database design is not that much complicated. Basic data tables with MyISAM storage engine and has FULLTEXT index for column names that is searchable. Nothing more.!

Facebook Style Search Database Design

Markup

The markup is built using the Twitter Bootstrap, amazing work by the guys at twitter. Really love this framework.

The markup contains a simple search bar and a pre element to display the data fetched from the server.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
    <link type="text/css" rel="stylesheet" href="css/style.css" />
    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>
  <body>
  
    <div class="container-fluid" style="background-color: #3A5795;">
      <div id="globalContainer" class="row">
        <div class="col-md-12">
          <div id="searchContainer">
            <form>
              <div class="form-group">
                <input type="text" class="form-control search-query" name="search" placeholder="Search friends, groups or pages" />
                <span class="search-icon glyphicon glyphicon-search"></span>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    
    <br/><br/>
    
    <div class="container">
      <div class="row">
        <div class="col-md-12">
          <pre id="responseDataContainer"></pre>
        </div>
      </div>
    </div>
    
    <!-- JS Libs - Load all scripts at the bottom -->
    <script type="text/javascript" src="js/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS" crossorigin="anonymous"></script>
    <script type="text/javascript" src="js/typeahead.bundle.js"></script>
    <script type="text/javascript" src="js/search.js"></script>
  </body>
</html>

Ajax Scripts

search.php

This script is pretty straight forward, it detects the search type, constructs the query based on the type of input and executes the Fulltext search on the table and returns the result. Except for the popular search where we will add a search term if there are no data’s available.

<?php
// Connect to the database.
$mysql = new mysqli('db-host', 'db-user', 'db-pass', 'db-name') or die('could not connect to db');

if (!isset($_GET['type']) && empty($_GET['type'])) {
  echo json_encode(['error' => 'No type specified.']);
  exit;
}

$column = 'name';
$orderBy = '';

// Identify the correct table and column.
switch ($_GET['type']) {
  case 'friends':
    $table = 'friends';
    break;
  case 'groups':
    $table = 'groups';
    $orderBy = ' ORDER BY `members_count` DESC ';
    break;
  case 'pages':
    $table = 'pages';
    $orderBy = ' ORDER BY `likes` DESC ';
    break;
  default:
    $table = 'popular_search';
    $column = 'query';
    break;
}

$query = 'SELECT * FROM ' . $table .
  ' WHERE MATCH(`' . $column . '`) AGAINST("' . $_GET['query'] . '") ' .
  $orderBy . ' LIMIT 5 ';

$result = $mysql->query($query);

if ($result && $result->num_rows > 0) {
  $resultset = array();
  while ($row = $result->fetch_assoc()) {
    $resultset[] = $row;
  }
  
  if ('search' === $_GET['type']) {
    $qObj = new stdClass();
    $qObj->id = 0;
    $qObj->query = 'search for ' . $_GET['query'];
    array_unshift($resultset, $qObj);
  }
  
  // Send response and return the data.
  echo json_encode($resultset);
  exit;
}

// If the type is search, return this response by default.
if ('search' === $_GET['type']) {
  $query = new stdClass();
  $query->id = 0;
  $query->total_search_count = 0;
  $query->query = 'search for ' . $_GET['query'];
  echo json_encode([$query]);
}

Search System - search.js

Facebook Style Search System

Bloodhound - Defining the search datasets

The first thing we need to do is define the various datasets(Friends, Groups, Pages and Popular Search) that will provide typeahead the search suggestions. getBloodhoundSettings(type) method takes in the type of search to be made and returns an config object to construct the Bloodhound instance.

/**
 * Bloodhound suggestion engine setting constructor.
 *
 * @param string type
 * @param object bloodhound construct setting
 */
function getBloodhoundSettings(type) {
  
  return {
    datumTokenizer: Bloodhound.tokenizers.whitespace,
    queryTokenizer: Bloodhound.tokenizers.whitespace,
    
    /**
     * Must return the identifier for the datum
     */
    identify: function(datum) {
      return datum.id;
    },
    
    /**
     * Fetch data from remote source using ajax
     */
    remote: {
      url: "ajax/search.php",
      
      /**
       * Prepare the settings for ajax request
       */
      prepare: function (query, settings) {
        settings.type = "GET";
        settings.contentType = "application/json; charset=UTF-8";
        settings.data = {
          'query' : query,
          'type' : type
        };
        
        return settings;
      }
    }
    
  }
  
}

Then we make different Bloodhound instances for various datasets such as Friends, Groups, Pages and Popular Search.

// Bloodhound "search" suggestion dataset
var searchBHSettings = getBloodhoundSettings('search');
var search = new Bloodhound(searchBHSettings);
// Bloodhound "friends" suggestion dataset
var friendsBHSettings = getBloodhoundSettings('friends');
var friends = new Bloodhound(friendsBHSettings);
// Bloodhound "groups" suggestion dataset
var groupsBHSettings = getBloodhoundSettings('groups');
var groups = new Bloodhound(groupsBHSettings);
// Bloodhound "pages" suggestion dataset
var pagesBHSettings = getBloodhoundSettings('pages');
var pages = new Bloodhound(pagesBHSettings);

Initialising Typeahead

Once the Bloodhound datasets are defined we provide those datasets to the typeahead system while initialising it. Now typeahead has a data source where data can be fetched.

// Attach typeahead to the input
$('#searchContainer .search-query').typeahead({
  hint: true,
  highlight: true,
  minLength: 3
}, {
  name: 'search',
  source: search,
  templates: {
    header: '<h4 class="suggestion-header">Popular Searches</h4>',
    suggestion: function(datum) {
      if (datum) {
        return '<div id="popular-search-id-' + datum.id + '"><span><span class="popular-search-icon glyphicon glyphicon-search"></span> ' +
          datum.query + ' · <span class="meta-info">' + number_format(datum.total_search_count) + ' people talking about this</span></span></div>'; 
      }      
    }
  },
  display: function(suggestion) {
    // set the datum "identifier" that is selected or load data based on it.
    return suggestion.query;
  }
}, {
  name: 'search-friends',
  source: friends,
  templates: {
    header: '<h4 class="suggestion-header">Friends</h4>',
    suggestion: function(datum) {
      console.log('Freinds suggestion');
      console.log(datum);
      var img = '';
      if (datum.image) {
        img += '<img class="meta-img" src=storage/frds/' + datum.image + '></img>'; 
      }
      
      return '<div id="friend-search-id-' + datum.id + '"><span>' + img + datum.name +
        '<br/><span class="meta-info">' + datum.location + ' · ' + datum.occupation + '</span></span></div>';
    }
  },
  display: function(suggestion) {
    // set the datum "identifier" that is selected or load data based on it.
    return suggestion.name;
  }
}, {
  name: 'search-groups',
  source: groups,
  templates: {
    header: '<h4 class="suggestion-header">Groups</h4>',
    suggestion: function(datum) {
      var img = '';
      if (datum.image) {
        img += '<img class="meta-img" src=storage/grps/' + datum.image + '></img>'; 
      }
      
      return '<div id="friend-search-id-' + datum.id + '"><span>' + img + datum.name +
        '<br/><span class="meta-info">' + datum.type + ' · ' + number_format(datum.members_count) + ' members</span></span></div>';
    }
  },
  display: function(suggestion) {
    // set the datum "identifier" that is selected or load data based on it.
    return suggestion.name;
  }
}, {
  name: 'search-pages',
  source: pages,
  templates: {
    header: '<h4 class="suggestion-header">Pages</h4>',
    suggestion: function(datum) {
      var img = '';
      if (datum.image) {
        img += '<img class="meta-img" src=storage/pgs/' + datum.image + '></img>'; 
      }
      
      return '<div id="friend-search-id-' + datum.id + '"><span>' + img + datum.name +
        '<br/><span class="meta-info">' + datum.type + ' · ' + number_format(datum.likes) + ' like this</span></span></div>';
    }
  },
  display: function(suggestion) {
    // set the datum "identifier" that is selected or load data based on it.
    return suggestion.name;
  }
}).bind('typeahead:select', function(event, suggestion) {
  document.getElementById('responseDataContainer').innerHTML = JSON.stringify(suggestion);
});

/**
 * Number format function.
 *
 * https://github.com/kvz/phpjs/blob/master/functions/strings/number_format.js
 */

Style - style.css

In the style.css we override some of the default typeahead styles and some custom style classes.

/**
 * Author: Tamil selvan K
 */

#globalContainer {
  margin: 5px auto 0px auto;
  max-width: 900px;
}

.twitter-typeahead {
  width: 100%;
}

/** typeahead override styles */

.tt-menu {
  width: 100%;
  border: 1px solid lightgray;
  border-radius: 4px;
  background-color: white;
}

div.tt-dataset .tt-suggestion:last-child {
  border-bottom: 0px !important;
}

.tt-suggestion {
  padding: 3px;
  margin: 2px;
  border-bottom: 1px solid lightgray;
}

.tt-suggestion:hover {
  cursor: pointer;
}

.tt-dataset {
  border-bottom: 4px solid #f6f7f8;
}

/** Custom modifications and styles */

.selection-header {
  border-radius: 4px;
}

.selection-footer {
  border-radius: 4px;
}

.suggestion-header {
  color: #b6b6b6;
  font-size: 15px;
  font-weight: 300;
  padding: 2px 5px;
}

.meta-info {
  font-size: 13px;
  color: #b6b6b7;
}

.meta-img {
  width: 36px;
  height: 36px;
  float: left;
  margin-right: 8px;
}

.search-icon {
  top: -25px;
  float: right;
  padding: 0px 10px;
}

.popular-search-icon {
  background-color: #4F85E8;
  border-radius: 25px;
  border-style: solid;
  border-width: 1px;
  padding: 8px;
  color: white;
  margin-right: 5px;
}

That is it. Checkout the documentation for twitter typeahead.js for more info on the syntax and configuration options to modify it to your need.