add some basic search functionality

This commit is contained in:
2024-07-15 04:30:05 +02:00
parent 55257f3bb4
commit c935314c4f
16 changed files with 226 additions and 5 deletions

View File

@ -9,6 +9,7 @@ from grapple.models import (
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
@ -32,6 +33,8 @@ class AssociationIndex(Page):
GraphQLStreamfield("body"), GraphQLStreamfield("body"),
] ]
search_fields = Page.search_fields
class AssociationPage(Page): class AssociationPage(Page):
subpage_types = [] subpage_types = []
@ -75,3 +78,8 @@ class AssociationPage(Page):
GraphQLString("website_url"), GraphQLString("website_url"),
GraphQLString("association_type"), GraphQLString("association_type"),
] ]
search_fields = Page.search_fields + [
index.SearchField("excerpt"),
index.SearchField("body"),
]

View File

@ -9,6 +9,7 @@ from wagtail.admin.panels import (
) )
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from contacts.blocks import ContactSectionBlock from contacts.blocks import ContactSectionBlock
@ -37,6 +38,8 @@ class ContactIndex(Page):
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")] graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
search_fields = Page.search_fields + [index.SearchField("body")]
@register_snippet @register_snippet
@register_query_field("contactEntity", "contactEntities") @register_query_field("contactEntity", "contactEntities")

View File

@ -25,6 +25,7 @@ from wagtail.admin.panels import (
TitleFieldPanel, TitleFieldPanel,
) )
from wagtail.models import Orderable, Page, PageManager, PageQuerySet from wagtail.models import Orderable, Page, PageManager, PageQuerySet
from wagtail.search import index
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from associations.widgets import AssociationChooserWidget from associations.widgets import AssociationChooserWidget
@ -65,6 +66,8 @@ class EventIndex(Page):
), ),
] ]
search_fields = []
@register_snippet @register_snippet
@register_query_field("eventCategory", "eventCategories") @register_query_field("eventCategory", "eventCategories")
@ -374,6 +377,8 @@ class EventPage(Page):
), ),
] ]
search_fields = Page.search_fields + [index.SearchField("body")]
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -2,6 +2,7 @@ from grapple.models import GraphQLRichText, GraphQLStreamfield
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index
from dnscms.blocks import PageSectionBlock from dnscms.blocks import PageSectionBlock
from dnscms.fields import BASE_BLOCKS from dnscms.fields import BASE_BLOCKS
@ -23,3 +24,5 @@ class GenericPage(Page):
GraphQLRichText("lead"), GraphQLRichText("lead"),
GraphQLStreamfield("body"), GraphQLStreamfield("body"),
] ]
search_fields = Page.search_fields + [index.SearchField("lead"), index.SearchField("body")]

View File

@ -30,6 +30,8 @@ class HomePage(Page):
), ),
] ]
search_fields = []
class HomePageFeaturedEvents(ClusterableModel, Orderable): class HomePageFeaturedEvents(ClusterableModel, Orderable):
parent = ParentalKey("home.HomePage", related_name="featured_events") parent = ParentalKey("home.HomePage", related_name="featured_events")

View File

@ -43,6 +43,8 @@ class CustomImage(AbstractImage):
GraphQLString("attribution"), GraphQLString("attribution"),
] ]
search_fields = []
class Rendition(AbstractRendition): class Rendition(AbstractRendition):
image = models.ForeignKey(CustomImage, on_delete=models.CASCADE, related_name="renditions") image = models.ForeignKey(CustomImage, on_delete=models.CASCADE, related_name="renditions")

View File

@ -4,6 +4,7 @@ from grapple.models import GraphQLImage, GraphQLRichText, GraphQLStreamfield, Gr
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
@ -23,6 +24,8 @@ class NewsIndex(Page):
GraphQLRichText("lead"), GraphQLRichText("lead"),
] ]
search_fields = []
class NewsPage(Page): class NewsPage(Page):
subpage_types = [] subpage_types = []
@ -65,3 +68,9 @@ class NewsPage(Page):
GraphQLStreamfield("body"), GraphQLStreamfield("body"),
GraphQLImage("featured_image"), GraphQLImage("featured_image"),
] ]
search_fields = Page.search_fields + [
index.SearchField("excerpt", boost=1),
index.SearchField("lead", boost=1),
index.SearchField("body"),
]

View File

@ -10,6 +10,7 @@ from grapple.models import (
from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
@ -137,3 +138,8 @@ class VenuePage(Page):
GraphQLString("capacity_standing"), GraphQLString("capacity_standing"),
GraphQLString("capacity_sitting"), GraphQLString("capacity_sitting"),
] ]
search_fields = Page.search_fields + [
index.SearchField("lead"),
index.SearchField("body"),
]

14
web/package-lock.json generated
View File

@ -23,7 +23,8 @@
"react-intersection-observer": "^9.13.0", "react-intersection-observer": "^9.13.0",
"sass": "^1.77.8", "sass": "^1.77.8",
"swiper": "^11.1.4", "swiper": "^11.1.4",
"urql": "^4.1.0" "urql": "^4.1.0",
"use-debounce": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
@ -8306,6 +8307,17 @@
"react": ">= 16.8.0" "react": ">= 16.8.0"
} }
}, },
"node_modules/use-debounce": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.1.tgz",
"integrity": "sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -25,7 +25,8 @@
"react-intersection-observer": "^9.13.0", "react-intersection-observer": "^9.13.0",
"sass": "^1.77.8", "sass": "^1.77.8",
"swiper": "^11.1.4", "swiper": "^11.1.4",
"urql": "^4.1.0" "urql": "^4.1.0",
"use-debounce": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

60
web/src/app/sok/page.tsx Normal file
View File

@ -0,0 +1,60 @@
import { graphql } from "@/gql";
import { getClient } from "@/app/client";
import { SearchContainer } from "@/components/search/SearchContainer";
import { Suspense } from "react";
export default async function Page({
searchParams,
}: {
searchParams?: {
q?: string;
};
}) {
const { q: query } = searchParams ?? {};
let results = [];
if (query) {
const searchQuery = graphql(`
query search($query: String) {
results: search(query: $query) {
__typename
... on NewsPage {
id
title
}
... on EventPage {
id
title
}
... on GenericPage {
id
title
}
... on VenuePage {
id
title
}
... on AssociationPage {
id
title
associationType
}
}
}
`);
const { data, error } = await getClient().query(searchQuery, {
query: query,
});
results = (data?.results ?? []) as any;
}
return (
<main className="site-main" id="main">
<Suspense key={query}>
<SearchContainer query={query ?? ""} results={results} />
</Suspense>
</main>
);
}

View File

@ -1,17 +1,19 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import styles from "./header.module.scss"; import styles from "./header.module.scss";
import { Logo, LogoIcon } from "@/components/general/Logo"; import { Logo, LogoIcon } from "@/components/general/Logo";
import Icon from "../general/Icon"; import Icon from "../general/Icon";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import { getSearchPath } from "@/lib/common";
export const Header = () => { export const Header = () => {
const { ref: observer, inView: isInView } = useInView({ const { ref: observer, inView: isInView } = useInView({
triggerOnce: false, triggerOnce: false,
initialInView: true, initialInView: true,
}); });
const { replace } = useRouter();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
function toggleMenu() { function toggleMenu() {
@ -42,6 +44,17 @@ export const Header = () => {
undefined undefined
); );
const handleSearch = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key != "Enter") {
return;
}
const query = e.currentTarget.value;
if (query) {
setShowMenu(false);
replace(getSearchPath(query));
}
};
return ( return (
<> <>
<header <header
@ -144,7 +157,7 @@ export const Header = () => {
<li className={styles.search}> <li className={styles.search}>
<label> <label>
<p>Søk</p> <p>Søk</p>
<input type="text" /> <input type="text" onKeyDown={handleSearch} />
</label> </label>
</li> </li>
<li className={styles.galtinn}> <li className={styles.galtinn}>

View File

@ -0,0 +1,74 @@
"use client";
import { useDebouncedCallback } from "use-debounce";
import { PageHeader } from "../general/PageHeader";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { getSearchPath } from "@/lib/common";
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>
<PageHeader heading="Søk" />
<input
name="query"
type="text"
autoFocus
defaultValue={query ?? ""}
onChange={(e) => {
onQueryChange(e.target.value);
}}
/>
{query && <SearchResults results={results} />}
</div>
);
}
function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
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>Ingen resultater 😔</div>;
}
const supportedResults = results.filter(
(result: any) =>
!!result?.id && Object.keys(PAGE_TYPES).includes(result.__typename)
);
return (
<div>
{supportedResults.map((result: any) => {
let resultType = PAGE_TYPES[result.__typename] ?? "";
if (result.__typename === "AssociationPage") {
resultType = capitalizeFirstLetter(result?.associationType);
}
return (
<div key={result.id}>
<span>{resultType}</span>
<span>{result.title}</span>
</div>
);
})}
</div>
);
}

View File

@ -34,6 +34,7 @@ const documents = {
"\n query venueIndex {\n index: venueIndex {\n ... on VenueIndex {\n ...VenueIndex\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n ...Venue\n }\n }\n }\n ": types.VenueIndexDocument, "\n query venueIndex {\n index: venueIndex {\n ... on VenueIndex {\n ...VenueIndex\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n ...Venue\n }\n }\n }\n ": types.VenueIndexDocument,
"\n fragment Home on HomePage {\n ... on HomePage {\n featuredEvents {\n id\n }\n }\n }\n": types.HomeFragmentDoc, "\n fragment Home on HomePage {\n ... on HomePage {\n featuredEvents {\n id\n }\n }\n }\n": types.HomeFragmentDoc,
"\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n ": types.HomeDocument, "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n ": types.HomeDocument,
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\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 fragment VenueRentalIndex on VenueRentalIndex {\n ... on VenueRentalIndex {\n title\n lead\n body {\n ...Blocks\n }\n }\n }\n": types.VenueRentalIndexFragmentDoc, "\n fragment VenueRentalIndex on VenueRentalIndex {\n ... on VenueRentalIndex {\n title\n lead\n body {\n ...Blocks\n }\n }\n }\n": types.VenueRentalIndexFragmentDoc,
"\n query venueRentalIndex {\n index: venueRentalIndex {\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n ...Venue\n }\n }\n }\n ": types.VenueRentalIndexDocument, "\n query venueRentalIndex {\n index: venueRentalIndex {\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n }\n venues: pages(contentType: \"venues.VenuePage\") {\n ... on VenuePage {\n ...Venue\n }\n }\n }\n ": types.VenueRentalIndexDocument,
"\n fragment OneLevelOfBlocks on StreamFieldInterface {\n id\n blockType\n field\n ... on RichTextBlock {\n rawValue\n value\n }\n ... on ImageWithTextBlock {\n image {\n ...Image\n }\n imageFormat\n text\n }\n ... on ImageSliderBlock {\n images {\n ... on ImageSliderItemBlock {\n image {\n ...Image\n }\n text\n }\n }\n }\n ... on HorizontalRuleBlock {\n color\n }\n ... on FeaturedBlock {\n title\n featuredBlockText: text\n linkText\n imagePosition\n backgroundColor\n featuredPage {\n contentType\n pageType\n url\n ... on EventPage {\n featuredImage {\n ...Image\n }\n }\n ... on NewsPage {\n featuredImage {\n ...Image\n }\n }\n }\n featuredImageOverride {\n ...Image\n }\n }\n ... on ContactListBlock {\n items {\n blockType\n ... on ContactEntityBlock {\n contactEntity {\n ...ContactEntity\n }\n }\n }\n }\n ... on EmbedBlock {\n url\n embed\n rawEmbed\n }\n }\n": types.OneLevelOfBlocksFragmentDoc, "\n fragment OneLevelOfBlocks on StreamFieldInterface {\n id\n blockType\n field\n ... on RichTextBlock {\n rawValue\n value\n }\n ... on ImageWithTextBlock {\n image {\n ...Image\n }\n imageFormat\n text\n }\n ... on ImageSliderBlock {\n images {\n ... on ImageSliderItemBlock {\n image {\n ...Image\n }\n text\n }\n }\n }\n ... on HorizontalRuleBlock {\n color\n }\n ... on FeaturedBlock {\n title\n featuredBlockText: text\n linkText\n imagePosition\n backgroundColor\n featuredPage {\n contentType\n pageType\n url\n ... on EventPage {\n featuredImage {\n ...Image\n }\n }\n ... on NewsPage {\n featuredImage {\n ...Image\n }\n }\n }\n featuredImageOverride {\n ...Image\n }\n }\n ... on ContactListBlock {\n items {\n blockType\n ... on ContactEntityBlock {\n contactEntity {\n ...ContactEntity\n }\n }\n }\n }\n ... on EmbedBlock {\n url\n embed\n rawEmbed\n }\n }\n": types.OneLevelOfBlocksFragmentDoc,
@ -149,6 +150,10 @@ export function graphql(source: "\n fragment Home on HomePage {\n ... on Hom
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n "): (typeof documents)["\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n "]; export function graphql(source: "\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n "): (typeof documents)["\n query home {\n events: eventIndex {\n ... on EventIndex {\n futureEvents {\n ... on EventPage {\n ...Event\n }\n }\n }\n }\n home: page(contentType: \"home.HomePage\", urlPath: \"/home/\") {\n ... on HomePage {\n ...Home\n }\n }\n news: pages(contentType: \"news.newsPage\", order: \"-first_published_at\", limit: 4) {\n ... on NewsPage {\n ...News\n }\n }\n }\n "];
/**
* 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 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 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 "];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */

File diff suppressed because one or more lines are too long

View File

@ -27,6 +27,16 @@ export const PIG_NAMES = [
"peek", "peek",
]; ];
export function getSearchPath(query: string): string {
const params = new URLSearchParams();
if (query) {
params.set("q", query);
} else {
params.delete("q");
}
return `/sok?${params.toString()}`;
}
export function randomElement(array: any[]): any | undefined { export function randomElement(array: any[]): any | undefined {
return array.length return array.length
? array[Math.floor(Math.random() * array.length)] ? array[Math.floor(Math.random() * array.length)]