This commit is contained in:
@@ -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 Link from "next/link";
|
||||||
import { Image } from "../general/Image";
|
import { Image } from "../general/Image";
|
||||||
import styles from "./searchContainer.module.scss";
|
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];
|
export type { SearchResult };
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlight(text: string, query: string): React.ReactNode {
|
function highlight(text: string, query: string): React.ReactNode {
|
||||||
if (query.length < 2) return text;
|
return splitMatches(text, query).map((segment, i) =>
|
||||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
segment.match ? <mark key={i}>{segment.text}</mark> : segment.text
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchResults({
|
export function SearchResults({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user