feat(ui): add searchDebounceMs/filterDebounceMs to useTable complete mode (#34127)
* feat(ui): add internal useDebouncedValue hook for Table Signed-off-by: Johan Persson <johanopersson@gmail.com> * feat(ui): expose searchDebounceMs and filterDebounceMs on UseTableCompleteOptions Signed-off-by: Johan Persson <johanopersson@gmail.com> * feat(ui): debounce search/filter in useTable complete mode pipeline Signed-off-by: Johan Persson <johanopersson@gmail.com> * docs(ui): add SearchWithDebounce Table dev story Signed-off-by: Johan Persson <johanopersson@gmail.com> * docs(ui): document searchDebounceMs/filterDebounceMs and fix Table search note Signed-off-by: Johan Persson <johanopersson@gmail.com> * docs(ui): add searchDebounceMs/filterDebounceMs to Table props reference Signed-off-by: Johan Persson <johanopersson@gmail.com> * docs(ui): note controlled-callback behavior on Table debounce props Signed-off-by: Johan Persson <johanopersson@gmail.com> * chore(ui): regenerate API report for useTable debounce options Signed-off-by: Johan Persson <johanopersson@gmail.com> * chore(ui): changeset for useTable complete-mode debounce options Signed-off-by: Johan Persson <johanopersson@gmail.com> * chore: accept 'debouncing' in Vale vocabulary Signed-off-by: Johan Persson <johanopersson@gmail.com> --------- Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -3569,6 +3569,8 @@ export type UseTableCompleteOptions<
|
||||
sortFn?: (data: T[], sort: SortDescriptor) => T[];
|
||||
filterFn?: (data: T[], filter: TFilter) => T[];
|
||||
searchFn?: (data: T[], search: string) => T[];
|
||||
searchDebounceMs?: number;
|
||||
filterDebounceMs?: number;
|
||||
} & (
|
||||
| {
|
||||
data: T[] | undefined;
|
||||
|
||||
@@ -111,6 +111,18 @@ export type UseTableCompleteOptions<
|
||||
sortFn?: (data: T[], sort: SortDescriptor) => T[];
|
||||
filterFn?: (data: T[], filter: TFilter) => T[];
|
||||
searchFn?: (data: T[], search: string) => T[];
|
||||
/**
|
||||
* Trailing-edge debounce delay (ms) applied to the search value before it
|
||||
* reaches `searchFn`. Defaults to `0` — no debounce, no extra render. The
|
||||
* controlled `search` / `onSearchChange` surface is unaffected.
|
||||
*/
|
||||
searchDebounceMs?: number;
|
||||
/**
|
||||
* Trailing-edge debounce delay (ms) applied to the filter value before it
|
||||
* reaches `filterFn`. Defaults to `0` — no debounce, no extra render. The
|
||||
* controlled `filter` / `onFilterChange` surface is unaffected.
|
||||
*/
|
||||
filterDebounceMs?: number;
|
||||
} & (
|
||||
| {
|
||||
data: T[] | undefined;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
UseTableCompleteOptions,
|
||||
} from './types';
|
||||
import { useStableCallback } from './useStableCallback';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
import { getEffectivePageSize } from './getEffectivePageSize';
|
||||
|
||||
/** @internal */
|
||||
@@ -36,6 +37,8 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
sortFn,
|
||||
filterFn,
|
||||
searchFn,
|
||||
searchDebounceMs = 0,
|
||||
filterDebounceMs = 0,
|
||||
} = options;
|
||||
const hasGetData = 'getData' in options;
|
||||
const noPagination = paginationOptions.type === 'none';
|
||||
@@ -97,14 +100,25 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
};
|
||||
}, [data, getData, hasGetData, loadCount]);
|
||||
|
||||
// Reset offset when query changes (query object is memoized)
|
||||
const prevQueryRef = useRef(query);
|
||||
// Debounced surrogates of search and filter feed the processing pipeline.
|
||||
// At delayMs === 0 (the default) these are referentially equal to the live
|
||||
// values, so behavior is identical to before this refactor.
|
||||
const debouncedSearch = useDebouncedValue(search, searchDebounceMs);
|
||||
const debouncedFilter = useDebouncedValue(filter, filterDebounceMs);
|
||||
|
||||
// Reset offset when the *debounced* query changes — keying on the live query
|
||||
// would briefly flash page 1 of unfiltered data while the debounce settles.
|
||||
const debouncedQuery = useMemo(
|
||||
() => ({ sort, filter: debouncedFilter, search: debouncedSearch }),
|
||||
[sort, debouncedFilter, debouncedSearch],
|
||||
);
|
||||
const prevDebouncedQueryRef = useRef(debouncedQuery);
|
||||
useEffect(() => {
|
||||
if (prevQueryRef.current !== query) {
|
||||
prevQueryRef.current = query;
|
||||
if (prevDebouncedQueryRef.current !== debouncedQuery) {
|
||||
prevDebouncedQueryRef.current = debouncedQuery;
|
||||
setOffset(0);
|
||||
}
|
||||
}, [query]);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const resolvedItems = useMemo(() => data ?? items, [data, items]);
|
||||
|
||||
@@ -114,17 +128,25 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
return undefined;
|
||||
}
|
||||
let result = [...resolvedItems];
|
||||
if (filter !== undefined && filterFn) {
|
||||
result = filterFn(result, filter);
|
||||
if (debouncedFilter !== undefined && filterFn) {
|
||||
result = filterFn(result, debouncedFilter);
|
||||
}
|
||||
if (search && searchFn) {
|
||||
result = searchFn(result, search);
|
||||
if (debouncedSearch && searchFn) {
|
||||
result = searchFn(result, debouncedSearch);
|
||||
}
|
||||
if (sort && sortFn) {
|
||||
result = sortFn(result, sort);
|
||||
}
|
||||
return result;
|
||||
}, [resolvedItems, sort, filter, search, filterFn, searchFn, sortFn]);
|
||||
}, [
|
||||
resolvedItems,
|
||||
sort,
|
||||
debouncedFilter,
|
||||
debouncedSearch,
|
||||
filterFn,
|
||||
searchFn,
|
||||
sortFn,
|
||||
]);
|
||||
|
||||
const totalCount = processedData?.length ?? 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Returns a trailing-edge debounced surrogate of `value`.
|
||||
*
|
||||
* - When `delayMs <= 0`, the live `value` is returned directly without arming
|
||||
* a timer — a true bypass with no observable change relative to using
|
||||
* `value` itself.
|
||||
* - When `delayMs > 0`, the returned value lags behind `value` by `delayMs`
|
||||
* of stability. The timer is cleared on `value` change, `delayMs` change,
|
||||
* and on unmount.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (delayMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return delayMs <= 0 ? value : debounced;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, Fragment } from 'react';
|
||||
import { useState, Fragment, useMemo } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
Table,
|
||||
@@ -212,6 +212,95 @@ export const Search: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchWithDebounce: Story = {
|
||||
render: () => {
|
||||
// Amplify the ~100-row mocked dataset to several thousand rows so the
|
||||
// perf difference between debounced and non-debounced typing is visible.
|
||||
const largeData = useMemo(() => {
|
||||
const result: Data1Item[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
for (const item of data1) {
|
||||
result.push({ ...item, id: `${item.id}-${i}` });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const columns: ColumnConfig<Data1Item>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Name',
|
||||
isRowHeader: true,
|
||||
cell: item => <CellText title={item.name} />,
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
label: 'Owner',
|
||||
cell: item => <CellText title={item.owner.name} />,
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
label: 'Type',
|
||||
cell: item => <CellText title={item.type} />,
|
||||
},
|
||||
];
|
||||
|
||||
const searchFn = (items: Data1Item[], query: string) => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return items.filter(
|
||||
item =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
item.owner.name.toLowerCase().includes(lowerQuery) ||
|
||||
item.type.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
};
|
||||
|
||||
const immediate = useTable({
|
||||
mode: 'complete',
|
||||
data: largeData,
|
||||
paginationOptions: { pageSize: 10 },
|
||||
searchFn,
|
||||
});
|
||||
|
||||
const debounced = useTable({
|
||||
mode: 'complete',
|
||||
data: largeData,
|
||||
paginationOptions: { pageSize: 10 },
|
||||
searchFn,
|
||||
searchDebounceMs: 200,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '32px' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '8px' }}>
|
||||
Immediate (searchDebounceMs: 0)
|
||||
</h3>
|
||||
<SearchField
|
||||
aria-label="Immediate search"
|
||||
placeholder="Type to search..."
|
||||
style={{ marginBottom: '16px' }}
|
||||
{...immediate.search}
|
||||
/>
|
||||
<Table columnConfig={columns} {...immediate.tableProps} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '8px' }}>
|
||||
Debounced (searchDebounceMs: 200)
|
||||
</h3>
|
||||
<SearchField
|
||||
aria-label="Debounced search"
|
||||
placeholder="Type to search..."
|
||||
style={{ marginBottom: '16px' }}
|
||||
{...debounced.search}
|
||||
/>
|
||||
<Table columnConfig={columns} {...debounced.tableProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Selection: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
|
||||
|
||||
Reference in New Issue
Block a user