Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement WordPress native search endpoint #671

Merged
merged 19 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}` : '';
nicholasio marked this conversation as resolved.
Show resolved Hide resolved
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
Loading