web: extract search result helpers to lib and add tests
web / ci (push) Successful in 1m57s

This commit is contained in:
2026-08-31 23:34:44 +02:00
parent f9bc0d404c
commit b7ff7a3ddc
3 changed files with 309 additions and 89 deletions
+14 -89
View File
@@ -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<SearchResult, { __typename: SupportedTypename }>;
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(<mark key={`m-${match.index}`}>{match[0]}</mark>);
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 ? <mark key={i}>{segment.text}</mark> : segment.text
);
}
export function SearchResults({
+191
View File
@@ -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<string, unknown>) =>
({ 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: "<p>Hei <b>du</b></p> " })
)
).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);
});
});
+104
View File
@@ -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;
}