Building Modern Search with Yext: Part 2 - Expanding Search Capabilities
Tags: yext, search, webdev, react
Published: 2022-03-02 16:36:51

In [part 1](https://dev.to/yext/building-modern-search-with-yext-part-1-getting-started-5g41) of this blog series, I introduced how to quickly get a simple search page up and running using the Yext Knowledge Graph, Answers, and the React site starter repo. So far, I only have *Movie* entities in my Knowledge Graph. 

In this guide, I want to add a new *Actors* search vertical and improve the user interface of my search experience. I’m going to add *Actor* entities, link them to the *Movies* they are associated with (and vice-versa), and reconfigure my Answers API to return the most relevant results possible. I may be using Movies and Actors in this project, but I could build a similar experience associating restaurants with limited time offers or gym locations with workout classes. 

Finally, I’ll update my React application to provide a more visually appealing search experience.

NOTE: If you would like to copy the Yext account I create in this guide, check out the [README of the Github repo](https://github.com/YextSolutions/movies-answers-part-two) to copy the experience described below to your Yext account.

## Adding a Custom Actor Entity

I need a new custom entity type to represent the top actors from each of the movies that I added to my Knowledge Graph in part 1.  After adding a new entity type with the name “Actor” and the plural name “Actors,” I’ll navigate to the newly-created type and add the following fields:

- *Primary Photo* - This is a built-in field that I’ll use to store an image URL as the headshot for each Actor’s entity.
- *Birth Place* - Custom Single-Line Text field used to represent the Actor’s home city, state, and country.
- *Bio* - Custom Multi-Line Text field that contains a description of the Actor and their career.
- *Height* - Custom Single-Line Text field containing the Actor’s Height
- *Birth Date* - Custom Single-Line Text field containing the Actor’s Birth Date

By adding these fields, users will be able to search for some basic information about movie stars. But what if a user wants to see the movies that Keanu Reeves has starred in or find out who voiced Buzz Lightyear in Toy Story 2? I need a [custom field type](https://hitchhikers.yext.com/modules/kg122-fields-intro/04-custom-field-types/) that will contain the role the actor played/voiced (e.g. Neo, Buzz Lightyear) and the movie which they played it in (e.g. The Matrix, Toy Story 2). I’ll navigate to the Field Type section in my Knowledge Graph Configuration, add a new custom field type, and name it *Role*. 

![add custom field type](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6mq8ceu3i8ktpalh9goh.png)

![add field type name](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yjrl9bpr9608r0k7ayja.png)

Before saving, I need to add 2 subfields to my custom type:

- *Character Name* - Single-line text field for the name of the character that an actor played or voiced in a given movie
- *Movie* - Entity List type to link the role to the movie that the actor performed the role in. I have added validation here to ensure that only *Movie* type entities can be linked.

![add subfields](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x81k63ym4hyurmdo7cec.png)

When I go back to my *Actor* entity type configuration, I will create a new custom field called *Filmography*. When choosing the Field Specification, I’ll choose *Role* as the field type and Make it a List.

## Expanding the Movie Entity Type

I need to add some new fields to my custom *Movie* entity to provide more interesting search results, enhance my search experience UI, and link *Movies* back to *Actors*:

- *Poster* - Simple Photo field for storing the promotional movie poster associated with the movie’s release.
- *MPA Rating*- Single-line text field for storing the Motion Picture Association film rating (e.g. G, PG, PG-13, R)
- *Runtime* - Number field for storing the movie’s runtime in minutes. The field will be returned as a string by the Answers API.

Just like how I linked Movies to Actors with the Filmography field and Role field type, I’m going to create a custom Star field type to establish a relationship between Movie and Actor entities.  I’ll add the following subfields to the new field type:

- *Role* - Single-line text field to represent the name of a movie role
- *Actor* -  Entity List type to link the role to the *Actor* that performed the role. I have added validation here to ensure that only *Actor* type entities can be added

![star subfields](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xadm7wruj0bbtzevwcng.png)

I will add a field called Stars to the Movie entity type with a field type of Star and I will select “Make it a List” so that I can link all of the Actors from a given movie.

## Actors Search Configuration

In my search configuration, I need to add an *Actors* vertical so that I can search on the new entities that I have added. The *Entity Type* and *Name* fields will be enabled as searchable fields by default. I will leave the *Entity Type* search field configuration as-is with NLP filter still enabled so that any universal search containing the phrase “actor” or “actors” will only return *Actor* entity types. For the *Name* field on the other hand, I will disable Semantic Text Search and apply Phrase Match and Text Search. 

If I search for “buzz lightyear”, I want the Buzz Lightyear voice actor to appear at the top of the search results while searching for “toy story 2” should return the top voice actors from that movie. I’ll add *c_filmography.characterName* and *c_filmography.movie.name* as searchable fields with the NLP Filter turned on. That way, a search for a character name will filter out any actors who don’t have that character in their *Filmography* and searching for a movie will remove actors who didn’t star in that movie.

![actor vertical](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/988wonlsvrpix856ldq0.png)

I’m also going to add Height, Birth Date, and Birth Place as [Direct Answer](https://hitchhikers.yext.com/modules/ans109-core-config-verticals/09-direct-answers/) fields so that those fields can be extracted from the entity separately from the entity itself. I’ll also add some [synonym sets](https://hitchhikers.yext.com/modules/ans111-core-config-synonyms/01-what-are-synonyms/) in my search configuration to teach the Answers algorithm to apply the same meaning to specific sets of words. Now if user asks questions like “How tall is keanu reeves” or “where was brad pitt born” the answer will be extracted from the Actor entity and returned before the other results.

![actor direct answers](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l2gfc2ehj1p1taf2dhob.png)

![synonyms](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mxcb08vw0k1wxhlyvmw8.png)

## Updating the Movies Vertical

I also need to update my *Movie* vertical configuration to account for the new fields that are part of my *Movie* entities. Similar to the *Actors* vertical, I’m going to add *c_stars.role* and *c_stars.actor.name* as NLP Filter fields. That way, when I search for a character or actor’s name, only movies that meet those conditions should be returned. Searching for ‘Neo’ should just return The Matrix and searching for Keanu Reeves should return any movies in the Knowledge Graph that he has starred in.

I’ll also add *MPA Rating* as a Direct Answer field and “mpa rating” “rated” and “rating” as a synonym set so that a universal query can answer questions like “what is the matrix rated”

After adding all of the configuration for my *Movies* and *Actors*, I can test out some search queries in my Answers test search:
{% vimeo 683106767 %}

## Customizing the UI

In part 1, I cloned the [Yext React Site Search Starter repo](https://github.com/yext/react-site-search-starter) and added my Answers configuration. Now I want to add some of my own components and change some of the default stying to give my application it’s own look and feel. 

The Site Search Starter includes [Tailwind CSS](https://tailwindcss.com/); a CSS framework that provides utility classes for customizing components in a web application. Unlike CSS frameworks like Bootstrap or Materialize, Tailwind does not provide pre-built components. Instead, its utility classes make it easy to style components that are built from scratch. 

In `App.tsx`, I’m going to remove some of the default styling to make the content of my search experience take up most of the page. I’m still going to leave some [padding](https://tailwindcss.com/docs/padding) on the outer container

```jsx
// App.tsx

export default function App() {
  return (
    <AnswersHeadlessProvider {...answersHeadlessConfig}>
      <div className='flex py-4 px-6'>
        <div className='w-full'>
          <PageRouter
            Layout={StandardLayout}
            routes={routeConfig}
          />
        </div>
      </div>
    </AnswersHeadlessProvider>
  );
}
```

`StandardLayout.tsx` is provided to the `PageRouter` to organize the search bar and search results for each page in my application. I’m going to modify the `StandardLayout` component by commenting out `Navigation` and `SampleVisualSearchBar` because I’m only going to worry about my Universal Search results and page for now.

I also want to override some of the `builtInCssClasses` within the `SearchBar` component so I’m passing `searchBarCssStyles` and a `cssCompositionMethod` of `‘assign’` as props. The `‘assign'` method will assign the Tailwind classes I have added to `searchBarCssStyles` to their corresponding elements. Every built-in style not included in `searchBarCssStyles` will be left alone. The [README in the React Site Search Starter](https://github.com/yext/react-site-search-starter) provides an explanation of `cssCompositionMethod` and its different options.

```jsx
// StandardLayout.tsx

const searchBarCssStyles = {
  container: 'h-12 font-display text-xl w-2/5', 
  logoContainer: 'hidden', 
  inputContainer: 'inline-flex items-center justify-between w-full mt-1',
  inputDropdownContainer: 'bg-white border rounded-lg border-gray-200 w-full overflow-hidden text-black',
}

const StandardLayout: LayoutComponent = ({ page }) => {
  // const isVertical = useAnswersState(s => s.meta.searchType) === SearchTypeEnum.Vertical;

  return (
    <>
      {/* example use of the VisualSearchBar which is used to display entity previews on autocomplete for Vertical searches */}
      {/* {isVertical
        ? 
        : <SampleVisualSearchBar />
      } */}
      <SearchBar
        placeholder='Search...'
        screenReaderInstructionsId='SearchBar__srInstructions'
        customCssClasses={searchBarCssStyles}
        cssCompositionMethod='assign'
      />
      {/* Navigation is commented out as app only displays Universal Search results */}
      {/* <Navigation links={navLinks} */}
      {page}
    </>
  )
}
```

In the `container` field within my custom css classes, I am including a class called `font-display`. This class is not included with Tailwind and is something that I added to my Tailwind configuration. After downloading some fonts from [Google fonts](https://fonts.google.com/) and adding them to my project, I imported them into the application CSS via the `tailwind.css`file.

```css
/* tailwind.css */

@font-face {
  font-family: "Bebas Neue";
  src: url(./fonts/BebasNeue-Regular.ttf);
}

@font-face {
  font-family: "Roberto";
  src: url(./fonts/RobotoSerif-VariableFont.ttf);
}

@tailwind base;
@tailwind components;
@tailwind utilities;
```

`tailwind.config.js` is where I can [extend the the default theme](https://tailwindcss.com/docs/theme#extending-the-default-theme) and add new font family classes that reference the fonts that I imported. I have also added a color to the default color palette, added a custom box shadow class, and added a custom `min-width` class. I’ll be using these classes later on when creating some of my own components. 

```jsx
// tailwind.config.js

module.exports = {
  purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {
      fontFamily: {
        display: [ 'Bebas Neue'],
        body: ['Roberto']
      },
      colors: {
        'slate': '#0f2027',
      },
      boxShadow: {
        'movie': 'rgba(243, 244, 246, 0.35) 0px 5px 15px',
      },
      minWidth: {
        '1/3': '33.3333333%'
      }
    },
  },
  variants: {
    extend: {
      dropShadow: ['hover']
    },
  },
  plugins: [ 
    require("@tailwindcss/forms")({
      strategy: 'class',
    }),
  ],
}
```

I’m also going to rearrange the `InputDropdown` component within `SearchBar` to move the search icon to the start of the container. Because I assigned the `hidden` Tailwind class to the `logoContainer`, the Yext logo will no longer appear in the search bar.

```jsx
// InputDropdown.tsx

return (
    <div className={inputDropdownContainerCssClasses} ref={inputDropdownRef} onBlur={handleBlur}>
      <div className={cssClasses?.inputContainer}>
        <div className={cssClasses.searchButtonContainer}>
          {renderSearchButton()}
        </div>
        <div className={cssClasses.logoContainer}>
          {renderLogo()}
        </div>
        <input
          className={cssClasses.inputElement}
          placeholder={placeholder}
          onChange={evt => {
            const value = evt.target.value;
            setLatestUserInput(value);
            onInputChange(value);
            onInputFocus(value);
            setChildrenKey(childrenKey + 1);
            dispatch({ type: 'ShowSections' });
            setScreenReaderKey(screenReaderKey + 1);
          }}
          onClick={() => {
            onInputFocus(inputValue);
            setChildrenKey(childrenKey + 1);
            dispatch({ type: 'ShowSections' });
            if (numSections > 0 || inputValue) {
              setScreenReaderKey(screenReaderKey + 1);
            }
          }}
          onKeyDown={handleInputElementKeydown}
          value={inputValue}
          ref={inputRef}
          aria-describedby={screenReaderInstructionsId}
          aria-activedescendant={focusedOptionId}
        />

      </div>
      {/* ...other code */}
    </div>
  );
```

After making these changes, saving, and starting my application locally with `npm start`, I now have a slightly different layout for my application.

![layout change](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bpk95yzfiw9l2ozei2l2.png)

## Adding MovieCard and MovieSection

Each *Movie* search result is currently using the `StandardCard` component. I want to replace this with my own movie card component to make each card more visually appealing and interactive. Each card I add to my app needs to be of type `CardComponent`. The `CardProps` passed to each card component contain the search result from which I can pull out the data I need to customize the look and feel of my card. 

In `MovieCard.tsx`, I have defined the interface `Movie` which contains the fields I will use in my new result card. Because I know that each result will be of type *Movie*, I can safely use a type assertion to convert the unknown `rawData` contained in `props.result` to a `Movie`. I’m going to use the poster url from `movie` as the background image of an element and style it with some Tailwind classes. 

```jsx
// MovieCard.tsx

// Name is only required field for a Movie entity, every other field needs the conditional operator
interface Movie {
  name: string,
  description?: string,
  c_poster?: {
    url: string
  },
  c_genres?: string[],
  c_mpaRating?: string,
  c_runtime?: string
}

export function MovieCard(props: CardProps): JSX.Element {
  // type asserting unknown because I know it will contain Movie entity
  const movie = props.result.rawData as unknown as Movie;

return (
    <div className='w-64 h-96 flex flex-col rounded-lg relative group' >
      <div className='w-full h-96 bg-cover rounded-lg shadow-movie' style={{ backgroundImage: `url(${movie.c_poster?.url})` }}/>
    </div>
  );
}
```

I also want to change how my movie search result cards are organized when they appear in a universal search. Universal search results are laid out in vertical search sections. For example, search results for ‘the matrix’ will return a *Movies* vertical section containing the Matrix result card followed by an *Actor* vertical section containing the Keanu Reeves, Carrie-Anne Moss, and Laurence Fishburne result cards.

`VerticalResults.tsx` is where the results for each vertical section of the universal search results are rendered. I’m going to add a field to the `VerticalResultsCssClasses` interface called `container` and modify the `resultsClassNames` object in the `VerticalResultsDisplay` component to include the `container` field.

```jsx
// VerticalResults.tsx

export interface VerticalResultsCssClasses {
  results___loading?: string,
  container?: string // Added to existing component
}

const builtInCssClasses: VerticalResultsCssClasses = {
  results___loading: 'opacity-50',
  container: '' // Added to existing component
}

interface VerticalResultsDisplayProps {
  CardComponent: CardComponent,
  cardConfig?: CardConfigTypes,
  isLoading?: boolean,
  results: Result[],
  customCssClasses?: VerticalResultsCssClasses,
  cssCompositionMethod?: CompositionMethod
}

/**
 * A Component that displays all the search results for a given vertical.
 * 
 * @param props - The props for the Component, including the results and the card type
 *                to be used.
 */
export function VerticalResultsDisplay(props: VerticalResultsDisplayProps): JSX.Element | null {
  const { CardComponent, results, cardConfig = {}, isLoading = false, customCssClasses, cssCompositionMethod } = props;
  const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses, cssCompositionMethod);

  if (results.length === 0) {
    return null;
  }

  const resultsClassNames = cssClasses.results___loading
    ? classNames({ [cssClasses.results___loading]: isLoading }, cssClasses.container) // Added to existing component
    : '';

  return (
    <div className={resultsClassNames}>
      {results && results.map(result => renderResult(CardComponent, cardConfig, result))}
    </div>
  )
}
```

Now, I can add my `MoviesSection` component. It is almost identical to the built-in `StandardSection` component, but I am passing custom container styling to layout my movie cards in a grid rather than a list. I am using [Tailwind responsive utility variants](https://tailwindcss.com/docs/responsive-design) to change the number of grid columns based on the size of the screen.

```jsx
// MoviesSection.tsx

import { VerticalResultsDisplay } from "../components/VerticalResults";
import { SectionComponent, SectionConfig } from "../models/sectionComponent";
import { StandardCard } from "../components/cards/StandardCard";

const verticalResultsContainerCssStyles = { container: 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8' }

const MoviesSection: SectionComponent = function (props: SectionConfig): JSX.Element | null {
  const { results, cardConfig, header } = props;
  
  if (results.length === 0) {
    return null;
  }
  const cardComponent = cardConfig?.CardComponent || StandardCard;
  
  return (
    <section>
      {header}
      <VerticalResultsDisplay
        results={results}
        CardComponent={cardComponent}
        {...(cardConfig && { cardConfig })}
        customCssClasses={verticalResultsContainerCssStyles}
      />
    </section>
  );
}
export default MoviesSection;
```

In `universalResultsConfig.ts`, I’ll add my new movie card and section. 

```jsx
// universalResultsConfig.ts

/* 
  Adding a new config for a vertical section looks like:
  
  [verticalKey]: {
    cardConfig: {
      CardComponent: [custom card component]
    },
    SectionComponent: [custom section component]
  }
  
*/

export type UniversalResultsConfig = Record<string, VerticalConfig>;

export const universalResultsConfig: UniversalResultsConfig = {
  movie: {
    cardConfig: {
      CardComponent: MovieCard,
    },
    SectionComponent: MoviesSection
  }
}
```

I added some white-colored box shadow to my movie cards to give them a glowing effect. I won’t be able to see the effect on a white background so I’m going to change the background color of the body of the entire application in `tailwind.css` using the custom color I defined in `tailwind.config.js` earlier. 

```jsx
// tailwind.css

@layer base {
  body {
    @apply bg-slate;
  }
}
```

Now, if I save everything and take a look at my app, my movie results look a lot different than before.

![movie cards](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mlbmefacb3ulrcav0vu7.png)

## MovieCard Enhancements

I want each `MovieCard` to show more of the *Movie* entity fields from my Knowledge Graph. Each time the mouse is hovered over the card, I want the *Name*, *MPA Rating*, *Runtime*, *Genres*, and *Description* to appear overtop the movie poster. Tailwind makes it easy to style elements based on the state of their parent element. By adding the `group` class to the parent element Tailwind classes, changes to the parent element’s state can be used to change the styling of its elements. 

I’ve added a `div` element that is absolutely positioned on top of its parent element (Tailwind classes: `absolute top-0 bottom-0 right-0 left-0`). It has a gray background color (`bg-gray-200`), rounded border (`rounded-lg`), and is invisible (`opacity-0`). By adding `group-hover:opacity-90`, the element will go from invisible to visible when the mouse is hovered on its parent element. On hover, the element will transition to being visible over the course of 300 milliseconds at an even speed (`transition duration-300 ease-linear`). At the same time, the text container `div` will go from invisible to visible over a slightly longer duration (`opacity-0 transition duration-500 group-hover:opacity-100`). I have left out some helper functions below for brevity but you can see the whole component [here](https://github.com/YextSolutions/movies-answers-part-two/blob/main/src/components/cards/MovieCard.tsx).

```jsx
const movieCardCssStyles = {
  container: 'w-64 h-96 flex flex-col rounded-lg relative group',
  moviePosterContainer: 'w-full h-96 bg-cover rounded-lg shadow-movie',

  // textPanel and textContainer each have the transition Tailwind classes mentioned in the blog
  textPanel: 'absolute top-0 bottom-0 right-0 left-0 rounded-lg bg-gray-200 opacity-0 transition duration-300 ease-linear group-hover:opacity-90',
  textContainer: 'w-60 px-4 mt-1 mb-2 flex flex-col font-body text-gray-800 absolute opacity-0 transition duration-500 group-hover:opacity-100',

  // the following 4 fields are used by helper functions
  descriptionContainer: 'flex flex-col mt-4',
  descriptionText: 'text-sm',
  headingText: 'font-display text-lg',
  movieInfoList: 'space-x-1 text-sm'
}

export function MovieCard(props: CardProps): JSX.Element {
  const movie = props.result.rawData as unknown as Movie;

	// helper functions

  return (
    <div className={movieCardCssStyles.container} >
      <div className={movieCardCssStyles.textPanel}></div>
      <div className={movieCardCssStyles.moviePosterContainer} style={{ backgroundImage: `url(${movie.c_poster?.url})` }}/>
      <div className={movieCardCssStyles.textContainer}>
        <span className={movieCardCssStyles.headingText}>{movie.name}</span>
        {renderMovieInfo()}
        {renderDescription()}
      </div>
    </div>
  );
}
```

After saving these changes, I can see my new animation in action:

![movie poster hover](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/borfb3qa5bvx6tgprg1k.gif)

## Actors Cards and Other Styling

For *Actor* search results, I have created an `ActorCard` and `ActorSection` component and added them to the `universalResultsConfig.ts`. In addition, I added some other minor styling to the application:

- Passing `sectionHeaderStyles` and the `‘assign’` CSS composition method as props to `SectionHeader` in `UniversalResults.tsx`
- Where `AppliedFiltersDisplay` is rendered in `SectionHeader.tsx`, I added `cssCompositionMethod` as a prop so that I can use the `‘assign’` method when passing the the `appiledFiltersConfig` prop to `UniversalResults` in `UniversalSearchPage.tsx`
- Added `nlpFilter` styling and the `‘assign’` CSS composition method to the `universalResultsFilterConfig` that is passed as the `appliedFiltersConfig` prop to `UniversalResults` in `UniversalSearchPage.tsx`
- Passing custom styling to the `DirectAnswer` component in `UniversalSearchPage.tsx`

Running the same test searches I tested in the platform earlier, I can see all of the UI changes I have made to the cloned repo:

{% vimeo 683428762 %}

## Next Steps

I have hosted the code for this project [here](https://github.com/YextSolutions/movies-answers-part-two) and the live version of the app [here](https://main-imperfect--stuffed--cocoa-sbx-pgsdemo-com.sbx.preview.pagescdn.com/).

At this point, I have added a second search vertical to my Answers configuration and my own React web application for surfacing search results. However, I only have 10 *Movies* and 30 *Actors* in my Knowledge Graph. Next, I’m going to use a Data Connector and a Typescript plugin function to add hundreds of more entities to my KG. In the UI, I’m going to add individual vertical sections so that users more easily filter on the results they are looking for. Stay tuned!