From 8bd35e6c186f1a4398108abd76dbd006c2b734b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Vannicatte?= <20689156+shortcuts@users.noreply.github.com> Date: Wed, 20 Jan 2021 13:43:58 +0100 Subject: [PATCH] feat(emptyStates): implements empty source template and renderEmpty method (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implements `empty` template and `renderEmpty` method * Add wait function to `test/utils` folder Co-authored-by: François Chalifour --- bundlesize.config.json | 2 +- examples/js/app.ts | 57 ++++- .../autocomplete-core/src/getDefaultProps.ts | 9 +- .../autocomplete-core/src/stateReducer.ts | 4 +- packages/autocomplete-core/src/utils/index.ts | 1 - .../src/__tests__/autocomplete.test.ts | 197 +++++++++++++++++- packages/autocomplete-js/src/autocomplete.ts | 33 ++- .../autocomplete-js/src/getDefaultOptions.ts | 3 + packages/autocomplete-js/src/render.ts | 11 + .../src/types/AutocompleteClassNames.ts | 1 + .../src/types/AutocompleteOptions.ts | 2 +- .../src/types/AutocompleteSource.ts | 8 + .../src}/__tests__/getItemsCount.test.ts | 2 +- .../src}/getItemsCount.ts | 6 +- packages/autocomplete-shared/src/index.ts | 2 +- packages/website/docs/autocomplete-js.md | 20 ++ packages/website/docs/sources.md | 8 + test/utils/index.ts | 1 + test/utils/wait.ts | 5 + 19 files changed, 352 insertions(+), 20 deletions(-) rename packages/{autocomplete-core/src/utils => autocomplete-shared/src}/__tests__/getItemsCount.test.ts (91%) rename packages/{autocomplete-core/src/utils => autocomplete-shared/src}/getItemsCount.ts (60%) create mode 100644 test/utils/wait.ts diff --git a/bundlesize.config.json b/bundlesize.config.json index 3600617cb..c437ffbab 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -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", diff --git a/examples/js/app.ts b/examples/js/app.ts index 82e36304a..14402393e 100644 --- a/examples/js/app.ts +++ b/examples/js/app.ts @@ -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'; @@ -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); + }, + }, + }, + ]; + }, }); diff --git a/packages/autocomplete-core/src/getDefaultProps.ts b/packages/autocomplete-core/src/getDefaultProps.ts index d43347fe0..69eb4657a 100644 --- a/packages/autocomplete-core/src/getDefaultProps.ts +++ b/packages/autocomplete-core/src/getDefaultProps.ts @@ -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( props: AutocompleteOptions, diff --git a/packages/autocomplete-core/src/stateReducer.ts b/packages/autocomplete-core/src/stateReducer.ts index 74951d0cc..0bc3ae730 100644 --- a/packages/autocomplete-core/src/stateReducer.ts +++ b/packages/autocomplete-core/src/stateReducer.ts @@ -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) { diff --git a/packages/autocomplete-core/src/utils/index.ts b/packages/autocomplete-core/src/utils/index.ts index 8c9be18d2..c5d499066 100644 --- a/packages/autocomplete-core/src/utils/index.ts +++ b/packages/autocomplete-core/src/utils/index.ts @@ -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'; diff --git a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts index 1ee83b137..e52ebec57 100644 --- a/packages/autocomplete-js/src/__tests__/autocomplete.test.ts +++ b/packages/autocomplete-js/src/__tests__/autocomplete.test.ts @@ -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', () => { @@ -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('.aa-Input'); + + fireEvent.input(input, { + target: { value: 'aasdjfaisdf' }, + }); + input.focus(); + + await waitFor(() => { + expect( + panelContainer.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + expect( + panelContainer.querySelector('.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('.aa-Input'); + + fireEvent.input(input, { + target: { value: 'aasdjfaisdf' }, + }); + input.focus(); + + await waitFor(() => { + expect( + panelContainer.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + expect(renderEmpty).toHaveBeenCalledWith({ + root: expect.anything(), + state: expect.anything(), + sections: expect.anything(), + }); + + expect( + panelContainer.querySelector('.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('.aa-Input'); + + fireEvent.input(input, { + target: { value: 'aasdjfaisdf' }, + }); + input.focus(); + + await waitFor(() => { + expect( + panelContainer.querySelector('.aa-Panel') + ).toBeInTheDocument(); + }); + + expect( + panelContainer.querySelector('.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('.aa-Input'); + + fireEvent.input(input, { + target: { value: 'aasdjfaisdf' }, + }); + input.focus(); + + await wait(50); + + expect( + panelContainer.querySelector('.aa-Panel') + ).not.toBeInTheDocument(); + }); + test('renders with autoFocus', () => { const container = document.createElement('div'); autocomplete<{ label: string }>({ diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index b83021a3a..5fdc0119c 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -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'; @@ -25,6 +30,7 @@ export function autocomplete( const { runEffect, cleanupEffects, runEffects } = createEffectWrapper(); const { reactive, runReactives } = createReactiveWrapper(); + const hasEmptySourceTemplateRef = createRef(true); const optionsRef = createRef(options); const onStateChangeRef = createRef< AutocompleteOptions['onStateChange'] @@ -37,8 +43,20 @@ export function autocomplete( 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(null); const lastStateRef = createRef>({ collections: [], @@ -50,6 +68,7 @@ export function autocomplete( status: 'idle', ...props.value.core.initialState, }); + const isTouch = reactive( () => window.matchMedia(props.value.renderer.touchMediaQuery).matches ); @@ -113,8 +132,18 @@ export function autocomplete( 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) { diff --git a/packages/autocomplete-js/src/getDefaultOptions.ts b/packages/autocomplete-js/src/getDefaultOptions.ts index 6fff5989e..e9f662ed1 100644 --- a/packages/autocomplete-js/src/getDefaultOptions.ts +++ b/packages/autocomplete-js/src/getDefaultOptions.ts @@ -21,6 +21,7 @@ const defaultClassNames: AutocompleteClassNames = { source: 'aa-Source', sourceFooter: 'aa-SourceFooter', sourceHeader: 'aa-SourceHeader', + sourceEmpty: 'aa-SourceEmpty', submitButton: 'aa-SubmitButton', touchCancelButton: 'aa-TouchCancelButton', touchFormContainer: 'aa-TouchFormContainer', @@ -37,6 +38,7 @@ export function getDefaultOptions( container, panelContainer, render, + renderEmpty, panelPlacement, classNames, touchMediaQuery, @@ -56,6 +58,7 @@ export function getDefaultOptions( ? getHTMLElement(panelContainer) : document.body, render: render ?? defaultRenderer, + renderEmpty, panelPlacement: panelPlacement ?? 'input-wrapper-width', classNames: mergeClassNames( defaultClassNames, diff --git a/packages/autocomplete-js/src/render.ts b/packages/autocomplete-js/src/render.ts index 732645acb..6083e7515 100644 --- a/packages/autocomplete-js/src/render.ts +++ b/packages/autocomplete-js/src/render.ts @@ -136,6 +136,17 @@ export function renderPanel( listElement.appendChild(listFragment); sourceElement.appendChild(listElement); + } else if (source.templates.empty) { + const emptyElement = Element('div', { class: classNames.sourceEmpty }); + renderTemplate({ + template: source.templates.empty({ + root: emptyElement, + state, + source, + }), + parent: sourceElement, + element: emptyElement, + }); } if (source.templates.footer) { diff --git a/packages/autocomplete-js/src/types/AutocompleteClassNames.ts b/packages/autocomplete-js/src/types/AutocompleteClassNames.ts index cd0fc6b4a..d647b3516 100644 --- a/packages/autocomplete-js/src/types/AutocompleteClassNames.ts +++ b/packages/autocomplete-js/src/types/AutocompleteClassNames.ts @@ -15,6 +15,7 @@ export type AutocompleteClassNames = { source: string; sourceFooter: string; sourceHeader: string; + sourceEmpty: string; submitButton: string; touchCancelButton: string; touchFormContainer: string; diff --git a/packages/autocomplete-js/src/types/AutocompleteOptions.ts b/packages/autocomplete-js/src/types/AutocompleteOptions.ts index ad7991b6b..13a87d0e2 100644 --- a/packages/autocomplete-js/src/types/AutocompleteOptions.ts +++ b/packages/autocomplete-js/src/types/AutocompleteOptions.ts @@ -15,7 +15,6 @@ export type AutocompleteRenderer = (params: { sections: HTMLElement[]; state: AutocompleteState; }) => void; - export interface AutocompleteOptions extends AutocompleteCoreOptions, Partial> { @@ -71,6 +70,7 @@ export interface AutocompleteOptions * ``` */ render?: AutocompleteRenderer; + renderEmpty?: AutocompleteRenderer; initialState?: Partial>; onStateChange?(props: { state: AutocompleteState; diff --git a/packages/autocomplete-js/src/types/AutocompleteSource.ts b/packages/autocomplete-js/src/types/AutocompleteSource.ts index 97136c2a3..3e5f087a1 100644 --- a/packages/autocomplete-js/src/types/AutocompleteSource.ts +++ b/packages/autocomplete-js/src/types/AutocompleteSource.ts @@ -40,6 +40,14 @@ export type SourceTemplates = { source: AutocompleteSource; items: TItem[]; }>; + /** + * The template for the empty section. + */ + empty?: Template<{ + root: HTMLElement; + state: AutocompleteState; + source: AutocompleteSource; + }>; }; type WithTemplates = TType & { diff --git a/packages/autocomplete-core/src/utils/__tests__/getItemsCount.test.ts b/packages/autocomplete-shared/src/__tests__/getItemsCount.test.ts similarity index 91% rename from packages/autocomplete-core/src/utils/__tests__/getItemsCount.test.ts rename to packages/autocomplete-shared/src/__tests__/getItemsCount.test.ts index dbc8f1944..0b9ab257f 100644 --- a/packages/autocomplete-core/src/utils/__tests__/getItemsCount.test.ts +++ b/packages/autocomplete-shared/src/__tests__/getItemsCount.test.ts @@ -1,4 +1,4 @@ -import { createCollection, createState } from '../../../../../test/utils'; +import { createCollection, createState } from '../../../../test/utils'; import { getItemsCount } from '../getItemsCount'; describe('getItemsCount', () => { diff --git a/packages/autocomplete-core/src/utils/getItemsCount.ts b/packages/autocomplete-shared/src/getItemsCount.ts similarity index 60% rename from packages/autocomplete-core/src/utils/getItemsCount.ts rename to packages/autocomplete-shared/src/getItemsCount.ts index c01d0f38f..b80385b6f 100644 --- a/packages/autocomplete-core/src/utils/getItemsCount.ts +++ b/packages/autocomplete-shared/src/getItemsCount.ts @@ -1,6 +1,6 @@ -import { AutocompleteState } from '../types'; - -export function getItemsCount(state: AutocompleteState) { +export function getItemsCount< + TAutocompleteState extends { collections: any[] } +>(state: TAutocompleteState) { if (state.collections.length === 0) { return 0; } diff --git a/packages/autocomplete-shared/src/index.ts b/packages/autocomplete-shared/src/index.ts index 9528d6af1..7b68a9b05 100644 --- a/packages/autocomplete-shared/src/index.ts +++ b/packages/autocomplete-shared/src/index.ts @@ -1,7 +1,7 @@ export * from './createRef'; export * from './debounce'; +export * from './getItemsCount'; export * from './invariant'; export * from './isEqual'; export * from './MaybePromise'; export * from './warn'; -export * from './warn'; diff --git a/packages/website/docs/autocomplete-js.md b/packages/website/docs/autocomplete-js.md index 3e8421916..23084b911 100644 --- a/packages/website/docs/autocomplete-js.md +++ b/packages/website/docs/autocomplete-js.md @@ -140,6 +140,26 @@ autocomplete({ }); ``` +### `renderEmpty` + +> `(params: { root: HTMLElement, sections: HTMLElement[], state: AutocompleteState }) => void` + +Function called to render an empty section when no hits are returned. It is useful for letting the user know that the query returned no results. + +There is no default implementation, which closes the panel when there's no results. + +```js +autocomplete({ + // ... + renderEmpty({ root }) { + const div = document.createElement('div'); + + div.innerHTML = 'Your query returned no results'; + root.appendChild(div); + }, +}); +``` + ## Returned props ```js diff --git a/packages/website/docs/sources.md b/packages/website/docs/sources.md index 51f5c1bb8..fd0ab0038 100644 --- a/packages/website/docs/sources.md +++ b/packages/website/docs/sources.md @@ -310,6 +310,11 @@ type SourceTemplate = { source: AutocompleteSource; items: TItem[]; }>; + empty?: Template<{ + root: HTMLElement; + state: AutocompleteState; + source: AutocompleteSource; + }>; }; ``` @@ -358,6 +363,9 @@ const autocompleteSearch = autocomplete({ footer() { return 'Footer'; }, + empty() { + return 'No results'; + }, }, }, ]; diff --git a/test/utils/index.ts b/test/utils/index.ts index 4755910bc..77cc9c684 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -5,3 +5,4 @@ export * from './createSource'; export * from './createState'; export * from './defer'; export * from './runAllMicroTasks'; +export * from './wait'; diff --git a/test/utils/wait.ts b/test/utils/wait.ts new file mode 100644 index 000000000..a68b7baa2 --- /dev/null +++ b/test/utils/wait.ts @@ -0,0 +1,5 @@ +export function wait(time: number) { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +}