Skip to content

Commit ce89722

Browse files
authored
feat(search): add locale and facet support (#557)
* add locale support * add facet support * address PR review feedback: fix tests, ESLint, add hook tests, add changeset
1 parent 33dddbc commit ce89722

11 files changed

Lines changed: 547 additions & 13 deletions

.changeset/search-locale-facets.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@sitecore-content-sdk/search': minor
3+
'@sitecore-content-sdk/react': minor
4+
---
5+
6+
Add locale and facet support to search package and React hooks
7+
8+
- `SearchParameters` now accepts an optional `locale` field for multi-locale index configurations
9+
- `SearchParameters` now accepts an optional `facet` field (`FacetRequest`) to request facet counts and filter by facet values
10+
- `SearchResponse` now includes an optional `facets` field (`FacetResult[]`) with facet data when requested
11+
- Six new public types exported from `@sitecore-content-sdk/search`: `FacetRequest`, `FacetField`, `FacetFilter`, `FacetFilterOperator`, `FacetValue`, `FacetResult`
12+
- `useSearch` and `useInfiniteSearch` hooks in `@sitecore-content-sdk/react` updated to support the new `locale` and `facet` options and expose `facets` in the returned state

packages/react/api/content-sdk-react.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { EditMode } from '@sitecore-content-sdk/content/layout';
2020
import { enableDebug } from '@sitecore-content-sdk/core';
2121
import { EnhancedOmit } from '@sitecore-content-sdk/core/tools';
2222
import { ErrorPage } from '@sitecore-content-sdk/content/client';
23+
import { FacetRequest } from '@sitecore-content-sdk/search';
24+
import { FacetResult } from '@sitecore-content-sdk/search';
2325
import * as FEAAS from '@sitecore-feaas/clientside/react';
2426
import { Field } from '@sitecore-content-sdk/content/layout';
2527
import { FieldMetadata } from '@sitecore-content-sdk/content/layout';
@@ -484,6 +486,8 @@ export const useInfiniteSearch: <T extends SearchDocument = SearchDocument>(opti
484486
// @public
485487
export interface UseInfiniteSearchOptions<T extends SearchDocument = SearchDocument> {
486488
enabled?: boolean;
489+
facet?: FacetRequest;
490+
locale?: string;
487491
pageSize?: number;
488492
query?: string;
489493
searchIndexId: string;
@@ -509,7 +513,9 @@ export const useSearch: <T extends SearchDocument = SearchDocument>(options: Use
509513
// @public
510514
export interface UseSearchOptions<T extends SearchDocument = SearchDocument> {
511515
enabled?: boolean;
516+
facet?: FacetRequest;
512517
keepPreviousData?: boolean;
518+
locale?: string;
513519
page?: number;
514520
pageSize?: number;
515521
query?: string;

packages/react/src/search/useInfiniteSearch.test.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
44
import { expect } from 'chai';
55
import { render, waitFor, fireEvent, RenderResult } from '@testing-library/react';
66
import { createSandbox, SinonSandbox, SinonStub } from 'sinon';
7-
import { SearchService, SortSetting } from '@sitecore-content-sdk/search';
7+
import { SearchService, SortSetting, FacetRequest } from '@sitecore-content-sdk/search';
88
import {
99
SitecoreProviderReactContext,
1010
SitecoreProviderState,
@@ -886,4 +886,87 @@ describe('useInfiniteSearch', () => {
886886

887887
expect(searchServiceStub.calledTwice).to.be.true;
888888
});
889+
890+
it('should pass locale to the search service when provided', async () => {
891+
const TestComponent: React.FC<any> = () => {
892+
const state = useInfiniteSearch<Model>({
893+
searchIndexId: '1234567890',
894+
query: 'test',
895+
locale: 'fr-FR',
896+
});
897+
898+
return renderState(state);
899+
};
900+
901+
searchServiceStub.resolves({ results: [{ id: 1 }], total: 1 });
902+
903+
render(
904+
<SitecoreProviderReactContext.Provider value={defaultProviderState}>
905+
<TestComponent />
906+
</SitecoreProviderReactContext.Provider>
907+
);
908+
909+
await waitFor(() => {
910+
expect(
911+
searchServiceStub.calledOnceWith({
912+
searchIndexId: '1234567890',
913+
keyphrase: 'test',
914+
limit: 10,
915+
offset: 0,
916+
sort: undefined,
917+
locale: 'fr-FR',
918+
})
919+
).to.be.true;
920+
});
921+
});
922+
923+
it('should pass facet config to the search service and return facets in state', async () => {
924+
const facetConfig: FacetRequest = { all: true };
925+
926+
const TestComponent: React.FC<any> = () => {
927+
const state = useInfiniteSearch<Model>({
928+
searchIndexId: '1234567890',
929+
query: 'test',
930+
facet: facetConfig,
931+
});
932+
933+
return (
934+
<>
935+
{renderState(state)}
936+
<span id="facetName">{state.facets?.[0]?.name ?? 'none'}</span>
937+
<span id="facetValue">{state.facets?.[0]?.value?.[0]?.text?.toString() ?? 'none'}</span>
938+
</>
939+
);
940+
};
941+
942+
searchServiceStub.resolves({
943+
results: [{ id: 1 }],
944+
total: 1,
945+
facets: [{ name: 'Category', value: [{ text: 'Running', count: 5 }] }],
946+
});
947+
948+
const wrapper = render(
949+
<SitecoreProviderReactContext.Provider value={defaultProviderState}>
950+
<TestComponent />
951+
</SitecoreProviderReactContext.Provider>
952+
);
953+
954+
await waitFor(() => {
955+
expect(
956+
searchServiceStub.calledOnceWith({
957+
searchIndexId: '1234567890',
958+
keyphrase: 'test',
959+
limit: 10,
960+
offset: 0,
961+
sort: undefined,
962+
facet: facetConfig,
963+
})
964+
).to.be.true;
965+
});
966+
967+
await waitFor(() => {
968+
expect(wrapper.container.querySelector('#facetName')?.textContent).equal('Category');
969+
expect(wrapper.container.querySelector('#facetValue')?.textContent).equal('Running');
970+
});
971+
});
889972
});

packages/react/src/search/useInfiniteSearch.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable no-unused-vars */
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
import { DEFAULT_PAGE_SIZE, SearchStatus, useSearchService } from './utils';
4-
import { SearchDocument, SearchParameters } from '@sitecore-content-sdk/search';
4+
import { SearchDocument, SearchParameters, FacetRequest, FacetResult } from '@sitecore-content-sdk/search';
55

66
/**
77
* Options for the useInfiniteSearch hook.
@@ -31,6 +31,17 @@ export interface UseInfiniteSearchOptions<T extends SearchDocument = SearchDocum
3131
* @default true
3232
*/
3333
enabled?: boolean;
34+
/**
35+
* The locale to use for the search. Required for multi-locale index configurations.
36+
* Format: letters and hyphens only (e.g. 'en', 'fr-FR', 'el-GR').
37+
* Omit for single-locale indexes.
38+
*/
39+
locale?: string;
40+
/**
41+
* Facet configuration. Use 'all: true' to retrieve counts for all enabled facets.
42+
* Use 'fields' to filter results by specific facet values. Both can be combined.
43+
*/
44+
facet?: FacetRequest;
3445
}
3546

3647
/**
@@ -84,6 +95,10 @@ type InternalInfiniteSearchState<T extends SearchDocument = SearchDocument> = {
8495
* Total number of pages available based on `total` and `pageSize`.
8596
*/
8697
totalPages: number;
98+
/**
99+
* Facet results, present only when facets were requested.
100+
*/
101+
facets?: FacetResult[];
87102
/**
88103
* The error object if the last search request failed, or null if no error occurred.
89104
*/
@@ -123,7 +138,7 @@ type InternalInfiniteSearchState<T extends SearchDocument = SearchDocument> = {
123138
export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
124139
options: UseInfiniteSearchOptions<T>
125140
): UseInfiniteSearchState<T> => {
126-
const { query, searchIndexId, pageSize = DEFAULT_PAGE_SIZE, sort, enabled = true } = options;
141+
const { query, searchIndexId, pageSize = DEFAULT_PAGE_SIZE, sort, enabled = true, locale, facet } = options;
127142

128143
const [state, setState] = useState<InternalInfiniteSearchState<T>>(() => {
129144
const error = !searchIndexId
@@ -136,6 +151,7 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
136151
results: [],
137152
total: 0,
138153
totalPages: 0,
154+
facets: undefined,
139155
error,
140156
offset: 0,
141157
status,
@@ -171,6 +187,7 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
171187
results: [],
172188
total: 0,
173189
totalPages: 0,
190+
facets: undefined,
174191
error: null,
175192
offset: 0,
176193
status: 'loading',
@@ -185,9 +202,11 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
185202
limit: pageSize,
186203
offset,
187204
sort,
205+
...(locale !== undefined && { locale }),
206+
...(facet !== undefined && { facet }),
188207
};
189208

190-
const { results: searchResults, total: totalResults } = await searchService.search<T>(
209+
const { results: searchResults, total: totalResults, facets } = await searchService.search<T>(
191210
searchParams,
192211
{ signal }
193212
);
@@ -201,6 +220,7 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
201220
const totalPages = Math.ceil(totalResults / pageSize);
202221
return {
203222
results,
223+
facets,
204224
error: null,
205225
offset,
206226
status: 'success',
@@ -229,6 +249,7 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
229249
results: [],
230250
total: 0,
231251
totalPages: 0,
252+
facets: undefined,
232253
error: errorMessage,
233254
offset: 0,
234255
status: 'error',
@@ -237,7 +258,7 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
237258
}
238259
}
239260
},
240-
[searchService, pageSize, sort, query, searchIndexId]
261+
[searchService, pageSize, sort, query, searchIndexId, locale, facet]
241262
);
242263

243264
useEffect(() => {
@@ -264,6 +285,7 @@ export const useInfiniteSearch = <T extends SearchDocument = SearchDocument>(
264285
return useMemo(
265286
(): UseInfiniteSearchState<T> => ({
266287
results: state.results,
288+
facets: state.facets,
267289
status: state.status,
268290
loadMoreStatus: state.loadMoreStatus,
269291
total: state.total,

packages/react/src/search/useSearch.test.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
44
import { expect } from 'chai';
55
import { render, waitFor, fireEvent, RenderResult } from '@testing-library/react';
66
import { createSandbox, SinonSandbox, SinonStub } from 'sinon';
7-
import { SearchService, SortSetting } from '@sitecore-content-sdk/search';
7+
import { SearchService, SortSetting, FacetRequest } from '@sitecore-content-sdk/search';
88
import {
99
SitecoreProviderReactContext,
1010
SitecoreProviderState,
@@ -855,4 +855,87 @@ describe('useSearch', () => {
855855
isPreviousData: false,
856856
});
857857
});
858+
859+
it('should pass locale to the search service when provided', async () => {
860+
const TestComponent: React.FC<any> = () => {
861+
const state = useSearch<Model>({
862+
searchIndexId: '1234567890',
863+
query: 'test',
864+
locale: 'fr-FR',
865+
});
866+
867+
return renderState(state);
868+
};
869+
870+
searchServiceStub.resolves({ results: [{ id: 1 }], total: 1 });
871+
872+
render(
873+
<SitecoreProviderReactContext.Provider value={defaultProviderState}>
874+
<TestComponent />
875+
</SitecoreProviderReactContext.Provider>
876+
);
877+
878+
await waitFor(() => {
879+
expect(
880+
searchServiceStub.calledOnceWith({
881+
searchIndexId: '1234567890',
882+
keyphrase: 'test',
883+
limit: 10,
884+
offset: 0,
885+
sort: undefined,
886+
locale: 'fr-FR',
887+
})
888+
).to.be.true;
889+
});
890+
});
891+
892+
it('should pass facet config to the search service and return facets in state', async () => {
893+
const facetConfig: FacetRequest = { all: true };
894+
895+
const TestComponent: React.FC<any> = () => {
896+
const state = useSearch<Model>({
897+
searchIndexId: '1234567890',
898+
query: 'test',
899+
facet: facetConfig,
900+
});
901+
902+
return (
903+
<>
904+
{renderState(state)}
905+
<span id="facetName">{state.facets?.[0]?.name ?? 'none'}</span>
906+
<span id="facetValue">{state.facets?.[0]?.value?.[0]?.text?.toString() ?? 'none'}</span>
907+
</>
908+
);
909+
};
910+
911+
searchServiceStub.resolves({
912+
results: [{ id: 1 }],
913+
total: 1,
914+
facets: [{ name: 'Category', value: [{ text: 'Running', count: 5 }] }],
915+
});
916+
917+
const wrapper = render(
918+
<SitecoreProviderReactContext.Provider value={defaultProviderState}>
919+
<TestComponent />
920+
</SitecoreProviderReactContext.Provider>
921+
);
922+
923+
await waitFor(() => {
924+
expect(
925+
searchServiceStub.calledOnceWith({
926+
searchIndexId: '1234567890',
927+
keyphrase: 'test',
928+
limit: 10,
929+
offset: 0,
930+
sort: undefined,
931+
facet: facetConfig,
932+
})
933+
).to.be.true;
934+
});
935+
936+
await waitFor(() => {
937+
expect(wrapper.container.querySelector('#facetName')?.textContent).equal('Category');
938+
expect(wrapper.container.querySelector('#facetValue')?.textContent).equal('Running');
939+
});
940+
});
858941
});

0 commit comments

Comments
 (0)