Compare commits

..

6 Commits

11 changed files with 534 additions and 155 deletions
+3
View File
@@ -174,6 +174,9 @@ MEDIA_URL = "/media/"
WAGTAIL_SITE_NAME = "dnscms"
WAGTAIL_ALLOW_UNICODE_SLUGS = False
# Headless: the Next.js frontend uses trailing-slash-free URLs, so strip
# trailing slashes from links generated by Wagtail (e.g. the GraphQL `url` field).
WAGTAIL_APPEND_SLASH = False
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
+36
View File
@@ -1,6 +1,12 @@
from django.apps import apps as django_apps
from django.templatetags.static import static
from django.utils.html import format_html
from grapple.registry import registry as grapple_registry
from wagtail import hooks
from wagtail.documents import get_document_model
from wagtail.images import get_image_model
from wagtail.models import Page
from wagtail.search.backends import get_search_backend
@hooks.register("register_rich_text_features")
@@ -8,6 +14,36 @@ def enable_additional_rich_text_features(features):
features.default_features.extend(["h5", "h6", "blockquote"])
@hooks.register("register_schema_query")
def filter_search_to_live_pages(query_mixins):
"""
Grapple's default `search` resolver hits every page regardless of publish
state, exposing drafts on the public API. Prepend a mixin so MRO picks our
`resolve_search`, which restricts Page subclasses to live + public.
"""
if not grapple_registry.class_models:
return
class SearchLivePublicMixin:
def resolve_search(self, info, **kwargs):
query = kwargs.get("query")
if not query:
return None
s = get_search_backend()
results = []
models = [get_document_model(), get_image_model()]
for app in grapple_registry.apps:
models += django_apps.all_models[app].values()
for model in models:
if issubclass(model, Page):
results += s.search(query, model.objects.live().public())
else:
results += s.search(query, model)
return results
query_mixins.insert(0, SearchLivePublicMixin)
@hooks.register("construct_page_action_menu")
def make_publish_default_action(menu_items, request, context):
for index, item in enumerate(menu_items):
+88
View File
@@ -0,0 +1,88 @@
from wagtail.search.backends import get_search_backend
from tests.conftest import EventPageFactory, GenericPageFactory
SEARCH_QUERY = """
query Search($query: String) {
results: search(query: $query) {
__typename
... on PageInterface {
title
}
}
}
"""
def _index(page):
# Wagtail's post_save signal enqueues indexing via django-tasks, which isn't
# drained synchronously in tests. Call the backend directly so the page is
# findable through the live search code path.
get_search_backend().add(page)
def _titles_for(body, typename):
return [r["title"] for r in body["data"]["results"] if r["__typename"] == typename]
def test_search_returns_live_generic_page(home_page, graphql_post):
page = GenericPageFactory(
parent=home_page,
title="PublishedGenericSearchToken",
slug="published-generic-search",
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedGenericSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "PublishedGenericSearchToken" in _titles_for(body, "GenericPage")
def test_search_excludes_draft_generic_page(home_page, graphql_post):
page = GenericPageFactory(
parent=home_page,
title="DraftGenericSearchToken",
slug="draft-generic-search",
live=False,
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftGenericSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "DraftGenericSearchToken" not in _titles_for(body, "GenericPage")
def test_search_returns_live_event_page(home_page, event_index, graphql_post):
page = EventPageFactory(
parent=event_index,
title="PublishedEventSearchToken",
slug="published-event-search",
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedEventSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "PublishedEventSearchToken" in _titles_for(body, "EventPage")
def test_search_excludes_draft_event_page(home_page, event_index, graphql_post):
page = EventPageFactory(
parent=event_index,
title="DraftEventSearchToken",
slug="draft-event-search",
live=False,
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftEventSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "DraftEventSearchToken" not in _titles_for(body, "EventPage")
+45 -23
View File
@@ -1,7 +1,10 @@
import { graphql } from "@/gql";
import { getClient } from "@/app/client";
import { SearchContainer } from "@/components/search/SearchContainer";
import { Suspense } from "react";
import {
type SearchResult,
SearchResults,
} from "@/components/search/SearchResults";
import { SearchShell } from "@/components/search/SearchShell";
import { graphql } from "@/gql";
// TODO: seo metadata?
@@ -13,7 +16,9 @@ export default async function Page({
}>;
}) {
const { q: query } = (await searchParams) ?? {};
let results = [];
let results: SearchResult[] = [];
let totalCount = 0;
const RESULT_LIMIT = 500;
if (query) {
const searchQuery = graphql(`
@@ -21,45 +26,62 @@ export default async function Page({
results: search(query: $query) {
__typename
... on PageInterface {
slug
id
title
url
}
... on NewsPage {
id
title
excerpt
featuredImage {
...Image
}
firstPublishedAt
}
... on EventPage {
id
title
subtitle
featuredImage {
...Image
}
occurrences {
start
}
}
... on GenericPage {
id
title
lead
}
... on VenuePage {
id
title
featuredImage {
...Image
}
}
... on AssociationPage {
id
title
excerpt
associationType
logo {
...Image
}
}
}
}
`);
const { data, error } = await getClient().query(searchQuery, {
query: query,
});
results = (data?.results ?? []) as any;
const { data } = await getClient().query(searchQuery, { query });
const all = (data?.results ?? []) as SearchResult[];
totalCount = all.length;
results = all.slice(0, RESULT_LIMIT);
}
return (
<main className="site-main" id="main">
<Suspense key={query}>
<SearchContainer query={query ?? ""} results={results} />
</Suspense>
<SearchShell initialQuery={query ?? ""}>
{query ? (
<SearchResults
results={results}
totalCount={totalCount}
query={query}
/>
) : null}
</SearchShell>
</main>
);
}
@@ -1,112 +0,0 @@
"use client";
import { useDebouncedCallback } from "use-debounce";
import { PageHeader } from "../general/PageHeader";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { getSearchPath } from "@/lib/common";
import styles from './searchContainer.module.scss';
import { Icon } from "../general/Icon";
import Link from "next/link";
export function SearchContainer({
query,
results,
}: {
query: string;
results: any;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const onQueryChange = useDebouncedCallback((query) => {
replace(getSearchPath(query));
}, 500);
return (
<div className={styles.searchContainer}>
<PageHeader heading="Søk" />
<div className={styles.searchField}>
<input
name="query"
type="text"
autoFocus
defaultValue={query ?? ""}
onChange={(e) => {
onQueryChange(e.target.value);
}}
/>
<div className={styles.searchIcon}>
<Icon type="search" />
</div>
</div>
{query && <SearchResults results={results} />}
</div>
);
}
function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function linkTo(page: any): string | null {
if (page.__typename === "EventPage") {
return `/arrangementer/${page.slug}`;
}
if (page.__typename === "NewsPage") {
return `/aktuelt/${page.slug}`;
}
if (page.__typename === "AssociationPage") {
return `/foreninger/${page.slug}`;
}
if (page.__typename === "GenericPage") {
return `/{page.slug}`;
}
if (page.__typename === "VenuePage") {
return `/lokaler/${page.slug}`;
}
return null;
}
const PAGE_TYPES: Record<string, string> = {
NewsPage: "Nyhet",
EventPage: "Arrangement",
GenericPage: "Underside",
VenuePage: "Lokale",
AssociationPage: "Forening",
};
function SearchResults({ results }: { results: any }) {
if (!results.length) {
return <div className={styles.noResults}>Ingen resultater</div>;
}
const supportedResults = results.filter(
(result: any) =>
!!result?.id && Object.keys(PAGE_TYPES).includes(result.__typename)
);
return (
<div>
<p className={styles.resultsCounter}>{results.length} resultater</p>
{supportedResults.map((result: any) => {
let resultType = PAGE_TYPES[result.__typename] ?? "";
if (result.__typename === "AssociationPage") {
resultType = capitalizeFirstLetter(result?.associationType);
}
const link = linkTo(result);
const ResultItem = () => (
<div className={styles.resultItem}>
<span className={styles.suphead}>{resultType}</span>
<h2 className={styles.title}>{result.title}</h2>
</div>
);
if (link) {
return (
<Link key={result.id} href={link}>
<ResultItem />
</Link>
);
}
return <ResultItem key={result.id} />;
})}
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
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";
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;
}
}
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;
}
export function SearchResults({
results,
totalCount,
query,
}: {
results: SearchResult[];
totalCount: number;
query: string;
}) {
if (!results.length) {
return (
<div className={styles.noResults} aria-live="polite">
<p className={styles.noResultsHeading}>Ingen treff «{query}»</p>
</div>
);
}
const supportedResults = results.filter(isSupported);
const truncated = totalCount > results.length;
return (
<div>
<p className={styles.resultsCounter} aria-live="polite">
{truncated
? `Viser de første ${results.length} av ${totalCount} treff — prøv et mer spesifikt søk.`
: `${results.length} resultater`}
</p>
{supportedResults.map((result) => (
<ResultRow key={result.id} result={result} query={query} />
))}
</div>
);
}
function ResultRow({
result,
query,
}: {
result: SupportedResult;
query: string;
}) {
const image = getResultImage(result);
const snippet = getResultSnippet(result);
const date = getResultDate(result);
const resultType = getResultType(result);
const link = result.url;
const body = (
<div className={styles.resultItem}>
<div className={styles.resultBody}>
<span className={styles.suphead}>{resultType}</span>
<h2 className={styles.title}>{highlight(result.title, query)}</h2>
{date && <p className={styles.date}>{date}</p>}
{snippet && (
<p className={styles.snippet}>{highlight(snippet, query)}</p>
)}
</div>
{image?.url && (
<div className={styles.thumb}>
<Image
src={image.url}
alt={image.alt ?? ""}
width={image.width}
height={image.height}
sizes="100px"
/>
</div>
)}
</div>
);
if (link) {
return <Link href={link}>{body}</Link>;
}
return body;
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { getSearchPath } from "@/lib/common";
import { useRouter } from "next/navigation";
import {
type ReactNode,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { useDebouncedCallback } from "use-debounce";
import { Icon } from "../general/Icon";
import { PageHeader } from "../general/PageHeader";
import styles from "./searchContainer.module.scss";
export function SearchShell({
initialQuery,
children,
}: {
initialQuery: string;
children: ReactNode;
}) {
const { replace } = useRouter();
const [inputValue, setInputValue] = useState(initialQuery);
const [isPending, startTransition] = useTransition();
const lastPushedRef = useRef(initialQuery);
const fetching = isPending || inputValue !== lastPushedRef.current;
const pushQuery = useDebouncedCallback((next: string) => {
lastPushedRef.current = next;
startTransition(() => {
replace(getSearchPath(next));
});
}, 300);
useEffect(() => {
if (initialQuery !== lastPushedRef.current) {
lastPushedRef.current = initialQuery;
setInputValue(initialQuery);
}
}, [initialQuery]);
return (
<div className={styles.searchContainer}>
<PageHeader heading={initialQuery ? `Søk: «${initialQuery}»` : "Søk"} />
<form
action="/sok"
method="get"
onSubmit={(e) => {
e.preventDefault();
pushQuery.cancel();
lastPushedRef.current = inputValue;
startTransition(() => {
replace(getSearchPath(inputValue));
});
}}
>
<div className={styles.searchField}>
<label htmlFor="search-query" className="sr-only">
Søk
</label>
<input
id="search-query"
name="q"
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
pushQuery(e.target.value);
}}
/>
<div className={styles.searchIcon} aria-hidden="true">
<Icon type="search" />
</div>
</div>
</form>
<div
className={fetching ? styles.fetching : undefined}
aria-busy={fetching}
>
{children}
</div>
</div>
);
}
@@ -5,6 +5,13 @@
a {
text-decoration: none;
}
mark {
background: var(--color-goldenBeige);
color: inherit;
padding: 0 .05em;
border-radius: 2px;
}
}
.searchField {
@@ -29,11 +36,52 @@
}
.resultItem {
display: flex;
align-items: flex-start;
gap: var(--spacing-m);
border-top: var(--border);
margin-top: var(--spacing-m);
padding-top: var(--spacing-s);
}
.resultBody {
flex: 1;
min-width: 0;
}
.date {
margin-top: 0.25em;
font-family: var(--font-serif);
font-size: var(--font-size-caption);
color: var(--color-chateauBlue-05);
}
.snippet {
margin-top: 0.25em;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fetching {
opacity: 0.5;
transition: opacity .15s ease;
}
.thumb {
flex: 0 0 5rem;
width: 5rem;
height: 5rem;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.suphead {
display: block;
font-size: var(--font-size-caption);
+3 -3
View File
@@ -20,7 +20,7 @@ type Documents = {
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": typeof types.SearchDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": typeof types.SearchDocument,
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": typeof types.AssociationIndexFragmentDoc,
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": typeof types.AssociationFragmentDoc,
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": typeof types.AllAssociationsDocument,
@@ -88,7 +88,7 @@ const documents: Documents = {
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": types.SearchDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": types.SearchDocument,
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": types.AssociationIndexFragmentDoc,
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": types.AssociationFragmentDoc,
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": types.AllAssociationsDocument,
@@ -191,7 +191,7 @@ export function graphql(source: "\n query previewPage($token: String!) {\n p
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "];
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
+16 -16
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -95,6 +95,44 @@ export function groupConsecutiveDates(dates: string[]): string[][] {
return groupedDates;
}
export function formatOccurrenceMonths(starts: string[]): string {
if (starts.length === 0) return "";
const months = unique(
starts.map((s) => format(toLocalTime(s), "yyyy-MM"))
).sort() as string[];
const monthIndex = (ym: string) => {
const [y, m] = ym.split("-").map(Number);
return y * 12 + (m - 1);
};
const groups: string[][] = [];
for (const ym of months) {
const last = groups[groups.length - 1];
if (last && monthIndex(ym) === monthIndex(last[last.length - 1]) + 1) {
last.push(ym);
} else {
groups.push([ym]);
}
}
return groups
.map((g) => {
const first = parse(g[0], "yyyy-MM", new Date());
if (g.length === 1) {
return formatDate(first, "MMMM yyyy");
}
const last = parse(g[g.length - 1], "yyyy-MM", new Date());
const firstFmt =
first.getFullYear() === last.getFullYear()
? formatDate(first, "MMMM")
: formatDate(first, "MMMM yyyy");
return `${firstFmt} ${formatDate(last, "MMMM yyyy")}`;
})
.join(", ");
}
export function formatDateRange(dates: string[]): string {
if (dates.length === 1) {
return formatDate(dates[0], "d. MMMM");