Skip to content

Commit

Permalink
feat(hits): add banner to react instantsearch hits (#6170)
Browse files Browse the repository at this point in the history
* feat(hits): add banner to react instantsearch hits

* refactor(hits): avoid passing undefined bannerComponent

* refactor(hits): simplify forwarding banner data
  • Loading branch information
taylorcjohnson authored May 2, 2024
1 parent 1405aeb commit b3139ed
Show file tree
Hide file tree
Showing 2 changed files with 224 additions and 25 deletions.
23 changes: 21 additions & 2 deletions packages/react-instantsearch/src/widgets/Hits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import type { UseHitsProps } from 'react-instantsearch-core';

type UiProps<THit extends BaseHit> = Pick<
HitsUiComponentProps<Hit<THit>>,
'hits' | 'sendEvent' | 'itemComponent' | 'emptyComponent'
| 'hits'
| 'sendEvent'
| 'itemComponent'
| 'emptyComponent'
| 'banner'
| 'bannerComponent'
>;

export type HitsProps<THit extends BaseHit> = Omit<
Expand All @@ -23,6 +28,13 @@ export type HitsProps<THit extends BaseHit> = Omit<
hit: Hit<THit>;
sendEvent: SendEventForHits;
}>;
} & {
bannerComponent?:
| React.JSXElementConstructor<{
banner: Required<HitsUiComponentProps<Hit<THit>>>['banner'];
className: string;
}>
| false;
} & UseHitsProps<THit>;

// @MAJOR: Move default hit component back to the UI library
Expand All @@ -48,9 +60,10 @@ export function Hits<THit extends BaseHit = BaseHit>({
escapeHTML,
transformItems,
hitComponent: HitComponent = DefaultHitComponent,
bannerComponent: BannerComponent,
...props
}: HitsProps<THit>) {
const { hits, sendEvent } = useHits<THit>(
const { hits, banner, sendEvent } = useHits<THit>(
{ escapeHTML, transformItems },
{ $$widgetType: 'ais.hits' }
);
Expand All @@ -65,10 +78,16 @@ export function Hits<THit extends BaseHit = BaseHit>({
</li>
);

const bannerComponent = (
BannerComponent === false ? () => null : BannerComponent
) as HitsUiComponentProps<Hit<THit>>['bannerComponent'];

const uiProps: UiProps<THit> = {
hits,
sendEvent,
itemComponent,
banner,
bannerComponent,
};

return <HitsUiComponent {...props} {...uiProps} />;
Expand Down
226 changes: 203 additions & 23 deletions packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,57 @@ import { Hits } from '../Hits';
import type { MockSearchClient } from '@instantsearch/mocks';
import type { AlgoliaHit } from 'instantsearch.js';

describe('Hits', () => {
test('renders with a custom hit component', async () => {
type CustomRecord = {
somethingSpecial: string;
};

const searchClient = createSearchClient({
search: jest.fn((requests) =>
Promise.resolve(
createMultiSearchResponse(
...requests.map(
(request: Parameters<MockSearchClient['search']>[0][number]) =>
createSingleSearchResponse<AlgoliaHit<CustomRecord>>({
hits: [
{ objectID: '1', somethingSpecial: 'a' },
{ objectID: '2', somethingSpecial: 'b' },
{ objectID: '3', somethingSpecial: 'c' },
],
index: request.indexName,
})
)
type CustomRecord = {
somethingSpecial: string;
};

const hits = [
{ objectID: '1', somethingSpecial: 'a' },
{ objectID: '2', somethingSpecial: 'b' },
{ objectID: '3', somethingSpecial: 'c' },
];

const bannerWidgetRenderingContent = {
widgets: {
banners: [
{
image: {
urls: [{ url: 'https://via.placeholder.com/550x250' }],
},
link: {
url: 'https://www.algolia.com',
},
},
],
},
};

function getSearchClient(withBannerWidget = false) {
return createSearchClient({
search: jest.fn((requests) =>
Promise.resolve(
createMultiSearchResponse(
...requests.map(
(request: Parameters<MockSearchClient['search']>[0][number]) =>
createSingleSearchResponse<AlgoliaHit<CustomRecord>>({
hits,
index: request.indexName,
// @TODO: remove once algoliasearch js client has been updated
// @ts-expect-error
renderingContent: withBannerWidget
? bannerWidgetRenderingContent
: undefined,
})
)
)
) as MockSearchClient['search'],
});
)
) as MockSearchClient['search'],
});
}

describe('Hits', () => {
test('renders with a custom hit component', async () => {
const searchClient = getSearchClient();

const { container } = render(
<InstantSearchTestWrapper searchClient={searchClient}>
Expand Down Expand Up @@ -103,4 +129,158 @@ describe('Hits', () => {
expect(root).toHaveClass('MyHits', 'ROOT');
expect(root).toHaveAttribute('aria-hidden', 'true');
});

describe('banner', () => {
test('renders a banner', async () => {
const searchClient = getSearchClient(true);

const { container } = render(
<InstantSearchTestWrapper searchClient={searchClient}>
<Hits<CustomRecord> />
</InstantSearchTestWrapper>
);

await waitFor(() => {
expect(container.querySelectorAll('img')).toHaveLength(1);
expect(container.querySelectorAll('a')).toHaveLength(1);
expect(container.querySelector('.ais-Hits')).toMatchInlineSnapshot(`
<div
class="ais-Hits"
>
<aside
class="ais-Hits-banner"
>
<a
class="ais-Hits-banner-link"
href="https://www.algolia.com"
>
<img
class="ais-Hits-banner-image"
src="https://via.placeholder.com/550x250"
/>
</a>
</aside>
<ol
class="ais-Hits-list"
>
<li
class="ais-Hits-item"
>
<div
style="word-break: break-all;"
>
{"objectID":"1","somethingSpecial":"a","__position":1}
</div>
</li>
<li
class="ais-Hits-item"
>
<div
style="word-break: break-all;"
>
{"objectID":"2","somethingSpecial":"b","__position":2}
</div>
</li>
<li
class="ais-Hits-item"
>
<div
style="word-break: break-all;"
>
{"objectID":"3","somethingSpecial":"c","__position":3}
</div>
</li>
</ol>
</div>
`);
});
});

test('does not render a banner when "bannerComponent" is set to `false`', async () => {
const searchClient = getSearchClient(true);

const { container } = render(
<InstantSearchTestWrapper searchClient={searchClient}>
<Hits<CustomRecord> bannerComponent={false} />
</InstantSearchTestWrapper>
);

await waitFor(() => {
expect(container.querySelectorAll('img')).toHaveLength(0);
expect(container.querySelector('.ais-Hits')).toMatchInlineSnapshot(`
<div
class="ais-Hits ais-Hits--empty"
>
<ol
class="ais-Hits-list"
/>
</div>
`);
});
});

test('renders custom banner component', async () => {
const searchClient = getSearchClient(true);

const { container } = render(
<InstantSearchTestWrapper searchClient={searchClient}>
<Hits<CustomRecord>
bannerComponent={({ banner }) => (
<img src={banner.image.urls[0].url} />
)}
/>
</InstantSearchTestWrapper>
);

await waitFor(() => {
expect(container.querySelectorAll('img')).toHaveLength(1);
expect(container.querySelector('.ais-Hits')).toMatchInlineSnapshot(`
<div
class="ais-Hits"
>
<img
src="https://via.placeholder.com/550x250"
/>
<ol
class="ais-Hits-list"
>
<li
class="ais-Hits-item"
>
<div
style="word-break: break-all;"
>
{"objectID":"1","somethingSpecial":"a","__position":1}
</div>
</li>
<li
class="ais-Hits-item"
>
<div
style="word-break: break-all;"
>
{"objectID":"2","somethingSpecial":"b","__position":2}
</div>
</li>
<li
class="ais-Hits-item"
>
<div
style="word-break: break-all;"
>
{"objectID":"3","somethingSpecial":"c","__position":3}
</div>
</li>
</ol>
</div>
`);
});
});
});
});

0 comments on commit b3139ed

Please sign in to comment.