Christiaan Westerbeek
Christiaan Westerbeek

Reputation: 11137

How to not change the url on list filtering with react-admin?

Assume I have a route /customers where a List of customers is being rendered. On that same route there's also a side drawer present. That drawer contains a List with a Filter of help topics.

When I start typing in the Filter in the side drawer, the url changes. That's according to how react-admin's list filter is working.

The problem is that the customers list is noticing the route changes. In effect it starts querying and reloading customers based on a search term that is related to help topics. No customers found of course.

I want the customer list to not notice I'm filtering on help topics. The solution I'm aiming for is that the list filter in the side drawer will not change the url while I input a help topic search term.

How can I configure or customise the filter in the side drawer to not change the url while typing, but store the current filter value in something like component state instead?

Actually, since the filter lives in a form (by react-final-form), which keeps its own state, I could live with a solution like this. But of course publishToUrl isn't an available prop for Filter.

const MyFilter = props => (
    <Filter {...props} publishToUrl={false} >
        <TextInput source="title" />
    </Filter>
);

Related:

Upvotes: 2

Views: 8403

Answers (6)

WhereIsThisBundle
WhereIsThisBundle

Reputation: 36

For those who, like me, are looking for a reliable solution and find this topic.

https://marmelab.com/react-admin/List.html#synchronize-with-url

For multi list on page without sync all on same filters and pagination.

Use ResourceContextProvider > List (with or without syncWithLocation):

 <ResourceContextProvider value="posts">
   <List syncWithLocation basePath="/posts">
            <Datagrid>
                ... Sync with url
            </Datagrid>
      </List>
 </ResourceContextProvider>

 <ResourceContextProvider value="users">
      <List  basePath="/users" >
          <Datagrid>
               ... Not Sync with url
          </Datagrid>
      </List>
 </ResourceContextProvider>

Upvotes: 1

gomcodoctor
gomcodoctor

Reputation: 49

Following code will remove attachment of List component with Redux Store and Location, it can be used to display multiple list on single page

use this List instead of react admin's List

Create Own List component

import * as React from 'react';
import PropTypes from 'prop-types';
import {
} from 'ra-core';
import {ListView} from 'ra-ui-materialui';
import {useListController} from '../../controller/useListController';

export const TitlePropType = PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.element,
]);

/**
 * List page component
 *
 * The <List> component renders the list layout (title, buttons, filters, pagination),
 * and fetches the list of records from the REST API.
 * It then delegates the rendering of the list of records to its child component.
 * Usually, it's a <Datagrid>, responsible for displaying a table with one row for each post.
 *
 * In Redux terms, <List> is a connected component, and <Datagrid> is a dumb component.
 *
 * The <List> component accepts the following props:
 *
 * - actions
 * - aside
 * - component
 * - filter (the permanent filter to apply to the query)
 * - filters (a React component used to display the filter form)
 * - pagination
 * - perPage
 * - sort
 * - title
 *
 * @example
 *
 * const PostFilter = (props) => (
 *     <Filter {...props}>
 *         <TextInput label="Search" source="q" alwaysOn />
 *         <TextInput label="Title" source="title" />
 *     </Filter>
 * );
 * export const PostList = (props) => (
 *     <List {...props}
 *         title="List of posts"
 *         sort={{ field: 'published_at' }}
 *         filter={{ is_published: true }}
 *         filters={PostFilter}
 *     >
 *         <Datagrid>
 *             <TextField source="id" />
 *             <TextField source="title" />
 *             <EditButton />
 *         </Datagrid>
 *     </List>
 * );
 */
export const List = props => {
    return <ListView {...props} {...useListController(props)} />;
}

List.propTypes = {
    // the props you can change
    actions: PropTypes.element,
    aside: PropTypes.element,
    bulkActionButtons: PropTypes.oneOfType([PropTypes.element, PropTypes.bool]),
    children: PropTypes.node,
    classes: PropTypes.object,
    className: PropTypes.string,
    filter: PropTypes.object,
    filterDefaultValues: PropTypes.object,
    filters: PropTypes.element,
    pagination: PropTypes.element,
    perPage: PropTypes.number.isRequired,
    sort: PropTypes.shape({
        field: PropTypes.string,
        order: PropTypes.string,
    }),
    title: TitlePropType,
    // the props managed by react-admin
    authProvider: PropTypes.func,
    hasCreate: PropTypes.bool.isRequired,
    hasEdit: PropTypes.bool.isRequired,
    hasList: PropTypes.bool.isRequired,
    hasShow: PropTypes.bool.isRequired,
    location: PropTypes.object,
    match: PropTypes.object,
    path: PropTypes.string,
    resource: PropTypes.string.isRequired,
};

List.defaultProps = {
    filter: {},
    perPage: 10,
};

controller/useListController

import { isValidElement, ReactElement, useEffect, useMemo } from 'react';
import inflection from 'inflection';
import { useSelector } from 'react-redux';
import get from 'lodash/get';

import {useCheckMinimumRequiredProps, useTranslate,
    useNotify, useGetList, CRUD_GET_LIST, useVersion, useRecordSelection } from 'react-admin';
import { ListParams } from 'ra-core';
import { Sort, RecordMap, Identifier, ReduxState, Record } from 'ra-core';

import {SORT_ASC} from 'ra-core/esm/reducer/admin/resource/list/queryReducer';
import useListParams from './useListParams';

export interface ListProps {
    // the props you can change
    filter?: object;
    filters?: ReactElement<any>;
    filterDefaultValues?: object;
    pagination?: ReactElement<any>;
    perPage?: number;
    sort?: Sort;
    // the props managed by react-admin
    basePath: string;
    debounce?: number;
    hasCreate?: boolean;
    hasEdit?: boolean;
    hasList?: boolean;
    hasShow?: boolean;
    path?: string;
    query: ListParams;
    resource: string;
    [key: string]: any;
}

const defaultSort = {
    field: 'id',
    order: SORT_ASC,
};

const defaultData = {};

export interface ListControllerProps<RecordType = Record> {
    basePath: string;
    currentSort: Sort;
    data: RecordMap<RecordType>;
    defaultTitle: string;
    displayedFilters: any;
    filterValues: any;
    hasCreate: boolean;
    hideFilter: (filterName: string) => void;
    ids: Identifier[];
    loading: boolean;
    loaded: boolean;
    onSelect: (ids: Identifier[]) => void;
    onToggleItem: (id: Identifier) => void;
    onUnselectItems: () => void;
    page: number;
    perPage: number;
    resource: string;
    selectedIds: Identifier[];
    setFilters: (filters: any, displayedFilters: any) => void;
    setPage: (page: number) => void;
    setPerPage: (page: number) => void;
    setSort: (sort: string) => void;
    showFilter: (filterName: string, defaultValue: any) => void;
    total: number;
    version: number;
}

/**
 * Prepare data for the List view
 *
 * @param {Object} props The props passed to the List component.
 *
 * @return {Object} controllerProps Fetched and computed data for the List view
 *
 * @example
 *
 * import { useListController } from 'react-admin';
 * import ListView from './ListView';
 *
 * const MyList = props => {
 *     const controllerProps = useListController(props);
 *     return <ListView {...controllerProps} {...props} />;
 * }
 */
export const useListController = <RecordType = Record>(
    props: ListProps
): ListControllerProps<RecordType> => {
    useCheckMinimumRequiredProps('List', ['basePath', 'resource'], props);

    const {
        basePath,
        resource,
        hasCreate,
        filterDefaultValues,
        sort = defaultSort,
        perPage = 10,
        filter,
        debounce = 500,
    } = props;

    if (filter && isValidElement(filter)) {
        throw new Error(
            '<List> received a React element as `filter` props. If you intended to set the list filter elements, use the `filters` (with an s) prop instead. The `filter` prop is internal and should not be set by the developer.'
        );
    }

    const translate = useTranslate();
    const notify = useNotify();
    const version = useVersion();

    const [query, queryModifiers] = useListParams({
        resource,
        filterDefaultValues,
        sort,
        perPage,
        debounce
    });

    const [selectedIds, selectionModifiers] = useRecordSelection(resource);

    /**
     * We want the list of ids to be always available for optimistic rendering,
     * and therefore we need a custom action (CRUD_GET_LIST) that will be used.
     */
    const { ids, total, loading, loaded } = useGetList<RecordType>(
        resource,
        {
            page: query.page,
            perPage: query.perPage,
        },
        { field: query.sort, order: query.order },
        { ...query.filter, ...filter },
        {
            action: CRUD_GET_LIST,
            onFailure: error =>
                notify(
                    typeof error === 'string'
                        ? error
                        : error.message || 'ra.notification.http_error',
                    'warning'
                ),
        }
    );

    const data = useSelector(
        (state: ReduxState): RecordMap<RecordType> =>
            get(
                state.admin.resources,
                [resource, 'data'],
                defaultData
            ) as RecordMap<RecordType>
    );

    // When the user changes the page/sort/filter, this controller runs the
    // useGetList hook again. While the result of this new call is loading,
    // the ids and total are empty. To avoid rendering an empty list at that
    // moment, we override the ids and total with the latest loaded ones.
    const defaultIds = [];
    const defaultTotal = 0;

    useEffect(() => {
        if (
            query.page <= 0 ||
            (!loading && query.page > 1 && (ids || []).length === 0)
        ) {
            // query for a page that doesn't exist, set page to 1
            queryModifiers.setPage(1);
        }
    }, [loading, query.page, ids, queryModifiers]);

    const currentSort = useMemo(
        () => ({
            field: query.sort,
            order: query.order,
        }),
        [query.sort, query.order]
    );

    const resourceName = translate(`resources.${resource}.name`, {
        smart_count: 2,
        _: inflection.humanize(inflection.pluralize(resource)),
    });
    const defaultTitle = translate('ra.page.list', {
        name: resourceName,
    });

    return {
        basePath,
        currentSort,
        data,
        defaultTitle,
        displayedFilters: query.displayedFilters,
        filterValues: query.filterValues,
        hasCreate,
        hideFilter: queryModifiers.hideFilter,
        ids: typeof total === 'undefined' ? defaultIds : ids,
        loaded: loaded || defaultIds.length > 0,
        loading,
        onSelect: selectionModifiers.select,
        onToggleItem: selectionModifiers.toggle,
        onUnselectItems: selectionModifiers.clearSelection,
        page: query.page,
        perPage: query.perPage,
        resource,
        selectedIds,
        setFilters: queryModifiers.setFilters,
        setPage: queryModifiers.setPage,
        setPerPage: queryModifiers.setPerPage,
        setSort: queryModifiers.setSort,
        showFilter: queryModifiers.showFilter,
        total: typeof total === 'undefined' ? defaultTotal : total,
        version,
    };
};

export const injectedProps = [
    'basePath',
    'currentSort',
    'data',
    'defaultTitle',
    'displayedFilters',
    'filterValues',
    'hasCreate',
    'hideFilter',
    'ids',
    'loading',
    'loaded',
    'onSelect',
    'onToggleItem',
    'onUnselectItems',
    'page',
    'perPage',
    'refresh',
    'resource',
    'selectedIds',
    'setFilters',
    'setPage',
    'setPerPage',
    'setSort',
    'showFilter',
    'total',
    'version',
];

export default useListController;

controller/useListParams

import {useCallback, useMemo, useState} from 'react';
import lodashDebounce from 'lodash/debounce';
import set from 'lodash/set';
import { ListParams } from 'ra-core';
import { Sort, removeKey, removeEmpty } from 'ra-core';
import queryReducer from 'ra-core/esm/reducer/admin/resource/list/queryReducer';
import {SORT_ASC, SET_SORT, SET_PAGE, SET_PER_PAGE, SET_FILTER} from 'ra-core/esm/reducer/admin/resource/list/queryReducer';

interface ListParamsOptions {
    resource: string;
    perPage?: number;
    sort?: Sort;
    filterDefaultValues?: object;
    debounce?: number;
}

interface Parameters extends ListParams {
    filterValues: object;
    displayedFilters: {
        [key: string]: boolean;
    };
    requestSignature: any[];
}

interface Modifiers {
    changeParams: (action: any) => void;
    setPage: (page: number) => void;
    setPerPage: (pageSize: number) => void;
    setSort: (sort: string) => void;
    setFilters: (filters: any, displayedFilters: any) => void;
    hideFilter: (filterName: string) => void;
    showFilter: (filterName: string, defaultValue: any) => void;
}

const emptyObject = {};

const defaultSort = {
    field: 'id',
    order: SORT_ASC,
};

const defaultParams = {};

/**
 * Get the list parameters (page, sort, filters) and modifiers.
 *
 * These parameters are merged from 3 sources:
 *   - the query string from the URL
 *   - the params stored in the state (from previous navigation)
 *   - the options passed to the hook (including the filter defaultValues)
 *
 * @returns {Array} A tuple [parameters, modifiers].
 * Destructure as [
 *    { page, perPage, sort, order, filter, filterValues, displayedFilters, requestSignature },
 *    { setFilters, hideFilter, showFilter, setPage, setPerPage, setSort }
 * ]
 *
 * @example
 *
 * const [listParams, listParamsActions] = useListParams({
 *      resource: 'posts',
 *      location: location // From react-router. Injected to your component by react-admin inside a List
 *      filterDefaultValues: {
 *          published: true
 *      },
 *      sort: {
 *          field: 'published_at',
 *          order: 'DESC'
 *      },
 *      perPage: 25
 * });
 *
 * const {
 *      page,
 *      perPage,
 *      sort,
 *      order,
 *      filter,
 *      filterValues,
 *      displayedFilters,
 *      requestSignature
 * } = listParams;
 *
 * const {
 *      setFilters,
 *      hideFilter,
 *      showFilter,
 *      setPage,
 *      setPerPage,
 *      setSort,
 * } = listParamsActions;
 */
const useListParams = ({
                           resource,
                           filterDefaultValues,
                           sort = defaultSort,
                           perPage = 10,
                           debounce = 500,
                       }: ListParamsOptions): [Parameters, Modifiers] => {

    const [params, setParams] = useState(defaultParams);

    const requestSignature = [
        resource,
        params,
        filterDefaultValues,
        JSON.stringify(sort),
        perPage
    ];

    const query = useMemo(
        () =>
            getQuery({
                params,
                filterDefaultValues,
                sort,
                perPage
            }),
        requestSignature // eslint-disable-line react-hooks/exhaustive-deps
    );

    const changeParams = useCallback(action => {
        const newParams = queryReducer(query, action);
        setParams(newParams);
    }, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps

    const setSort = useCallback(
        (newSort: string) =>
            changeParams({ type: SET_SORT, payload: { sort: newSort } }),
        requestSignature // eslint-disable-line react-hooks/exhaustive-deps
    );

    const setPage = useCallback(
        (newPage: number) => changeParams({ type: SET_PAGE, payload: newPage }),
        requestSignature // eslint-disable-line react-hooks/exhaustive-deps
    );

    const setPerPage = useCallback(
        (newPerPage: number) =>
            changeParams({ type: SET_PER_PAGE, payload: newPerPage }),
        requestSignature // eslint-disable-line react-hooks/exhaustive-deps
    );

    const filterValues = query.filter || emptyObject;
    const displayedFilterValues = query.displayedFilters || emptyObject;

    const debouncedSetFilters = lodashDebounce(
        (newFilters, newDisplayedFilters) => {
            let payload = {
                filter: removeEmpty(newFilters),
                displayedFilters: undefined,
            };
            if (newDisplayedFilters) {
                payload.displayedFilters = Object.keys(
                    newDisplayedFilters
                ).reduce((filters, filter) => {
                    return newDisplayedFilters[filter]
                        ? { ...filters, [filter]: true }
                        : filters;
                }, {});
            }
            changeParams({
                type: SET_FILTER,
                payload,
            });
        },
        debounce
    );

    const setFilters = useCallback(
        (filters, displayedFilters) =>
            debouncedSetFilters(filters, displayedFilters),
        requestSignature // eslint-disable-line react-hooks/exhaustive-deps
    );

    const hideFilter = useCallback((filterName: string) => {
        const newFilters = removeKey(filterValues, filterName);
        const newDisplayedFilters = {
            ...displayedFilterValues,
            [filterName]: undefined,
        };

        setFilters(newFilters, newDisplayedFilters);
    }, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps

    const showFilter = useCallback((filterName: string, defaultValue: any) => {
        const newFilters = set(filterValues, filterName, defaultValue);
        const newDisplayedFilters = {
            ...displayedFilterValues,
            [filterName]: true,
        };
        setFilters(newFilters, newDisplayedFilters);
    }, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps

    return [
        {
            displayedFilters: displayedFilterValues,
            filterValues,
            requestSignature,
            ...query,
        },
        {
            changeParams,
            setPage,
            setPerPage,
            setSort,
            setFilters,
            hideFilter,
            showFilter,
        },
    ];
};

/**
 * Check if user has already set custom sort, page, or filters for this list
 *
 * User params come from the Redux store as the params props. By default,
 * this object is:
 *
 * { filter: {}, order: null, page: 1, perPage: null, sort: null }
 *
 * To check if the user has custom params, we must compare the params
 * to these initial values.
 *
 * @param {Object} params
 */
export const hasCustomParams = (params: ListParams) => {
    return (
        params &&
        params.filter &&
        (Object.keys(params.filter).length > 0 ||
            params.order != null ||
            params.page !== 1 ||
            params.perPage != null ||
            params.sort != null)
    );
};

/**
 * Merge list params from 3 different sources:
 *   - the query string
 *   - the params stored in the state (from previous navigation)
 *   - the props passed to the List component (including the filter defaultValues)
 */
export const getQuery = ({
                             filterDefaultValues,
                             params,
                             sort,
                             perPage,
                         }) => {
    const query: Partial<ListParams> =
        hasCustomParams(params)
            ? { ...params } : { filter: filterDefaultValues || {} };

    if (!query.sort) {
        query.sort = sort.field;
        query.order = sort.order;
    }
    if (!query.perPage) {
        query.perPage = perPage;
    }
    if (!query.page) {
        query.page = 1;
    }
    return {
        ...query,
        page: getNumberOrDefault(query.page, 1),
        perPage: getNumberOrDefault(query.perPage, 10),
    } as ListParams;
};

export const getNumberOrDefault = (
    possibleNumber: string | number | undefined,
    defaultValue: number
) =>
    (typeof possibleNumber === 'string'
        ? parseInt(possibleNumber, 10)
        : possibleNumber) || defaultValue;

export default useListParams;

Upvotes: 3

packasik
packasik

Reputation: 11

I had similar issue. I have one route with three tabs. On each tab I have different List rendered. Once I had selected filters on tab1, they were propagated to url and applied to tab2 list and tab3 list.

I found solution to this: I've analyzed react-admin source code. It is using method "changeParams" in useListParams.ts. This method is using useHistory(); from 'react-router-dom', and pushes filter params to url:

 history.push({
            search: `?${stringify({
                ...newParams,
                filter: JSON.stringify(newParams.filter),
                displayedFilters: JSON.stringify(newParams.displayedFilters),
            })}`,
        });

So my solution is that on tabs change I did history.push({ search: '' }); (of course you have to first install react-router-dom, import useHistory, and then make history as a const const history = useHistory(); ).

This solution cleans url params on tab change, so search params(filters, sort and range) are not applied anymore to other tabs (and lists).

Upvotes: 1

Tzahi Fadida
Tzahi Fadida

Reputation: 306

I have tried a different solution, perhaps it will be of help to someone:

const FunctionsFilter = ({resource, ...rest}) => {
    const classes = useStyles();
    const location = useLocation();
    const [query, queryModifiers] = useMyListParams({
        resource,
        location,
        filterDefaultValues: {},
        sort: {
            field: 'name',
            order: 'asc',
        },
        perPage: 5,
        debounce: 500,
    });
    return (
        <Filter resource={resource} {...rest} setFilters={queryModifiers.setFilters}>
            <TextInput className={classes.dialogformfilter} source="name" alwaysOn resettable/>
        </Filter>
    );
};

Now, for some reason it sends the query twice, so i also copied useListController to useMyListController, and now it only sends it once. The downside is that you have to maintain it on version upgrades.

Also, a combination of Christiaan Westerbeek solution with useMyListController, worked best for me.

Upvotes: 0

Christiaan Westerbeek
Christiaan Westerbeek

Reputation: 11137

Thanks to the directions provided by d0berm4n, I was able to compile a working solution (for react-admin 3.x). It's pretty limited in terms of the query that it creates (just filter), but it's just wat I need.

import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import lodashDebounce from 'lodash/debounce';
import { Filter, TextInput, changeListParams } from 'react-admin';

const MyFilter = ({ resource, ...rest }) => {
    const dispatch = useDispatch();

    const debouncedSetFilters = lodashDebounce((filter = {}) => {
        const query = { filter };

        dispatch(changeListParams(resource, query));
    }, 500);

    const setFilters = useCallback(debouncedSetFilters, []);

    return (
        <Filter resource={resource} {...rest} setFilters={setFilters}>
            <TextInput source="title" alwaysOn resettable />
        </Filter>
    );
};
MyFilter.propTypes = {
    resource: PropTypes.string,
};
MyFilter.displayName = 'MyFilter';

export default MyFilter;

Update: This solution is incomplete. Other filters on the same page are now starting to query on the List that MyFilter is on. I suspect that's a problem that I can isolate. More on this later...

Upvotes: 0

d0berm4n
d0berm4n

Reputation: 71

setFilters() property is passed down to Filter component from his parent List.

So you need to implement your own useListParams hook with removed/wrapped with condition commented lines:

const changeParams = useCallback(action => {
        const newQuery = getQuery({
            location,
            params,
            filterDefaultValues,
            sort,
            perPage,
        });
        const newParams = queryReducer(newQuery, action);
        // history.push({
        //     search: `?${stringify({
        //         ...newParams,
        //         filter: JSON.stringify(newParams.filter),
        //     })}`,
        // });
        dispatch(changeListParams(resource, newParams));
    }, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps

Then you have to implement useListController and call your hook instead of react-admin's one.

const [query, queryModifiers] = useListParams({
    resource,
    location,
    filterDefaultValues,
    sort,
    perPage,
    debounce,
});

Finally you implement List component and pass your new cool useListController. Filter values will not be reflected in query string as well as paging and sorting.

Another and simpler way is to intercept setFilters call in your Filter component and do

dispatch(changeListParams(resource, newParams));

with new filter values without implementation bunch of hooks and components.

Upvotes: 1

Related Questions