diff --git a/web/src/components/search/SearchResults.tsx b/web/src/components/search/SearchResults.tsx index ef44a74..fdcb81f 100644 --- a/web/src/components/search/SearchResults.tsx +++ b/web/src/components/search/SearchResults.tsx @@ -1,98 +1,23 @@ -import { unmaskFragment } from "@/gql"; -import type { ImageFragment, SearchQuery } from "@/gql/graphql"; -import { ImageFragmentDefinition, stripHtml } from "@/lib/common"; -import { formatDate, formatOccurrenceMonths } from "@/lib/date"; import Link from "next/link"; import { Image } from "../general/Image"; import styles from "./searchContainer.module.scss"; +import { + type SearchResult, + type SupportedResult, + getResultDate, + getResultImage, + getResultSnippet, + getResultType, + isSupported, + splitMatches, +} from "@/lib/search"; -export type SearchResult = SearchQuery["results"][number]; - -const PAGE_TYPES = { - NewsPage: "Nyhet", - EventPage: "Arrangement", - GenericPage: "Underside", - VenuePage: "Lokale", - AssociationPage: "Forening", -} as const; - -type SupportedTypename = keyof typeof PAGE_TYPES; -type SupportedResult = Extract; - -function capitalizeFirstLetter(s: string) { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -function isSupported(result: SearchResult): result is SupportedResult { - return result.__typename in PAGE_TYPES && "id" in result && !!result.id; -} - -function getResultType(result: SupportedResult): string { - if (result.__typename === "AssociationPage" && result.associationType) { - return capitalizeFirstLetter(result.associationType); - } - return PAGE_TYPES[result.__typename]; -} - -function getResultImage(result: SupportedResult): ImageFragment | null { - switch (result.__typename) { - case "NewsPage": - case "EventPage": - case "VenuePage": - return unmaskFragment(ImageFragmentDefinition, result.featuredImage); - case "AssociationPage": - return unmaskFragment(ImageFragmentDefinition, result.logo); - default: - return null; - } -} - -function getResultDate(result: SupportedResult): string | null { - if (result.__typename === "EventPage") { - const starts = result.occurrences - .map((o) => o.start) - .filter((s): s is string => !!s); - if (starts.length === 0) return null; - if (starts.length === 1) return formatDate(starts[0], "d. MMMM yyyy"); - return formatOccurrenceMonths(starts); - } - if (result.__typename === "NewsPage" && result.firstPublishedAt) { - return formatDate(result.firstPublishedAt, "d. MMMM yyyy"); - } - return null; -} - -function getResultSnippet(result: SupportedResult): string | null { - switch (result.__typename) { - case "NewsPage": - case "AssociationPage": - return result.excerpt ?? null; - case "EventPage": - return result.subtitle ?? null; - case "GenericPage": - return result.lead ? stripHtml(result.lead).trim() : null; - default: - return null; - } -} +export type { SearchResult }; function highlight(text: string, query: string): React.ReactNode { - if (query.length < 2) return text; - const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const pattern = new RegExp(escaped, "gi"); - const nodes: React.ReactNode[] = []; - let lastIndex = 0; - for (const match of text.matchAll(pattern)) { - if (match.index > lastIndex) { - nodes.push(text.slice(lastIndex, match.index)); - } - nodes.push({match[0]}); - lastIndex = match.index + match[0].length; - } - if (lastIndex < text.length) { - nodes.push(text.slice(lastIndex)); - } - return nodes; + return splitMatches(text, query).map((segment, i) => + segment.match ? {segment.text} : segment.text + ); } export function SearchResults({ diff --git a/web/src/lib/search.test.ts b/web/src/lib/search.test.ts new file mode 100644 index 0000000..c953f31 --- /dev/null +++ b/web/src/lib/search.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { + type SearchResult, + type SupportedResult, + getResultDate, + getResultImage, + getResultSnippet, + getResultType, + isSupported, + splitMatches, +} from "./search.ts"; + +const result = (fields: Record) => + ({ id: "1", ...fields }) as unknown as SupportedResult; + +describe("splitMatches", () => { + it("matches nothing for queries shorter than 2 characters", () => { + expect(splitMatches("Bokbad", "b")).toEqual([ + { text: "Bokbad", match: false }, + ]); + }); + + it("marks all case-insensitive matches, preserving the original text", () => { + expect(splitMatches("Bokbad: bok og bøker", "bok")).toEqual([ + { text: "Bok", match: true }, + { text: "bad: ", match: false }, + { text: "bok", match: true }, + { text: " og bøker", match: false }, + ]); + }); + + it("treats regex characters in the query literally", () => { + expect(splitMatches("Jazz (live) på taket", "(live)")).toEqual([ + { text: "Jazz ", match: false }, + { text: "(live)", match: true }, + { text: " på taket", match: false }, + ]); + }); + + it("handles matches at the start and end", () => { + expect(splitMatches("konsert", "kon")).toEqual([ + { text: "kon", match: true }, + { text: "sert", match: false }, + ]); + expect(splitMatches("på Kroa", "oa")).toEqual([ + { text: "på Kr", match: false }, + { text: "oa", match: true }, + ]); + }); + + it("returns a single unmatched segment when nothing matches", () => { + expect(splitMatches("Sjakklubben", "quiz")).toEqual([ + { text: "Sjakklubben", match: false }, + ]); + }); +}); + +describe("isSupported", () => { + it("accepts known page types with an id", () => { + for (const __typename of [ + "NewsPage", + "EventPage", + "GenericPage", + "VenuePage", + "AssociationPage", + ]) { + expect(isSupported({ __typename, id: "1" } as unknown as SearchResult)).toBe( + true + ); + } + }); + + it("rejects unknown types and missing ids", () => { + expect( + isSupported({ __typename: "Redirect", id: "1" } as unknown as SearchResult) + ).toBe(false); + expect( + isSupported({ __typename: "NewsPage" } as unknown as SearchResult) + ).toBe(false); + expect( + isSupported({ __typename: "NewsPage", id: "" } as unknown as SearchResult) + ).toBe(false); + }); +}); + +describe("getResultType", () => { + it("capitalizes the association type when set", () => { + expect( + getResultType( + result({ __typename: "AssociationPage", associationType: "kor" }) + ) + ).toBe("Kor"); + expect( + getResultType(result({ __typename: "AssociationPage" })) + ).toBe("Forening"); + }); + + it("labels the other page types", () => { + expect(getResultType(result({ __typename: "EventPage" }))).toBe( + "Arrangement" + ); + expect(getResultType(result({ __typename: "GenericPage" }))).toBe( + "Underside" + ); + }); +}); + +describe("getResultDate", () => { + it("formats a single event occurrence as a full date, ignoring null starts", () => { + expect( + getResultDate( + result({ + __typename: "EventPage", + occurrences: [{ start: null }, { start: "2026-07-09T18:00:00Z" }], + }) + ) + ).toBe("9. juli 2026"); + }); + + it("collapses multiple occurrences to months", () => { + expect( + getResultDate( + result({ + __typename: "EventPage", + occurrences: [ + { start: "2026-07-09T18:00:00Z" }, + { start: "2026-08-01T18:00:00Z" }, + ], + }) + ) + ).toBe("juli – august 2026"); + }); + + it("uses the publish date for news and null otherwise", () => { + expect( + getResultDate(result({ __typename: "EventPage", occurrences: [] })) + ).toBeNull(); + expect( + getResultDate( + result({ + __typename: "NewsPage", + firstPublishedAt: "2026-07-01T08:00:00Z", + }) + ) + ).toBe("1. juli 2026"); + expect(getResultDate(result({ __typename: "NewsPage" }))).toBeNull(); + expect(getResultDate(result({ __typename: "GenericPage" }))).toBeNull(); + }); +}); + +describe("getResultSnippet", () => { + it("picks the right field per page type", () => { + expect( + getResultSnippet(result({ __typename: "NewsPage", excerpt: "Utdrag" })) + ).toBe("Utdrag"); + expect( + getResultSnippet( + result({ __typename: "AssociationPage", excerpt: "Om oss" }) + ) + ).toBe("Om oss"); + expect( + getResultSnippet(result({ __typename: "EventPage", subtitle: "Med kor" })) + ).toBe("Med kor"); + expect( + getResultSnippet(result({ __typename: "VenuePage" })) + ).toBeNull(); + }); + + it("strips html from generic page leads", () => { + expect( + getResultSnippet( + result({ __typename: "GenericPage", lead: "

Hei du

" }) + ) + ).toBe("Hei du"); + expect(getResultSnippet(result({ __typename: "GenericPage" }))).toBeNull(); + }); +}); + +describe("getResultImage", () => { + const image = { url: "/img.jpg", alt: "alt", width: 100, height: 100 }; + + it("uses featuredImage for most types and logo for associations", () => { + expect( + getResultImage(result({ __typename: "NewsPage", featuredImage: image })) + ).toEqual(image); + expect( + getResultImage(result({ __typename: "AssociationPage", logo: image })) + ).toEqual(image); + }); +}); diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts new file mode 100644 index 0000000..1b1a552 --- /dev/null +++ b/web/src/lib/search.ts @@ -0,0 +1,104 @@ +import { unmaskFragment } from "@/gql"; +import type { ImageFragment, SearchQuery } from "@/gql/graphql"; +import { ImageFragmentDefinition, stripHtml } from "@/lib/common"; +import { formatDate, formatOccurrenceMonths } from "@/lib/date"; + +export type SearchResult = SearchQuery["results"][number]; + +const PAGE_TYPES = { + NewsPage: "Nyhet", + EventPage: "Arrangement", + GenericPage: "Underside", + VenuePage: "Lokale", + AssociationPage: "Forening", +} as const; + +type SupportedTypename = keyof typeof PAGE_TYPES; +export type SupportedResult = Extract< + SearchResult, + { __typename: SupportedTypename } +>; + +function capitalizeFirstLetter(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +export function isSupported(result: SearchResult): result is SupportedResult { + return result.__typename in PAGE_TYPES && "id" in result && !!result.id; +} + +export function getResultType(result: SupportedResult): string { + if (result.__typename === "AssociationPage" && result.associationType) { + return capitalizeFirstLetter(result.associationType); + } + return PAGE_TYPES[result.__typename]; +} + +export function getResultImage(result: SupportedResult): ImageFragment | null { + switch (result.__typename) { + case "NewsPage": + case "EventPage": + case "VenuePage": + return unmaskFragment(ImageFragmentDefinition, result.featuredImage); + case "AssociationPage": + return unmaskFragment(ImageFragmentDefinition, result.logo); + default: + return null; + } +} + +export function getResultDate(result: SupportedResult): string | null { + if (result.__typename === "EventPage") { + const starts = result.occurrences + .map((o) => o.start) + .filter((s): s is string => !!s); + if (starts.length === 0) return null; + if (starts.length === 1) return formatDate(starts[0], "d. MMMM yyyy"); + return formatOccurrenceMonths(starts); + } + if (result.__typename === "NewsPage" && result.firstPublishedAt) { + return formatDate(result.firstPublishedAt, "d. MMMM yyyy"); + } + return null; +} + +export function getResultSnippet(result: SupportedResult): string | null { + switch (result.__typename) { + case "NewsPage": + case "AssociationPage": + return result.excerpt ?? null; + case "EventPage": + return result.subtitle ?? null; + case "GenericPage": + return result.lead ? stripHtml(result.lead).trim() : null; + default: + return null; + } +} + +/* + Split text into segments marked as matching the query or not, for + highlighting. Queries shorter than 2 characters match nothing; the query is + matched literally (regex characters escaped) and case-insensitively. +*/ +export function splitMatches( + text: string, + query: string +): { text: string; match: boolean }[] { + if (query.length < 2) return [{ text, match: false }]; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(escaped, "gi"); + const segments: { text: string; match: boolean }[] = []; + let lastIndex = 0; + for (const match of text.matchAll(pattern)) { + if (match.index > lastIndex) { + segments.push({ text: text.slice(lastIndex, match.index), match: false }); + } + segments.push({ text: match[0], match: true }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) { + segments.push({ text: text.slice(lastIndex), match: false }); + } + return segments; +}