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