Merged in fix/SW-2253-consolidate-autocomplete-search (pull request #1795)

Consolidate autocomplete search SW-2253 SW-2338

* use fuse.js for fuzzy search
* Handle weird behaviour when search field loses focus on destinationPage
* Add error logging for JumpTo when no URL was provided
* Switch to use <Typography /> over <Caption />
* fix: bookingWidget search label should always be red
* fix: searchHistory can no longer add invalid items
* fix: list more hits when searching
* fix: issue when searchField value was undefined
* fix: don't show searchHistory label if no searchHistory items
* simplify skeleton for listitems in search

Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-04-17 06:39:42 +00:00
parent 8c0597727b
commit b98d6c10c0
35 changed files with 797 additions and 1602 deletions

View File

@@ -2,13 +2,16 @@ import { z } from "zod"
import { Lang } from "@/constants/languages"
import { safeProtectedServiceProcedure } from "@/server/trpc"
import { isDefined } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import { safeTry } from "@/utils/safeTry"
import { getCityPageUrls } from "../contentstack/destinationCityPage/utils"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { getCitiesByCountry, getCountries, getLocations } from "../hotels/utils"
import { filterLocationByQuery } from "./util/filterLocationByQuery"
import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete"
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
import { sortAutocompleteLocations } from "./util/sortAutocompleteLocations"
import type { AutoCompleteLocation } from "./schema"
@@ -35,10 +38,13 @@ export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
.query(async ({ ctx, input }): Promise<DestinationsAutoCompleteOutput> => {
const cacheClient = await getCacheClient()
const lang = input.lang || ctx.lang
const locations: AutoCompleteLocation[] = await cacheClient.cacheOrGet(
`autocomplete:destinations:locations:${input.lang}`,
`autocomplete:destinations:locations:${lang}`,
async () => {
const lang = input.lang || ctx.lang
const hotelUrlsPromise = safeTry(getHotelPageUrls(lang))
const cityUrlsPromise = safeTry(getCityPageUrls(lang))
const countries = await getCountries({
lang: lang,
serviceToken: ctx.serviceToken,
@@ -60,16 +66,44 @@ export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
citiesByCountry: citiesByCountry,
})
const [hotelUrls, hotelUrlsError] = await hotelUrlsPromise
const [cityUrls, cityUrlsError] = await cityUrlsPromise
if (hotelUrlsError || cityUrlsError || !hotelUrls || !cityUrls) {
throw new Error("Unable to fetch location URLs")
}
return locations
.map((location) => {
let url: string | undefined
if (location.type === "cities") {
url = cityUrls.find(
(c) =>
c.city &&
location.cityIdentifier &&
c.city === location.cityIdentifier
)?.url
}
if (location.type === "hotels") {
url = hotelUrls.find(
(h) => h.hotelId && location.id && h.hotelId === location.id
)?.url
}
return { ...location, url }
})
.map(mapLocationToAutoCompleteLocation)
.filter(isDefined)
},
"1d"
)
const filteredLocations = locations.filter((location) =>
filterLocationByQuery({ location, query: input.query })
)
const hits = filterAndCategorizeAutoComplete({
locations,
query: input.query,
})
const selectedHotel = locations.find(
(location) =>
@@ -81,21 +115,8 @@ export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
location.type === "cities" && location.name === input.selectedCity
)
const sortedCities = sortAutocompleteLocations(
filteredLocations.filter(isCity),
input.query
)
const sortedHotels = sortAutocompleteLocations(
filteredLocations.filter(isHotel),
input.query
)
return {
hits: {
cities: sortedCities,
hotels: sortedHotels,
},
hits: hits,
currentSelection: {
city: isCity(selectedCity) ? selectedCity : null,
hotel: isHotel(selectedHotel) ? selectedHotel : null,
@@ -114,9 +135,3 @@ function isCity(
): location is AutoCompleteLocation & { type: "cities" } {
return !!location && location.type === "cities"
}
function isDefined(
value: AutoCompleteLocation | null | undefined
): value is AutoCompleteLocation {
return !!value
}

View File

@@ -6,5 +6,7 @@ export const autoCompleteLocationSchema = z.object({
type: z.enum(["cities", "hotels"]),
searchTokens: z.array(z.string()),
destination: z.string(),
url: z.string().optional(),
})
export type AutoCompleteLocation = z.infer<typeof autoCompleteLocationSchema>

View File

@@ -0,0 +1,44 @@
import { filterAutoCompleteLocations } from "./filterAutoCompleteLocations"
import type { AutoCompleteLocation } from "../schema"
export type DestinationsAutoCompleteOutput = {
hits: {
hotels: AutoCompleteLocation[]
cities: AutoCompleteLocation[]
}
currentSelection: {
hotel: (AutoCompleteLocation & { type: "hotels" }) | null
city: (AutoCompleteLocation & { type: "cities" }) | null
}
}
export function filterAndCategorizeAutoComplete({
locations,
query,
}: {
locations: AutoCompleteLocation[]
query: string
}) {
const rankedLocations = filterAutoCompleteLocations(locations, query)
const sortedCities = rankedLocations.filter(isCity)
const sortedHotels = rankedLocations.filter(isHotel)
return {
cities: sortedCities,
hotels: sortedHotels,
}
}
function isHotel(
location: AutoCompleteLocation | null | undefined
): location is AutoCompleteLocation & { type: "hotels" } {
return !!location && location.type === "hotels"
}
function isCity(
location: AutoCompleteLocation | null | undefined
): location is AutoCompleteLocation & { type: "cities" } {
return !!location && location.type === "cities"
}

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "@jest/globals"
import { filterAutoCompleteLocations } from "./filterAutoCompleteLocations"
import type { DeepPartial } from "@/types/DeepPartial"
import type { AutoCompleteLocation } from "../schema"
describe("rankAutoCompleteLocations", () => {
it("should give no hits when the query does not match", () => {
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
const query = "NonMatchingQuery"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(0)
})
it("should include items when the query matches parts of name", () => {
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
const query = "Øst"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(1)
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
})
it("should allow multiple search terms", () => {
const locations = [scandicAlborgOst] as DeepPartial<AutoCompleteLocation>[]
const query = "Aalborg Øst"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(1)
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
})
it("should rank full word higher than part of word", () => {
const locations = [
scandicSyvSostre,
scandicAlborgOst,
] as DeepPartial<AutoCompleteLocation>[]
const query = "Øst"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(2)
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
expect(ranked.at(1)!.name).toEqual("Scandic Syv Søstre")
})
it("should ignore items without match", () => {
const locations = [
scandicSyvSostre,
scandicAlborgOst,
berlinLodge,
scandicBrennemoen,
] as DeepPartial<AutoCompleteLocation>[]
const query = "Øst"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(3)
expect(ranked.at(0)!.name).toEqual("Scandic Aalborg Øst")
expect(ranked.at(1)!.name).toEqual("Scandic Syv Søstre")
expect(ranked.at(2)!.name).toEqual("Scandic Brennemoen")
})
it("should ignore 'scandic' from name and destination when searching", () => {
const locations = [scandicAlborgOst, scandicBrennemoen].map((x) => ({
...x,
searchTokens: [],
})) as DeepPartial<AutoCompleteLocation>[]
const query = "scandic"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(0)
})
it("should get hits for destination", () => {
const locations = [
scandicAlborgOst,
scandicBrennemoen,
] as DeepPartial<AutoCompleteLocation>[]
const query = "Mysen"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(1)
expect(ranked.at(0)!.name).toBe("Scandic Brennemoen")
})
it("should get hits for searchTokens", () => {
const locations = [
scandicAlborgOst,
scandicBrennemoen,
] as DeepPartial<AutoCompleteLocation>[]
const query = "tusenfryd"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(1)
expect(ranked.at(0)!.name).toBe("Scandic Brennemoen")
})
it("should match when using the wrong aumlaut ö -> ø", () => {
const locations = [scandicBodo] as DeepPartial<AutoCompleteLocation>[]
const query = "bodö"
const ranked = filterAutoCompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(ranked.length).toBe(1)
expect(ranked.at(0)!.name).toBe("Scandic Bodø")
})
})
const scandicAlborgOst: DeepPartial<AutoCompleteLocation> = {
name: "Scandic Aalborg Øst",
destination: "Aalborg",
searchTokens: [
"aalborg",
"aalborg øst",
"scandic aalborg øst",
"aalborg ost",
"scandic aalborg ost",
],
}
const scandicBrennemoen: DeepPartial<AutoCompleteLocation> = {
name: "Scandic Brennemoen",
destination: "Mysen",
searchTokens: [
"mysen",
"askim",
"indre østfold",
"drøbak",
"slitu",
"morenen",
"østfoldbadet",
"tusenfryd",
"brennemoen",
"scandic brennemoen",
"indre ostfold",
"drobak",
"ostfoldbadet",
],
}
const scandicSyvSostre: DeepPartial<AutoCompleteLocation> = {
name: "Scandic Syv Søstre",
destination: "Sandnessjoen",
searchTokens: [
"syv sostre",
"sandnessjoen",
"sandnessjøen",
"syv søstre",
"scandic syv søstre",
"scandic syv sostre",
],
}
const berlinLodge: DeepPartial<AutoCompleteLocation> = {
name: "Berlin Lodge",
searchTokens: [],
}
const scandicBodo: DeepPartial<AutoCompleteLocation> = {
name: "Scandic Bodø",
destination: "Bodo",
searchTokens: [
"bodo",
"kjerringoy",
"bodo i vinden",
"visit bodo",
"badin",
"scandic bodo",
"bodø",
"stormen",
"midnattsol",
"hurtigruten",
"saltstraumen",
"nord universitet",
"kjerringøy",
"nordlys",
"tuvsjyen",
"stella polaris",
"topptur",
"svartisen",
"polarsirkelen",
"aurora borealis",
"bodø i vinden",
"visit bodø",
"bådin",
"norsk luftfartsmuseum",
"rib",
"scandic bodø",
],
}

View File

@@ -0,0 +1,65 @@
import Fuse from "fuse.js"
import type { AutoCompleteLocation } from "../schema"
type SearchableAutoCompleteLocation = AutoCompleteLocation & {
nameTokens: string[]
destinationTokens: string[]
}
type SearchableKey = keyof SearchableAutoCompleteLocation
const fuseConfig = new Fuse([] as SearchableAutoCompleteLocation[], {
minMatchCharLength: 2,
isCaseSensitive: false,
ignoreDiacritics: true,
includeMatches: true,
includeScore: true,
threshold: 0.2,
keys: [
{
name: "nameTokens" satisfies SearchableKey,
weight: 3,
},
{
name: "destinationTokens" satisfies SearchableKey,
weight: 2,
},
{
name: "searchTokens" satisfies SearchableKey,
weight: 1,
},
],
})
export function filterAutoCompleteLocations<T extends AutoCompleteLocation>(
locations: T[],
query: string
) {
const searchable = locations.map((x) => ({
...x,
nameTokens: extractTokens(x.name),
destinationTokens: extractTokens(x.destination),
}))
fuseConfig.setCollection(searchable)
const searchResults = fuseConfig.search(query, { limit: 50 })
return searchResults.map(
(x) =>
({
id: x.item.id,
name: x.item.name,
destination: x.item.destination,
searchTokens: x.item.searchTokens,
type: x.item.type,
url: x.item.url,
}) satisfies AutoCompleteLocation
)
}
function extractTokens(value: string): string[] {
const cleaned = value?.toLowerCase().replaceAll("scandic", "").trim() ?? ""
const output = [...new Set([cleaned, ...cleaned.split(" ")])]
return output
}

View File

@@ -1,93 +0,0 @@
import { describe, expect, it } from "@jest/globals"
import { filterLocationByQuery } from "./filterLocationByQuery"
import type { DeepPartial } from "@/types/DeepPartial"
import type { AutoCompleteLocation } from "../schema"
describe("filterLocationByQuery", () => {
it("should return false if the query is too short", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "luxury"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: " a ",
})
).toBe(false)
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: " ",
})
).toBe(false)
})
it("should return true if one of the search tokens includes part of a valid query token", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "Bea",
})
).toBe(true)
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "hotel",
})
).toBe(true)
})
it("should return false if none of the search tokens include a valid query token", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "xyz",
})
).toBe(false)
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "garbage",
})
).toBe(false)
})
it("should correctly handle queries with punctuation and extra spaces", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: " Grand Hotel! ",
})
).toBe(true)
})
it("should work with queries containing multiple valid tokens", () => {
const location: DeepPartial<AutoCompleteLocation> = {
searchTokens: ["beach", "luxury", "grand hotel", "stockholm"],
}
expect(
filterLocationByQuery({
location: location as AutoCompleteLocation,
query: "luxury beach",
})
).toBe(true)
})
})

View File

@@ -1,23 +0,0 @@
import type { AutoCompleteLocation } from "../schema"
export function filterLocationByQuery({
location,
query,
}: {
location: AutoCompleteLocation
query: string
}) {
const queryable = query
.trim()
.toLowerCase()
.replace(/[^A-Za-zÀ-ÖØ-öø-ÿ0-9\s]/g, "") // Only keep alphanumeric characters and it's accents
.substring(0, 30)
.split(/\s+/)
.filter((s) => s.length > 2)
if (queryable.length === 0) return false
return location.searchTokens?.some((token) =>
queryable.some((q) => token.toLowerCase().includes(q))
)
}

View File

@@ -1,3 +1,5 @@
import { normalizeAumlauts } from "./normalizeAumlauts"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export function getSearchTokens(location: Location) {
@@ -11,25 +13,8 @@ export function getSearchTokens(location: Location) {
.filter(hasValue)
.map((x) => x.toLocaleLowerCase())
const additionalTokens: string[] = []
tokens.forEach((token) => {
const replaced = token
.replace(/å/g, "a")
.replace(/ä/g, "a")
.replace(/ö/g, "o")
.replace(/æ/g, "a")
.replace(/ø/g, "o")
.replace(/é/g, "e")
.replace(/ü/g, "u")
if (replaced !== token) {
additionalTokens.push(replaced)
}
})
const allTokens = [...new Set([...tokens, ...additionalTokens])]
return allTokens
const normalizedTokens = normalizeAumlauts(tokens)
return normalizedTokens
}
function hasValue(value: string | null | undefined): value is string {

View File

@@ -4,7 +4,7 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
import type { AutoCompleteLocation } from "../schema"
export function mapLocationToAutoCompleteLocation(
location: Location | null | undefined
location: (Location & { url?: string }) | null | undefined
): AutoCompleteLocation | null {
if (!location) return null
@@ -12,6 +12,7 @@ export function mapLocationToAutoCompleteLocation(
id: location.id,
name: location.name,
type: location.type,
url: location.url,
searchTokens: getSearchTokens(location),
destination:
location.type === "hotels"

View File

@@ -0,0 +1,22 @@
export function normalizeAumlauts(terms: string[]): string[] {
const additionalTerms: string[] = []
terms.forEach((token) => {
if (!token) return
const replaced = token
.replace(/å/g, "a")
.replace(/ä/g, "a")
.replace(/ö/g, "o")
.replace(/ø/g, "o")
.replace(/æ/g, "a")
.replace(/é/g, "e")
.replace(/ü/g, "u")
if (replaced !== token) {
additionalTerms.push(replaced)
}
})
return [...new Set([...additionalTerms, ...terms])]
}

View File

@@ -1,106 +0,0 @@
import { describe, expect, it } from "@jest/globals"
import { sortAutocompleteLocations } from "./sortAutocompleteLocations"
import type { DeepPartial } from "@/types/DeepPartial"
import type { AutoCompleteLocation } from "../schema"
describe("sortAutocompleteLocations", () => {
it("should put locations with names starting with the query at the top", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Paris Hotel" },
{ name: "London Inn" },
{ name: "paradise Resort" },
{ name: "Berlin Lodge" },
]
const query = "par"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"paradise Resort",
"Paris Hotel",
"Berlin Lodge",
"London Inn",
])
})
it("should sort locations alphabetically if both start with the query", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Alpha Place" },
{ name: "alphabet City" },
]
const query = "al"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"Alpha Place",
"alphabet City",
])
})
it("should sort locations alphabetically if neither name starts with the query", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Zenith" },
{ name: "apple orchard" },
{ name: "Mountain Retreat" },
]
const query = "xyz"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"apple orchard",
"Mountain Retreat",
"Zenith",
])
})
it("should handle an empty query by sorting alphabetically", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Delta" },
{ name: "Alpha" },
{ name: "Charlie" },
{ name: "Bravo" },
]
const query = ""
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((loc) => loc.name)).toEqual([
"Alpha",
"Bravo",
"Charlie",
"Delta",
])
})
it("should be case-insensitive when sorting names", () => {
const locations: DeepPartial<AutoCompleteLocation>[] = [
{ name: "Mountain Cabin" },
{ name: "Beachside Villa" },
{ name: "beach House" },
]
const query = "beach"
const sorted = sortAutocompleteLocations(
locations as AutoCompleteLocation[],
query
)
expect(sorted.map((x) => x.name)).toEqual([
"beach House",
"Beachside Villa",
"Mountain Cabin",
])
})
})

View File

@@ -1,17 +0,0 @@
import type { AutoCompleteLocation } from "../schema"
export function sortAutocompleteLocations<T extends AutoCompleteLocation>(
locations: T[],
query: string
) {
return locations.toSorted((a, b) => {
const queryLower = query.toLowerCase()
const aStarts = a.name.toLowerCase().startsWith(queryLower)
const bStarts = b.name.toLowerCase().startsWith(queryLower)
if (aStarts && !bStarts) return -1
if (!aStarts && bStarts) return 1
return a.name.localeCompare(b.name)
})
}