Merged in feature/autocomplete-search (pull request #1725)

Feature/autocomplete search

* wip autocomplete search

* add skeletons to loading

* Using aumlauts/accents when searching will still give results
remove unused reducer
sort autocomplete results

* remove testcode

* Add tests for autocomplete

* cleanup tests

* use node@20

* use node 22

* use node22

* merge
fix: search button outside of viewport

* merge

* remove more unused code

* fix: error message when empty search field in booking widget

* fix: don't display empty white box when search field is empty and no searchHistory is present

* merge

* fix: set height of shimmer for search skeleton

* rename autocomplete trpc -> destinationsAutocomplete

* more accute cache key naming

* fix: able to control wether bookingwidget is visible on startPage
fix: sticky booking widget under alert

* remove unused code

* fix: skeletons
fix: error overlay on search startpage

* remove extra .nvmrc

* merge


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-04-09 10:43:08 +00:00
parent 7e6abe1f03
commit da07e8a458
40 changed files with 1024 additions and 666 deletions

View File

@@ -0,0 +1,93 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@jest/globals"
import { getSearchTokens } from "./getSearchTokens"
import type { DeepPartial } from "@/types/DeepPartial"
import type { Location } from "@/types/trpc/routers/hotel/locations"
describe("getSearchTokens", () => {
it("should return lowercased tokens for a hotel location", () => {
const location: DeepPartial<Location> = {
keyWords: ["Beach", "Luxury"],
name: "Grand Hotel",
type: "hotels",
relationships: { city: { name: "Stockholm" } },
}
const result = getSearchTokens(location as Location)
expect(result).toEqual(["beach", "luxury", "grand hotel", "stockholm"])
})
it("should generate additional tokens for diacritics replacement on a non-hotel location", () => {
const location: DeepPartial<Location> = {
keyWords: ["Ångström", "Café"],
name: "München",
country: "Frånce",
type: "cities",
}
const result = getSearchTokens(location as Location)
expect(result).toEqual([
"ångström",
"café",
"münchen",
"frånce",
"angstrom",
"cafe",
"munchen",
"france",
])
})
it("should filter out empty or falsey tokens", () => {
const location: DeepPartial<Location> = {
keyWords: ["", "Valid"],
name: "",
type: "hotels",
relationships: { city: { name: "" } },
}
const result = getSearchTokens(location as Location)
expect(result).toEqual(["valid"])
})
})

View File

@@ -0,0 +1,37 @@
import type { Location } from "@/types/trpc/routers/hotel/locations"
export function getSearchTokens(location: Location) {
const tokens = [
...(location.keyWords?.map((x) => x.toLocaleLowerCase()) ?? []),
location.name,
location.type === "hotels"
? location.relationships.city.name
: location.country,
]
.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
}
function hasValue(value: string | null | undefined): value is string {
return !!value && value.length > 0
}

View File

@@ -0,0 +1,21 @@
import { getSearchTokens } from "./getSearchTokens"
import type { Location } from "@/types/trpc/routers/hotel/locations"
import type { AutoCompleteLocation } from "../schema"
export function mapLocationToAutoCompleteLocation(
location: Location | null | undefined
): AutoCompleteLocation | null {
if (!location) return null
return {
id: location.id,
name: location.name,
type: location.type,
searchTokens: getSearchTokens(location),
destination:
location.type === "hotels"
? location.relationships.city.name
: location.country,
}
}

View File

@@ -0,0 +1,106 @@
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

@@ -0,0 +1,17 @@
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)
})
}