Search with Typesense for Laravel Scout

Using Typesense as your search provider for Laravel Scout and Instantsearch.

Typesense is a great open-source alternative for Algolia. Given that Algolia has a pretty expensive pricing package, it could be worth having a look to Typesense.

On top of that, it has already some integrations with famous packages like Instantsearch.js and Laravel Scout.

It started recently, so it’s documentation still lacks some examples. That is why I have written this post:

Using Typesense for Laravel

What is great is that there is a Laravel Scout package available: https://github.com/devloopsnet/laravel-scout-typesense-engine

Fixing the first Typesense errors with Laravel:

Most likely, you will receive errors when you use the default settings as described in the Typesense Laravel Scout package:

{"message": "Document's `id` field should be a string."}

That is solvable by converting the id to a string in the Searchable Array:

/**
* Get the indexable data array for the model.
*
* @return array
*/
public function toSearchableArray()
{
   $array = $this->toArray();
   // Typesense specific
   if (config('scout.driver') == 'typesensesearch') {
      $array['id'] = (string)$array['id']; $array['created_at'] = (integer)\Carbon::parse($array['created_at'])->timestamp;
   }
   return $array;
}

Note that I also converted the created_at datetime value to a timestamp integer, so that Typesense could easy sort on the integer value of the date.

Adding the Instantsearch.js Typesense Adapter to Laravel

You can follow the steps as described by the Typesense Adapter instructions. Also don’t forget to npm install algoliasearch and instantsearch.js .

Inserting in Laravel Mix

You can insert the following code in the webpack.mix.js file for Laravel Mix:

mix.extract(['algoliasearch', 'instantsearch.js', 'typesense-instantsearch-adapter'])
   .js(['resources/js/app.js'], 'public/js');

In your resources/js/app.js also get the imports ready:

import instantsearch from "instantsearch.js";
window.instantsearch = instantsearch;

import { searchBox, hits, index } from "instantsearch.js/es/widgets";
window.searchBox = searchBox;
window.hits = hits;
window.index = index;

import { connectAutocomplete } from 'instantsearch.js/es/connectors';
window.connectAutocomplete = connectAutocomplete;

import TypesenseInstantSearchAdapter from "typesense-instantsearch-adapter";
window.TypesenseInstantSearchAdapter = TypesenseInstantSearchAdapter;

Note that I have also included index and connectAutocomplete which are required for the Autocomplete Widget. If you are not planning to use those, you can leave them out.

Run Laravel Mix with (also use npm install if Mix has never been initialized):

npm run dev

In your main layout blade file add the generated mix files:

<!-- Scripts -->
<script src="{{ mix('/js/manifest.js') }}"></script>
<script src="{{ mix('/js/vendor.js') }}"></script>
<script src="{{ mix('/js/app.js') }}"></script>

Then you can simply insert the hits and searchbox:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@7.4.5/themes/reset-min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@7.4.5/themes/algolia-min.css">


<div id="hits"></div>
<div id="searchbox"></div>

<script>
const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: "{{ env('TYPESENSE_SEARCH_KEY') }}", // Be sure to use the search-only-api-key
    nodes: [
      {
        host: "{{ env('TYPESENSE_HOST') }}",
        port: "443",
        protocol: "https"
      }
    ]
  },
  // The following parameters are directly passed to Typesense's search API endpoint.
  //  So you can pass any parameters supported by the search endpoint below. 
  //  queryBy is required. 
  additionalSearchParameters: {
    queryBy: "name"
  }
});
const searchClient = typesenseInstantsearchAdapter.searchClient;

const search = instantsearch({
  searchClient,
  indexName: "products"
});

// Create the custom widget
const customAutocomplete = connectAutocomplete(
  renderAutocomplete
);

// Instantiate the custom widget
search.addWidgets([
  index({ searchClient, indexName: 'autolandings' }),

  customAutocomplete({
    container: document.querySelector('#autocomplete'),
  })
]);

search.start();
</script>

I’ve added the CSS theming of instantsearch.js to give it some basic styling. This one does not seem to work for Autocomplete yet. You have to fully style this one yourself (as far as I know).

How to use Instantsearch.js Autocomplete with Typesense

Typesense wrote an adapter that works with Instantsearch.js for Algolia. It has some basic examples to use the hits and searchBox component.

In some cases you would like to extend this functionality. For example, if you would like to use the Instantsearch.js autocomplete functionality you have to write additional code. To make a basic autocomplete functionality with multiple indices you can use this code:

Using multiple indices and Autocomplete for Instantsearch.js

In this example I have copied the code from Algolia’s Instantsearch.js Autocomplete website and integrated the Typesense adapter:

<div id="autocomplete"></div>

<script>
const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: "{{ env('TYPESENSE_SEARCH_KEY') }}", // Be sure to use the search-only-api-key
    nodes: [
      {
        host: "{{ env('TYPESENSE_HOST') }}",
        port: "443",
        protocol: "https"
      }
    ]
  },
  // The following parameters are directly passed to Typesense's search API endpoint.
  //  So you can pass any parameters supported by the search endpoint below. 
  //  queryBy is required. 
  additionalSearchParameters: {
    queryBy: "name"
  }
});
const searchClient = typesenseInstantsearchAdapter.searchClient;

const search = instantsearch({
  searchClient,
  indexName: "products"
});

// Helper for the render function
const renderIndexListItem = ({ indexId, hits }) => `
  <li>
    Index: ${indexId}
    <ol>
      ${hits
        .map(
          (hit, hitIndex) =>
            `
              <li>
                <p>${instantsearch.highlight({ attribute: 'name', hit })}</p>
                <button
                  type="button"
                  class="btn-add-to-cart"
                  data-index-id="${indexId}"
                  data-hit-index="${hitIndex}"
                >
                  Add to Cart
                </button>
              </li>
            `
        )
        .join('')}
    </ol>
  </li>
`;

// Create the render function
const renderAutocomplete = (renderOptions, isFirstRender) => {
  const { indices, currentRefinement, refine, widgetParams } = renderOptions;

  if (isFirstRender) {
    const input = document.createElement('input');
    const ul = document.createElement('ul');

    input.addEventListener('input', event => {
      refine(event.currentTarget.value);
    });

    widgetParams.container.appendChild(input);
    widgetParams.container.appendChild(ul);

    ul.addEventListener('click', (event) => {
      if (event.target.className === 'btn-add-to-cart') {
        const indexId = event.target.getAttribute('data-index-id');
        const hitIndex = event.target.getAttribute('data-hit-index');
        const index = indices.find(index => index.indexId === indexId);
        const hit = index.hits[hitIndex];

        index.sendEvent('conversion', hit, 'Product Added');
      }
    });
  }

  widgetParams.container.querySelector('input').value = currentRefinement;
  widgetParams.container.querySelector('ul').innerHTML = indices
    .map(renderIndexListItem)
    .join('');
};

// Create the custom widget
const customAutocomplete = connectAutocomplete(
  renderAutocomplete
);

// Instantiate the custom widget
search.addWidgets([
  index({ searchClient, indexName: 'landings' }),

  customAutocomplete({
    container: document.querySelector('#autocomplete'),
  })
]);

search.start();
</script>

Don’t forget to fill out the TYPESENSE_SEARCH_KEY and the TYPESENSE_HOST in your environment variables. And replace the first index products with your desired index. Replace ‘landings’ with your desired second index. You can add any more indices by adding:

index({ searchClient, indexName: 'anotherIndex' }),

to the search.AddWidgets part.

Mind that we also need to import the autocomplete connector and the index component widget via Laravel Mix.

Leave a Reply

Your email address will not be published.