Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/__tests__/use-collection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,25 @@ describe('Property filtering', () => {
expect(container?.textContent?.split(',')).toEqual(['a']);
});

test('should return precomputed filteringOptions as-is when provided', () => {
const precomputed = [
{ propertyKey: 'id', value: 'custom-1' },
{ propertyKey: 'id', value: 'custom-2' },
];
function App() {
const items: Item[] = [{ id: 'one' }, { id: 'two' }];
const result = useCollection(items, {
propertyFiltering: {
filteringProperties: [{ key: 'id', groupValuesLabel: 'Id values', propertyLabel: 'Id' }],
filteringOptions: precomputed,
},
});
return <>{result.propertyFilterProps.filteringOptions.map(({ value }) => value).join(',')}</>;
}
const { container } = testRender(<App />);
expect(container?.textContent?.split(',')).toEqual(['custom-1', 'custom-2']);
});

test('should not generate filtering options for "falsy" values except boolean false and number zero', () => {
const MixedOptions = () => {
const propertyFiltering = {
Expand Down
10 changes: 10 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ export interface UseCollectionOptions<T> {
empty?: React.ReactNode;
noMatch?: React.ReactNode;
filteringProperties: readonly PropertyFilterProperty[];
/**
* Pre-computed filtering options to use instead of scanning all items.
*
* When provided, `useCollection` skips its O(items × filteringProperties) scan
* and returns this list directly from `propertyFilterProps.filteringOptions`.
*
* Use this when you already know the full set of valid filter values.
* The caller is responsible for keeping this list up-to-date and for deduplication.
*/
filteringOptions?: readonly PropertyFilterOption[];
// custom filtering function
filteringFunction?: (item: T, query: PropertyFilterQuery) => boolean;
defaultQuery?: PropertyFilterQuery;
Expand Down
18 changes: 16 additions & 2 deletions src/use-collection.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { useRef } from 'react';
import { useRef, useMemo } from 'react';
import { processItems, processSelectedItems, itemsAreEqual } from './operations/index.js';
import { UseCollectionOptions, UseCollectionResult, CollectionRef } from './interfaces';
import { createSyncProps } from './utils.js';
import { createSyncProps, computeFilteringOptions } from './utils.js';
import { useCollectionState } from './use-collection-state.js';

export function useCollection<T>(allItems: ReadonlyArray<T>, options: UseCollectionOptions<T>): UseCollectionResult<T> {
Expand Down Expand Up @@ -61,6 +61,19 @@ export function useCollection<T>(allItems: ReadonlyArray<T>, options: UseCollect
}
}

// Memoize `computeFilteringOptions` scan so it only runs when `allItems` or
// `filteringProperties` change. When the caller supplies
// `options.propertyFiltering.filteringOptions`, the scan is skipped entirely
// and the pre-computed list is returned directly.
const filteringProperties = options.propertyFiltering?.filteringProperties;
const precomputedOptions = options.propertyFiltering?.filteringOptions;
const filteringOptions = useMemo(
() => computeFilteringOptions(allItems, filteringProperties, precomputedOptions),
// filteringProperties and precomputedOptions are typically stable references (defined outside render or
// wrapped in useMemo by the caller), so array-identity comparison is correct here.
[allItems, filteringProperties, precomputedOptions]
);

// When normal selection is used, the selectedItems are taken from state.
// When group selection is used, the selectedItems are derived from group selection state.
const extendedState = selectedItems ? { ...state, selectedItems } : state;
Expand All @@ -75,6 +88,7 @@ export function useCollection<T>(allItems: ReadonlyArray<T>, options: UseCollect
allItems,
totalItemsCount,
expandableRows,
filteringOptions,
}),
};
}
59 changes: 41 additions & 18 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CollectionRef,
PropertyFilterQuery,
PropertyFilterOption,
PropertyFilterProperty,
CollectionActions,
GroupSelectionState,
ExpandableRowsResultBase,
Expand Down Expand Up @@ -119,6 +120,44 @@ export function createActions<T>({
};
}

/**
* Scans all items for each filterable property and collects unique values.
*
* Complexity: O(items × filteringProperties). Callers should memoize the result
* so it is only recomputed when `allItems` or `filteringProperties` change.
*
* When `options.propertyFiltering.filteringOptions` is provided by the caller,
* this scan is skipped entirely and the caller-supplied list is returned as-is.
*/
export function computeFilteringOptions<T>(
allItems: readonly T[],
filteringProperties: readonly PropertyFilterProperty[] | undefined,
precomputedOptions: readonly PropertyFilterOption[] | undefined
): PropertyFilterOption[] {
if (!filteringProperties) {
return [];
}
if (precomputedOptions !== undefined) {
return precomputedOptions as PropertyFilterOption[];
}
return filteringProperties.reduce<PropertyFilterOption[]>((acc, property) => {
Object.keys(
allItems.reduce<{ [key in string]: boolean }>((acc, item) => {
acc['' + fixupFalsyValues(item[property.key as keyof T])] = true;
return acc;
}, {})
).forEach(value => {
if (value !== '') {
acc.push({
propertyKey: property.key,
value,
});
}
});
return acc;
}, []);
}

export function createSyncProps<T>(
options: UseCollectionOptions<T>,
{
Expand All @@ -138,12 +177,14 @@ export function createSyncProps<T>(
allItems,
totalItemsCount,
expandableRows,
filteringOptions,
}: {
pagesCount?: number;
actualPageIndex?: number;
allItems: readonly T[];
totalItemsCount: number;
expandableRows?: ExpandableRowsResultBase<T>;
filteringOptions: readonly PropertyFilterOption[];
}
): Pick<UseCollectionResult<T>, 'collectionProps' | 'filterProps' | 'paginationProps' | 'propertyFilterProps'> {
let empty: ReactNode | null = options.filtering
Expand All @@ -156,24 +197,6 @@ export function createSyncProps<T>(
? options.propertyFiltering.noMatch
: options.propertyFiltering.empty
: empty;
const filteringOptions = options.propertyFiltering
? options.propertyFiltering.filteringProperties.reduce<PropertyFilterOption[]>((acc, property) => {
Object.keys(
allItems.reduce<{ [key in string]: boolean }>((acc, item) => {
acc['' + fixupFalsyValues(item[property.key as keyof T])] = true;
return acc;
}, {})
).forEach(value => {
if (value !== '') {
acc.push({
propertyKey: property.key,
value,
});
}
});
return acc;
}, [])
: [];

return {
collectionProps: {
Expand Down
Loading