JavaScript Search Framework Tutorial 10 - Advanced Integration With Custom TypeScript Component (Implementation)

In this tutorial, you will inspect each individual TypeScript file in the tutorial project and learn what is happening inside them.

For more information on how to implement custom components, see Creating Custom Components.

The Index.ts file

Manually Include the Base JavaScript File

Right now, the only content of the ./src/Index.ts file is:

export {HelloWorld} from './ui/HelloWorld';

This makes the HelloWorld class available under the global namespace of the library, which is defined as  ‘CoveoExtension’ in the ./webpack.config.js file. Concretely, this means that the HelloWorld class can be accessed from CoveoExtension.HelloWorld in a web browser console.

In the ./pages/index.html page header, both the base CoveoJsSearch.Lazy.js and the coveo.extension.js files are included. The extension file currently contains only a minimal amount of code, which is a version of the HelloWorld class that is compiled to JavaScript.

As explained in the following examples, it is important to include the code in the correct order:

<!-- This will work. The base file is included before the extension file. --> 
<script src="js/CoveoJsSearch.Lazy.js"></script>
<script src="js/coveo.extension.js"></script>
<!-- This will not work. The base file is not included, and the extension file cannot work on its own. -->
<script src="js/coveo.extension.js"></script>
<!-- This will not work. The base file is included after the extension. -->
<!-- Consequently, the extension file will produce an error because it references the Component class, which does not yet exist when the file is parsed by the browser. -->
<script src="js/coveo.extension.js"></script>
<script src="js/CoveoJsSearch.Lazy.js"></script>

The HelloWorld.ts file

Currently, the HelloWorld.ts file only instantiates a basic (and somewhat useless) component which is used in the ./pages/index.html file. It is a simple div with CoveoHelloWorld as a class name. It outputs a (not so pretty) green and red section.

Import Definitions

The beginning of the component simply imports definitions from the Coveo JavaScript Search Framework.

These definitions are taken from the CoveoJsSearch.d.ts file, which comes with the Coveo JavaScript Search Framework.

You can find a complete list of everything you can reference by looking at the Coveo JavaScript Search Framework reference documentation (see Coveo JavaScript Search Framework - Reference Documentation).

import {
  Component,
  ComponentOptions,
  IComponentBindings,
  $$,
  QueryEvents,
  IBuildingQueryEventArgs,
  Initialization
} from 'coveo-search-ui';

Option Export

When a component has options, you need to create an interface to expose them.

The HelloWorld component has two options, both of which expect strings.

export interface IHelloWorldOptions {
  dummyOptionText: string;
  dummyOptionQuery: string;
}

Component Export

The component export is the most important part of your component, and is usually where most of your code is going to be, including all of the functions you need to call.

export class HelloWorld extends Component {
  // The component code here...
}

Component ID

Each component needs an ID. It is with this ID that the class name of the component is determined.

Class names start with the Coveo prefix, followed by the component ID. The HelloWorld component thus has the CoveoHelloWorld class name.

static ID = 'HelloWorld';

Options

This is where your component options are implemented. The ComponentOptions class allows you to specify options, which will then be built and parsed when your component is instantiated (see Class ComponentOptions).

static options: IHelloWorldOptions = {
  dummyOptionText: ComponentOptions.buildStringOption({
    defaultValue: 'Hello world'
  }),
    dummyOptionQuery: ComponentOptions.buildStringOption({
    defaultValue: '@uri'
  })
};

Constructor

Your component needs a constructor so it can be instantiated.

Every component has the same parameters for their constructor:

  • The element parameter is the HTMLElement on which the component is being instantiated.
  • The options parameter is the component instance options, which will be built with the ComponentOptions class.
  • The bindings parameter contains different singleton objects available in a search interface (see Interface IComponentBindings).
constructor(public element: HTMLElement, public options: IHelloWorldOptions, public bindings: IComponentBindings) {
  // "super" references the base class, Coveo.Component.
  super(element, HelloWorld.ID, bindings);
  // The options set on the component in the markup are parsed here.
  this.options = ComponentOptions.initComponentOptions(element, HelloWorld, options);
  $$(this.element).text(this.options.dummyOptionText);
  // The bind.onRootElement is a thin wrapper around a standard event listener.
  // In this case, a simple event when a query is being built is binded.
  this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args));
}

As explained in the code example above, the this.bind.onRootElement function call only serves as a thin wrapper around standard JavaScript events (see the ComponentEvents class).

Essentially, this wrapper will take care of not executing an event handler when a component has been disabled. It will also remove the e parameters (the JavaScript event), which is mostly useless for components to receive. Only the specific args parameter (the specific argument for each event type) is truly useful.

Other than that, everything that was explained about events in this tutorial still stands (see JavaScript Search Framework Tutorial 3 - Understanding the Event System).

Functions

Most of the time, your component needs to do something. It is usually after the constructor that you will be able to write all the functions you need to make your component behave the way you want it to.

In the HelloWorld example, only one function is created. It takes care of taking the value of the dummyOptionQuery option and adding it to the query advancedExpression, or aq.

For more information on the queryBuilder class used, see QueryBuilder.

private handleBuildingQuery(args: IBuildingQueryEventArgs) {
  args.queryBuilder.advancedExpression.add(this.options.dummyOptionQuery);
}

Component Registration

Finally, you need to register your component, so that the framework can recognize it in your search page.

This is done by using the registerAutoCreateComponent method (see Initialization - registerAutoCreateComponent).

Initialization.registerAutoCreateComponent(HelloWorld);

Exercise

The end goal of this tutorial is to create a component which can display the total result count next to all currently inactive tabs.

This component will allow end users to see how many results they can expect to get if they select another tab, as shown in the following image:

Remember to add tabs to your search page to be able to see your component in action.

The following code excerpt shows how this could be achieved:

Solution (TypeScript)

import {
  Component,
  ComponentOptions,
  IComponentBindings,
  $$,
  QueryEvents,
  IBuildingQueryEventArgs,
  Initialization,
  InitializationEvents,
  Tab,
  IStringMap,
  get,
  IQueryResults,
  IQuery,
  Dom
} from 'coveo-search-ui';
// The dummy options have been removed, since they are no longer used.
export interface IHelloWorldOptions {
}
/**
 * The interface is declared, but not exported.
 * It is only used as an internal data structure for this component.
 */
interface ITabDefinition {
  tabElement: HTMLElement;
  tab: Tab;
  expression: string;
  resultCountSection: Dom;
}
export class HelloWorld extends Component {
  static ID = 'HelloWorld';
  static options: IHelloWorldOptions = {
  };
  /**
   * This data structure is used to interact with the tabs in the interface.
   * It will be a JSON mapping TabID to ITabDefinition.
   */
  private tabsDefinition: IStringMap<ITabDefinition> = {};
  constructor(public element: HTMLElement, public options: IHelloWorldOptions, public bindings: IComponentBindings) {
    super(element, HelloWorld.ID, bindings);
    this.options = ComponentOptions.initComponentOptions(element, HelloWorld, options);
    /**
     * After all components in the interface have been initialized 
     * (which implies that all tabs in the interface can now be interacted with), 
     * the populateTabsDefinitions property is populated.
     */
    this.bind.onRootElement(InitializationEvents.afterComponentsInitialization, () => this.populateTabsDefinitions());
    /**
     * During every query, the required subqueries are executed to get the result count.
     */
    this.bind.onRootElement(QueryEvents.duringQuery, () => this.handleDuringQuery());
  }
  /**
   * Returns the last executed query 
   * (see https://coveo.github.io/search-ui/interfaces/iquery.html).
   */
  private handleDuringQuery() {
    let lastQuery = this.queryController.getLastQuery();
    /**
     * Iterates over all tab expressions, and checks if the tabDefinition is the currently active tab.
     * When it is not, it sends a query to get the correct result count for that tab.
     * 
     * You do not want to call Coveo.executeQuery, as it would alert the whole interface that a query was performed.
     * Calling the search point directly does not have that effect.
     * 
     * When the results from the query are returned, a new section is added to the tab to hold the result count.
     */
    _.each(this.tabsDefinition, (tabDefinition: ITabDefinition) => {
      this.cleanOldResultCount(tabDefinition);
      if (this.queryStateModel.get('t') != tabDefinition.tab.options.id) {
        let queryToExecuteForThisTabCount = this.getQueryForTabCount(lastQuery, tabDefinition);
        this.queryController.getEndpoint()
          .search(queryToExecuteForThisTabCount)
          .then((response: IQueryResults) => {
            let totalCount = response.totalCount;
            let section = $$('span', { className: 'my-count-section' }, `(${totalCount.toString(10)} results)`);
            tabDefinition.tabElement.appendChild(section.el);
            tabDefinition.resultCountSection = section
          })
      }
    })
  }
  /**
   * Finds all tabs in the interface, suing their CSS class name.
   * It then iterates over each element to get the Tab component instance.
   * The instance is then used to build the tabsDefinitions property.
   */
  private populateTabsDefinitions() {
    let allTabsInInterface = $$(this.root).findAll(`.${Component.computeCssClassNameForType(Tab.ID)}`);
    _.each(allTabsInInterface, (tabElement: HTMLElement) => {
      let tab = <Tab>get(tabElement);
      this.tabsDefinition[tab.options.id] = {
        tabElement: tabElement,
        tab: tab,
        expression: tab.options.expression,
        resultCountSection: null
      }
    });
  }
  /**
   * Resets the result count on every new query.
   * 
   * @param tabDefinition The tab from which to remove the current query results.
   */
  private cleanOldResultCount(tabDefinition: ITabDefinition) {
    if (tabDefinition.resultCountSection != null) {
      tabDefinition.resultCountSection.remove();
      tabDefinition.resultCountSection = null;
    }
  }
  /**
   * Builds the query that needs to be executed to get the correct result count for each tab.
   * The tab expression is added as an aq (advanced query) to the last performed query.
   * The cq (constant query) is then cleared, as it uses the currently selected tab.
   * Clearing it allows your queries to be identitical except for the specified tab.
   * 
   * @param lastQuery The last query that was performed.
   * @param tabDefinition The current tab.
   */
  private getQueryForTabCount(lastQuery: IQuery, tabDefinition: ITabDefinition) {
    let queryToExecuteForThisTabCount = _.clone(lastQuery);
    if (tabDefinition.expression != null && tabDefinition.expression != '') {
      if (queryToExecuteForThisTabCount.aq == null) {
        queryToExecuteForThisTabCount.aq = tabDefinition.expression;
      } else {
        queryToExecuteForThisTabCount.aq = `${queryToExecuteForThisTabCount.aq} ${tabDefinition.expression}`
      }
    }
    queryToExecuteForThisTabCount.cq = null;
    return queryToExecuteForThisTabCount;
  }
}
Initialization.registerAutoCreateComponent(HelloWorld);

You can add a bit of CSS for further customization. The following changes were made in the ./sass/HelloWorld.scss file:

Solution (CSS)

.CoveoHelloWorld {
  display: none;
}
.my-count-section {
  font-size: 10px;
  margin-left: 10px;
  color: red;
}

To build the SCSS file, you can run the following command line in a terminal:

npm run css

However, it effectively triggers an additional query for each non-active tab in the interface.

Sometimes, this could be a problem, as every single search would require additional resources from the Index and the Search API.

Another approach to this problem would be to instead arrange for every field that used to create the filter expression on a tab, to be a group by field.