Skip to content

Commit 8bd35e6

Browse files
feat(emptyStates): implements empty source template and renderEmpty method (#395)
* Implements `empty` template and `renderEmpty` method * Add wait function to `test/utils` folder Co-authored-by: François Chalifour <[email protected]>
1 parent 326ced9 commit 8bd35e6

File tree

19 files changed

+352
-20
lines changed

19 files changed

+352
-20
lines changed

bundlesize.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
},
77
{
88
"path": "packages/autocomplete-js/dist/umd/index.production.js",
9-
"maxSize": "10.1 kB"
9+
"maxSize": "10.2 kB"
1010
},
1111
{
1212
"path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js",

examples/js/app.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { autocomplete } from '@algolia/autocomplete-js';
1+
import {
2+
autocomplete,
3+
getAlgoliaHits,
4+
reverseHighlightHit,
5+
} from '@algolia/autocomplete-js';
26
import { createAlgoliaInsightsPlugin } from '@algolia/autocomplete-plugin-algolia-insights';
37
import { createQuerySuggestionsPlugin } from '@algolia/autocomplete-plugin-query-suggestions';
48
import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches';
@@ -36,4 +40,55 @@ autocomplete({
3640
recentSearchesPlugin,
3741
querySuggestionsPlugin,
3842
],
43+
getSources({ query }) {
44+
if (!query) {
45+
return [];
46+
}
47+
48+
return [
49+
{
50+
getItems() {
51+
return getAlgoliaHits({
52+
searchClient,
53+
queries: [{ indexName: 'instant_search', query }],
54+
});
55+
},
56+
templates: {
57+
item({ item, root }) {
58+
const itemContent = document.createElement('div');
59+
const ItemSourceIcon = document.createElement('div');
60+
const itemTitle = document.createElement('div');
61+
const sourceIcon = document.createElement('img');
62+
63+
sourceIcon.width = 20;
64+
sourceIcon.height = 20;
65+
sourceIcon.src = item.image;
66+
67+
ItemSourceIcon.classList.add('aa-ItemSourceIcon');
68+
ItemSourceIcon.appendChild(sourceIcon);
69+
70+
itemTitle.innerHTML = reverseHighlightHit({
71+
hit: item,
72+
attribute: 'name',
73+
});
74+
itemTitle.classList.add('aa-ItemTitle');
75+
76+
itemContent.classList.add('aa-ItemContent');
77+
itemContent.appendChild(ItemSourceIcon);
78+
itemContent.appendChild(itemTitle);
79+
80+
root.appendChild(itemContent);
81+
},
82+
empty({ root }) {
83+
const itemContent = document.createElement('div');
84+
85+
itemContent.innerHTML = 'No results for this query';
86+
itemContent.classList.add('aa-ItemContent');
87+
88+
root.appendChild(itemContent);
89+
},
90+
},
91+
},
92+
];
93+
},
3994
});

packages/autocomplete-core/src/getDefaultProps.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1+
import { getItemsCount } from '@algolia/autocomplete-shared';
2+
13
import {
24
AutocompleteOptions,
35
BaseItem,
46
InternalAutocompleteOptions,
57
AutocompleteSubscribers,
68
} from './types';
7-
import {
8-
generateAutocompleteId,
9-
getItemsCount,
10-
getNormalizedSources,
11-
flatten,
12-
} from './utils';
9+
import { generateAutocompleteId, getNormalizedSources, flatten } from './utils';
1310

1411
export function getDefaultProps<TItem extends BaseItem>(
1512
props: AutocompleteOptions<TItem>,

packages/autocomplete-core/src/stateReducer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { invariant } from '@algolia/autocomplete-shared';
1+
import { getItemsCount, invariant } from '@algolia/autocomplete-shared';
22

33
import { getCompletion } from './getCompletion';
44
import { Reducer } from './types';
5-
import { getItemsCount, getNextActiveItemId } from './utils';
5+
import { getNextActiveItemId } from './utils';
66

77
export const stateReducer: Reducer = (state, action) => {
88
switch (action.type) {

packages/autocomplete-core/src/utils/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export * from './createConcurrentSafePromise';
22
export * from './flatten';
33
export * from './generateAutocompleteId';
4-
export * from './getItemsCount';
54
export * from './getNextActiveItemId';
65
export * from './getNormalizedSources';
76
export * from './getActiveItem';

packages/autocomplete-js/src/__tests__/autocomplete.test.ts

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { fireEvent } from '@testing-library/dom';
1+
import { fireEvent, waitFor } from '@testing-library/dom';
22

3+
import { wait } from '../../../../test/utils';
34
import { autocomplete } from '../autocomplete';
45

56
describe('autocomplete-js', () => {
@@ -156,6 +157,200 @@ describe('autocomplete-js', () => {
156157
`);
157158
});
158159

160+
test('renders empty template on no results', async () => {
161+
const container = document.createElement('div');
162+
const panelContainer = document.createElement('div');
163+
164+
document.body.appendChild(panelContainer);
165+
autocomplete<{ label: string }>({
166+
container,
167+
panelContainer,
168+
getSources() {
169+
return [
170+
{
171+
getItems() {
172+
return [];
173+
},
174+
templates: {
175+
item({ item }) {
176+
return item.label;
177+
},
178+
empty() {
179+
return 'No results template';
180+
},
181+
},
182+
},
183+
];
184+
},
185+
});
186+
187+
const input = container.querySelector<HTMLInputElement>('.aa-Input');
188+
189+
fireEvent.input(input, {
190+
target: { value: 'aasdjfaisdf' },
191+
});
192+
input.focus();
193+
194+
await waitFor(() => {
195+
expect(
196+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
197+
).toBeInTheDocument();
198+
});
199+
200+
expect(
201+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
202+
).toHaveTextContent('No results template');
203+
});
204+
205+
test('calls renderEmpty without empty template on no results', async () => {
206+
const container = document.createElement('div');
207+
const panelContainer = document.createElement('div');
208+
const renderEmpty = jest.fn(({ root }) => {
209+
const div = document.createElement('div');
210+
div.innerHTML = 'No results render';
211+
212+
root.appendChild(div);
213+
});
214+
215+
document.body.appendChild(panelContainer);
216+
autocomplete<{ label: string }>({
217+
container,
218+
panelContainer,
219+
getSources() {
220+
return [
221+
{
222+
getItems() {
223+
return [];
224+
},
225+
templates: {
226+
item({ item }) {
227+
return item.label;
228+
},
229+
},
230+
},
231+
];
232+
},
233+
renderEmpty,
234+
});
235+
236+
const input = container.querySelector<HTMLInputElement>('.aa-Input');
237+
238+
fireEvent.input(input, {
239+
target: { value: 'aasdjfaisdf' },
240+
});
241+
input.focus();
242+
243+
await waitFor(() => {
244+
expect(
245+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
246+
).toBeInTheDocument();
247+
});
248+
249+
expect(renderEmpty).toHaveBeenCalledWith({
250+
root: expect.anything(),
251+
state: expect.anything(),
252+
sections: expect.anything(),
253+
});
254+
255+
expect(
256+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
257+
).toHaveTextContent('No results render');
258+
});
259+
260+
test('renders empty template over renderEmpty method on no results', async () => {
261+
const container = document.createElement('div');
262+
const panelContainer = document.createElement('div');
263+
264+
document.body.appendChild(panelContainer);
265+
autocomplete<{ label: string }>({
266+
container,
267+
panelContainer,
268+
getSources() {
269+
return [
270+
{
271+
getItems() {
272+
return [];
273+
},
274+
templates: {
275+
item({ item }) {
276+
return item.label;
277+
},
278+
empty() {
279+
return 'No results template';
280+
},
281+
},
282+
},
283+
];
284+
},
285+
renderEmpty({ root }) {
286+
const div = document.createElement('div');
287+
div.innerHTML = 'No results render';
288+
289+
root.appendChild(div);
290+
},
291+
});
292+
293+
const input = container.querySelector<HTMLInputElement>('.aa-Input');
294+
295+
fireEvent.input(input, {
296+
target: { value: 'aasdjfaisdf' },
297+
});
298+
input.focus();
299+
300+
await waitFor(() => {
301+
expect(
302+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
303+
).toBeInTheDocument();
304+
});
305+
306+
expect(
307+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
308+
).toHaveTextContent('No results template');
309+
});
310+
311+
test('allows user-provided shouldPanelShow', async () => {
312+
const container = document.createElement('div');
313+
const panelContainer = document.createElement('div');
314+
315+
document.body.appendChild(panelContainer);
316+
autocomplete<{ label: string }>({
317+
container,
318+
panelContainer,
319+
shouldPanelShow: () => false,
320+
getSources() {
321+
return [
322+
{
323+
getItems() {
324+
return [
325+
{ label: 'Item 1' },
326+
{ label: 'Item 2' },
327+
{ label: 'Item 3' },
328+
];
329+
},
330+
templates: {
331+
item({ item }) {
332+
return item.label;
333+
},
334+
},
335+
},
336+
];
337+
},
338+
});
339+
340+
const input = container.querySelector<HTMLInputElement>('.aa-Input');
341+
342+
fireEvent.input(input, {
343+
target: { value: 'aasdjfaisdf' },
344+
});
345+
input.focus();
346+
347+
await wait(50);
348+
349+
expect(
350+
panelContainer.querySelector<HTMLElement>('.aa-Panel')
351+
).not.toBeInTheDocument();
352+
});
353+
159354
test('renders with autoFocus', () => {
160355
const container = document.createElement('div');
161356
autocomplete<{ label: string }>({

packages/autocomplete-js/src/autocomplete.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import {
33
BaseItem,
44
createAutocomplete,
55
} from '@algolia/autocomplete-core';
6-
import { createRef, debounce, invariant } from '@algolia/autocomplete-shared';
6+
import {
7+
createRef,
8+
debounce,
9+
getItemsCount,
10+
invariant,
11+
} from '@algolia/autocomplete-shared';
712

813
import { createAutocompleteDom } from './createAutocompleteDom';
914
import { createEffectWrapper } from './createEffectWrapper';
@@ -25,6 +30,7 @@ export function autocomplete<TItem extends BaseItem>(
2530
const { runEffect, cleanupEffects, runEffects } = createEffectWrapper();
2631
const { reactive, runReactives } = createReactiveWrapper();
2732

33+
const hasEmptySourceTemplateRef = createRef(true);
2834
const optionsRef = createRef(options);
2935
const onStateChangeRef = createRef<
3036
AutocompleteOptions<TItem>['onStateChange']
@@ -37,8 +43,20 @@ export function autocomplete<TItem extends BaseItem>(
3743
onStateChangeRef.current?.(options as any);
3844
props.value.core.onStateChange?.(options as any);
3945
},
46+
shouldPanelShow:
47+
optionsRef.current.shouldPanelShow ||
48+
(({ state }) => {
49+
const hasItems = getItemsCount(state) > 0;
50+
const hasEmptyTemplate = Boolean(
51+
hasEmptySourceTemplateRef.current ||
52+
props.value.renderer.renderEmpty
53+
);
54+
55+
return (!hasItems && hasEmptyTemplate) || hasItems;
56+
}),
4057
})
4158
);
59+
4260
const renderRequestIdRef = createRef<number | null>(null);
4361
const lastStateRef = createRef<AutocompleteState<TItem>>({
4462
collections: [],
@@ -50,6 +68,7 @@ export function autocomplete<TItem extends BaseItem>(
5068
status: 'idle',
5169
...props.value.core.initialState,
5270
});
71+
5372
const isTouch = reactive(
5473
() => window.matchMedia(props.value.renderer.touchMediaQuery).matches
5574
);
@@ -113,8 +132,18 @@ export function autocomplete<TItem extends BaseItem>(
113132
autocompleteScopeApi,
114133
};
115134

135+
hasEmptySourceTemplateRef.current = renderProps.state.collections.some(
136+
(collection) => collection.source.templates.empty
137+
);
138+
139+
const render =
140+
(!getItemsCount(renderProps.state) &&
141+
!hasEmptySourceTemplateRef.current &&
142+
props.value.renderer.renderEmpty) ||
143+
props.value.renderer.render;
144+
116145
renderSearchBox(renderProps);
117-
renderPanel(props.value.renderer.render, renderProps);
146+
renderPanel(render, renderProps);
118147
}
119148

120149
function scheduleRender(state: AutocompleteState<TItem>) {

0 commit comments

Comments
 (0)