Skip to content

Commit

Permalink
feat: implement WordPress native search endpoint (#671)
Browse files Browse the repository at this point in the history
Co-authored-by: Nícholas André <[email protected]>
Co-authored-by: Nícholas André <[email protected]>
  • Loading branch information
3 people authored Feb 20, 2024
1 parent e0e7c82 commit 8452279
Show file tree
Hide file tree
Showing 24 changed files with 3,491 additions and 14 deletions.
7 changes: 7 additions & 0 deletions .changeset/wise-ligers-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@headstartwp/headstartwp": minor
"@headstartwp/core": minor
"@headstartwp/next": minor
---

Implement WordPress native search endpoint
163 changes: 163 additions & 0 deletions docs/documentation/02 - Data Fetching/useSearchNative.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
---
slug: /data-fetching/usesnative-earch
sidebar_position: 5
---

# The useSearchNative hook

> The [useSearchNative](/api/modules/headstartwp_next#usesearchnative) hook is the Next.js binding for the [useFetchSearchNative](/api/namespaces/headstartwp_core.react#usefetchsearchnative).
The `useSearchNative` hook is the implementation of core [Search Results](https://developer.wordpress.org/rest-api/reference/search-results/) endpoint.

:::caution
This hook was introduced in `@headstartwp/[email protected]`, `@headstartwp/[email protected]` and requires the the HeadstartWP WordPress plugin >= 1.1.0
:::caution

The headstartwp WordPress plugin does additional customizations to ensure the Search Results endpoints return all the embeddable data associated with search results.

## Basic Usage

Assuming a `src/pages/search/[[...path]].js` route with the following content.

:::info
This example is using the optional catch-all route `[[..path]].js` because we want the `/search` route to be handled by the same file and fetch the latest posts.
:::info

```js title="src/pages/search/[[...path]].js"
import { useSearchNative } from '@headstartwp/next';

const ArchivePage = () => {
const { loading, error, data } = useSearchNative({ per_page: 10 });

if (loading) {
return 'Loading...';
}

if (error) {
return 'error...';
}

if (data.pageInfo.totalItems === 0) {
return 'Nothing found';
}

return (
<>
<h1>Search Results</h1>
<ul>
{data.searchResults.map((item) => (
<li key={item.id}>
<Link href={item.url}>
{item.id} - {item.title}
</Link>
</li>
))}
</ul>
</>
);
};
```

The route will automatically render the latest 10 results if no search term is provided. The following paths are automatically handled:

- /search/search-term
- /search/search-term/page/2
- /search

## Searching from multiple post types

You can specify any of the supported parameters described in the [Search Results](https://developer.wordpress.org/rest-api/reference/search-results/#arguments) endpoint documentation.

```js title="src/pages/search/[[...path]].js"
import { useSearchNative } from '@headstartwp/next';

const ArchivePage = () => {
const { loading, error, data } = useSearchNative({
per_page: 10,
type: 'post',
subtype: ['post', 'page']
});

if (loading) {
return 'Loading...';
}

if (error) {
return 'error...';
}

if (data.pageInfo.totalItems === 0) {
return 'Nothing found';
}

return (
<>
<h1>Search Results</h1>
<ul>
{data.searchResults.map((item) => (
<li key={item.id}>
<Link href={item.url}>
{item.id} - {item.title}
</Link>
</li>
))}
</ul>
</>
);
};
```

## Searching for terms

You can also search for terms:

```js title="src/pages/terms/search/[[...path]].js"
import { useSearch } from '@headstartwp/next';

const ArchivePage = () => {
const { loading, error, data } = useSearchNative({
per_page: 10,
type: 'term',
subtype: ['category', 'category']
});

if (loading) {
return 'Loading...';
}

if (error) {
return 'error...';
}

if (data.pageInfo.totalItems === 0) {
return 'Nothing found';
}

return (
<>
<h1>Search Results</h1>
<ul>
{data.searchResults.map((item) => (
<li key={item.id}>
<Link href={item.url}>
{item.id} - {item.title}
</Link>
</li>
))}
</ul>
</>
);
};
```

## Accessing embeddable data
By default, the Search Results endpoints only return the object of the associated search results but do not return embeddable data of the search results entities themselves. For instance, when searching for posts, even if you pass the `_embed` parameter, WordPress won't return the associated term objects, author objects etc.

HeadstartWP plugin extends the core endpoint so that it returns these embedded objects to avoid the need for additional queries. Check the [PostSearchEntity](/api/interfaces/headstartwp_core.PostSearchEntity/) and [TermSearcheEntity](api/interfaces/headstartwp_core.TermSearchEntity/).

## QueriedObject

The `useNativeSearch` hook also exposes a `queriedObject`.

The queried object for this hook is an object of type [SearchEntity](/api/interfaces/headstartwp_core.SearchEntity/).

172 changes: 172 additions & 0 deletions packages/core/src/data/strategies/SearchNativeFetchStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { getSiteBySourceUrl, addQueryArgs, getWPUrl } from '../../utils';
import { endpoints } from '../utils';
import { apiGet } from '../api';
import { PostSearchEntity, TermSearchEntity, QueriedObject } from '../types';
import { searchMatchers } from '../utils/matchers';
import { parsePath } from '../utils/parsePath';
import { FetchOptions, AbstractFetchStrategy, EndpointParams } from './AbstractFetchStrategy';

/**
* The EndpointParams supported by the {@link SearchNativeFetchStrategy}
*/
export interface SearchParams extends EndpointParams {
/**
* Current page of the collection.
*
* @default 1
*/
page?: number;

/**
* Maximum number of items to be returned in result set.
*
* @default 10
*/
per_page?: number;

/**
* Limit results to those matching a string.
*/
search?: string;

/**
* Limit results to items of an object type.
*
* @default 'post'
*/
type?: 'post' | 'term' | 'post-format';

/**
* Limit results to items of one or more object subtypes.
*/
subtype?: string | string[];

/**
* Ensure result set excludes specific IDs.
*/
exclude?: number[];

/**
* Limit result set to specific IDs.
*/
include?: number[];
}

/**
* The SearchNativeFetchStrategy is used to fetch search results for a given search query
* Uses the native WordPress search endpoint.
*
* Note that custom post types and custom taxonomies should be defined in `headless.config.js`
*
* This strategy supports extracting endpoint params from url E.g:
* - `/page/2/` maps to `{ page: 2 }`
* - `/searched-term/page/2` maps to `{ search: 'searched-term', page: 2 }`
*
* @see {@link getParamsFromURL} to learn about url param mapping
*
* @category Data Fetching
*/
export class SearchNativeFetchStrategy<
T extends PostSearchEntity | TermSearchEntity = PostSearchEntity | TermSearchEntity,
P extends SearchParams = SearchParams,
> extends AbstractFetchStrategy<T[], P> {
path: string = '';

locale: string = '';

getDefaultEndpoint() {
return endpoints.search;
}

getDefaultParams(): Partial<P> {
return { _embed: true, ...super.getDefaultParams() } as P;
}

/**
* This strategy automatically extracts the search term and and pagination params from the URL
*
* @param path The URL path to extract params from
* @param params The params passed to the strategy
*/
getParamsFromURL(path: string, params: Partial<P> = {}): Partial<P> {
const config = getSiteBySourceUrl(this.baseURL);

// Required for search lang url.
this.locale = config.integrations?.polylang?.enable && params.lang ? params.lang : '';

return parsePath(searchMatchers, path) as Partial<P>;
}

/**
* Builds the endpoint url for the search endpoint
*
* @param params The params for the request
* @returns
*/
buildEndpointURL(params: Partial<P>): string {
const normalizedParams = { ...params };
if (Array.isArray(normalizedParams.subtype)) {
normalizedParams.subtype = normalizedParams.subtype.join(',');
}

return super.buildEndpointURL(normalizedParams);
}

/**
* The fetcher function is overridden to disable throwing if not found
*
* If a search request returns not found we do not want to redirect to a 404 page,
* instead the user should be informed that no posts were found
*
* @param url The url to parse
* @param params The params to build the endpoint with
* @param options FetchOptions
*/
async fetcher(url: string, params: Partial<P>, options: Partial<FetchOptions> = {}) {
const { burstCache = false } = options;
let seo_json: Record<string, any> = {};
let seo: string = '';

// Request SEO data.
try {
const wpUrl = getWPUrl().replace(/\/$/, ''); // Ensure no double slash in url param
const localeParam = this.locale ? `&lang=${this.locale}` : '';
const pageParam = params.page ? `/page/${params.page}` : '';

const result = await apiGet(
addQueryArgs(`${wpUrl}${endpoints.yoast}`, {
url: `${wpUrl}${pageParam}/?s=${params.search ?? ''}${localeParam}`,
}),
{},
burstCache,
);

seo = result.json.html ?? null;
seo_json = { ...result.json.json };
} catch (e) {
// do nothing
}

const queriedObject: QueriedObject = {
search: {
searchedValue: params.search ?? '',
type: params.type ?? 'post',
subtype: params.subtype ?? 'post',
yoast_head: seo,
yoast_head_json: {
...seo_json,
},
},
};

const response = await super.fetcher(url, params, {
...options,
throwIfNotFound: false,
});

return {
...response,
queriedObject,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { SearchNativeFetchStrategy } from '../SearchNativeFetchStrategy';
import { setHeadstartWPConfig } from '../../../utils';

describe('SearchNativeFetchStrategy', () => {
let fetchStrategy: SearchNativeFetchStrategy;

beforeEach(() => {
fetchStrategy = new SearchNativeFetchStrategy();

setHeadstartWPConfig({});
});

it('parse url properly', async () => {
expect(fetchStrategy.getParamsFromURL('/')).toEqual({});

expect(fetchStrategy.getParamsFromURL('/searched-term')).toEqual({
search: 'searched-term',
});

expect(fetchStrategy.getParamsFromURL('/searched-term/page/2')).toEqual({
search: 'searched-term',
page: '2',
});
expect(fetchStrategy.getParamsFromURL('/searched-term/page/2/')).toEqual({
search: 'searched-term',
page: '2',
});
});

it('builds the endpoint url properly', () => {
expect(fetchStrategy.buildEndpointURL({})).toBe('/wp-json/wp/v2/search');
});

it('allows overriding default params', () => {
const defaultParams = { taxonomy: 'genre' };
const fetcher = new SearchNativeFetchStrategy('http://sourceurl.com', defaultParams);
expect(fetcher.getDefaultParams()).toMatchObject(defaultParams);
});
});
1 change: 1 addition & 0 deletions packages/core/src/data/strategies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './AbstractFetchStrategy';
export * from './SinglePostFetchStrategy';
export * from './PostsArchiveFetchStrategy';
export * from './SearchFetchStrategy';
export * from './SearchNativeFetchStrategy';
export * from './AppSettingsStrategy';
export * from './TaxonomyTermsStrategy';
export * from './AuthorArchiveFetchStrategy';
Expand Down
Loading

0 comments on commit 8452279

Please sign in to comment.