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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user