Building Modern Search with Yext: Part 2 - Expanding Search Capabilities
In part 1 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 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 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.
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.
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
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.
I’m also going to add Height, Birth Date, and Birth Place as Direct Answer fields so that those fields can be extracted from the entity separately from the entity itself. I’ll also add some synonym sets 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.
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:
Customizing the UI
In part 1, I cloned the Yext React Site Search Starter repo 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; 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 on the outer container
// 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 provides an explanation of cssCompositionMethod
and its different options.
// 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 and adding them to my project, I imported them into the application CSS via the tailwind.css
file.
/* 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 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.
// 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.
// 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.
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.
// 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.
// 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 to change the number of grid columns based on the size of the screen.
// 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.
// universalResultsConfig.ts
/*
Adding a new config for a vertical section looks like:
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.
// 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.
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.
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:
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 toSectionHeader
inUniversalResults.tsx
- Where
AppliedFiltersDisplay
is rendered inSectionHeader.tsx
, I addedcssCompositionMethod
as a prop so that I can use the‘assign’
method when passing the theappiledFiltersConfig
prop toUniversalResults
inUniversalSearchPage.tsx
- Added
nlpFilter
styling and the‘assign’
CSS composition method to theuniversalResultsFilterConfig
that is passed as theappliedFiltersConfig
prop toUniversalResults
inUniversalSearchPage.tsx
- Passing custom styling to the
DirectAnswer
component inUniversalSearchPage.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:
Next Steps
I have hosted the code for this project here and the live version of the app here.
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!