ra-search

Plug your search engine and let users search across all resources via a smart Omnibox.

ra-search in Enterprise demo

In large admins, users need several clicks to get to one record. For repetitive tasks, this ends up costing minutes every day. The ra-search Omnibox simplifies navigation by providing a global, always-on search engine for records.

It even remembers the previous searches, so that end-users can easily find the record they were looking for.

Ra-search can take advantage of a search index like ElasticSearch if you have one, or it can rely on your REST API by searching across multiple resources in parallel.

Test it live in the Enterprise Edition Storybook and in the e-commerce demo.

Installation

npm install --save @react-admin/ra-search
# or
yarn add @react-admin/ra-search

Tip: ra-search is part of the React-Admin Enterprise Edition, and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to access this package.

ra-search relies on the dataProvider to communicate with the search engine. Whenever a user enters a search query, react-admin calls dataProvider.search(query). So for ra-search to work, you must implement this method in your data provider.

Example Search Query

dataProvider.search('roll').then(response => console.log(response));
// {
//     data: [
//         { id: 'a7535', type: 'artist', url: '/artists/7535', content: { label: 'The Rolling Stones', description: 'English rock band formed in London in 1962'  } }
//         { id: 'a5352', type: 'artist', url: '/artists/5352', content: { label: 'Sonny Rollins', description: 'American jazz tenor saxophonist'  } }
//         { id: 't7524', type: 'track', url: '/tracks/7524', content: { label: 'Like a Rolling Stone', year: 1965, recordCompany: 'Columbia', artistId: 345, albumId: 435456 } }
//         { id: 't2386', type: 'track', url: '/tracks/2386', content: { label: "It's Only Rock 'N Roll (But I Like It)", year: 1974, artistId: 7535, albumId: 6325 } }
//         { id: 'a6325', type: 'album', url: '/albums/6325', content: { label: "It's Only rock 'N Roll", year: 1974, artistId: 7535 }}
//     ],
//     total: 5
// }
dataProvider.search("roll").then((response) => console.log(response));
// {
//     data: [
//         { id: 'a7535', type: 'artist', url: '/artists/7535', content: { label: 'The Rolling Stones', description: 'English rock band formed in London in 1962'  } }
//         { id: 'a5352', type: 'artist', url: '/artists/5352', content: { label: 'Sonny Rollins', description: 'American jazz tenor saxophonist'  } }
//         { id: 't7524', type: 'track', url: '/tracks/7524', content: { label: 'Like a Rolling Stone', year: 1965, recordCompany: 'Columbia', artistId: 345, albumId: 435456 } }
//         { id: 't2386', type: 'track', url: '/tracks/2386', content: { label: "It's Only Rock 'N Roll (But I Like It)", year: 1974, artistId: 7535, albumId: 6325 } }
//         { id: 'a6325', type: 'album', url: '/albums/6325', content: { label: "It's Only rock 'N Roll", year: 1974, artistId: 7535 }}
//     ],
//     total: 5
// }

Input and Output Formats

The dataProvider.search() method should return a Promise for data containing an array of SearchResult objects. A SearchResult contains at least the following fields:

  • id: Identifier The unique identifier of the search result
  • type: string An arbitrary string which enables grouping
  • url: string The URL where to redirect to on click. It could be a custom page and not a resource if you want to
  • content: any Can contain any data that will be used to display the result. If used with the default <SearchResultItem> component, it must contain at least an id, label, and a description.
  • matches: any An optional object containing an extract of the data with matches. Can be anything that will be interpreted by a <SearchResultItem>

As for the total, it can be greater than the number of returned results. This is useful e.g. to show that there are more results.

It is your responsibility to add this search method to your dataProvider so that react-admin can send queries to and read responses from the search engine.

TypeScript Types

type search = (
    query: string,
    options?: SearchOptions
) => Promise<{ data: SearchResult[]; total: number }>;

interface SearchOptions {
    targets?: string[];
    historySize?: number;
    [key: string]: any;
}

interface SearchResult {
    id: Identifier;
    type: string;
    url: string;
    content: any;
    matches?: any;
}

addSearchMethod Helper

If you don't have a full-text search endpoint in your API, you can use the addSearchMethod() helper function. It adds a search() method to an existing dataProvider; this method calls dataProvider.getList() on several resources in parallel, and aggregates the result into a single response.

For example, to add a dataProvider.search() method that searches across the artists, albums, and tracks resources, you can do:

import simpleRestProvider from 'ra-data-simple-rest';
import { addSearchMethod } from '@react-admin/ra-search';

const dataProvider = simpleRestProvider('http://path.to.my.api/');

const dataProviderWithSearch = addSearchMethod(dataProvider, [
    'artists',
    'tracks',
    'albums',
]);

Calling dataProvider.search('roll') issues the following queries in parallel:

  • dataProvider.getList('artists', { filter: { q: "roll" }})
  • dataProvider.getList('tracks', { filter: { q: "roll" }})
  • dataProvider.getList('albumns', { filter: { q: "roll" }})

Then aggregate the results and return them in a single response.

We don't recommend using addSearchMethod() in production, because if there are many resources, the API may receive too many concurrent requests, and the generated dataProvider.search() method is as slow as the slowest dataProvider.getList() call. Instead, you should expose a search API endpoint, e.g. by using a search engine, and implement your own dataProvider.search() method to convert the results to the format expected by ra-search.

The second argument to addSearchMethod is the builder configuration. It can be either an array of resource names or a map of the resources specifying how to format their records for search results.

The (optional) third argument to addSearchMethod is the redirect option. It allows to choose whether the search results will redirect to the Show or the Edit page. You can also change this on a per-resource basis (see below). By default, search results redirect to the Edit page.

When called with an array of resources, addSearchMethod populates the search results content based on the records returned by dataProvider.getList(), with the following inference rules:

  • id: Returns the record id
  • label: Returns the record label or name or title
  • description: Returns the record description or body

Example with an array of resources:

const dataProviderWithSearch = addSearchMethod(dataProvider, [
    'artists',
    'albums',
]);

When called with a map, each key being a resource name, the value can have the following properties:

  • label: Either the field name to use as the label or a function that will be called with a record and must return a string. Defaults to the inference described above.
  • description: Either the field name to use as the description or a function that will be called with a record and must return a string. Defaults to the inference described above.
  • redirect: Optional. Argument that defines if the redirection is done on a show or edit page. In case the redirection is also defined globally, the per-resource one takes precedence.

Examples with a map of resources:

const dataProviderWithSearch = addSearchMethod(dataProvider, {
    artists: {
        label: 'full_name',
        description: record =>
            `${record.born_at}-${record.died_at} ${record.biography}`,
        redirect: 'show',
    },
    albums: {
        // no label specified, fallback on inference
        description: record =>
            `${record.released_at.getFullYear()} by ${record.recordCompany}`,
    },
});

const dataProviderWithSearchAndGlobalRedirection = addSearchMethod(
    dataProvider,
    {
        artists: {
            label: 'full_name',
            description: record =>
                `${record.born_at}-${record.died_at} ${record.biography}`,
        },
        albums: {
            // no label specified, fallback on inference
            description: record =>
                `${record.released_at.getFullYear()} by ${
                    record.recordCompany
                }`,
        },
    },
    'show'
);

The <Search> component includes an input and displays the search results inside an MUI PopOver.

The <Search> component

By default, it will group the search results by target, and show their content.label and content.description.

Here's how to include the <Search> component inside a custom <AppBar> component:

import { Admin, AppBar, Layout, Resource } from 'react-admin';
import { Typography } from '@mui/material';
import { Search } from '@react-admin/ra-search';

const MyAppbar = props => (
    <AppBar {...props}>
        <Typography
            variant="h6"
            color="inherit"
            sx={{
                flex: 1,
                textOverflow: 'ellipsis',
                whiteSpace: 'nowrap',
                overflow: 'hidden',
            }}
            id="react-admin-title"
        />
        <Search />
    </AppBar>
);

const MyLayout = props => <Layout {...props} appBar={MyAppbar} />;

export const App = () => (
    <Admin dataProvider={searchDataProvider} layout={MyLayout}>
        // ...
    </Admin>
);
import { Admin, AppBar, Layout } from "react-admin";
import { Typography } from "@mui/material";
import { Search } from "@react-admin/ra-search";

const MyAppbar = (props) => (
    <AppBar {...props}>
        <Typography
            variant="h6"
            color="inherit"
            sx={{
                flex: 1,
                textOverflow: "ellipsis",
                whiteSpace: "nowrap",
                overflow: "hidden",
            }}
            id="react-admin-title"
        />
        <Search />
    </AppBar>
);

const MyLayout = (props) => <Layout {...props} appBar={MyAppbar} />;

export const App = () => (
    <Admin dataProvider={searchDataProvider} layout={MyLayout}>
        // ...
    </Admin>
);

Props

The <Search> component accepts the following props:

  • options: object An object containing options to apply to the search :

    • targets: string[] An array of the indices on which to perform the search. Defaults to an empty array.
    • historySize: number The max number of search texts kept in the history. Default is 5.
    • {any}: {any} Any custom option to pass to the search engine.
  • wait: number The delay of debounce for the search to launch after typing in ms. Default is 500ms.

  • children: A component that will display the results inside the <Popover>. Defaults to <SearchResultsPanel>.

  • color: The color mode for the input, applying light or dark backgrounds. Accept either light or dark. Defaults to light.

Customizing The Result Items

By default, <Search> displays the results in <SearchResultsPanel>, which displays each results in a <SearchResultItem>. So rendering <Search> without children is equivalent to rendering:

const MySearch = () => (
    <Search>
        <SearchResultsPanel>
            <SearchResultItem />
        </SearchResultsPanel>
    </Search>
);
const MySearch = () => (
    <Search>
        <SearchResultsPanel>
            <SearchResultItem />
        </SearchResultsPanel>
    </Search>
);

<SearchResultItem> renders the content.label and content.description for each result. You can customize what it renders by providing a function as the label and the description props. This function takes the search result as a parameter and must return a React element.

For instance:

import {
    Search,
    SearchResultsPanel,
    SearchResultItem,
} from '@react-admin/ra-search';

const MySearch = () => (
    <Search>
        <SearchResultsPanel>
            <SearchResultItem
                label={record => (
                    <>
                        {record.type === 'artists' ? (
                            <PersonIcon />
                        ) : (
                            <MusicIcon />
                        )}
                        <span>{record.content.label}</span>
                    </>
                )}
            />
        </SearchResultsPanel>
    </Search>
);
import { Search, SearchResultsPanel, SearchResultItem } from "@react-admin/ra-search";

const MySearch = () => (
    <Search>
        <SearchResultsPanel>
            <SearchResultItem
                label={(record) => (
                    <>
                        {record.type === "artists" ? <PersonIcon /> : <MusicIcon />}
                        <span>{record.content.label}</span>
                    </>
                )}
            />
        </SearchResultsPanel>
    </Search>
);

You can also completely replace the search result item component:

import { Search, SearchResultsPanel } from '@react-admin/ra-search';

const MySearchResultItem = ({ data, onClose }) => (
    <li key={data.id}>
        <Link to={data.url} onClick={onClose}>
            <strong>{data.content.label}</strong>
        </Link>
        <p>{data.content.description}</p>
    </li>
);

const MySearch = () => (
    <Search>
        <SearchResultsPanel>
            <MySearchResultItem />
        </SearchResultsPanel>
    </Search>
);
import { Search, SearchResultsPanel } from "@react-admin/ra-search";

const MySearchResultItem = ({ data, onClose }) => (
    <li key={data.id}>
        <Link to={data.url} onClick={onClose}>
            <strong>{data.content.label}</strong>
        </Link>
        <p>{data.content.description}</p>
    </li>
);

const MySearch = () => (
    <Search>
        <SearchResultsPanel>
            <MySearchResultItem />
        </SearchResultsPanel>
    </Search>
);

Customizing the Entire Search Results

Pass a custom React element as a child of <Search> to customize the appearance of the search results. This can be useful e.g. to customize the results grouping, or to arrange search results differently.

ra-search renders the <Search> inside a SearchContext. You can use the useSearchResult hook to read the search results, as follows:

import { Search, useSearchResult } from '@react-admin/ra-search';

const MySearch = props => (
    <Search>
        <CustomSearchResultsPanel />
    </Search>
);

const CustomSearchResultsPanel = () => {
    const { data, onClose } = useSearchResult();

    return (
        <ul>
            {data.map(searchResult => (
                <li key={searchResult.id}>
                    <Link to={searchResult.url} onClick={onClose}>
                        <strong>{searchResult.content.label}</strong>
                    </Link>
                    <p>{searchResult.content.description}</p>
                </li>
            ))}
        </ul>
    );
};
import { Search, useSearchResult } from "@react-admin/ra-search";

const MySearch = (props) => (
    <Search>
        <CustomSearchResultsPanel />
    </Search>
);

const CustomSearchResultsPanel = () => {
    const { data, onClose } = useSearchResult();

    return (
        <ul>
            {data.map((searchResult) => (
                <li key={searchResult.id}>
                    <Link to={searchResult.url} onClick={onClose}>
                        <strong>{searchResult.content.label}</strong>
                    </Link>
                    <p>{searchResult.content.description}</p>
                </li>
            ))}
        </ul>
    );
};

useSearch

Just like useUpdate, useSearch returns a function allowing to call the dataProvider.search(), as well as a state object for the response. Use it to create your own <Search>. component.

import { useState } from 'React';
import { TextField } from '@mui/material';
import {
    useSearch,
    SearchResultContextProvider,
    SearchResultsPanel,
} from '@react-admin/ra-search';

const Search = () => {
    const [query, setQuery] = useState<string | undefined>();
    const [open, setOpen] = useState(false);
    const [search, searchResultState] = useSearch();

    const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
        setQuery(event.target.value);
        search(event.target.value);
        setOpen(true);
    };

    const handleClose = () => {
        setOpen(false);
    };

    const contextValue = useMemo(() => ({
        onClose: handleClose,
        ...searchResultState,
    }));

    return (
        <>
            <TextField value={query} onChange={handleChange} />
            <Modal
                open={open}
                onClose={handleClose}
                aria-labelledby="modal-modal-title"
            >
                <Typography id="modal-modal-title" variant="h6" component="h2">
                    Search results for: {searchResultState.query}
                </Typography>
                <SearchResultContextProvider value={contextValue}>
                    <SearchResultsPanel />
                </SearchResultContextProvider>
            </Modal>
        </>
    );
};
import { useState } from "React";
import { TextField } from "@mui/material";
import { useSearch, SearchResultContextProvider, SearchResultsPanel } from "@react-admin/ra-search";

const Search = () => {
    const [query, setQuery] = useState();
    const [open, setOpen] = useState(false);
    const [search, searchResultState] = useSearch();

    const handleChange = (event) => {
        setQuery(event.target.value);
        search(event.target.value);
        setOpen(true);
    };

    const handleClose = () => {
        setOpen(false);
    };

    const contextValue = useMemo(() => ({
        onClose: handleClose,
        ...searchResultState,
    }));

    return (
        <>
            <TextField value={query} onChange={handleChange} />
            <Modal open={open} onClose={handleClose} aria-labelledby="modal-modal-title">
                <Typography id="modal-modal-title" variant="h6" component="h2">
                    Search results for: {searchResultState.query}
                </Typography>
                <SearchResultContextProvider value={contextValue}>
                    <SearchResultsPanel />
                </SearchResultContextProvider>
            </Modal>
        </>
    );
};

By default, the <Search> component allows the user to navigate search results with the arrow keys. If you want to reimplement it, you have to use the useArrowKeysToNavigate hook.

Pass the list ref to the hook; also, each result must have a button role:

import { List, ListItem } from '@mui/material';

const SearchResults = () => {
    const listRef = React.useRef<HTMLUListElement>(null);
    useArrowKeysToNavigate(listRef);

    <List ref={listRef}>
        {data.map(resultData => {
            return <ListItem button data={resultData} key={resultData.id} />;
        })}
    </List>;
};
import { List, ListItem } from "@mui/material";

const SearchResults = () => {
    const listRef = React.useRef(null);
    useArrowKeysToNavigate(listRef);

    <List ref={listRef}>
        {data.map((resultData) => {
            return <ListItem button data={resultData} key={resultData.id} />;
        })}
    </List>;
};

I18N

You can customize the <Search> component text, by importing the custom messages into your i18nProvider:

// in src/i18nProvider.ts
import { mergeTranslations } from 'react-admin';
import polyglotI18nProvider from 'ra-i18n-polyglot';
import {
    raSearchEnglishMessages,
    raSearchFrenchMessages,
} from '@react-admin/ra-search';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';

export const i18nProvider = polyglotI18nProvider(locale => {
    if (locale === 'fr') {
        return mergeTranslations(frenchMessages, raSearchFrenchMessages);
    }
    // Always fallback on english
    return mergeTranslations(englishMessages, raSearchEnglishMessages);
}, 'en');
// in src/i18nProvider.ts
import { mergeTranslations } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import { raSearchEnglishMessages, raSearchFrenchMessages } from "@react-admin/ra-search";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";

export const i18nProvider = polyglotI18nProvider((locale) => {
    if (locale === "fr") {
        return mergeTranslations(frenchMessages, raSearchFrenchMessages);
    }
    // Always fallback on english
    return mergeTranslations(englishMessages, raSearchEnglishMessages);
}, "en");

CHANGELOG

v4.2.0

2022-12-22

  • (feat) Move search history from useSearch hook to <Search> component
  • (fix) Fix history shows too many items
  • (fix) Fix UI glitches in <SearchInput>
  • (fix) Fix <Search> component requires an i18n provider to be set

v4.1.2

2022-11-30

  • (feat) Use react-query to manage useSearch hook queries

v4.1.1

2022-10-20

  • (feat) Export SearchHistoryPanel and SearchHistoryItem components.

v4.1.0

2022-08-19

  • (feat) Add redirect option to addSearchMethod, offering ability to choose the page to redirect to (edit or show)

v4.0.5

2022-08-18

  • (doc) Fix documentation still mentioning useSearchResultContext instead of useSearchResults

v4.0.4

2022-08-09

  • (fix) Backport: Fix onSelect parameter types

v4.0.3

2022-06-28

  • (doc) Fix documentation referencing non existent hooks
  • (doc) Fix documentation incorrect instructions to setup of the SearchContext

v4.0.2

2022-06-14

  • (fix) Use theme shape.borderRadius settings for hover effect

v4.0.1

2022-06-08

  • (fix) Update peer dependencies ranges (support React 18)

v4.0.0

2022-06-07

  • Upgrade to react-admin v4

Breaking Change

  • The state returned by useSearch() now uses isLoading instead of loading
-const [doSearch, { loading }] = useSearch('foo');
+const [doSearch, { isLoading }] = useSearch('foo');

v2.2.1

2021-06-29

  • (fix) Update peer dependencies ranges (support react 17)

v2.2.0

2021-04-29

  • (feat) Add a search history

v2.1.0

2021-04-22

  • (feat) Allows users to navigate in results with up and down arrow keys

v2.0.1

2020-12-18

  • (fix) Fix search results panel markup and accessibility

v2.0.0

2020-12-17

  • (feat) rename search method options (facets -> targets)

v1.2.3

2020-12-08

  • (fix) Fix SearchInput value is not selectable while search results are displayed

v1.2.2

2020-12-03

  • (fix) Fix part of terms are sometimes discarded when response is slow
  • (fix) Fix empty popover is displayed when there are no more results

v1.2.1

2020-11-23

  • (fix) Fix search results popover closes and reopens at each keystroke

v1.2.0

2020-11-04

  • (feat) Export groupSearchResultsByResource function

v1.1.1

2020-10-19

  • (fix) Display the <PopOver> position at the bottom right of the <SearchInput>
  • (fix) Remove the stickyness of facet headers
  • (feat) Add a growing effect when focusing the menu

v1.1.0

2020-10-14

  • (fix) Hide clear button when there is no value in the <SearchInput>
  • (feat) Add color prop to both <SearchInput> and <Search> component supporting light and dark

v1.0.0

2020-10-13

  • First release