ajax user suggest

intermediate ajax, css, js, mysql, php, jquery, knockout 341 misterhaan

it’s been a few years since my ajax suggest guide, and since i’ve rewritten my ajax user suggestion code i figured an updated guide is in order. this guide covers using ajax with jquery and knockout to suggest users when typing in a text input field and support selection with the keyboard or mouse. familiarity with php, mysql, javascript, jquery, knockout, html, and css will make this guide more useful. specific css examples are not given since the best look will depend on the website using it. differences from the previous guide are including jquery and knockout, showing avatar and friend status with suggested usernames, and a single mysql query instead of two that get combined in php.

  1. find matching users
  2. define a view model
  3. create the search field
  4. support mouse selection
  5. support keyboard selection
  6. all 5 pages

find matching users

i’ll start with the php and mysql that generates a list of suggestions. this piece can be tested on its own by putting the request in the browser’s address bar, so i can see i got it right before having to write the other pieces.

for each user in the list i’ll want their id, avatar url, username, display name, and whether the user asking for the list considers them a friend. it’s possible to send this data in plain text or xml as well as json, but json fits into client-side scripts best. i’ll have an array of matching users as objects with all five of those items as properties.

since i have both username (used for the profile url) and display name, i want to send back all users who contain the search text in either username or display name. leave out the requesting user since it probably won’t make sense to let users select themselves. i’ll limit the list to the top eight; if whoever you’re looking for isn’t in there then get more specific to shorten the list. some of those matches are more likely to be the one that will get chosen though, so those should be listed first. users who the requesting user considers friends come first, then users whose username or display name matches the search text exactly, then username or display name starts with the search text, then contains the search text, and finally alphabetical order. most of those sort criteria actually look at the username and/or displayname columns, but mysql ignores all order by criteria that involve columns already used in an earlier criteria. i got around that by actually selecting the criteria and giving them names, then ordering by those names. here’s what that query looks like:

select u.id, coalesce(nullif(u.avatar, ''), 'DEFAULT_AVATAR') as avatar, u.displayname, u.username, f.fan as isfriend, u.username='MATCH' or u.displayname='MATCH' as exact, u.username like 'MATCH%' or u.displayname like 'MATCH%' as start from users as u left join users_friends as f on f.fan='USER_ID' and f.friend=u.id where u.id!='USER_ID' and (u.username like '%MATCH%' or u.displayname like '%MATCH%') order by isfriend desc, exact desc, start desc, coalesce(nullif(u.displayname, ''), u.username) limit 8

the all-caps stuff gets set by php code. also, since usernames can contain underscore characters and display names can contain percent signs but the sql like operator treats them as wildcards, i had to escape them with backslashes to use them as literal characters. the coalesce for the avatar uses a constant i have defined for the path to the default avatar if there’s no avatar defined for the user. the isfriend column will either be null (not a friend) or the requesting user’s id based on whether there’s an entry in the friends table. then i set up the columns for use in order by, which are all 1 or 0 for true or false. there’s a quick left join looking for the friends table row saying the signed-in user marked this user as a friend, then the order by clause which is mostly desc because i want ones that match first. finally i sort alphabetically by displayname, or username if there’s no displayname, because that’s how users are displayed.

the php code needs to make sure the search text is present, put it into the query and run it, then copy the query results into an array and output it in json format. here’s that section of code:

if(isset($_GET['ajax'])) {
  $ajax = new t7ajax();
  switch($_GET['ajax']) {
    case 'suggest':
      if(isset($_GET['match']) && strlen($_GET['match']) >= 3) {
        $matchsql = $db->escape_string(trim($_GET['match']));
        $matchlike = $db->escape_string(str_replace(['_', '%'], ['\\_', '\\%'], trim($_GET['match'])));
        // some columns aren't needed except to make the order by use unique columns
        if($us = $db->query('select u.id, coalesce(nullif(u.avatar, \'\'), \'' . t7user::DEFAULT_AVATAR . '\') as avatar, u.displayname, u.username, f.fan as isfriend, u.username=\'' . $matchsql . '\' or u.displayname=\'' . $matchsql . '\' as exact, u.username like \'' . $matchlike . '%\' or u.displayname like \'' . $matchlike . '%\' as start from users as u left join users_friends as f on f.fan=\'' . +$user->ID . '\' and f.friend=u.id where u.id!=\'' . +$user->ID . '\' and (u.username like \'%' . $matchlike . '%\' or u.displayname like \'%' . $matchlike . '%\') order by isfriend desc, exact desc, start desc, coalesce(nullif(u.displayname, \'\'), u.username) limit 8')) {
          $ajax->Data->users = [];
          while($u = $us->fetch_object()) {
            // remove ordering columns
            unset($u->exact, $u->start);
            $ajax->Data->users[] = $u;
          }
        } else
          $ajax->Fail('error looking for user suggestions.');
      } else
        $ajax->Fail('at least 3 characters are required to suggest users.');
      break;
  }
  $ajax->Send();
  die;
}

there are other cases in the switch statement but they’re for other ajax calls. track7 uses a class to generate ajax responses (which defaults to json) named t7ajax. feel free to look at its code. the essentials for json output from php are sending the header Content-Type: application/json and formatting a php data object into json with json_encode(). t7ajax does those with its Send function at the end, encoding its Data property.

with that code in place and access to the t7ajax class and the mysqli connection object $db it’s ready to be tested in a browser. visit the url to the script and pass ajax=suggest and at least three characters for match in the query string. for example http://www.track7.org/user/?ajax=suggest&match=jam currently finds two users who match “jam.” most likely you’ll just see raw json code like this (t7ajax always includes a true or false fail value):

{"fail":false,"users":[{"id":"9","avatar":"\/user\/avatar\/jameaghe.jpg","displayname":"","username":"jameaghe","isfriend":null},{"id":"21","avatar":"\/images\/user.jpg","displayname":"","username":"jamiemae","isfriend":null}]}

define a view model

now that the server’s ready to build a list of users to suggest, i need a place to put that list on the client. it’s mostly going to mirror how it was on the server, but the suggested users will be in an observable array in knockout. when something is set up as observable, knockout can automatically update html when the value changes. it will even show every item in an array, which is perfect considering that’s how i get the users from the server.

in addition to the array of suggested users i also want the value of the user lookup field, a highlighted user pointer, and a selected user. i don’t always use the selected user but it’ll help keep this example contained. here’s how that looks inside a javascript class which is also applied to the page:

$(function() {
  ko.applyBindings(window.ViewModel = new UserSuggestViewModel());
});

function UserSuggestViewModel() {
  var self = this;
  self.usermatch = ko.observable("");
  self.users = ko.observableArray([]);
  self.cursor = ko.observable(false);
  self.selectedUser = ko.observable(false);
}

now it’s possible to ask the server for matching users and then fit them into the view model. that should happen when the usermatch observable updates. not every time it updates though; just when it’s at least three characters and it hasn’t changed in a short amount of time. the short amount of time i set to 250 milliseconds and used a javascript timeout to handle the wait. if the value changes before the time is up, cancel it and start the timer again.

subscribe to the usermatch observable with a function that makes an ajax call to get the list and then stores it in the observable array. the subscribe function runs every time the subscribed observable changes.

function UserSuggestViewModel() {
  var self = this;
  // ...
  self.usermatch.subscribe(function() {
    if(window.waitUserSuggest)
      clearTimeout(window.waitUserSuggest);
    window.waitUserSuggest = false;
    if(self.usermatch().length < 3)
      self.matchingusers([]);
    else {
      window.waitUserSuggest = setTimeout(function() {
        self.findingusers(true);
        $.get("/user/", {ajax: "suggest", match: self.usermatch()}, function(data, status, xhr) {
          var result = $.parseJSON(xhr.responseText);
          if(!result.fail) {
            self.users([]);
            for(var u = 0; u < result.users.length; u++)
              self.matchingusers.push(result.users[u]);
          } else
            alert(result.message);
          self.findingusers(false);
        });
      }, 250);
    }
  });
}

the function starts with clearing the previous timer if it was still waiting. then if there are less than 3 characters it clears any previous search results. if there are 3 or more characters it sets a timeout for 250 milliseconds (the 250 after the function definition) to actually request user search results.

to pass the value of the knockout observable usermatch through ajax, call it as a function with no arguments. i’m using jquery to simplify the ajax get request and safely parse the json response into a javascript object. after that it’s copying the users from the result array into the view model’s observable array. at this point there’s no way to set the search value or see the results in the view model, so on to the next step.

create the search field

the other side of knockout is in html. the view model connects via the data-bind attribute. first i need an html input element for collecting usermatch, which will connect via the textInput binding:

<input id=usermatch placeholder="find a person" autocomplete=off data-bind="textInput: usermatch">

the id will be used later to quickly find this element from javascript. the placeholder attribute isn’t a requirement — i use it as an extra hint for what to do with the field. autocomplete does need to be turned off since the suggested users will take its place. i use knockout’s textInput binding because it updates the model while typing in the field rather than waiting until leaving the field.

just after the input i need a place for the results to display. technically my input tag is inside a label tag which itself is inside a form tag, and the results are in an ordered list just after that:

<ol class=usersuggest data-bind="visible: usermatch().length >=3">
  <li class=message data-bind="visible: findingusers">finding people...</li>
  <!-- ko foreach: users -->
  <li class=suggesteduser>
    <img class=avatar alt="" data-bind="attr: {src: avatar}">
    <span data-bind="text: displayname || username"></span>
    <img src="/images/friend.png" alt="*" data-bind="visible: isfriend == 1, attr: {title: (displayname || username) + ' is your friend'}">
  </li>
  <!-- /ko -->
  <li class=message data-bind="visible: !findingusers() && users().length < 1">nobody here by that name</li>
</ol>

there’s a lot more knockout here than there was on the input tag. i use the visible binding to handle different states. the whole ordered list should only show when there are at least 3 characters in the input field because i didn’t want to search for anything less specific than that. then there are messages for when the search is in progress or there were no results. within the user list items i use visible again to control when the friend indicator star displays. the foreach binding pairs well with observableArrays like users — it will repeat everything inside for each of the items in the observableArray. it also lets you access the properties of the array items directly, so avatar, displayname, username, and isfriend don’t need something like users[i]. in front of them. i set the src attribute of the avatar image and the title attribute of friend image using the attr binding. since most users don’t have a displayname and should show their username instead, displayname || username will show displayname if present, otherwise username.

the usersuggest ordered list needs some styling, but a lot of that depends on the style of the site it’s going on. two rules it needs almost all the time are list-style-type: none; to get rid of the numbers this list would normally display, and position: absolute; to keep it out of the flow of the page. i also add a non-transparent background and a border on the sides and bottom, along with margins to adjust its position.

now there’s enough to type in the input field and get back a list of matching users. next, it needs to be able to select one of the suggested users.

support mouse selection

selecting a user suggestion with the mouse needs to do something when the user clicks on a suggested user. add a Select function to the data model which takes a user as a parameter, which will later be linked up to clicking a user suggestion:

function UserSuggestViewModel() {
  var self = this;
  // ...
  self.Select = function(user) {
    self.selectedUser(user);
    self.usermatch("");
    $("#nextfield").focus();
  };
}

whichever user is selected gets stored in the selectedUser observable, then the usermatch field gets cleared, and the focus moves to whatever field is next (replace nextfield with the id of the field that comes after the user search field). clearing usermatch clears the field and gets rid of the suggestions list. some uses will be better suited with a different Select function. for example, on the track7 messages page for a logged-in user actually finds the existing conversation with the selected user or starts a new conversation with that user. it doesn’t track which user was selected and instead selects the conversation with that user immediately. it also doesn’t do this next part, so make changes an necessary to fit the desired use.

change the html so when a user has been selected, that user gets displayed instead of the input field:

<input id=usermatch placeholder="find a person" autocomplete=off data-bind="textInput: usermatch, visible: !selectedUser()">
<span data-bind="visible: selectedUser">
  <img class=avatar data-bind="attr: {src: selectedUser().avatar}">
  <a data-bind="attr: {href: '/user/' + selectedUser().username + '/'}, text: selectedUser().displayname || selectedUser().username"></a>
</span>

the input now has a visible binding in addition to its textInput binding, so it only shows when there is no user selected. the span is new, and contains an avatar image and a link showing the user’s name linked to their profile. in this case the user properties need to be accessed like selectedUser().username value, while previously inside the foreach just username was enough because foreach put it into a user context. another nice touch to add here is a clear icon that sets selectedUser back to false and focuses the usermatch field, so accidentally selecting the wrong user doesn’t mean you need to reload the page. track7 does this but i won’t go into it here.

add a click handler to each user li to call the Select function via the knockout click binding:

  <!-- ko foreach: users -->
  <li class=suggesteduser data-bind="click: $parent.Select">
    <!-- ... -->
  </li>
  <!-- /ko -->

from inside the foreach $parent.Select accesses the Select function defined in the view model and also passes the current user as the first parameter, which is exactly how Select was written. that covers selection with the mouse. next, keyboard selection will build on mouse selection.

support keyboard selection

the arrow keys need to move cursor up and down, and the suggestion the cursor is on needs to look different from the others. update the user suggestion li tag to use the highlight css class when the cursor is on it:

  <!-- ko foreach: users -->
  <li class=suggesteduser data-bind="click: $parent.Select, css: {highlight: id == $parent.cursor().id}">
    <!-- ... -->
  </li>
  <!-- /ko -->

knockout’s css binding is set up with possible class names (in this case, just highlight), which are applied when the value evaluates to true. add some css for the highlight class so that it stands out, probably with a different background color.

by default there’s no cursor, so pressing enter won’t do anything. the up arrow should put the cursor on the last suggestion or the down arrow should put it on the first. once there is a cursor, enter selects that user, up goes to the previous (or wraps around to the bottom), and down goes to the next (or wraps back up to the top). the keys need to work when typing in the usermatch field, so look for them in the keydown event (keypress won’t work because it doesn’t run for the arrow keys). using jquery to attach a function to the keydown event handles the relevant browser inconsistencies. here i manipulate the view model from the outside, which can be done because i saved a reference to the view model as a property of the window object:

$(function() {
  ko.applyBindings(window.ViewModel = new UserSuggestViewModel());

  $("#usermatch").keydown(function(e) {
    var vm = window.ViewModel;
    if(vm.users().length && (vm.cursor() && e.which == 13 || e.which == 38 || e.which == 40)) {
      if(vm.cursor())
        if(e.which == 13)
          $("li.highlight").click();
        else if(e.which == 38) {
          for(var u = vm.users().length - 1; u >= 0; u--)
            if(vm.users()[u] == vm.cursor()) {
              vm.cursor(u > 0 ? vm.users()[u - 1] : vm.users()[vm.users().length - 1]);
              break;
            }
        } else {
          for(var u = 0; u < vm.users().length; u++)
            if(vm.users()[u] == vm.cursor()) {
              vm.cursor(u >= vm.users().length - 1 ? vm.users()[0] : vm.users()[u + 1]);
              break;
            }
        }
      else
        vm.cursor(e.which == 38 ? vm.users()[vm.users().length - 1] : vm.users()[0]);
      e.preventDefault();
    }
  });
});

the keydown handler gets attached in the document ready function, which runs as soon as the structure of the page has loaded. i save a reference to the view model in a variable called vm so i don’t have to type out window.ViewModel each time i use it. keydown gets called for any key, so make sure there’s something to do here: only if there is at least one suggestion and either there’s a cursor and enter was pressed or the up or down arrow was pressed. i looked up the 38 and 40 codes for the arrow keys to know what to check e.which against.

the next branch is whether there’s a cursor. enter key only needs to be handled when there is a cursor, and all i need to do is simulate clicking on the highlighted user and let the function set up for the mouse take care of selecting the user. the up and down arrow keys move one space before or after the cursor, wrapping around if it tries to go up from the first or down from the last. without a cursor, just put the cursor on the first or last suggestion. finally, e.preventDefault() makes sure the browser doesn’t go on to do its own handling of the key, such as submitting the form when pressing enter.

with that the input field now searches for users when at least three characters are entered, displays suggestions in a styled dropdown list, supports selection of a suggestion using either the mouse or keyboard, and displays the selected user in place of the input field.

  1. find matching users
  2. define a view model
  3. create the search field
  4. support mouse selection
  5. support keyboard selection
  6. all 5 pages

how was it?

comments

there are no comments on this guide so far. you could be the first!

*