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

Support customizing the template for search results in Instant Results #2959

Merged
merged 11 commits into from
Oct 11, 2022
1 change: 1 addition & 0 deletions .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"mappings": {
"wp-content/mu-plugins/unique-index-name.php": "./tests/cypress/wordpress-files/test-mu-plugins/unique-index-name.php",
"wp-content/plugins/cpt-and-custom-tax.php": "./tests/cypress/wordpress-files/test-plugins/cpt-and-custom-tax.php",
"wp-content/plugins/custom-instant-results-template.php": "./tests/cypress/wordpress-files/test-plugins/custom-instant-results-template.php",
"wp-content/plugins/elasticpress-facet-by-meta.php": "./tests/cypress/wordpress-files/test-plugins/elasticpress-facet-by-meta.php",
"wp-content/plugins/fake-new-activation.php": "./tests/cypress/wordpress-files/test-plugins/fake-new-activation.php",
"wp-content/plugins/unsupported-server-software.php": "./tests/cypress/wordpress-files/test-plugins/unsupported-server-software.php",
Expand Down
86 changes: 86 additions & 0 deletions assets/js/instant-results/components/common/result.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* WordPress dependencies.
*/
import { React, WPElement } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';

/**
* Internal dependencies.
*/
import StarRating from './star-rating';
import Image from './image';

/**
* Search result.
*
* @param {object} props Component props.
* @param {number} props.averageRating Average rating.
* @param {string} props.date Localized date.
* @param {string} props.excerpt Highlighted excerpt.
* @param {string} props.priceHtml Product price HTML.
* @param {object} props.thumbnail Thumbnail image attributes.
* @param {string} props.title Highlighted title.
* @param {string} props.type Type label.
* @param {string} props.url URL.
* @returns {WPElement} Component element.
*/
const Result = ({ averageRating = 0, date, excerpt, priceHtml, thumbnail, title, type, url }) => {
return (
<article
className={`ep-search-result ${thumbnail ? 'ep-search-result--has-thumbnail' : null}`}
>
{thumbnail && (
<a className="ep-search-result__thumbnail" href={url}>
<Image {...thumbnail} />
</a>
)}

<header className="ep-search-result__header">
{type ? <span className="ep-search-result__type">{type}</span> : null}

<h2 className="ep-search-result__title">
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<a
href={url}
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{ __html: title }}
/>
</h2>

{priceHtml ? (
<p
className="price"
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{
__html: priceHtml,
}}
/>
) : null}
</header>

{excerpt.length > 0 ? (
<p
className="ep-search-result__description"
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{ __html: excerpt }}
/>
) : null}

<footer className="ep-search-result__footer">
{averageRating > 0 ? <StarRating rating={averageRating} /> : null}
{date}
</footer>
</article>
);
};

/**
* Filter the Result component.
*
* @filter ep.InstantResults.Result
* @since 4.4.0
*
* @param {React.Component|React.FunctionComponent} Result Result component.
* @returns {React.Component|React.FunctionComponent} Result component.
*/
export default applyFilters('ep.InstantResults.Result', Result);
78 changes: 27 additions & 51 deletions assets/js/instant-results/components/results/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,73 +6,49 @@ import { WPElement } from '@wordpress/element';
/**
* Internal dependencies.
*/
import { postTypeLabels, isWooCommerce } from '../../config';
import { postTypeLabels } from '../../config';
import { formatDate } from '../../functions';
import StarRating from '../common/star-rating';
import Image from '../common/image';
import Result from '../common/result';

/**
* Search result.
*
* @param {object} props Component props.
* @param {object} props Component props.
* @param {object} props.hit Elasticsearch hit.
* @returns {WPElement} Component element.
*/
export default ({ hit }) => {
const {
highlight: { post_title: resultTitle, post_content_plain: resultContent = [] },
highlight: { post_title: title, post_content_plain: postContent = [] },
_source: {
meta: { _wc_average_rating: [{ value: resultRating = 0 } = {}] = [] },
post_date: resultDate,
permalink: resultPermalink,
post_type: resultPostType,
meta: { _wc_average_rating: [{ value: averageRating } = {}] = [] },
permalink: url,
post_date: postDate,
post_id: id,
post_type: postType,
price_html: priceHtml,
thumbnail: resultThumbnail = false,
thumbnail,
},
} = hit;

const postTypeLabel = postTypeLabels[resultPostType]?.singular;
const date = postType === 'post' ? formatDate(postDate) : null;
const excerpt = postContent.join('…');
const type = postTypeLabels[postType]?.singular;

return (
<article
className={`ep-search-result ${resultThumbnail && 'ep-search-result--has-thumbnail'}`}
>
{resultThumbnail && (
<a className="ep-search-result__thumbnail" href={resultPermalink}>
<Image {...resultThumbnail} />
</a>
)}

<header className="ep-search-result__header">
{postTypeLabel && <span className="ep-search-result__type">{postTypeLabel}</span>}

<h2 className="ep-search-result__title">
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<a
href={resultPermalink}
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{ __html: resultTitle }}
/>
</h2>

{isWooCommerce && priceHtml && (
// eslint-disable-next-line react/no-danger
<p className="price" dangerouslySetInnerHTML={{ __html: priceHtml }} />
)}
</header>

{resultContent.length > 0 && (
<p
className="ep-search-result__description"
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{ __html: resultContent.join('…') }}
/>
)}

<footer className="ep-search-result__footer">
{isWooCommerce && resultRating > 0 && <StarRating rating={resultRating} />}
{resultPostType === 'post' && formatDate(resultDate)}
</footer>
</article>
<Result
{...{
averageRating,
date,
hit,
excerpt,
id,
priceHtml,
thumbnail,
title,
type,
url,
}}
/>
);
};
123 changes: 122 additions & 1 deletion docs/theme-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,130 @@ const autosuggestQueryFilter = (query, searchText, input) => {

wp.hooks.addFilter('ep.Autosuggest.query', 'myTheme/autosuggestQueryFilter', autosuggestQueryFilter);
```

## Instant Results

### Customize the Template Used for Results

When ElasticPress Instant Results renders search results it does so using a [React component](https://reactjs.org/docs/components-and-props.html). You can replace this component with your own from within a theme or plugin using the `ep.InstantResults.Result` [JavaScript hook](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-hooks/).

The result component receives the following props that your component can use to render the result:

| prop | type | description
| --------------- | ------ | -----------------------------------------------
| `averageRating` | number | Average review rating for WooCommerce products.
| `date` | string | Localized date.
| `hit` | object | Full result from Elasticsearch.
| `excerpt` | string | Highlighted excerpt.
| `id` | string | Post ID.
| `priceHtml` | string | Price HTML for a WooCommerce product.
| `thumbnail` | object | Thumbnail image attributes.
| `title` | string | Highlighted title.
| `type` | string | Post type label.
| `url` | string | Post permalink.

This example replaces the result component with a component that renders results as just a simple linked title and date in a div:

**JSX**
```js
const CustomResult = ({ date, title, url }) => {
return (
<div className="my-custom-result">
<strong><a href={url}>{title}</a></strong> {date}
</div>
)
};

wp.hooks.addFilter('ep.InstantResults.Result', 'myTheme/customResult', () => CustomResult);
```
**Plain**
```js
const el = wp.element.createElement;

const CustomResult = ({ date, title, url }) => {
return el(
'div',
{
className: 'my-custom-result',
},
el(
'strong',
{},
el(
'a',
{ href: url },
title
),
),
' ',
date
);
};

wp.hooks.addFilter('ep.InstantResults.Result', 'myTheme/customResult', () => CustomResult);
```

To conditionally replace the component based on each result you can pass a simple component that checks the result before either rendering the original component or a new custom component. This example renders the custom component from above but only for results with the `post` post type:

**JSX**
```js
wp.hooks.addFilter('ep.InstantResults.Result', 'myTheme/customResultForPosts', (Result) => {
return (props) => {
if (props.hit._source.post_type === 'post') {
return <CustomResult {...props} />;
}

return <Result {...props} />;
};
});
```
**Plain**
```js
const el = wp.element.createElement;

wp.hooks.addFilter('ep.InstantResults.Result', 'myTheme/customResultForPosts', (Result) => {
return (props) => {
if (props.hit._source.post_type === 'post') {
return el(CustomResult, props);
}

return el(Result, props);
};
});
```

By returning a new component that wraps the original component you can customize the props that are passed to it. This example uses this approach to remove the post type label from results with the `page` post type:

**JSX**
```js
wp.hooks.addFilter('ep.InstantResults.Result', 'myTheme/noTypeLabelsForPages', (Result) => {
return (props) => {
if (props.hit._source.post_type === 'page') {
return <Result {...props} type={null} />;
}

return <Result {...props} />;
};
});
```
**Plain**
```js
const el = wp.element.createElement;

wp.hooks.addFilter('ep.InstantResults.Result', 'myTheme/noTypeLabelsForPages', (Result) => {
return (props) => {
if (props.hit._source.post_type === 'page') {
return el(Result, {...props, type: null});
}

return el(Result, props);
};
});
```

**Notes:**
- To take advantage of JavaScript hooks, make sure to set `wp-hooks` as a [dependency](https://developer.wordpress.org/reference/functions/wp_enqueue_script/#parameters) of your script.
- These examples use [JSX](https://reactjs.org/docs/introducing-jsx.html) to render for readability. Using JSX will require a build tool such as [@wordpress/scripts](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/) to compile it into a format that can be understood by the browser. To create a component without a build process you will need to use the more verbose `createElement` method of [@wordpress/element](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-element/).

### Styling Instant Results

The default styles for Instant Results are as minimal as they can be to allow Instant Results to reflect the active theme's design and styles as much as possible while maintaining the expected layout. However, you may still wish to add styles to your theme specifically for Instant Results. To help with styling, Instant Results supports several [custom CSS properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) that can be used to update certain recurring styles without needing to target multiple selectors. For other elements Instant Results uses [BEM syntax](https://css-tricks.com/bem-101/) to allow easier styling of recurring components.
Expand Down
27 changes: 27 additions & 0 deletions tests/cypress/integration/features/instant-results.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ describe('Instant Results Feature', () => {
title: 'Test Post',
content: 'This is a sample test post.',
});

cy.deactivatePlugin('custom-instant-results-template', 'wpCli');
});

after(() => {
Expand Down Expand Up @@ -141,4 +143,29 @@ describe('Instant Results Feature', () => {
cy.get('#wpadminbar li#wp-admin-bar-debug-bar').click();
cy.get('#querylist').should('be.visible');
});

it('Can filter the result template', () => {
/**
* Activate test plugin with filter.
*/
cy.maybeEnableFeature('instant-results');
cy.activatePlugin('custom-instant-results-template', 'wpCli');

/**
* Perform a search.
*/
cy.intercept('*search=blog*').as('apiRequest');
cy.visit('/');
cy.get('.wp-block-search').last().as('searchBlock');
cy.get('@searchBlock').find('input[type="search"]').type('blog');
cy.get('@searchBlock').find('button').click();
cy.get('.ep-search-modal').as('searchModal').should('be.visible');
cy.wait('@apiRequest');

/**
* Results should use the filtered template with a custom class.
*/
cy.get('.my-custom-result').should('exist');
cy.get('.ep-search-result').should('not.exist');
});
});
Loading