Skip to content

Commit

Permalink
feat(emptyStates): implements empty source template and renderEmpty m…
Browse files Browse the repository at this point in the history
…ethod (#395)

* Implements `empty` template and `renderEmpty` method

* Add wait function to `test/utils` folder

Co-authored-by: François Chalifour <francoischalifour@users.noreply.github.com>
  • Loading branch information
shortcuts and francoischalifour committed Jan 20, 2021
1 parent 326ced9 commit 8bd35e6
Show file tree
Hide file tree
Showing 19 changed files with 352 additions and 20 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
{
"path": "packages/autocomplete-js/dist/umd/index.production.js",
"maxSize": "10.1 kB"
"maxSize": "10.2 kB"
},
{
"path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js",
Expand Down
57 changes: 56 additions & 1 deletion examples/js/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { autocomplete } from '@algolia/autocomplete-js';
import {
autocomplete,
getAlgoliaHits,
reverseHighlightHit,
} from '@algolia/autocomplete-js';
import { createAlgoliaInsightsPlugin } from '@algolia/autocomplete-plugin-algolia-insights';
import { createQuerySuggestionsPlugin } from '@algolia/autocomplete-plugin-query-suggestions';
import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches';
Expand Down Expand Up @@ -36,4 +40,55 @@ autocomplete({
recentSearchesPlugin,
querySuggestionsPlugin,
],
getSources({ query }) {
if (!query) {
return [];
}

return [
{
getItems() {
return getAlgoliaHits({
searchClient,
queries: [{ indexName: 'instant_search', query }],
});
},
templates: {
item({ item, root }) {
const itemContent = document.createElement('div');
const ItemSourceIcon = document.createElement('div');
const itemTitle = document.createElement('div');
const sourceIcon = document.createElement('img');

sourceIcon.width = 20;
sourceIcon.height = 20;
sourceIcon.src = item.image;

ItemSourceIcon.classList.add('aa-ItemSourceIcon');
ItemSourceIcon.appendChild(sourceIcon);

itemTitle.innerHTML = reverseHighlightHit({
hit: item,
attribute: 'name',
});
itemTitle.classList.add('aa-ItemTitle');

itemContent.classList.add('aa-ItemContent');
itemContent.appendChild(ItemSourceIcon);
itemContent.appendChild(itemTitle);

root.appendChild(itemContent);
},
empty({ root }) {
const itemContent = document.createElement('div');

itemContent.innerHTML = 'No results for this query';
itemContent.classList.add('aa-ItemContent');

root.appendChild(itemContent);
},
},
},
];
},
});
9 changes: 3 additions & 6 deletions packages/autocomplete-core/src/getDefaultProps.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { getItemsCount } from '@algolia/autocomplete-shared';

import {
AutocompleteOptions,
BaseItem,
InternalAutocompleteOptions,
AutocompleteSubscribers,
} from './types';
import {
generateAutocompleteId,
getItemsCount,
getNormalizedSources,
flatten,
} from './utils';
import { generateAutocompleteId, getNormalizedSources, flatten } from './utils';

export function getDefaultProps<TItem extends BaseItem>(
props: AutocompleteOptions<TItem>,
Expand Down
4 changes: 2 additions & 2 deletions packages/autocomplete-core/src/stateReducer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { invariant } from '@algolia/autocomplete-shared';
import { getItemsCount, invariant } from '@algolia/autocomplete-shared';

import { getCompletion } from './getCompletion';
import { Reducer } from './types';
import { getItemsCount, getNextActiveItemId } from './utils';
import { getNextActiveItemId } from './utils';

export const stateReducer: Reducer = (state, action) => {
switch (action.type) {
Expand Down
1 change: 0 additions & 1 deletion packages/autocomplete-core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export * from './createConcurrentSafePromise';
export * from './flatten';
export * from './generateAutocompleteId';
export * from './getItemsCount';
export * from './getNextActiveItemId';
export * from './getNormalizedSources';
export * from './getActiveItem';
Expand Down
197 changes: 196 additions & 1 deletion packages/autocomplete-js/src/__tests__/autocomplete.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fireEvent } from '@testing-library/dom';
import { fireEvent, waitFor } from '@testing-library/dom';

import { wait } from '../../../../test/utils';
import { autocomplete } from '../autocomplete';

describe('autocomplete-js', () => {
Expand Down Expand Up @@ -156,6 +157,200 @@ describe('autocomplete-js', () => {
`);
});

test('renders empty template on no results', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

document.body.appendChild(panelContainer);
autocomplete<{ label: string }>({
container,
panelContainer,
getSources() {
return [
{
getItems() {
return [];
},
templates: {
item({ item }) {
return item.label;
},
empty() {
return 'No results template';
},
},
},
];
},
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');

fireEvent.input(input, {
target: { value: 'aasdjfaisdf' },
});
input.focus();

await waitFor(() => {
expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toBeInTheDocument();
});

expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toHaveTextContent('No results template');
});

test('calls renderEmpty without empty template on no results', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');
const renderEmpty = jest.fn(({ root }) => {
const div = document.createElement('div');
div.innerHTML = 'No results render';

root.appendChild(div);
});

document.body.appendChild(panelContainer);
autocomplete<{ label: string }>({
container,
panelContainer,
getSources() {
return [
{
getItems() {
return [];
},
templates: {
item({ item }) {
return item.label;
},
},
},
];
},
renderEmpty,
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');

fireEvent.input(input, {
target: { value: 'aasdjfaisdf' },
});
input.focus();

await waitFor(() => {
expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toBeInTheDocument();
});

expect(renderEmpty).toHaveBeenCalledWith({
root: expect.anything(),
state: expect.anything(),
sections: expect.anything(),
});

expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toHaveTextContent('No results render');
});

test('renders empty template over renderEmpty method on no results', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

document.body.appendChild(panelContainer);
autocomplete<{ label: string }>({
container,
panelContainer,
getSources() {
return [
{
getItems() {
return [];
},
templates: {
item({ item }) {
return item.label;
},
empty() {
return 'No results template';
},
},
},
];
},
renderEmpty({ root }) {
const div = document.createElement('div');
div.innerHTML = 'No results render';

root.appendChild(div);
},
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');

fireEvent.input(input, {
target: { value: 'aasdjfaisdf' },
});
input.focus();

await waitFor(() => {
expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toBeInTheDocument();
});

expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).toHaveTextContent('No results template');
});

test('allows user-provided shouldPanelShow', async () => {
const container = document.createElement('div');
const panelContainer = document.createElement('div');

document.body.appendChild(panelContainer);
autocomplete<{ label: string }>({
container,
panelContainer,
shouldPanelShow: () => false,
getSources() {
return [
{
getItems() {
return [
{ label: 'Item 1' },
{ label: 'Item 2' },
{ label: 'Item 3' },
];
},
templates: {
item({ item }) {
return item.label;
},
},
},
];
},
});

const input = container.querySelector<HTMLInputElement>('.aa-Input');

fireEvent.input(input, {
target: { value: 'aasdjfaisdf' },
});
input.focus();

await wait(50);

expect(
panelContainer.querySelector<HTMLElement>('.aa-Panel')
).not.toBeInTheDocument();
});

test('renders with autoFocus', () => {
const container = document.createElement('div');
autocomplete<{ label: string }>({
Expand Down
33 changes: 31 additions & 2 deletions packages/autocomplete-js/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {
BaseItem,
createAutocomplete,
} from '@algolia/autocomplete-core';
import { createRef, debounce, invariant } from '@algolia/autocomplete-shared';
import {
createRef,
debounce,
getItemsCount,
invariant,
} from '@algolia/autocomplete-shared';

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

const hasEmptySourceTemplateRef = createRef(true);
const optionsRef = createRef(options);
const onStateChangeRef = createRef<
AutocompleteOptions<TItem>['onStateChange']
Expand All @@ -37,8 +43,20 @@ export function autocomplete<TItem extends BaseItem>(
onStateChangeRef.current?.(options as any);
props.value.core.onStateChange?.(options as any);
},
shouldPanelShow:
optionsRef.current.shouldPanelShow ||
(({ state }) => {
const hasItems = getItemsCount(state) > 0;
const hasEmptyTemplate = Boolean(
hasEmptySourceTemplateRef.current ||
props.value.renderer.renderEmpty
);

return (!hasItems && hasEmptyTemplate) || hasItems;
}),
})
);

const renderRequestIdRef = createRef<number | null>(null);
const lastStateRef = createRef<AutocompleteState<TItem>>({
collections: [],
Expand All @@ -50,6 +68,7 @@ export function autocomplete<TItem extends BaseItem>(
status: 'idle',
...props.value.core.initialState,
});

const isTouch = reactive(
() => window.matchMedia(props.value.renderer.touchMediaQuery).matches
);
Expand Down Expand Up @@ -113,8 +132,18 @@ export function autocomplete<TItem extends BaseItem>(
autocompleteScopeApi,
};

hasEmptySourceTemplateRef.current = renderProps.state.collections.some(
(collection) => collection.source.templates.empty
);

const render =
(!getItemsCount(renderProps.state) &&
!hasEmptySourceTemplateRef.current &&
props.value.renderer.renderEmpty) ||
props.value.renderer.render;

renderSearchBox(renderProps);
renderPanel(props.value.renderer.render, renderProps);
renderPanel(render, renderProps);
}

function scheduleRender(state: AutocompleteState<TItem>) {
Expand Down

0 comments on commit 8bd35e6

Please sign in to comment.