diff --git a/.changeset/spicy-flowers-allow.md b/.changeset/spicy-flowers-allow.md new file mode 100644 index 000000000000..a280a1ca8142 --- /dev/null +++ b/.changeset/spicy-flowers-allow.md @@ -0,0 +1,7 @@ +--- +"@refinedev/core": patch +--- + +Update `useTable` hook to handle case where a user navigates to current page by clicking side-nav link intending to reset the filters and sorters. + +[Resolves #6300](https://github.com/refinedev/refine/issues/6300) diff --git a/packages/core/src/definitions/table/index.ts b/packages/core/src/definitions/table/index.ts index 1fd8eba3267a..c7044dd7e1ae 100644 --- a/packages/core/src/definitions/table/index.ts +++ b/packages/core/src/definitions/table/index.ts @@ -186,3 +186,123 @@ export const getDefaultFilter = ( return undefined; }; + +export const mergeFilters = ( + currentUrlFilters: CrudFilter[], + currentFilters: CrudFilter[], +): CrudFilter[] => { + const mergedFilters = currentFilters.map((tableFilter) => { + const matchingURLFilter = currentUrlFilters.find( + (urlFilter) => + "field" in tableFilter && + "field" in urlFilter && + tableFilter.field === urlFilter.field && + tableFilter.operator === urlFilter.operator, + ); + + // override current filter wih url filter + if (matchingURLFilter) { + return { ...tableFilter, ...matchingURLFilter }; + } + + return tableFilter; + }); + + // add any other URL filters not in the current filters + const additionalURLFilters = currentUrlFilters.filter( + (urlFilter) => + !currentFilters.some( + (tableFilter) => + "field" in tableFilter && + "field" in urlFilter && + tableFilter.field === urlFilter.field && + tableFilter.operator === urlFilter.operator, + ), + ); + + return [...mergedFilters, ...additionalURLFilters]; +}; + +export const mergeSorters = ( + currentUrlSorters: CrudSort[], + currentSorters: CrudSort[], +): CrudSort[] => { + const merged: CrudSort[] = [...currentUrlSorters]; + + for (const sorter of currentSorters) { + const exists = merged.some((s) => compareSorters(s, sorter)); + if (!exists) { + merged.push(sorter); + } + } + + return merged; +}; + +export const isEqualFilters = ( + filter1: CrudFilter[] | undefined, + filter2: CrudFilter[] | undefined, +): boolean => { + if (!filter1 || !filter2) return false; + if (filter1.length !== filter2.length) return false; + + const isEqual = filter1.every((f1) => { + // same fields/keys and operators + const isEqualParamsF2 = filter2.find((f2) => compareFilters(f1, f2)); + + if (!isEqualParamsF2) return false; + + const filter1Value = f1.value; + const filter2Value = isEqualParamsF2.value; + + // if they both have values, compare + if (filter1Value && filter2Value) { + if (Array.isArray(filter1Value) && Array.isArray(filter2Value)) { + if (filter1Value.length === 0 && filter2Value.length === 0) { + return true; + } + + // if array of primitives, compare + if ( + filter1Value.every((v) => typeof v !== "object") && + filter2Value.every((v) => typeof v !== "object") + ) { + return ( + filter1Value.length === filter2Value.length && + filter1Value.every((v) => filter2Value.includes(v)) + ); + } + + // recursion because of type def. ConditionalFilter["value"] + return isEqualFilters(filter1Value, filter2Value); + } + + // compare primitives (string, number, ...null?) + // because of type def. LogicalFilter["value"] + return filter1Value === filter2Value; + } + + // if either is undefined, it means it was initialized, + // so logically equal + const isEmptyValue = (value: any) => { + if (value === "") { + return true; + } + if (Array.isArray(value) && value.length === 0) { + return true; + } + if (value === undefined) { + return true; + } + return false; + }; + + if (isEmptyValue(filter1Value) && isEmptyValue(filter2Value)) { + return true; + } + + return filter1Value === filter2Value; + }); + + return isEqual; +}; diff --git a/packages/core/src/hooks/useTable/index.ts b/packages/core/src/hooks/useTable/index.ts index b679e5392468..314742d535f1 100644 --- a/packages/core/src/hooks/useTable/index.ts +++ b/packages/core/src/hooks/useTable/index.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import type { QueryObserverResult, @@ -11,6 +11,9 @@ import warnOnce from "warn-once"; import { pickNotDeprecated } from "@definitions/helpers"; import { + isEqualFilters, + mergeFilters, + mergeSorters, parseTableParams, setInitialFilters, setInitialSorters, @@ -197,6 +200,11 @@ type SyncWithLocationParams = { filters: CrudFilter[]; }; +type LastUrlSyncParams = { + sorters: CrudSort[]; + filters: CrudFilter[]; +}; + export type useTableReturnType< TData extends BaseRecord = BaseRecord, TError extends HttpError = HttpError, @@ -385,6 +393,9 @@ export function useTable< const [current, setCurrent] = useState(defaultCurrent); const [pageSize, setPageSize] = useState(defaultPageSize); + const [urlUpdated, setUrlUpdated] = useState(false); + const lastSyncedUrlParams = useRef(); + const getCurrentQueryParams = (): object => { if (routerType === "new") { // We get QueryString parameters that are uncontrolled by refine. @@ -496,9 +507,85 @@ export function useTable< shallow: true, }); } + + setUrlUpdated(true); } }, [syncWithLocation, current, pageSize, sorters, filters]); + // update lastSynched url params + useEffect(() => { + if (urlUpdated) { + lastSyncedUrlParams.current = { + filters: differenceWith(filters, preferredPermanentFilters, isEqual), + sorters: differenceWith(sorters, preferredPermanentSorters, isEqual), + }; + + // reset + setUrlUpdated(false); + } + }, [urlUpdated, filters, sorters]); + + // watch URL filters, sorters to update internal filters, sorters + useEffect(() => { + if (syncWithLocation) { + // const currentFilters = filters; + // const currentUrlFilters = parsedParams?.params?.filters; + // const initialFilters = setInitialFilters( + // preferredPermanentFilters, + // defaultFilter ?? [], + // ); + // const filtersAreEqual = isEqualFilters(currentUrlFilters, currentFilters); + // const isInternalSyncWithUrlFilters = isEqualFilters( + // currentFilters, + // lastSyncedUrlParams.current?.filters, + // ); + // let newFilters: CrudFilter[] = []; + // const currentSorters = sorters; + // const currentUrlSorters = parsedParams.params?.sorters; + // const initialSorters = setInitialSorters( + // preferredPermanentSorters, + // defaultSorter ?? [], + // ); + // const sortersAreEqual = (() => { + // if (currentUrlSorters === undefined && currentSorters.length === 0) + // return true; + // return isEqual(currentUrlSorters, currentSorters); + // })(); + // const isInternalSyncWithUrlSorters = isEqual( + // currentSorters, + // lastSyncedUrlParams.current?.sorters, + // ); + // let newSorters: CrudSort[] = []; + // // if last changes were in sync; i.e: current internal state === last url state + // // && + // // current url state changed but did not affect current internal state + // if (isInternalSyncWithUrlFilters && !filtersAreEqual) { + // // fallback to initial + // if (!currentUrlFilters || currentUrlFilters.length === 0) { + // newFilters = initialFilters; + // } else { + // // since they aren't equal, merge the two + // newFilters = mergeFilters(currentUrlFilters, currentFilters); + // } + // setFilters(newFilters); + // } + // if (isInternalSyncWithUrlSorters && !sortersAreEqual) { + // // fallback to initial + // if (!currentUrlSorters || currentUrlSorters.length === 0) { + // newSorters = initialSorters; + // } else { + // // since they aren't equal, merge the two + // newSorters = mergeSorters(currentUrlSorters, currentSorters); + // } + // setSorters(newSorters); + // } + } + }, [ + parsedParams.params?.filters, + filters, + lastSyncedUrlParams.current?.filters, + ]); + const queryResult = useList({ resource: identifier, hasPagination,