Tutorial: Learn how to create a spotlight like search experience with autocomplete.js and appbase.io

Search, navigate and run custom JavaScript functions - ideal for SaaS apps wanting to build an omnibar or commandbar experience

Tutorial: Learn how to create a spotlight like search experience with autocomplete.js and appbase.io

In this post we are going to build a Spotlight like search experience using React and Elasticsearch: a searchbar which can not only search for results but also navigate to links and perform custom tasks. This lets you create a swiss army knife with search as the interface. Whether it is showing search results from your app, docs, external sources, or navigating to menu items or pages, or running an action like switching the app theme or sending out an e-mail, you can do it all!

You can check out the example app we will be building in this post over here.

Beyond searchbox

There are a lot of things we can do improve UX. Making our searchbox go beyond just searching for index results is one of them.

New users not familiar with the interface can get lost and not find features quickly. Help them by enabling discovery of features through search.

Users hate using the mouse. Eliminate the need to use the mouse by getting more done through the keyboard. Navigate pages without even having to click on a link.

If your site has a billion features then your users might not find the correct feature when they need it. Solve the problem of feature discovery through search.

Things needed

We would be building this app using autocomplete.js and autocomplete-suggestions-plugin.

Autocomplete UI

The suggestions which appear while typing, has become a much needed feature for search. It can minimize typing and enable users to search quicker. To build an interactive and accessible autocomplete UI we use autocomplete.js.

Getting data for autocomplete

Building autocomplete UI is just one part of the equation. We need data for our suggestions. We will be using a dataset of songs. We will need an Elasticsearch index to host our data. In this post, we will use reactivesearch.io for doing this but you can also run your own Elasticsearch cluster and create an index. You can also view it in dejavu data browser. We can use below details of the index for configuring the app.

{
  "url": "https://appbase-demo-ansible-abxiydt-arc.searchbase.io",
  "app": "best-buy-dataset",
  "credentials": "b8917d239a52:82a2f609-6439-4253-a542-3697f5545947"
}

In order to use the data, we use autocomplete-suggestion-plugin from ReactiveSearch. Reactivesearch has a wide range of suggestions. It supports popular suggestions based on the trending user searches. Not only that, it supports for recent suggestions, that the current user has last searched. Explore Reactivesearch for learning more.

Building the app

Step 1: Setup

We are going to use CRA for setting up the project. This generates a scaffolding for getting started to use React in a project. Run the below command from a CLI.

npm install -g create-react-app  # install CRA if you don't have it.
npx create-react-app saas-search      # initialize the boilerplate.
cd saas-search

The folder structure would something like below.

saas-search
├── README.md
├── node_modules
├── package.json
├── .gitignore
├── package-lock.json
├── public
│   └── favicon.ico
│   └── index.html
│   └── manifest.json
└── src
    └── App.css
    └── App.js
    └── App.test.js
    └── index.css
    └── index.js
    └── logo.svg
    └── registerServiceWorker.js

We have removed some unnecessary files and folders. You can check the resulting setup.

Make a file in src named Autocomplete.js.

We are going to make a autocomplete searchbar using autocomplete.js. Because autocomplete makes changes to the DOM which React is unaware, we have to use an escape-hatch that react provides useRef. We get a reference to a dom node using useRef and then pass it to autocomplete. Autocomplete also does some cleanup by calling search.destroy().

//Autocomplete.js
export function Autocomplete(props) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!containerRef.current) {
      return undefined;
    }

    // Initialize autocomplete on the newly created container
    const search = autocomplete({
      container: containerRef.current,
      ...props,
    });
    // Destroy the search instance in cleanup
    return () => {
      search.destroy();
    };
  }, [props]);

  return <div ref={containerRef} />;
}

We can then use the Autocomplete searchbar component inside of our app.

//index.js
function App() {
  return (
    <div className="App">
      <Autocomplete openOnFocus={true} defaultActiveItemId={0} />
    </div>
  );
}

Step 3: Theme and styles

Our app doesn't look exciting right now. Let's add some styling and theme to it. We would use a theme from algolia. We would import this theme in Autocomplete.js

//Autocomplete.js
import "@algolia/autocomplete-theme-classic";

We can add some custom styles to configure fonts, colors in App.css. The app should look better.

Step 4: Showing suggestions

Our app looks good by now, but we don't get any suggestions when we type. In this step we would be creating and configuring four plugins. The first two would be created completely from scratch: githubRepoPlugin and functionsPlugin. The other two we would create using autocomplete-suggestions-plugin.

This plugin renders repository links as suggestions. This is a custom plugin created using autocomplete. We have a static array as a data source. Each item contains a label for suggestions and description. Each item also contains information about whether to open the link in a new tab or same tab using target attribute.

//githubReposPlugin.js
const data = [
  {
    label: "@appbaseio/autocomplete-suggestions-plugin (Same tab)",
    description:
      "An appbase.io suggestions plugin for autocomplete.js library from Algolia",
    link: {
      target: "_self",
      url: "https://github.com/appbaseio/autocomplete-suggestions-plugin",
    },
  },
];

The plugin is created as below. <Suggestion/> is a UI component for rendering suggestions. We use the getSources which is used by autocomplete.js to get data for suggestions. We match the query(the search term typed by user) against the label in getItems method. The templates property is used to create custom UI elements for styling. We use item for rendering custom suggestion, but there are many more, whose usage is shown in documentation.. We also have a event handler onSelect which gets called when an item is clicked by the user.

//githubReposPlugin.js
const gitHubReposPlugin = {
  getSources({ query }) {
    return [
      {
        sourceId: "github-repos",
        getItems() {
          return data.filter(({ label }) =>
            label.toLowerCase().includes(query.toLowerCase())
          );
        },
        templates: {
          item({ item }) {
            return <Suggestion item={item} />;
          },
          noResults() {
            return "No results";
          },
        },
        onSelect({ item }) {
          if (item.link.url) window.open(item.link.url, item.link.target);
        },
      },
    ];
  },
};

Step 5: Showing functions as suggestions

We get suggestions, but it is still a normal searchbar. We want to search for functions and perform actions. We would be making a plugin for this as well.

The data for our app would look as below. The structure is same as the githubReposPlugin but we have two new properties: callback and matchAny. callback is used when the suggestion is clicked. It gets passed item as argument. matchAny tells whether to match any query searched by user.

const data = [
  {
    label: "/alert",
    description: "Throws a friendly alert",
    callback: (item) => alert(`An alert was triggered.`),
    icon: bellIcon,
    matchAny: true,
  },
];

The code for plugin is as below. It is similar to githubReposPlugin. We add a header to label this group of suggestions.

//functionsPlugin.js
const functionsPlugin = {
  getSources({ query }) {
    return [
      {
        sourceId: "functions",
        getItems() {
          return data.filter(({ label }) =>
            label.toLowerCase().includes(query.toLowerCase())
          );
        },
        templates: {
          header() {
            return <div className="header">Functions</div>;
          },
          item({ item }) {
            return <Suggestion item={item} icon={item.icon} />;
          },
          noResults() {
            return "No results";
          },
        },
        onSelect({ item }) {
          if (typeof item.callback === "function") item.callback(item);
        },
      },
    ];
  },
};

Dark mode function: Let's add more functions in suggestions. We would like to toggle theme of our website using our keyboard. We would add this function in data of functionsPlugin.js

//functionsPlugin.js
const data = [
  {
    label: "/dark",
    description: "Enable dark mode",
    callback: () => {
      const bodyElement = document.querySelector("body");
      bodyElement.classList.add("dark");
    },
    icon: moonIcon,
    matchAny: false,
  },
  {
    label: "/light",
    description: "Enable light mode",
    callback: () => {
      const bodyElement = document.querySelector("body");
      bodyElement.classList.remove("dark");
    },
    icon: sunIcon,
    matchAny: false,
  }
]

Google search function: We would now add a function to similar to "Search on google" context menu item. This would take the search query and open in a new tab a google search.

//functionPlugin.js
const data = [
  {
    label: "Search on Google",
    description: "Google search in new window",
    callback: (query) => {
      const queryParam = query.replace(" ", "+");
      //Open in new window
      window.open(`https://www.google.com/search?q=${queryParam}`, "_blank");
    },
    icon: googleIcon,
    matchAny: true,
  },
]

Step 6: Showing suggestions from our index

Our SAAS-searchbar is looking good and can do various things. But we also want it to show suggestions from our index. For that we would be using autocomplete-suggestions-plugin.

Configuration:

First we make some config variables to use in the plugin. This details are available from your appbase cluster.

Below is appbase config with app(index), url and credentials. The settings field is optional.

//config.js
// appbase client config object
export const appbaseClientConfig = {
  url: "https://appbase-demo-ansible-abxiydt-arc.searchbase.io",
  app: "best-buy-dataset",
  credentials: "b8917d239a52:82a2f609-6439-4253-a542-3697f5545947",
  settings: {
    userId: "s@s",
    enableQueryRules: true,
    recordAnalytics: true,
  },
};

Below is reactivesearch config. It is similar to props you would pass to SearchBox component.

//config.js
export const rsApiConfig = {
  highlight: true,
  dataField: [
    {
      field: "name.autosuggest",
      weight: "1",
    },
    {
      field: "name",
      weight: "3",
    },
  ],
  enableRecentSuggestions: true,
  recentSuggestionsConfig: {
    size: 2,
    minHits: 2,
    minChars: 4,
    index: "best-buy-dataset",
  },
  enablePopularSuggestions: true,
  popularSuggestionsConfig: {
    size: 2,
    minChars: 3,
    minCount: 3,
    index: "best-buy-dataset",
  },
  index: "best-buy-dataset",
  size: 5,
};

Default usage:

In order to fetch results from best-buy-dataset index, we need to create a plugin using autocomplete-suggestions-plugin. We can pass the variables created above to the plugin and we are done.

import createSuggestionsPlugin from "@appbaseio/autocomplete-suggestions-plugin";

const defaultUsagePlugin = createSuggestionPlugin(
  appbaseClientConfig,
  rsApiConfig
);

Then you can just pass it inside autocomplete.js..

// Initialize autocomplete on the newly created container
const search = autocomplete({
  container: containerRef.current,
  placeholder: "Search",
  plugins: [defaultUsagePlugin],
});

You would get the suggestions on search.

Advanced usage:

We can customize the UI rendered for suggestions, actions when a suggestion is clicked. We can do so by passing a third parameter to createSuggestionPlugin.

If we want to customize how suggestions get rendered then we can pass a third parameter with a property renderItem. It would be called by autocomplete-suggestion-plugin with props similar to template's item method in autocomplete..

//advancedUsagePlugin.js
const advancedUsagePlugin = createSuggestionsPlugin(
  appbaseClientConfig,
  rsApiConfig,
  {
    //props would be similar to template's item method.
    renderItem: (props) => {
      const { item } = props;
      return (
        <div className="aa-item product-item" target="_blank" rel="noreferrer">
          <div className="product-image">
            <img
              src={
                item._source
                  ? item._source.image
                  : "https://m.media-amazon.com/images/I/81c83vd4O+L._SY879_.jpg"
              }
              alt={item.value}
            />
          </div>
          <div className="product-details">
            <h4>{item.value} (Promoted)</h4>
            <p>
              {item._source
                ? item._source.shortDescription
                : "Samsung offers latest smartphones with advanced technology and design. Buy 3G, 4G, dual sim smartphone at best prices in India."}
            </p>
          </div>
        </div>
      );
    },
  }
);

Similarly, for customizing the UI for header, footer and no suggestions, autocomplete-suggestion-plugin provides us with renderHeader, renderFooter and renderNoSuggestions. Their usage is below.

const advancedUsagePlugin = createSuggestionsPlugin(
  appbaseClientConfig,
  rsApiConfig,
  {
    renderHeader: () => {
      return <div className="header">Product listings</div>;
    },
    renderFooter: (props) => {
      return <hr style={{ borderColor: "#d7d5f5" }} />;
    },
    renderNoResults: (props) => {
      if (props.state.query === "") {
        return <p>Search for something to get direct product suggestions!</p>;
      } else {
        return <p>No products found! Try searching for something else!</p>;
      }
    },
  }
);

We can also customize the action when a user clicks on one of the suggestion displayed. This can be done by passing a callback function as a property onItemSelect.

const advancedUsagePlugin = createSuggestionsPlugin(
  appbaseClientConfig,
  rsApiConfig,
  {
    onItemSelect: (props) => {
      const {
        item: { url, label, type },
        setQuery,
        refresh,
      } = props;

      if (url) {
        window.open(url, "_blank");
      }
    },
  }
);

Similar to default usage, add the plugin to autocomplete.

// Initialize autocomplete on the newly created container
const search = autocomplete({
  container: containerRef.current,
  placeholder: "Search",
  plugins: [advancedUsagePlugin],
});

Live code and demo

Congratulations! We have built a Spotlight like SAAS search completely from scratch. You can watch the demo, and also browse the code.

Summary

Step 1: Setup an app using CRA

Step 2: Make searchbar using algolia autocomplete

Step 3: Add styles and themes

Step 4: Show suggestions for github repositories

Step 5: Add functions as suggestions

Step 6: Show suggestions from index