diff --git a/docs/conceptual-guides/index.md b/docs/conceptual-guides/index.md index 1fcd52731..468f922c3 100644 --- a/docs/conceptual-guides/index.md +++ b/docs/conceptual-guides/index.md @@ -24,4 +24,5 @@ add-on-driven-configuration cookieplone-frontend-add-on routing slots +search ``` diff --git a/docs/conceptual-guides/search.md b/docs/conceptual-guides/search.md new file mode 100644 index 000000000..e8e523ace --- /dev/null +++ b/docs/conceptual-guides/search.md @@ -0,0 +1,185 @@ +--- +myst: + html_meta: + "description": "How the public search route in Plone Aurora works" + "property=og:description": "How the public search route in Plone Aurora works" + "property=og:title": "Search" + "keywords": "Plone Aurora, frontend, Plone, search, pagination, sorting, facets, tags, accessibility, @search" +--- + +# Search + +This guide describes the public search route in Plone Aurora. +To customize or extend the search, refer to {doc}`/how-to-guides/customize-the-search`. + +Plone Aurora ships a public search route at {file}`packages/publicui/routes/search.tsx`. +The search input itself lives in the site header. +This route reads the search term from the `SearchableText` query parameter. + +The route answers at `/search` for the whole site, and at `//search` for a top-level folder, such as a language root folder. +On a multilingual site, `/de/search` scopes the results to the `/de` language tree. + +The route queries the backend in its `loader` and renders on the server. +It returns search results with sorting, filtering, and pagination. +The results are friendly to accessibility tools, keyboard navigation, search engine indexers, web crawlers, and AI agents. + +The route stays thin. +It delegates rendering to reusable components in the {file}`@plone/layout/components/SearchResults/` directory, including `SearchResults`, `SearchSort`, `SearchFacets`, and `SearchPagination`. +The components live in `@plone/layout`, so both the CMS UI and the Public UI can reuse and theme them. + +## The query + +The `loader` reads the search term from the `SearchableText` query parameter, the requested page from `b_start`, the selected tags from repeated `Subject` parameters, and the sort order from `sort`. +On the scoped route, it adds a `path` query for the root segment, so the backend only returns matches from that subtree. +It then runs two queries against the `@search` endpoint through `@plone/client` in parallel. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx + +const baseQuery = { + SearchableText: `${query}*`, + use_site_search_settings: 1, + ...(root ? { path: { query: root } } : {}), +}; + +let results; +let facetSample; +try { + [results, facetSample] = await Promise.all([ + cli.search({ + query: { + ...baseQuery, + ...(subjects.length > 0 ? { Subject: subjects } : {}), + b_start: bStart, + b_size: PAGE_SIZE, + ...sortToQuery(url.searchParams.get('sort')), + }, + }), + cli.search({ + query: { + ...baseQuery, + metadata_fields: 'Subject', + b_size: FACET_SAMPLE_SIZE, + }, + }), + ]); +} catch (error: any) { + throw data('Search failed', { + status: typeof error.status === 'number' ? error.status : 500, + }); +} +``` + +The first query fetches the visible page of results. +The loader appends a trailing `*` to the term so partial words match. +`b_size` limits the request to one page, `b_start` selects the offset of the current page, and the backend returns the matching slice in `items` and the full match count in `items_total`. +That count is everything the pagination needs. + +The second query feeds {ref}`the tag facets `. +A backend failure becomes an error response that the route's error boundary renders. + +`use_site_search_settings: 1` makes the backend honor the site's search settings, such as content types excluded from search, matching Volto's behavior. +This flag is also the reason the loader trims the term, returns early, and renders only the heading when there is no `SearchableText`. +An empty term combined with `use_site_search_settings` makes the backend return a bare list instead of a batched result. + +## Result links stay in the app + +The result titles link to `item['@id']`, and the loader flattens the items to app-relative addresses on the server before it returns them. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx + +return { + search: flattenToAppURL(results.data.items ?? []), + total, + params: query, + bStart, + bSize: PAGE_SIZE, + facets: aggregateFacets(facetSample.data.items ?? []), +}; +``` + +`flattenToAppURL` rewrites backend addresses, for example, `http://backend/Plone/page`, to app-relative ones, such as `/page`, based on `config.settings.apiPath`, and that setting is only reliable on the server. +Flattening inside a component would set a correct `href` in the server-rendered markup, but leave the raw backend address in the data that react-aria navigates with on the client, so a click would open the backend. +Flattening once in the `loader` means the serialized result data is already app-relative, so the markup and the client navigation use the same in-app address. +The sitemap route follows the same approach. + +## Content-type icons + +Each result shows an icon for its content type. +`getContentIcon` from `@plone/helpers` resolves the icon from the result's `@type` by looking up the `config.settings.contentIcons` map, and `SearchResults` falls back to the page icon. + +`@plone/layout` registers the `contentIcons` map in {file}`packages/layout/config/settings.ts`, so both the CMS UI and the Public UI resolve the same icons. +The `@plone/contents` package also registers the map, but that package only installs in the CMS UI. + +## Sorting + +The `SearchSort` component stores the choice in a single `sort` query parameter and resets paging to the first page whenever the order changes. +`relevance` is the default, which the component omits from the address. + +The `loader` translates the parameter into the backend `sort_on` and `sort_order` fields with the `sortToQuery` helper, so the route stays the single source of truth for the query. + +```{code-block} ts +:caption: packages/layout/components/SearchResults/SearchSort.tsx + +export function sortToQuery(sort: string | null): { + sort_on?: string; + sort_order?: 'ascending' | 'descending'; +} { + switch (sort) { + case 'date': + return { sort_on: 'effective', sort_order: 'descending' }; + case 'title': + return { sort_on: 'sortable_title', sort_order: 'ascending' }; + default: + return {}; + } +} +``` + +## Pagination + +The `SearchPagination` component turns `total`, `bStart`, and `bSize` into numbered page links. +It computes the current page and the total page count, shows the first and last page with an ellipsis around the current page, and renders previous and next controls. +When the results fit on a single page, the component renders nothing. + +Each page is a link that updates the `b_start` query parameter and keeps every other parameter in the address, such as `SearchableText`, `sort`, and `Subject`. +The component reads the current parameters with `useSearchParams` and the current path with `useLocation`, so it carries any additional filters across pages. + +When the address requests a `b_start` beyond the last page, the loader redirects to the last real page instead of rendering an empty result list. + +(search-facet-sampling)= + +## Tag facets + +The `SearchFacets` component renders a checkbox, styled as a pill, per `Subject` (tag) with a result count. +The component stores selected tags as repeated `Subject` query parameters, and toggling one resets paging to the first page. +Multiple selected tags match results that carry any of them, because `@plone/client` serializes the array as Plone's `Subject:list` query. + +The tag list stays collapsed by default behind a {guilabel}`Filter by tag` disclosure button. +It opens automatically when the address already carries an active `Subject` filter, so a shared, pre-filtered link still shows its active tags. +The component animates the expand and collapse with the `grid-template-rows` technique, from `0fr` to `1fr`, and honors `prefers-reduced-motion`. + +The available tags can't come from the `plone.app.vocabularies.Keywords` vocabulary, because that vocabulary requires authentication and the public search is anonymous. +Instead, the `loader` runs the second, unfiltered `@search` query shown above, which requests `metadata_fields=Subject`, and the `aggregateFacets` helper counts the `Subject` values of those matches. +The loader computes this facet sample without the active `Subject` filter, so every tag stays visible with its full count even after you select one. + +```{important} +The loader caps the facet sample at `FACET_SAMPLE_SIZE` matches per request, so the tag counts reflect the first `FACET_SAMPLE_SIZE` matches rather than every match. +``` + +## Accessibility and the loading state + +The search markup carries the structure that assistive technology needs. + +- `SearchResults` renders the results in a named `region` landmark, a `
` with an `aria-label`. + Individual results are `
` elements, which aren't landmarks. +- The result count is a `

` with `role="status"`, a polite live region, so screen readers announce the new count. +- The result title links, the tag chips, the {guilabel}`Filter by tag` toggle, the sort selector, and the pagination links all show a visible `:focus-visible` outline for keyboard navigation. +- The pagination is a `nav` landmark with labeled previous and next controls, and the current page carries `aria-current="page"`. + +While the loader re-runs for a new query, sort order, page, or filter, the route detects the navigation with `useNavigation` and passes `loading` to `SearchResults`. +`SearchResults` then sets `aria-busy` on the region, swaps the count for a {guilabel}`Searching…` status, and dims the list. + +The Public UI also sets `scrollbar-gutter: stable` on `html` in {file}`packages/publicui/styles/publicui.css`. +This keeps the page width steady when expanding the tag filter or changing the result count shows or hides a scroll bar. diff --git a/docs/how-to-guides/customize-the-search.md b/docs/how-to-guides/customize-the-search.md new file mode 100644 index 000000000..2b3c8789d --- /dev/null +++ b/docs/how-to-guides/customize-the-search.md @@ -0,0 +1,63 @@ +--- +myst: + html_meta: + "description": "How to customize the public search route in Plone Aurora" + "property=og:description": "How to customize the public search route in Plone Aurora" + "property=og:title": "Customize the search" + "keywords": "Plone Aurora, frontend, Plone, search, customize, sorting, icons, page size, query" +--- + +# Customize the search + +This guide shows you how to customize the public search route in Plone Aurora. +Refer to {doc}`/conceptual-guides/search` for how the route works. + +## Change the number of results per page + +Adjust the `PAGE_SIZE` constant in the route's `loader` in {file}`packages/publicui/routes/search.tsx`. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx + +const PAGE_SIZE = 25; +``` + +The pagination derives the page count from this value, so no other change is needed. + +## Add a sort option + +The sort options live in {file}`packages/layout/components/SearchResults/SearchSort.tsx`. + +1. Add the option to the `SORT_OPTIONS` array. +2. Add a `case` for it in `sortToQuery` that returns the backend `sort_on` and `sort_order` fields. +3. Add the matching label under `layout.search.sort` in each locale file in {file}`packages/layout/locales/`. + +For example, to sort by creation date, add `'created'` to `SORT_OPTIONS`, and add the following `case` to `sortToQuery`. + +```{code-block} ts +:caption: new case to add in sortToQuery + +case 'created': + return { sort_on: 'created', sort_order: 'descending' }; +``` + +## Give a content type its own icon + +To give a content type its own icon in the results, add an entry for its `@type` to the `config.settings.contentIcons` map, for example, in your add-on's configuration. + +```{code-block} ts +:caption: your add-on's configuration + +import talkSVG from './icons/talk.svg?react'; + +config.settings.contentIcons = { + ...config.settings.contentIcons, + Talk: talkSVG, +}; +``` + +## Extend the query + +Extend the search by adding fields to the `query` object that the route's `loader` passes to `cli.search` in {file}`packages/publicui/routes/search.tsx`. +Read any extra query parameter you add to the address through `url.searchParams` in the `loader`. +`SearchSort`, `SearchFacets`, and `SearchPagination` keep the other parameters when they navigate, so your parameter survives sorting, filtering, and paging. diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index b42b76e4c..b0665f471 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -15,6 +15,7 @@ This section of the documentation contains how-to guides for developing with Plo :maxdepth: 2 routes +customize-the-search register-an-add-on extend-vite-configuration access-registry diff --git a/packages/layout/components/SearchResults/SearchFacets.test.tsx b/packages/layout/components/SearchResults/SearchFacets.test.tsx new file mode 100644 index 000000000..20ae184de --- /dev/null +++ b/packages/layout/components/SearchResults/SearchFacets.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router'; +import { SearchFacets, aggregateFacets } from './SearchFacets'; + +function LocationProbe() { + const { search } = useLocation(); + return {search}; +} + +const facets = [ + { subject: 'news', count: 3 }, + { subject: 'sport', count: 2 }, +]; + +const renderFacets = (url = '/search?SearchableText=foo') => + render( + + + + + + + } + /> + + , + ); + +describe('aggregateFacets', () => { + it('counts subjects and orders by count then name', () => { + const result = aggregateFacets([ + { Subject: ['news', 'sport'] }, + { Subject: ['news'] }, + { Subject: ['sport', 'politics'] }, + { Subject: [] }, + ]); + expect(result).toEqual([ + { subject: 'news', count: 2 }, + { subject: 'sport', count: 2 }, + { subject: 'politics', count: 1 }, + ]); + }); + + it('returns an empty list when there are no subjects', () => { + expect(aggregateFacets([{ Subject: [] }])).toEqual([]); + }); +}); + +describe('SearchFacets', () => { + it('renders nothing when there are no facets', () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('is collapsed by default and toggles open/closed with the button', () => { + renderFacets(); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('is expanded by default when a tag filter is already active', () => { + renderFacets('/search?SearchableText=foo&Subject=news'); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders a checkbox with a count per facet', () => { + renderFacets(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('checkbox', { name: /news/ })).not.toBeChecked(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('reflects the active Subject filters from the URL', () => { + renderFacets('/search?SearchableText=foo&Subject=news'); + expect(screen.getByRole('checkbox', { name: /news/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /sport/ })).not.toBeChecked(); + }); + + it('adds a Subject and resets paging when toggled on', () => { + renderFacets('/search?SearchableText=foo&b_start=25'); + fireEvent.click(screen.getByRole('button')); + + fireEvent.click(screen.getByRole('checkbox', { name: /sport/ })); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).toContain('Subject=sport'); + expect(search).toContain('SearchableText=foo'); + expect(search).not.toContain('b_start'); + }); + + it('removes a Subject when toggled off but keeps the others', () => { + renderFacets('/search?SearchableText=foo&Subject=news&Subject=sport'); + + fireEvent.click(screen.getByRole('checkbox', { name: /news/ })); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).not.toContain('Subject=news'); + expect(search).toContain('Subject=sport'); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchFacets.tsx b/packages/layout/components/SearchResults/SearchFacets.tsx new file mode 100644 index 000000000..a8b88c786 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchFacets.tsx @@ -0,0 +1,104 @@ +import { useState, useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router'; +import { ChevrondownIcon } from '@plone/components/Icons'; +import clsx from 'clsx'; +import type { Brain } from '@plone/types'; +import styles from './SearchResults.module.css'; + +export interface Facet { + subject: string; + count: number; +} + +export function aggregateFacets(items: Pick[]): Facet[] { + const counts = new Map(); + for (const item of items) { + for (const subject of item.Subject ?? []) { + counts.set(subject, (counts.get(subject) ?? 0) + 1); + } + } + return [...counts.entries()] + .map(([subject, count]) => ({ subject, count })) + .sort((a, b) => b.count - a.count || a.subject.localeCompare(b.subject)); +} + +export interface SearchFacetsProps { + facets: Facet[]; +} + +export function SearchFacets({ facets }: SearchFacetsProps) { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const selected = new Set(searchParams.getAll('Subject')); + // Collapsed by default; open when a tag filter is already active. + const listId = useId(); + const [open, setOpen] = useState(selected.size > 0); + + if (facets.length === 0) { + return null; + } + + const toggle = (subject: string) => { + const updated = new Set(selected); + if (updated.has(subject)) { + updated.delete(subject); + } else { + updated.add(subject); + } + + const next = new URLSearchParams(searchParams); + next.delete('Subject'); + for (const value of updated) { + next.append('Subject', value); + } + next.delete('b_start'); + setSearchParams(next); + }; + + return ( +

+ +
+
+
+ {facets.map(({ subject, count }) => ( + + ))} +
+
+
+
+ ); +} diff --git a/packages/layout/components/SearchResults/SearchPagination.test.tsx b/packages/layout/components/SearchResults/SearchPagination.test.tsx new file mode 100644 index 000000000..c856b00b5 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchPagination.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { SearchPagination } from './SearchPagination'; + +const renderPagination = ( + props: { total: number; bStart: number; bSize: number }, + url = '/search?SearchableText=foo', +) => + render( + + + , + ); + +describe('SearchPagination', () => { + it('renders nothing when there is only one page', () => { + const { container } = renderPagination({ total: 10, bStart: 0, bSize: 25 }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a page link per page', () => { + renderPagination({ total: 60, bStart: 0, bSize: 25 }); + // 60 results / 25 => 3 pages + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.queryByText('4')).not.toBeInTheDocument(); + }); + + it('marks the current page and preserves the query in links', () => { + renderPagination( + { total: 60, bStart: 25, bSize: 25 }, + '/search?SearchableText=foo&b_start=25', + ); + + // current page (page 2, index 1) is not a link + const current = screen.getByText('2'); + expect(current.closest('a')).toBeNull(); + expect(current).toHaveAttribute('aria-current', 'page'); + + // page 3 link keeps SearchableText and sets b_start, scoped to the route + const thirdPage = screen.getByText('3').closest('a'); + expect(thirdPage).toHaveAttribute( + 'href', + expect.stringContaining('/search?'), + ); + expect(thirdPage).toHaveAttribute( + 'href', + expect.stringContaining('SearchableText=foo'), + ); + expect(thirdPage).toHaveAttribute( + 'href', + expect.stringContaining('b_start=50'), + ); + + // first page link drops b_start + const firstPage = screen.getByText('1').closest('a'); + expect(firstPage?.getAttribute('href')).not.toContain('b_start'); + expect(firstPage).toHaveAttribute( + 'href', + expect.stringContaining('SearchableText=foo'), + ); + }); + + it('windows long page lists with ellipses around the current page', () => { + renderPagination( + { total: 250, bStart: 100, bSize: 25 }, + '/search?SearchableText=foo&b_start=100', + ); + + for (const page of ['1', '4', '5', '6', '10']) { + expect(screen.getByText(page)).toBeInTheDocument(); + } + for (const page of ['2', '3', '7', '8', '9']) { + expect(screen.queryByText(page)).not.toBeInTheDocument(); + } + expect(screen.getAllByText('…')).toHaveLength(2); + }); + + it('windows the edges without a leading or trailing ellipsis', () => { + renderPagination({ total: 250, bStart: 0, bSize: 25 }); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.queryByText('3')).not.toBeInTheDocument(); + expect(screen.getAllByText('…')).toHaveLength(1); + }); + + it('windows the last page without a trailing ellipsis', () => { + renderPagination( + { total: 250, bStart: 225, bSize: 25 }, + '/search?SearchableText=foo&b_start=225', + ); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('9')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.queryByText('8')).not.toBeInTheDocument(); + expect(screen.getAllByText('…')).toHaveLength(1); + }); + + it('disables previous on the first page and next on the last', () => { + const { rerender } = renderPagination({ total: 60, bStart: 0, bSize: 25 }); + expect(screen.getByText('‹').closest('a')).toBeNull(); + expect(screen.getByText('›').closest('a')).not.toBeNull(); + + rerender( + + + , + ); + expect(screen.getByText('‹').closest('a')).not.toBeNull(); + expect(screen.getByText('›').closest('a')).toBeNull(); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchPagination.tsx b/packages/layout/components/SearchResults/SearchPagination.tsx new file mode 100644 index 000000000..532601cb0 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchPagination.tsx @@ -0,0 +1,140 @@ +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useSearchParams } from 'react-router'; +import { Link } from '@plone/components'; +import styles from './SearchResults.module.css'; + +export interface SearchPaginationProps { + total: number; + bStart: number; + bSize: number; +} + +type PageItem = number | 'ellipsis'; + +function getPageItems(currentPage: number, totalPages: number): PageItem[] { + const pages = new Set([ + 0, + totalPages - 1, + currentPage - 1, + currentPage, + currentPage + 1, + ]); + + const visible = [...pages] + .filter((page) => page >= 0 && page < totalPages) + .sort((a, b) => a - b); + + const items: PageItem[] = []; + let previous: number | undefined; + for (const page of visible) { + if (previous !== undefined && page - previous > 1) { + items.push('ellipsis'); + } + items.push(page); + previous = page; + } + return items; +} + +export function SearchPagination({ + total, + bStart, + bSize, +}: SearchPaginationProps) { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + + const totalPages = Math.ceil(total / bSize); + const currentPage = Math.floor(bStart / bSize); + + if (totalPages <= 1) { + return null; + } + + const linkTo = (page: number) => { + const next = new URLSearchParams(searchParams); + if (page <= 0) { + next.delete('b_start'); + } else { + next.set('b_start', String(page * bSize)); + } + const search = next.toString(); + return search ? `${pathname}?${search}` : pathname; + }; + + const items = getPageItems(currentPage, totalPages); + + return ( + + ); +} diff --git a/packages/layout/components/SearchResults/SearchResults.module.css b/packages/layout/components/SearchResults/SearchResults.module.css new file mode 100644 index 000000000..2c9b616d9 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchResults.module.css @@ -0,0 +1,286 @@ +@layer custom { + .count { + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; + color: var(--muted-foreground); + font-size: 0.875rem; + } + + .facets { + margin: 0 0 12px; + } + + .facetsToggle { + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: none; + color: var(--muted-foreground); + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + gap: 6px; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .facetsToggle:focus-visible { + border-radius: 2px; + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .facetsChevron { + width: 14px; + height: 14px; + transition: transform 0.2s ease; + } + + .facetsChevronOpen { + transform: rotate(180deg); + } + + .facetListWrap { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + + .facetListWrapOpen { + grid-template-rows: 1fr; + } + + .facetListInner { + overflow: hidden; + } + + .facetList { + display: flex; + flex-wrap: wrap; + padding-top: 10px; + gap: 8px; + } + + @media (prefers-reduced-motion: reduce) { + .facetsChevron, + .facetListWrap { + transition: none; + } + } + + .facet { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--foreground); + cursor: pointer; + font-size: 0.8125rem; + gap: 6px; + line-height: 1.4; + transition: + background-color 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; + user-select: none; + } + + .facet input { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + padding: 0; + border: 0; + margin: -1px; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .facet:hover { + border-color: var(--color-quanta-peacock); + } + + .facet:has(input:checked) { + border-color: var(--color-quanta-peacock); + background-color: var(--color-quanta-peacock); + color: var(--color-quanta-air); + } + + .facet:has(input:focus-visible) { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .facetCount { + color: var(--muted-foreground); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + } + + .facet:has(input:checked) .facetCount { + color: var(--color-quanta-air); + opacity: 0.85; + } + + .sort { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: 8px; + gap: 8px; + } + + .sortLabel { + color: var(--muted-foreground); + font-size: 0.875rem; + } + + .sortSelect { + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background-color: var(--background); + color: var(--foreground); + font-size: 0.875rem; + } + + .sortSelect:focus-visible { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .item { + display: flex; + align-items: flex-start; + padding: 16px 0; + gap: 12px; + } + + .icon { + display: flex; + flex-shrink: 0; + margin-top: 2px; + color: var(--muted-foreground); + } + + .icon svg { + width: 1.5rem; + height: 1.5rem; + } + + .body { + min-width: 0; + } + + .description { + margin: 0; + color: var(--foreground); + } + + .meta { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 6px; + color: var(--muted-foreground); + font-size: 0.75rem; + gap: 8px; + } + + .metaDate { + font-variant-numeric: tabular-nums; + } + + .list { + transition: opacity 0.15s ease; + } + + .results[aria-busy='true'] .list { + opacity: 0.5; + } + + .pagination { + display: flex; + align-items: center; + padding-top: 16px; + border-top: 1px solid var(--border); + margin-top: 24px; + gap: 4px; + } + + .pageItem, + .pageNav { + display: inline-flex; + min-width: 32px; + height: 32px; + align-items: center; + justify-content: center; + padding: 0 8px; + border-radius: 4px; + color: var(--color-quanta-sapphire); + font-size: 0.875rem; + text-decoration: none; + } + + .pageItem:hover, + .pageNav:hover { + background-color: var(--muted); + } + + .pageItem:focus-visible, + .pageNav:focus-visible { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .pageCurrent { + background-color: var(--color-quanta-sapphire); + color: var(--primary-foreground); + font-weight: 600; + } + + .pageCurrent:hover { + background-color: var(--color-quanta-sapphire); + } + + .pageDisabled { + color: var(--muted-foreground); + pointer-events: none; + } + + .ellipsis { + display: inline-flex; + min-width: 32px; + height: 32px; + align-items: center; + justify-content: center; + color: var(--muted-foreground); + } +} + +/* Outside @layer custom on purpose: must beat the unlayered global h2 typography. */ +.headline { + margin: 0 0 4px; + font-size: 1.25rem; + line-height: 1.3; +} + +.headlineLink { + color: var(--color-quanta-sapphire); + font-size: 1.25rem; + font-weight: 500; + text-decoration: none; +} + +.headlineLink:hover { + text-decoration: underline; +} + +.headlineLink:focus-visible { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; +} diff --git a/packages/layout/components/SearchResults/SearchResults.test.tsx b/packages/layout/components/SearchResults/SearchResults.test.tsx new file mode 100644 index 000000000..560f353e6 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchResults.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { SearchResults } from './SearchResults'; + +const items = [ + { + '@id': '/news/a', + '@type': 'News Item', + title: 'Article A', + description: 'About A', + review_state: 'published', + effective: '2024-03-15T10:00:00+00:00', + }, + { '@id': '/news/b', title: 'Article B', description: '' }, +]; + +const renderResults = ( + data: unknown[], + { total = data.length, loading = false } = {}, +) => + render( + + + , + ); + +describe('SearchResults', () => { + it('links each result title to its item', () => { + renderResults(items); + expect(screen.getByRole('link', { name: 'Article A' })).toHaveAttribute( + 'href', + '/news/a', + ); + expect(screen.getByRole('link', { name: 'Article B' })).toHaveAttribute( + 'href', + '/news/b', + ); + }); + + it('shows the description when present', () => { + renderResults(items); + expect(screen.getByText('About A')).toBeInTheDocument(); + }); + + it('renders no result articles for an empty result set', () => { + const { container } = renderResults([], { total: 0 }); + expect(container.querySelectorAll('article')).toHaveLength(0); + }); + + it('renders an accessible results region and a status count', () => { + renderResults(items); + const region = screen.getByRole('region', { name: /resultsLabel/i }); + expect(region).toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('shows the formatted effective date as metadata', () => { + renderResults(items); + const first = screen + .getByRole('link', { name: 'Article A' }) + .closest('article') as HTMLElement; + // a localized date is rendered (year present), and not the 1969 placeholder + expect(within(first).getByText(/2024/)).toBeInTheDocument(); + }); + + it('suppresses the Plone placeholder date', () => { + renderResults([ + { + '@id': '/news/c', + title: 'Article C', + description: '', + effective: '1969-12-31T00:00:00+00:00', + }, + ]); + const article = screen + .getByRole('link', { name: 'Article C' }) + .closest('article') as HTMLElement; + expect(within(article).queryByText(/1969/)).toBeNull(); + }); + + it('keeps a real date from 1970', () => { + renderResults([ + { + '@id': '/news/d', + title: 'Article D', + description: '', + effective: '1970-06-15T00:00:00+00:00', + }, + ]); + const article = screen + .getByRole('link', { name: 'Article D' }) + .closest('article') as HTMLElement; + expect(within(article).getByText(/1970/)).toBeInTheDocument(); + }); + + it('does not render a redundant "read more" link', () => { + const { container } = renderResults(items); + expect(container.querySelector('a[aria-hidden="true"]')).toBeNull(); + }); + + it('marks the region busy and shows the loading label while loading', () => { + renderResults(items, { loading: true }); + expect( + screen.getByRole('region', { name: /resultsLabel/i }), + ).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('status')).toHaveTextContent( + 'layout.search.loading', + ); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchResults.tsx b/packages/layout/components/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..8f2fc36da --- /dev/null +++ b/packages/layout/components/SearchResults/SearchResults.tsx @@ -0,0 +1,78 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from '@plone/components'; +import { PageIcon } from '@plone/components/Icons'; +import { getContentIcon } from '@plone/helpers'; +import type { Brain } from '@plone/types'; +import styles from './SearchResults.module.css'; + +export interface SearchResultsProps { + items: Brain[]; + total: number; + loading?: boolean; +} + +const PLACEHOLDER_DATE = '1969-12-31T00:00:00+00:00'; + +function formatDate(value: string | undefined, locale: string) { + if (!value || value === PLACEHOLDER_DATE) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeZone: 'UTC', + }).format(date); +} + +export function SearchResults({ + items, + total, + loading = false, +}: SearchResultsProps) { + const { t, i18n } = useTranslation(); + + return ( +
+

+ {loading + ? t('layout.search.loading') + : t('layout.search.resultCount', { count: total })} +

+
+ {items.map((item) => { + const Icon = getContentIcon(item['@type']) ?? PageIcon; + const date = formatDate(item.effective, i18n.language); + return ( +
+ + + +
+

+ + {item.title} + +

+ {item.description && ( +

{item.description}

+ )} + {date && ( +
+ {date} +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/layout/components/SearchResults/SearchSort.test.tsx b/packages/layout/components/SearchResults/SearchSort.test.tsx new file mode 100644 index 000000000..a8007b059 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchSort.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router'; +import { SearchSort, sortToQuery } from './SearchSort'; + +function LocationProbe() { + const { search } = useLocation(); + return {search}; +} + +const renderSort = (url = '/search?SearchableText=foo') => + render( + + + + + + + } + /> + + , + ); + +describe('sortToQuery', () => { + it('omits sort_on for relevance / unknown / null', () => { + expect(sortToQuery(null)).toEqual({}); + expect(sortToQuery('relevance')).toEqual({}); + expect(sortToQuery('whatever')).toEqual({}); + }); + + it('maps date to effective descending', () => { + expect(sortToQuery('date')).toEqual({ + sort_on: 'effective', + sort_order: 'descending', + }); + }); + + it('maps title to sortable_title ascending', () => { + expect(sortToQuery('title')).toEqual({ + sort_on: 'sortable_title', + sort_order: 'ascending', + }); + }); +}); + +describe('SearchSort', () => { + it('reflects the current sort from the URL', () => { + renderSort('/search?SearchableText=foo&sort=title'); + expect(screen.getByRole('combobox')).toHaveValue('title'); + }); + + it('defaults to relevance when no sort param is present', () => { + renderSort(); + expect(screen.getByRole('combobox')).toHaveValue('relevance'); + }); + + it('updates the sort param and resets paging on change', () => { + renderSort('/search?SearchableText=foo&b_start=25'); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'date' }, + }); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).toContain('sort=date'); + expect(search).toContain('SearchableText=foo'); + expect(search).not.toContain('b_start'); + }); + + it('removes the sort param when switching back to relevance', () => { + renderSort('/search?SearchableText=foo&sort=title'); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'relevance' }, + }); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).not.toContain('sort'); + expect(search).toContain('SearchableText=foo'); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchSort.tsx b/packages/layout/components/SearchResults/SearchSort.tsx new file mode 100644 index 000000000..24b0014d2 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchSort.tsx @@ -0,0 +1,65 @@ +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router'; +import styles from './SearchResults.module.css'; + +export const SORT_OPTIONS = ['relevance', 'date', 'title'] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; + +export const DEFAULT_SORT: SortOption = 'relevance'; + +export function sortToQuery(sort: string | null): { + sort_on?: string; + sort_order?: 'ascending' | 'descending'; +} { + switch (sort) { + case 'date': + return { sort_on: 'effective', sort_order: 'descending' }; + case 'title': + return { sort_on: 'sortable_title', sort_order: 'ascending' }; + default: + return {}; + } +} + +export function SearchSort() { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const raw = searchParams.get('sort'); + const current: SortOption = SORT_OPTIONS.includes(raw as SortOption) + ? (raw as SortOption) + : DEFAULT_SORT; + const selectId = useId(); + + const onChange = (event: React.ChangeEvent) => { + const next = new URLSearchParams(searchParams); + if (event.target.value === DEFAULT_SORT) { + next.delete('sort'); + } else { + next.set('sort', event.target.value); + } + next.delete('b_start'); + setSearchParams(next); + }; + + return ( +
+ + +
+ ); +} diff --git a/packages/layout/config/settings.ts b/packages/layout/config/settings.ts index 3641ac6a0..b201b4124 100644 --- a/packages/layout/config/settings.ts +++ b/packages/layout/config/settings.ts @@ -1,6 +1,25 @@ import type { ConfigType } from '@plone/registry'; +import pageSVG from '@plone/components/icons/page.svg?react'; +import folderSVG from '@plone/components/icons/folder.svg?react'; +import newsSVG from '@plone/components/icons/news.svg?react'; +import calendarSVG from '@plone/components/icons/calendar.svg?react'; +import imageSVG from '@plone/components/icons/image.svg?react'; +import linkSVG from '@plone/components/icons/link.svg?react'; + export default function install(config: ConfigType) { config.settings.hideBreadcrumbs = ['Plone Site', 'Subsite', 'LRF']; + // Registered here (not only in @plone/contents) so the public UI has it too. + config.settings.contentIcons = { + Document: pageSVG, + Folder: folderSVG, + 'News Item': newsSVG, + Event: calendarSVG, + Image: imageSVG, + File: pageSVG, + Link: linkSVG, + ...(config.settings.contentIcons ?? {}), + }; + return config; } diff --git a/packages/layout/locales/de/common.json b/packages/layout/locales/de/common.json index a9282556f..30c2ca422 100644 --- a/packages/layout/locales/de/common.json +++ b/packages/layout/locales/de/common.json @@ -3,6 +3,23 @@ "languageSwitcher": { "switchTo": "Zu {{ lang }} wechseln" }, + "search": { + "resultCount_one": "{{count}} Ergebnis", + "resultCount_other": "{{count}} Ergebnisse", + "pagination": "Seiten der Suchergebnisse", + "previous": "Vorherige Seite", + "next": "Nächste Seite", + "page": "Seite {{page}}", + "filterByTag": "Nach Schlagwort filtern", + "resultsLabel": "Suchergebnisse", + "loading": "Suche läuft…", + "sort": { + "label": "Sortieren nach", + "relevance": "Relevanz", + "date": "Datum", + "title": "Titel" + } + }, "contenttypes": { "common": { "size": "Größe:" diff --git a/packages/layout/locales/en/common.json b/packages/layout/locales/en/common.json index 015eedda6..4939ef570 100644 --- a/packages/layout/locales/en/common.json +++ b/packages/layout/locales/en/common.json @@ -3,6 +3,23 @@ "languageSwitcher": { "switchTo": "Switch to {{ lang }}" }, + "search": { + "resultCount_one": "{{count}} result", + "resultCount_other": "{{count}} results", + "pagination": "Search results pages", + "previous": "Previous page", + "next": "Next page", + "page": "Page {{page}}", + "filterByTag": "Filter by tag", + "resultsLabel": "Search results", + "loading": "Searching…", + "sort": { + "label": "Sort by", + "relevance": "Relevance", + "date": "Date", + "title": "Title" + } + }, "contenttypes": { "common": { "size": "Size:" diff --git a/packages/layout/locales/it/common.json b/packages/layout/locales/it/common.json index 6a5bf7a24..49052dbfd 100644 --- a/packages/layout/locales/it/common.json +++ b/packages/layout/locales/it/common.json @@ -3,6 +3,23 @@ "languageSwitcher": { "switchTo": "Passa a {{ lang }}" }, + "search": { + "resultCount_one": "{{count}} risultato", + "resultCount_other": "{{count}} risultati", + "pagination": "Pagine dei risultati di ricerca", + "previous": "Pagina precedente", + "next": "Pagina successiva", + "page": "Pagina {{page}}", + "filterByTag": "Filtra per tag", + "resultsLabel": "Risultati della ricerca", + "loading": "Ricerca in corso…", + "sort": { + "label": "Ordina per", + "relevance": "Rilevanza", + "date": "Data", + "title": "Titolo" + } + }, "contenttypes": { "common": { "size": "Dimensione:" diff --git a/packages/layout/news/+search-content-type-icons.feature b/packages/layout/news/+search-content-type-icons.feature new file mode 100644 index 000000000..c4ed02ecb --- /dev/null +++ b/packages/layout/news/+search-content-type-icons.feature @@ -0,0 +1 @@ +Search results now show a per-content-type icon (resolved via `getContentIcon`/`config.settings.contentIcons`, registered in `@plone/layout` so it is available in the public UI) and the localized effective date of each result. @nils-pzr diff --git a/packages/layout/news/+search-facets.feature b/packages/layout/news/+search-facets.feature new file mode 100644 index 000000000..bd98b0de6 --- /dev/null +++ b/packages/layout/news/+search-facets.feature @@ -0,0 +1 @@ +Added tag (`Subject`) facets to the search results. Available tags and their counts are aggregated from the matches, and selecting one or more tags filters the results via repeated `Subject` query parameters while resetting paging. @nils-pzr diff --git a/packages/layout/news/+search-filter-collapsible.feature b/packages/layout/news/+search-filter-collapsible.feature new file mode 100644 index 000000000..8d3525861 --- /dev/null +++ b/packages/layout/news/+search-filter-collapsible.feature @@ -0,0 +1 @@ +The search tag filter is now collapsed by default behind an animated "Filter by tag" disclosure (it auto-opens when a tag filter is active), and the results show a loading state (`aria-busy` + a "Searching…" status) while a new query, sort, page or filter is fetched. @nils-pzr diff --git a/packages/layout/news/+search-result-count-plural.bugfix b/packages/layout/news/+search-result-count-plural.bugfix new file mode 100644 index 000000000..14c5e5e4f --- /dev/null +++ b/packages/layout/news/+search-result-count-plural.bugfix @@ -0,0 +1 @@ +Fixed the search result count to use the singular form for a single match. @nils-pzr diff --git a/packages/layout/news/+search-results.feature b/packages/layout/news/+search-results.feature new file mode 100644 index 000000000..9abcd2e66 --- /dev/null +++ b/packages/layout/news/+search-results.feature @@ -0,0 +1 @@ +Added shared `SearchResults` and `SearchPagination` components for the public search page. Results show a content icon, a linked title, and the description; pagination renders crawlable previous/next and numbered page links that preserve the active query. Styling uses themeable CSS tokens so it can be re-themed. @nils-pzr diff --git a/packages/layout/news/+search-sort.feature b/packages/layout/news/+search-sort.feature new file mode 100644 index 000000000..de75bf12a --- /dev/null +++ b/packages/layout/news/+search-sort.feature @@ -0,0 +1 @@ +Added a sort selector to the search results (relevance, date, title). The choice is kept in a `sort` query parameter, resets paging to the first page, and is preserved across pagination. @nils-pzr diff --git a/packages/publicui/index.ts b/packages/publicui/index.ts index 33e02cc54..a4427f359 100644 --- a/packages/publicui/index.ts +++ b/packages/publicui/index.ts @@ -27,6 +27,14 @@ export default function install(config: ConfigType) { path: 'search', file: '@plone/publicui/routes/search.tsx', }, + { + type: 'route', + path: ':root/search', + file: '@plone/publicui/routes/search.tsx', + options: { + id: 'search-scoped', + }, + }, { type: 'route', path: '*', diff --git a/packages/publicui/locales/de/common.json b/packages/publicui/locales/de/common.json new file mode 100644 index 000000000..7b0826a1e --- /dev/null +++ b/packages/publicui/locales/de/common.json @@ -0,0 +1,11 @@ +{ + "publicui": { + "sitemap": "Sitemap", + "search": { + "title": "Suchergebnisse für", + "results": "Suchergebnisse", + "placeholder": "Hier schreiben...", + "noResults": "Keine Ergebnisse gefunden" + } + } +} diff --git a/packages/publicui/locales/it/common.json b/packages/publicui/locales/it/common.json index 25a5ad33b..2322c5a2a 100644 --- a/packages/publicui/locales/it/common.json +++ b/packages/publicui/locales/it/common.json @@ -2,7 +2,7 @@ "publicui": { "sitemap": "Sitemap", "search": { - "title":"Risultati della ricerca per", + "title": "Risultati della ricerca per", "results": "Risultati della ricerca", "placeholder": "Scrivi qui...", "noResults": "Nessun risultato" diff --git a/packages/publicui/news/+german-locale.feature b/packages/publicui/news/+german-locale.feature new file mode 100644 index 000000000..747ce0acf --- /dev/null +++ b/packages/publicui/news/+german-locale.feature @@ -0,0 +1 @@ +Added the German locale for the public UI strings. @nils-pzr diff --git a/packages/publicui/news/+search-language-scoped.feature b/packages/publicui/news/+search-language-scoped.feature new file mode 100644 index 000000000..1b9f3a61f --- /dev/null +++ b/packages/publicui/news/+search-language-scoped.feature @@ -0,0 +1 @@ +The search route also answers under a top-level folder, such as a language root: `/de/search` scopes the results to that subtree. @nils-pzr diff --git a/packages/publicui/news/+search-no-layout-shift.feature b/packages/publicui/news/+search-no-layout-shift.feature new file mode 100644 index 000000000..51c885671 --- /dev/null +++ b/packages/publicui/news/+search-no-layout-shift.feature @@ -0,0 +1 @@ +The public UI reserves the scrollbar gutter (`scrollbar-gutter: stable`) so the page width no longer shifts when the search result height changes (for example, when expanding the tag filter or changing the result count). @nils-pzr diff --git a/packages/publicui/news/+search-pagination.feature b/packages/publicui/news/+search-pagination.feature new file mode 100644 index 000000000..5cda856fa --- /dev/null +++ b/packages/publicui/news/+search-pagination.feature @@ -0,0 +1 @@ +Added pagination to the search results and styled the search heading in the brand teal. Results are now batched per page, with crawlable previous/next and numbered page links that preserve the active query. @nils-pzr diff --git a/packages/publicui/news/+search-result-links.bugfix b/packages/publicui/news/+search-result-links.bugfix new file mode 100644 index 000000000..3fb782c47 --- /dev/null +++ b/packages/publicui/news/+search-result-links.bugfix @@ -0,0 +1 @@ +Fixed search result links opening the backend instead of navigating within Aurora: backend URLs are now flattened to app-relative ones in the loader (server-side), so clicking a result navigates to the correct Aurora page. @nils-pzr diff --git a/packages/publicui/news/+search-results-clickable.feature b/packages/publicui/news/+search-results-clickable.feature new file mode 100644 index 000000000..d2bae0acc --- /dev/null +++ b/packages/publicui/news/+search-results-clickable.feature @@ -0,0 +1 @@ +Made search results clickable and aligned the results list with the design: each result now shows a content icon, a linked title, the description, and a result count. @nils-pzr diff --git a/packages/publicui/news/+search-site-settings-a11y.feature b/packages/publicui/news/+search-site-settings-a11y.feature new file mode 100644 index 000000000..837916770 --- /dev/null +++ b/packages/publicui/news/+search-site-settings-a11y.feature @@ -0,0 +1 @@ +The search route now honours the site's search settings (`use_site_search_settings`), matching Volto, and improves accessibility: the form sits in a native `` landmark, the results are a named `region`, and the result count is a `role="status"` live region linked to the results via `aria-controls`. @nils-pzr diff --git a/packages/publicui/routes/search.test.tsx b/packages/publicui/routes/search.test.tsx new file mode 100644 index 000000000..800640d3b --- /dev/null +++ b/packages/publicui/routes/search.test.tsx @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import config from '@plone/registry'; +import { ploneClientContext } from '@plone/aurora/app/middleware.server'; +import { loader } from './search'; + +const makeArgs = ( + urlString: string, + cli: unknown, + params: Record = {}, +) => { + const context = new RouterContextProvider(); + context.set(ploneClientContext, cli as never); + const request = new Request(urlString); + return { + request, + context, + params, + unstable_pattern: '/search', + unstable_url: new URL(urlString), + } as never; +}; + +describe('Search loader', () => { + afterEach(() => { + vi.restoreAllMocks(); + config.settings = {} as never; + }); + + it('short-circuits on an empty term without querying the backend', async () => { + const cli = { search: vi.fn() }; + const result = await loader(makeArgs('http://example.com/search', cli)); + expect(cli.search).not.toHaveBeenCalled(); + expect(result).toMatchObject({ search: [], total: 0, facets: [] }); + }); + + it('short-circuits on a whitespace-only term', async () => { + const cli = { search: vi.fn() }; + const result = await loader( + makeArgs('http://example.com/search?SearchableText=%20%20', cli), + ); + expect(cli.search).not.toHaveBeenCalled(); + expect(result).toMatchObject({ search: [], total: 0 }); + }); + + it('queries the page and the facet sample, returns flattened data', async () => { + config.settings = { apiPath: 'http://example.com' } as never; + const cli = { + search: vi + .fn() + .mockResolvedValueOnce({ + data: { + items: [{ '@id': 'http://example.com/a', title: 'A' }], + items_total: 1, + }, + }) + .mockResolvedValueOnce({ + data: { items: [{ Subject: ['x'] }, { Subject: ['x', 'y'] }] }, + }), + }; + + const result = await loader( + makeArgs('http://example.com/search?SearchableText=foo', cli), + ); + + expect(cli.search).toHaveBeenCalledTimes(2); + expect(cli.search.mock.calls[0][0].query).toMatchObject({ + SearchableText: 'foo*', + use_site_search_settings: 1, + b_start: 0, + }); + expect(cli.search.mock.calls[1][0].query).toMatchObject({ + metadata_fields: 'Subject', + }); + expect((result as { search: { '@id': string }[] }).search[0]['@id']).toBe( + '/a', + ); + expect(result).toMatchObject({ + total: 1, + facets: [ + { subject: 'x', count: 2 }, + { subject: 'y', count: 1 }, + ], + }); + }); + + it('scopes the query to the root segment on the scoped route', async () => { + const cli = { + search: vi + .fn() + .mockResolvedValueOnce({ data: { items: [], items_total: 0 } }) + .mockResolvedValueOnce({ data: { items: [] } }), + }; + + await loader( + makeArgs('http://example.com/en/search?SearchableText=foo', cli, { + root: 'en', + }), + ); + + expect(cli.search.mock.calls[0][0].query.path).toEqual({ query: '/en' }); + expect(cli.search.mock.calls[1][0].query.path).toEqual({ query: '/en' }); + }); + + it('does not scope the global route', async () => { + const cli = { + search: vi + .fn() + .mockResolvedValueOnce({ data: { items: [], items_total: 0 } }) + .mockResolvedValueOnce({ data: { items: [] } }), + }; + + await loader(makeArgs('http://example.com/search?SearchableText=foo', cli)); + + expect(cli.search.mock.calls[0][0].query.path).toBeUndefined(); + }); + + it('redirects a b_start beyond the last page to the last page', async () => { + const cli = { + search: vi + .fn() + .mockResolvedValueOnce({ data: { items: [], items_total: 30 } }) + .mockResolvedValueOnce({ data: { items: [] } }), + }; + + await expect( + loader( + makeArgs( + 'http://example.com/search?SearchableText=foo&b_start=500', + cli, + ), + ), + ).rejects.toSatisfy((thrown: unknown) => { + const response = thrown as Response; + return ( + response instanceof Response && + response.status === 302 && + (response.headers.get('Location') ?? '').includes('b_start=25') + ); + }); + }); + + it('drops b_start entirely when the last page is the first', async () => { + const cli = { + search: vi + .fn() + .mockResolvedValueOnce({ data: { items: [], items_total: 3 } }) + .mockResolvedValueOnce({ data: { items: [] } }), + }; + + await expect( + loader( + makeArgs( + 'http://example.com/search?SearchableText=foo&b_start=500', + cli, + ), + ), + ).rejects.toSatisfy((thrown: unknown) => { + const response = thrown as Response; + return ( + response instanceof Response && + !(response.headers.get('Location') ?? '').includes('b_start') + ); + }); + }); + + it('maps backend failures to a thrown error response', async () => { + const cli = { + search: vi.fn().mockRejectedValue({ status: 503, data: {} }), + }; + + await expect( + loader(makeArgs('http://example.com/search?SearchableText=foo', cli)), + ).rejects.toMatchObject({ init: { status: 503 } }); + }); +}); diff --git a/packages/publicui/routes/search.tsx b/packages/publicui/routes/search.tsx index 472f6edfa..de22de7bb 100644 --- a/packages/publicui/routes/search.tsx +++ b/packages/publicui/routes/search.tsx @@ -1,18 +1,34 @@ import { useTranslation } from 'react-i18next'; import { data, - Form, + redirect, RouterContextProvider, useLoaderData, + useLocation, + useNavigation, type LoaderFunctionArgs, } from 'react-router'; import { ploneClientContext } from '@plone/aurora/app/middleware.server'; -import { Container, Input } from '@plone/components/quanta'; +import { flattenToAppURL } from '@plone/helpers'; +import { Container } from '@plone/components/quanta'; +import { SearchResults } from '@plone/layout/components/SearchResults/SearchResults'; +import { SearchPagination } from '@plone/layout/components/SearchResults/SearchPagination'; +import { + SearchSort, + sortToQuery, +} from '@plone/layout/components/SearchResults/SearchSort'; +import { + SearchFacets, + aggregateFacets, +} from '@plone/layout/components/SearchResults/SearchFacets'; export const handle = { bodyClass: 'search-route', }; +const PAGE_SIZE = 25; +const FACET_SAMPLE_SIZE = 1000; + export async function loader({ request, params, @@ -20,29 +36,78 @@ export async function loader({ }: LoaderFunctionArgs) { const cli = context.get(ploneClientContext); - const path = `/${params['*'] || ''}`; const url = new URL(request.url); - const query = url.searchParams.get('SearchableText') || ''; - - try { - const results = await cli.search({ - query: { - SearchableText: query ? `${query}*` : '', - path: { - query: path || '/', - }, - }, - }); + const query = (url.searchParams.get('SearchableText') || '').trim(); + const bStart = Math.max(0, Number(url.searchParams.get('b_start')) || 0); + const subjects = url.searchParams.getAll('Subject'); + const root = params.root ? `/${params.root}` : null; + // An empty term with use_site_search_settings returns a bare list, not a + // batch, so skip the query entirely. + if (!query) { return { - search: results.data.items, + search: [], + total: 0, params: query, + bStart, + bSize: PAGE_SIZE, + facets: [], }; + } + + const baseQuery = { + SearchableText: `${query}*`, + use_site_search_settings: 1, + ...(root ? { path: { query: root } } : {}), + }; + + let results; + let facetSample; + try { + [results, facetSample] = await Promise.all([ + cli.search({ + query: { + ...baseQuery, + ...(subjects.length > 0 ? { Subject: subjects } : {}), + b_start: bStart, + b_size: PAGE_SIZE, + ...sortToQuery(url.searchParams.get('sort')), + }, + }), + cli.search({ + query: { + ...baseQuery, + metadata_fields: 'Subject', + b_size: FACET_SAMPLE_SIZE, + }, + }), + ]); } catch (error: any) { throw data('Search failed', { status: typeof error.status === 'number' ? error.status : 500, }); } + + const total = results.data.items_total ?? 0; + + if (total > 0 && bStart >= total) { + const lastStart = Math.floor((total - 1) / PAGE_SIZE) * PAGE_SIZE; + if (lastStart > 0) { + url.searchParams.set('b_start', String(lastStart)); + } else { + url.searchParams.delete('b_start'); + } + throw redirect(`${url.pathname}?${url.searchParams.toString()}`); + } + + return { + search: flattenToAppURL(results.data.items ?? []), + total, + params: query, + bStart, + bSize: PAGE_SIZE, + facets: aggregateFacets(facetSample.data.items ?? []), + }; } export const meta = () => { @@ -51,7 +116,14 @@ export const meta = () => { export default function SearchRoute() { const { t } = useTranslation(); - const { search, params } = useLoaderData(); + const { search, total, params, bStart, bSize, facets } = + useLoaderData(); + + const navigation = useNavigation(); + const location = useLocation(); + const isLoading = + navigation.state === 'loading' && + navigation.location?.pathname === location.pathname; return ( @@ -60,51 +132,17 @@ export default function SearchRoute() { ? `${t('publicui.search.title')} "${params}"` : t('publicui.search.results')} -
- - {/* */} - - {/* */} - {/* */} - {search?.length > 0 ? ( - search.map((item) => ( -
-

- {/* */} - {item.title} - {/* */} -

- {item.description && ( -
- {item.description} -
- )} -
- Read more - {/* - - */} -
-
-
- )) - ) : ( -
-

{t('publicui.search.noResults')}

-
- )} + {/* Search input lives in the site header (issue #59). */} + + {search.length > 0 ? ( + <> + + + + + ) : params ? ( +

{t('publicui.search.noResults')}

+ ) : null}
); } diff --git a/packages/publicui/styles/publicui.css b/packages/publicui/styles/publicui.css index 922d9dbaa..de25165a3 100644 --- a/packages/publicui/styles/publicui.css +++ b/packages/publicui/styles/publicui.css @@ -1,5 +1,14 @@ @layer custom { + /* Reserve the scrollbar gutter so the page width doesn't shift. */ + html { + scrollbar-gutter: stable; + } + .with-toolbar #main { margin-inline-start: var(--plone-toolbar-width); } + + .route-search .documentFirstHeading { + color: var(--color-quanta-peacock); + } } diff --git a/packages/publicui/vitest.config.ts b/packages/publicui/vitest.config.ts index 8f7778679..6b6f48c9d 100644 --- a/packages/publicui/vitest.config.ts +++ b/packages/publicui/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'; // https://vitejs.dev/config/ export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, test: { globals: true, environment: 'jsdom',