From 818736f726b4bbca37784802cb5c6ef4e36cd785 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 10:52:13 -0500 Subject: [PATCH 01/53] feat: create initial generic table component --- src/components/Table/Table.tsx | 122 +++++++++++++++ src/components/Table/TableBody.tsx | 67 +++++++++ src/components/Table/TableContext.tsx | 55 +++++++ src/components/Table/TableFilterButtons.tsx | 159 ++++++++++++++++++++ src/components/Table/TableHeader.tsx | 21 +++ src/components/Table/TableSearchBar.tsx | 46 ++++++ src/components/Table/TableSortButtons.tsx | 73 +++++++++ src/components/Table/index.ts | 22 +++ 8 files changed, 565 insertions(+) create mode 100644 src/components/Table/Table.tsx create mode 100644 src/components/Table/TableBody.tsx create mode 100644 src/components/Table/TableContext.tsx create mode 100644 src/components/Table/TableFilterButtons.tsx create mode 100644 src/components/Table/TableHeader.tsx create mode 100644 src/components/Table/TableSearchBar.tsx create mode 100644 src/components/Table/TableSortButtons.tsx create mode 100644 src/components/Table/index.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 000000000000..523dbeff7311 --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,122 @@ +<<<<<<< Current (Your changes) +======= +import React, {useCallback, useMemo, useState} from 'react'; +import type {ReactNode} from 'react'; +import {TableContext, type FilterConfig, type SortByConfig} from './TableContext'; + +type TableProps = { + data: T[]; + filters?: Record; + sortBy?: SortByConfig; + onSearch?: (items: T[], searchString: string) => T[]; + children: ReactNode; +}; + +function Table({data, filters, sortBy, onSearch, children}: TableProps) { + const [filterValues, setFilterValues] = useState>(() => { + const initialFilters: Record = {}; + if (filters) { + Object.keys(filters).forEach((key) => { + initialFilters[key] = filters[key].default; + }); + } + return initialFilters; + }); + + const [currentSortBy, setCurrentSortBy] = useState(sortBy?.default); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + const [searchString, setSearchString] = useState(''); + + const setFilter = useCallback((key: string, value: unknown) => { + setFilterValues((prev) => ({ + ...prev, + [key]: value, + })); + }, []); + + const setSortByHandler = useCallback((key: string, order: 'asc' | 'desc') => { + setCurrentSortBy(key); + setSortOrder(order); + }, []); + + const setSearchStringHandler = useCallback((value: string) => { + setSearchString(value); + }, []); + + // Apply filters using predicate functions + const filteredData = useMemo(() => { + if (!filters) { + return data; + } + + return data.filter((item) => { + return Object.keys(filters).every((filterKey) => { + const filterConfig = filters[filterKey]; + const filterValue = filterValues[filterKey]; + + // If filter value is empty/undefined, include the item + if (filterValue === undefined || filterValue === null) { + return true; + } + + // Handle multi-select filters (array values) + if (filterConfig.filterType === 'multi-select') { + const filterValueArray = Array.isArray(filterValue) ? filterValue : []; + if (filterValueArray.length === 0) { + return true; + } + // For multi-select, item passes if it matches any selected value + return filterValueArray.some((value) => filterConfig.predicate(item, value)); + } + + // Handle single-select filters + return filterConfig.predicate(item, filterValue); + }); + }); + }, [data, filters, filterValues]); + + // Apply search using onSearch callback + const searchedData = useMemo(() => { + if (!onSearch || !searchString.trim()) { + return filteredData; + } + return onSearch(filteredData, searchString); + }, [filteredData, onSearch, searchString]); + + // Apply sorting using comparator function + const filteredAndSortedData = useMemo(() => { + if (!sortBy || !currentSortBy) { + return searchedData; + } + + const sortedData = [...searchedData]; + sortedData.sort((a, b) => { + return sortBy.comparator(a, b, currentSortBy, sortOrder); + }); + + return sortedData; + }, [searchedData, sortBy, currentSortBy, sortOrder]); + + const contextValue = useMemo( + () => ({ + filteredAndSortedData, + filters: filterValues, + sortBy: currentSortBy, + sortOrder, + searchString, + setFilter, + setSortBy: setSortByHandler, + setSearchString: setSearchStringHandler, + filterConfigs: filters, + sortByConfig: sortBy, + }), + [filteredAndSortedData, filterValues, currentSortBy, sortOrder, searchString, setFilter, setSortByHandler, setSearchStringHandler, filters, sortBy], + ); + + return {children}; +} + +Table.displayName = 'Table'; + +export default Table; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx new file mode 100644 index 000000000000..ac3072ed5da5 --- /dev/null +++ b/src/components/Table/TableBody.tsx @@ -0,0 +1,67 @@ +<<<<<<< Current (Your changes) +======= +import React from 'react'; +import type {FlatListProps, StyleProp, ViewStyle} from 'react-native'; +import {FlatList, View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {useTableContext} from './TableContext'; + +type TableBodyProps = { + renderItem: (item: T, index: number) => React.ReactNode; + keyExtractor?: (item: T, index: number) => string; + ListEmptyComponent?: React.ComponentType | React.ReactElement | null; + contentContainerStyle?: StyleProp; + onScroll?: FlatListProps['onScroll']; + onEndReached?: FlatListProps['onEndReached']; + onEndReachedThreshold?: number; + // Allow other FlatList props to be passed through + [key: string]: unknown; +}; + +function TableBody({renderItem, keyExtractor, ListEmptyComponent, contentContainerStyle, onScroll, onEndReached, onEndReachedThreshold, ...flatListProps}: TableBodyProps) { + const styles = useThemeStyles(); + const {filteredAndSortedData} = useTableContext(); + + const defaultKeyExtractor = (item: T, index: number): string => { + if (keyExtractor) { + return keyExtractor(item, index); + } + + // Try to extract a key from common object properties + if (typeof item === 'object' && item !== null) { + const obj = item as Record; + if ('id' in obj && typeof obj.id === 'string') { + return obj.id; + } + if ('key' in obj && typeof obj.key === 'string') { + return obj.key; + } + } + + return `item-${index}`; + }; + + const renderItemWithIndex = ({item, index}: {item: T; index: number}) => { + return <>{renderItem(item, index)}; + }; + + return ( + + ); +} + +TableBody.displayName = 'TableBody'; + +export default TableBody; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx new file mode 100644 index 000000000000..1033539ce2d9 --- /dev/null +++ b/src/components/Table/TableContext.tsx @@ -0,0 +1,55 @@ +<<<<<<< Current (Your changes) +======= +import React, {createContext, useContext} from 'react'; + +export type FilterConfig = { + options: Array<{label: string; value: unknown}>; + filterType: 'multi-select' | 'single-select'; + default: unknown; + predicate: (item: T, filterValue: unknown) => boolean; +}; + +export type SortByConfig = { + options: Array<{label: string; value: string}>; + default: string; + comparator: (a: T, b: T, sortKey: string, order: 'asc' | 'desc') => number; +}; + +export type TableContextValue = { + filteredAndSortedData: T[]; + filters: Record; + sortBy: string | undefined; + sortOrder: 'asc' | 'desc'; + searchString: string; + setFilter: (key: string, value: unknown) => void; + setSortBy: (key: string, order: 'asc' | 'desc') => void; + setSearchString: (value: string) => void; + filterConfigs: Record | undefined; + sortByConfig: SortByConfig | undefined; +}; + +const defaultTableContextValue: TableContextValue = { + filteredAndSortedData: [], + filters: {}, + sortBy: undefined, + sortOrder: 'asc', + searchString: '', + setFilter: () => {}, + setSortBy: () => {}, + setSearchString: () => {}, + filterConfigs: undefined, + sortByConfig: undefined, +}; + +const TableContext = createContext>(defaultTableContextValue); + +export function useTableContext(): TableContextValue { + const context = useContext(TableContext); + if (context === defaultTableContextValue && context.filterConfigs === undefined) { + throw new Error('useTableContext must be used within a Table provider'); + } + return context as TableContextValue; +} + +export {TableContext}; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableFilterButtons.tsx b/src/components/Table/TableFilterButtons.tsx new file mode 100644 index 000000000000..46cca8495e64 --- /dev/null +++ b/src/components/Table/TableFilterButtons.tsx @@ -0,0 +1,159 @@ +<<<<<<< Current (Your changes) +======= +import React, {useCallback, useMemo} from 'react'; +import type {ReactNode} from 'react'; +import {FlatList, View} from 'react-native'; +import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; +import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; +import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; +import withViewportOffsetTop from '@components/withViewportOffsetTop'; +import {useTableContext} from './TableContext'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type FilterButtonItem = { + key: string; + label: string; + value: string | string[] | null; + PopoverComponent: (props: PopoverComponentProps) => ReactNode; +}; + +function TableFilterButtons() { + const styles = useThemeStyles(); + const {filterConfigs, filters, setFilter} = useTableContext(); + + // Build filter button items from filter configs + const filterItems = useMemo(() => { + if (!filterConfigs) { + return []; + } + + return Object.keys(filterConfigs).map((filterKey) => { + const filterConfig = filterConfigs[filterKey]; + const currentFilterValue = filters[filterKey]; + + // Format display value based on filter type + const getDisplayValue = (): string | string[] | null => { + if (currentFilterValue === undefined || currentFilterValue === null) { + return null; + } + + if (filterConfig.filterType === 'multi-select') { + const filterValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; + if (filterValueArray.length === 0) { + return null; + } + + // Find matching option labels for selected values + const selectedOptions = filterConfig.options.filter((option) => filterValueArray.includes(option.value)); + return selectedOptions.map((option) => option.label); + } + + // Single-select: find the matching option label + const selectedOption = filterConfig.options.find((option) => option.value === currentFilterValue); + return selectedOption?.label ?? null; + }; + + // Create popover component based on filter type + const createPopoverComponent = (): (props: PopoverComponentProps) => ReactNode => { + if (filterConfig.filterType === 'multi-select') { + return ({closeOverlay}: PopoverComponentProps) => { + const currentValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; + const selectedItems = filterConfig.options + .filter((option) => currentValueArray.includes(option.value)) + .map((option) => ({ + text: option.label, + value: option.value as string, + })); + + const handleChange = (items: Array<{text: string; value: string}>) => { + const values = items.map((item) => item.value); + setFilter(filterKey, values); + }; + + return ( + ({ + text: option.label, + value: option.value as string, + }))} + value={selectedItems} + closeOverlay={closeOverlay} + onChange={handleChange} + /> + ); + }; + } + + // Single-select popover + return ({closeOverlay}: PopoverComponentProps) => { + const selectedItem = filterConfig.options.find((option) => option.value === currentFilterValue) + ? { + text: filterConfig.options.find((option) => option.value === currentFilterValue)!.label, + value: currentFilterValue as string, + } + : null; + + const handleChange = (item: {text: string; value: string} | null) => { + setFilter(filterKey, item?.value ?? null); + }; + + return ( + ({ + text: option.label, + value: option.value as string, + }))} + value={selectedItem} + closeOverlay={closeOverlay} + onChange={handleChange} + /> + ); + }; + }; + + return { + key: filterKey, + label: filterKey, + value: getDisplayValue(), + PopoverComponent: createPopoverComponent(), + }; + }); + }, [filterConfigs, filters, setFilter]); + + const renderFilterItem = useCallback( + ({item}: {item: FilterButtonItem}) => { + const DropdownButtonWithViewport = withViewportOffsetTop(DropdownButton); + return ( + + ); + }, + [], + ); + + if (filterItems.length === 0) { + return null; + } + + return ( + item.key} + renderItem={renderFilterItem} + contentContainerStyle={[styles.flexRow, styles.gap2]} + showsHorizontalScrollIndicator={false} + /> + ); +} + +TableFilterButtons.displayName = 'TableFilterButtons'; + +export default TableFilterButtons; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx new file mode 100644 index 000000000000..f4ec33ed4e0f --- /dev/null +++ b/src/components/Table/TableHeader.tsx @@ -0,0 +1,21 @@ +<<<<<<< Current (Your changes) +======= +import React from 'react'; +import type {ReactNode} from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type TableHeaderProps = { + children: ReactNode; +}; + +function TableHeader({children}: TableHeaderProps) { + const styles = useThemeStyles(); + + return {children}; +} + +TableHeader.displayName = 'TableHeader'; + +export default TableHeader; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableSearchBar.tsx b/src/components/Table/TableSearchBar.tsx new file mode 100644 index 000000000000..8efc17ee8478 --- /dev/null +++ b/src/components/Table/TableSearchBar.tsx @@ -0,0 +1,46 @@ +<<<<<<< Current (Your changes) +======= +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import TextInput from '@components/TextInput'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {useTableContext} from './TableContext'; + +function TableSearchBar() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass'] as const); + const {searchString, setSearchString} = useTableContext(); + + const handleChangeText = useCallback( + (text: string) => { + setSearchString(text); + }, + [setSearchString], + ); + + return ( + + setSearchString('')} + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + /> + + ); +} + +TableSearchBar.displayName = 'TableSearchBar'; + +export default TableSearchBar; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableSortButtons.tsx b/src/components/Table/TableSortButtons.tsx new file mode 100644 index 000000000000..4136eb6ed44b --- /dev/null +++ b/src/components/Table/TableSortButtons.tsx @@ -0,0 +1,73 @@ +<<<<<<< Current (Your changes) +======= +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import Icon from '@components/Icon'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {useTableContext} from './TableContext'; + +function TableSortButtons() { + const styles = useThemeStyles(); + const theme = useTheme(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong'] as const); + const {sortByConfig, sortBy, sortOrder, setSortBy} = useTableContext(); + + const handleSortPress = useCallback( + (sortKey: string) => { + // If clicking the same sort key, toggle order; otherwise set new sort key with ascending order + if (sortBy === sortKey) { + const newOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + setSortBy(sortKey, newOrder); + return; + } + + setSortBy(sortKey, 'asc'); + }, + [sortBy, sortOrder, setSortBy], + ); + + const sortButtons = useMemo(() => { + if (!sortByConfig) { + return []; + } + + return sortByConfig.options.map((option) => { + const isActive = sortBy === option.value; + const sortIcon = sortOrder === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong; + + return ( + + ); + }); + }, [sortByConfig, sortBy, sortOrder, handleSortPress, expensifyIcons, styles, theme]); + + if (!sortByConfig || sortButtons.length === 0) { + return null; + } + + return {sortButtons}; +} + +TableSortButtons.displayName = 'TableSortButtons'; + +export default TableSortButtons; +>>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts new file mode 100644 index 000000000000..205c544de18d --- /dev/null +++ b/src/components/Table/index.ts @@ -0,0 +1,22 @@ +import Table from './Table'; +import TableBody from './TableBody'; +import TableFilterButtons from './TableFilterButtons'; +import TableHeader from './TableHeader'; +import TableSearchBar from './TableSearchBar'; +import TableSortButtons from './TableSortButtons'; + +// Attach sub-components to Table for compositional API +Table.Header = TableHeader; +Table.Body = TableBody; +Table.FilterButtons = TableFilterButtons; +Table.SearchBar = TableSearchBar; +Table.SortButtons = TableSortButtons; + +export default Table; +export {TableContext, useTableContext} from './TableContext'; +export type {FilterConfig, SortByConfig, TableContextValue} from './TableContext'; +export {default as TableHeader} from './TableHeader'; +export {default as TableBody} from './TableBody'; +export {default as TableFilterButtons} from './TableFilterButtons'; +export {default as TableSearchBar} from './TableSearchBar'; +export {default as TableSortButtons} from './TableSortButtons'; From ca9fa299d820b0f5c3a95cf59ec7839503bc194e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 10:54:29 -0500 Subject: [PATCH 02/53] fix: remove merge conflicts for initial table --- src/components/Table/Table.tsx | 10 +++---- src/components/Table/TableBody.tsx | 3 --- src/components/Table/TableContext.tsx | 3 --- src/components/Table/TableFilterButtons.tsx | 30 +++++++++------------ src/components/Table/TableHeader.tsx | 3 --- src/components/Table/TableSearchBar.tsx | 3 --- src/components/Table/TableSortButtons.tsx | 3 --- 7 files changed, 16 insertions(+), 39 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 523dbeff7311..4532e89e386b 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,8 +1,7 @@ -<<<<<<< Current (Your changes) -======= import React, {useCallback, useMemo, useState} from 'react'; import type {ReactNode} from 'react'; -import {TableContext, type FilterConfig, type SortByConfig} from './TableContext'; +import {TableContext} from './TableContext'; +import type {FilterConfig, SortByConfig} from './TableContext'; type TableProps = { data: T[]; @@ -16,9 +15,9 @@ function Table({data, filters, sortBy, onSearch, children}: TableProps) { const [filterValues, setFilterValues] = useState>(() => { const initialFilters: Record = {}; if (filters) { - Object.keys(filters).forEach((key) => { + for (const key of Object.keys(filters)) { initialFilters[key] = filters[key].default; - }); + } } return initialFilters; }); @@ -119,4 +118,3 @@ function Table({data, filters, sortBy, onSearch, children}: TableProps) { Table.displayName = 'Table'; export default Table; ->>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index ac3072ed5da5..d06d72490529 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -1,5 +1,3 @@ -<<<<<<< Current (Your changes) -======= import React from 'react'; import type {FlatListProps, StyleProp, ViewStyle} from 'react-native'; import {FlatList, View} from 'react-native'; @@ -64,4 +62,3 @@ function TableBody({renderItem, keyExtractor, ListEmptyComponent, contentCont TableBody.displayName = 'TableBody'; export default TableBody; ->>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 1033539ce2d9..53627e49f75d 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -1,5 +1,3 @@ -<<<<<<< Current (Your changes) -======= import React, {createContext, useContext} from 'react'; export type FilterConfig = { @@ -52,4 +50,3 @@ export function useTableContext(): TableContextValue { } export {TableContext}; ->>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableFilterButtons.tsx b/src/components/Table/TableFilterButtons.tsx index 46cca8495e64..4c74e51ca677 100644 --- a/src/components/Table/TableFilterButtons.tsx +++ b/src/components/Table/TableFilterButtons.tsx @@ -1,5 +1,3 @@ -<<<<<<< Current (Your changes) -======= import React, {useCallback, useMemo} from 'react'; import type {ReactNode} from 'react'; import {FlatList, View} from 'react-native'; @@ -8,8 +6,8 @@ import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/Dro import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; import withViewportOffsetTop from '@components/withViewportOffsetTop'; -import {useTableContext} from './TableContext'; import useThemeStyles from '@hooks/useThemeStyles'; +import {useTableContext} from './TableContext'; type FilterButtonItem = { key: string; @@ -55,7 +53,7 @@ function TableFilterButtons() { }; // Create popover component based on filter type - const createPopoverComponent = (): (props: PopoverComponentProps) => ReactNode => { + const createPopoverComponent = (): ((props: PopoverComponentProps) => ReactNode) => { if (filterConfig.filterType === 'multi-select') { return ({closeOverlay}: PopoverComponentProps) => { const currentValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; @@ -123,19 +121,16 @@ function TableFilterButtons() { }); }, [filterConfigs, filters, setFilter]); - const renderFilterItem = useCallback( - ({item}: {item: FilterButtonItem}) => { - const DropdownButtonWithViewport = withViewportOffsetTop(DropdownButton); - return ( - - ); - }, - [], - ); + const renderFilterItem = useCallback(({item}: {item: FilterButtonItem}) => { + const DropdownButtonWithViewport = withViewportOffsetTop(DropdownButton); + return ( + + ); + }, []); if (filterItems.length === 0) { return null; @@ -156,4 +151,3 @@ function TableFilterButtons() { TableFilterButtons.displayName = 'TableFilterButtons'; export default TableFilterButtons; ->>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index f4ec33ed4e0f..916ed145a8f7 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -1,5 +1,3 @@ -<<<<<<< Current (Your changes) -======= import React from 'react'; import type {ReactNode} from 'react'; import {View} from 'react-native'; @@ -18,4 +16,3 @@ function TableHeader({children}: TableHeaderProps) { TableHeader.displayName = 'TableHeader'; export default TableHeader; ->>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableSearchBar.tsx b/src/components/Table/TableSearchBar.tsx index 8efc17ee8478..90fde7e17ff1 100644 --- a/src/components/Table/TableSearchBar.tsx +++ b/src/components/Table/TableSearchBar.tsx @@ -1,5 +1,3 @@ -<<<<<<< Current (Your changes) -======= import React, {useCallback} from 'react'; import {View} from 'react-native'; import TextInput from '@components/TextInput'; @@ -43,4 +41,3 @@ function TableSearchBar() { TableSearchBar.displayName = 'TableSearchBar'; export default TableSearchBar; ->>>>>>> Incoming (Background Agent changes) diff --git a/src/components/Table/TableSortButtons.tsx b/src/components/Table/TableSortButtons.tsx index 4136eb6ed44b..f045db07374e 100644 --- a/src/components/Table/TableSortButtons.tsx +++ b/src/components/Table/TableSortButtons.tsx @@ -1,5 +1,3 @@ -<<<<<<< Current (Your changes) -======= import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; @@ -70,4 +68,3 @@ function TableSortButtons() { TableSortButtons.displayName = 'TableSortButtons'; export default TableSortButtons; ->>>>>>> Incoming (Background Agent changes) From 73ed5ecf148fcf1f7f1eea3256ff59ec754a134b Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 11:09:41 -0500 Subject: [PATCH 03/53] fix: types for compound components --- src/components/Table/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts index 205c544de18d..6026607b3ff3 100644 --- a/src/components/Table/index.ts +++ b/src/components/Table/index.ts @@ -1,11 +1,21 @@ -import Table from './Table'; +import TableComponent from './Table'; import TableBody from './TableBody'; import TableFilterButtons from './TableFilterButtons'; import TableHeader from './TableHeader'; import TableSearchBar from './TableSearchBar'; import TableSortButtons from './TableSortButtons'; +// Define the compound component type +type TableComponentType = typeof TableComponent & { + Header: typeof TableHeader; + Body: typeof TableBody; + FilterButtons: typeof TableFilterButtons; + SearchBar: typeof TableSearchBar; + SortButtons: typeof TableSortButtons; +}; + // Attach sub-components to Table for compositional API +const Table = TableComponent as TableComponentType; Table.Header = TableHeader; Table.Body = TableBody; Table.FilterButtons = TableFilterButtons; From f3165ff175236d3d032ade32f34c6685e0e2e4ae Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 11:21:21 -0500 Subject: [PATCH 04/53] refactor: fix eslint/ts errors and restructure exports --- src/components/Table/Table.tsx | 79 +++--- src/components/Table/TableBody.tsx | 16 +- src/components/Table/TableContext.tsx | 14 +- src/components/Table/TableFilterButtons.tsx | 260 +++++++++++--------- src/components/Table/TableHeader.tsx | 2 - src/components/Table/TableSearchBar.tsx | 20 +- src/components/Table/TableSortButtons.tsx | 108 ++++---- src/components/Table/index.ts | 9 +- 8 files changed, 275 insertions(+), 233 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 4532e89e386b..5cc81a881840 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,6 +1,6 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {ReactNode} from 'react'; -import {TableContext} from './TableContext'; +import TableContext from './TableContext'; import type {FilterConfig, SortByConfig} from './TableContext'; type TableProps = { @@ -26,29 +26,26 @@ function Table({data, filters, sortBy, onSearch, children}: TableProps) { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [searchString, setSearchString] = useState(''); - const setFilter = useCallback((key: string, value: unknown) => { + const setFilter = (key: string, value: unknown) => { setFilterValues((prev) => ({ ...prev, [key]: value, })); - }, []); + }; - const setSortByHandler = useCallback((key: string, order: 'asc' | 'desc') => { + const setSortByHandler = (key: string, order: 'asc' | 'desc') => { setCurrentSortBy(key); setSortOrder(order); - }, []); + }; - const setSearchStringHandler = useCallback((value: string) => { + const setSearchStringHandler = (value: string) => { setSearchString(value); - }, []); + }; // Apply filters using predicate functions - const filteredData = useMemo(() => { - if (!filters) { - return data; - } - - return data.filter((item) => { + let filteredData = data; + if (filters) { + filteredData = data.filter((item) => { return Object.keys(filters).every((filterKey) => { const filterConfig = filters[filterKey]; const filterValue = filterValues[filterKey]; @@ -72,45 +69,37 @@ function Table({data, filters, sortBy, onSearch, children}: TableProps) { return filterConfig.predicate(item, filterValue); }); }); - }, [data, filters, filterValues]); + } // Apply search using onSearch callback - const searchedData = useMemo(() => { - if (!onSearch || !searchString.trim()) { - return filteredData; - } - return onSearch(filteredData, searchString); - }, [filteredData, onSearch, searchString]); + let searchedData = filteredData; + if (onSearch && searchString.trim()) { + searchedData = onSearch(filteredData, searchString); + } // Apply sorting using comparator function - const filteredAndSortedData = useMemo(() => { - if (!sortBy || !currentSortBy) { - return searchedData; - } - + let filteredAndSortedData = searchedData; + if (sortBy && currentSortBy) { const sortedData = [...searchedData]; sortedData.sort((a, b) => { return sortBy.comparator(a, b, currentSortBy, sortOrder); }); - - return sortedData; - }, [searchedData, sortBy, currentSortBy, sortOrder]); - - const contextValue = useMemo( - () => ({ - filteredAndSortedData, - filters: filterValues, - sortBy: currentSortBy, - sortOrder, - searchString, - setFilter, - setSortBy: setSortByHandler, - setSearchString: setSearchStringHandler, - filterConfigs: filters, - sortByConfig: sortBy, - }), - [filteredAndSortedData, filterValues, currentSortBy, sortOrder, searchString, setFilter, setSortByHandler, setSearchStringHandler, filters, sortBy], - ); + filteredAndSortedData = sortedData; + } + + // eslint-disable-next-line react/jsx-no-constructed-context-values + const contextValue = { + filteredAndSortedData, + filters: filterValues, + sortBy: currentSortBy, + sortOrder, + searchString, + setFilter, + setSortBy: setSortByHandler, + setSearchString: setSearchStringHandler, + filterConfigs: filters, + sortByConfig: sortBy, + }; return {children}; } diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index d06d72490529..55914a556d96 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type {FlatListProps, StyleProp, ViewStyle} from 'react-native'; -import {FlatList, View} from 'react-native'; +import {FlatList} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import {useTableContext} from './TableContext'; @@ -16,6 +16,16 @@ type TableBodyProps = { [key: string]: unknown; }; +type RenderItemProps = { + item: T; + index: number; + renderItem: (item: T, index: number) => React.ReactNode; +}; + +function TableBodyRenderItem({item, index, renderItem}: RenderItemProps) { + return <>{renderItem(item, index)}; +} + function TableBody({renderItem, keyExtractor, ListEmptyComponent, contentContainerStyle, onScroll, onEndReached, onEndReachedThreshold, ...flatListProps}: TableBodyProps) { const styles = useThemeStyles(); const {filteredAndSortedData} = useTableContext(); @@ -39,8 +49,8 @@ function TableBody({renderItem, keyExtractor, ListEmptyComponent, contentCont return `item-${index}`; }; - const renderItemWithIndex = ({item, index}: {item: T; index: number}) => { - return <>{renderItem(item, index)}; + const renderItemWithIndex = ({item: flatListItem, index: flatListIndex}: {item: T; index: number}) => { + return ; }; return ( diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 53627e49f75d..2b6625f3f50f 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -1,19 +1,19 @@ -import React, {createContext, useContext} from 'react'; +import {createContext, useContext} from 'react'; -export type FilterConfig = { +type FilterConfig = { options: Array<{label: string; value: unknown}>; filterType: 'multi-select' | 'single-select'; default: unknown; predicate: (item: T, filterValue: unknown) => boolean; }; -export type SortByConfig = { +type SortByConfig = { options: Array<{label: string; value: string}>; default: string; comparator: (a: T, b: T, sortKey: string, order: 'asc' | 'desc') => number; }; -export type TableContextValue = { +type TableContextValue = { filteredAndSortedData: T[]; filters: Record; sortBy: string | undefined; @@ -41,7 +41,7 @@ const defaultTableContextValue: TableContextValue = { const TableContext = createContext>(defaultTableContextValue); -export function useTableContext(): TableContextValue { +function useTableContext(): TableContextValue { const context = useContext(TableContext); if (context === defaultTableContextValue && context.filterConfigs === undefined) { throw new Error('useTableContext must be used within a Table provider'); @@ -49,4 +49,6 @@ export function useTableContext(): TableContextValue { return context as TableContextValue; } -export {TableContext}; +export default TableContext; +export {useTableContext}; +export type {FilterConfig, SortByConfig, TableContextValue}; diff --git a/src/components/Table/TableFilterButtons.tsx b/src/components/Table/TableFilterButtons.tsx index 4c74e51ca677..08158a2d6f50 100644 --- a/src/components/Table/TableFilterButtons.tsx +++ b/src/components/Table/TableFilterButtons.tsx @@ -1,6 +1,6 @@ -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {ReactNode} from 'react'; -import {FlatList, View} from 'react-native'; +import {FlatList} from 'react-native'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; @@ -8,6 +8,7 @@ import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPo import withViewportOffsetTop from '@components/withViewportOffsetTop'; import useThemeStyles from '@hooks/useThemeStyles'; import {useTableContext} from './TableContext'; +import type {FilterConfig} from './TableContext'; type FilterButtonItem = { key: string; @@ -16,121 +17,154 @@ type FilterButtonItem = { PopoverComponent: (props: PopoverComponentProps) => ReactNode; }; -function TableFilterButtons() { - const styles = useThemeStyles(); - const {filterConfigs, filters, setFilter} = useTableContext(); +type MultiSelectPopoverFactoryProps = { + filterKey: string; + filterConfig: FilterConfig; + currentFilterValue: unknown; + setFilter: (key: string, value: unknown) => void; +}; - // Build filter button items from filter configs - const filterItems = useMemo(() => { - if (!filterConfigs) { - return []; - } +function createMultiSelectPopover({filterKey, filterConfig, currentFilterValue, setFilter}: MultiSelectPopoverFactoryProps) { + return ({closeOverlay}: PopoverComponentProps) => { + const currentValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; + const selectedItems = filterConfig.options + .filter((option) => currentValueArray.includes(option.value)) + .map((option) => ({ + text: option.label, + value: option.value as string, + })); + + const handleChange = (items: Array<{text: string; value: string}>) => { + const values = items.map((item) => item.value); + setFilter(filterKey, values); + }; + + return ( + ({ + text: option.label, + value: option.value as string, + }))} + value={selectedItems} + closeOverlay={closeOverlay} + onChange={handleChange} + /> + ); + }; +} + +type SingleSelectPopoverFactoryProps = { + filterKey: string; + filterConfig: FilterConfig; + currentFilterValue: unknown; + setFilter: (key: string, value: unknown) => void; +}; + +function createSingleSelectPopover({filterKey, filterConfig, currentFilterValue, setFilter}: SingleSelectPopoverFactoryProps) { + return ({closeOverlay}: PopoverComponentProps) => { + const selectedItem = filterConfig.options.find((option) => option.value === currentFilterValue) + ? { + text: filterConfig.options.find((option) => option.value === currentFilterValue)?.label, + value: currentFilterValue as string, + } + : null; + + const handleChange = (item: {text: string; value: string} | null) => { + setFilter(filterKey, item?.value ?? null); + }; - return Object.keys(filterConfigs).map((filterKey) => { - const filterConfig = filterConfigs[filterKey]; - const currentFilterValue = filters[filterKey]; - - // Format display value based on filter type - const getDisplayValue = (): string | string[] | null => { - if (currentFilterValue === undefined || currentFilterValue === null) { - return null; - } - - if (filterConfig.filterType === 'multi-select') { - const filterValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; - if (filterValueArray.length === 0) { - return null; - } - - // Find matching option labels for selected values - const selectedOptions = filterConfig.options.filter((option) => filterValueArray.includes(option.value)); - return selectedOptions.map((option) => option.label); - } - - // Single-select: find the matching option label - const selectedOption = filterConfig.options.find((option) => option.value === currentFilterValue); - return selectedOption?.label ?? null; - }; - - // Create popover component based on filter type - const createPopoverComponent = (): ((props: PopoverComponentProps) => ReactNode) => { - if (filterConfig.filterType === 'multi-select') { - return ({closeOverlay}: PopoverComponentProps) => { - const currentValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; - const selectedItems = filterConfig.options - .filter((option) => currentValueArray.includes(option.value)) - .map((option) => ({ - text: option.label, - value: option.value as string, - })); - - const handleChange = (items: Array<{text: string; value: string}>) => { - const values = items.map((item) => item.value); - setFilter(filterKey, values); - }; - - return ( - ({ - text: option.label, - value: option.value as string, - }))} - value={selectedItems} - closeOverlay={closeOverlay} - onChange={handleChange} - /> - ); - }; - } - - // Single-select popover - return ({closeOverlay}: PopoverComponentProps) => { - const selectedItem = filterConfig.options.find((option) => option.value === currentFilterValue) - ? { - text: filterConfig.options.find((option) => option.value === currentFilterValue)!.label, - value: currentFilterValue as string, - } - : null; - - const handleChange = (item: {text: string; value: string} | null) => { - setFilter(filterKey, item?.value ?? null); - }; - - return ( - ({ - text: option.label, - value: option.value as string, - }))} - value={selectedItem} - closeOverlay={closeOverlay} - onChange={handleChange} - /> - ); - }; - }; - - return { - key: filterKey, - label: filterKey, - value: getDisplayValue(), - PopoverComponent: createPopoverComponent(), - }; - }); - }, [filterConfigs, filters, setFilter]); - - const renderFilterItem = useCallback(({item}: {item: FilterButtonItem}) => { - const DropdownButtonWithViewport = withViewportOffsetTop(DropdownButton); return ( - ({ + text: option.label, + value: option.value as string, + }))} + value={selectedItem} + closeOverlay={closeOverlay} + onChange={handleChange} /> ); - }, []); + }; +} + +type FilterItemRendererProps = { + item: FilterButtonItem; +}; + +function FilterItemRenderer({item}: FilterItemRendererProps) { + const DropdownButtonWithViewport = withViewportOffsetTop(DropdownButton); + return ( + + ); +} + +function getDisplayValue(filterConfig: FilterConfig, currentFilterValue: unknown): string | string[] | null { + if (currentFilterValue === undefined || currentFilterValue === null) { + return null; + } + + if (filterConfig.filterType === 'multi-select') { + const filterValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; + if (filterValueArray.length === 0) { + return null; + } + + // Find matching option labels for selected values + const selectedOptions = filterConfig.options.filter((option) => filterValueArray.includes(option.value)); + return selectedOptions.map((option) => option.label); + } + + // Single-select: find the matching option label + const selectedOption = filterConfig.options.find((option) => option.value === currentFilterValue); + return selectedOption?.label ?? null; +} + +function createPopoverComponent( + filterKey: string, + filterConfig: FilterConfig, + currentFilterValue: unknown, + setFilter: (key: string, value: unknown) => void, +): (props: PopoverComponentProps) => ReactNode { + if (filterConfig.filterType === 'multi-select') { + return createMultiSelectPopover({filterKey, filterConfig, currentFilterValue, setFilter}); + } + + return createSingleSelectPopover({filterKey, filterConfig, currentFilterValue, setFilter}); +} + +function buildFilterItems(filterConfigs: Record | undefined, filters: Record, setFilter: (key: string, value: unknown) => void): FilterButtonItem[] { + if (!filterConfigs) { + return []; + } + + return Object.keys(filterConfigs).map((filterKey) => { + const filterConfig = filterConfigs[filterKey]; + const currentFilterValue = filters[filterKey]; + + return { + key: filterKey, + label: filterKey, + value: getDisplayValue(filterConfig, currentFilterValue), + PopoverComponent: createPopoverComponent(filterKey, filterConfig, currentFilterValue, setFilter), + }; + }); +} + +function renderFilterItem({item}: {item: FilterButtonItem}) { + return ; +} + +function TableFilterButtons() { + const styles = useThemeStyles(); + const {filterConfigs, filters, setFilter} = useTableContext(); + + const filterItems = buildFilterItems(filterConfigs, filters, setFilter); if (filterItems.length === 0) { return null; @@ -148,6 +182,4 @@ function TableFilterButtons() { ); } -TableFilterButtons.displayName = 'TableFilterButtons'; - export default TableFilterButtons; diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 916ed145a8f7..6ef916a4515f 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -13,6 +13,4 @@ function TableHeader({children}: TableHeaderProps) { return {children}; } -TableHeader.displayName = 'TableHeader'; - export default TableHeader; diff --git a/src/components/Table/TableSearchBar.tsx b/src/components/Table/TableSearchBar.tsx index 90fde7e17ff1..2223e870651b 100644 --- a/src/components/Table/TableSearchBar.tsx +++ b/src/components/Table/TableSearchBar.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import TextInput from '@components/TextInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -12,12 +12,13 @@ function TableSearchBar() { const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass'] as const); const {searchString, setSearchString} = useTableContext(); - const handleChangeText = useCallback( - (text: string) => { - setSearchString(text); - }, - [setSearchString], - ); + const handleChangeText = (text: string) => { + setSearchString(text); + }; + + const handleClearInput = () => { + setSearchString(''); + }; return ( @@ -29,7 +30,7 @@ function TableSearchBar() { icon={searchString.length === 0 ? expensifyIcons.MagnifyingGlass : undefined} shouldShowClearButton shouldHideClearButton={searchString.length === 0} - onClearInput={() => setSearchString('')} + onClearInput={handleClearInput} autoCapitalize="none" autoCorrect={false} spellCheck={false} @@ -37,7 +38,4 @@ function TableSearchBar() { ); } - -TableSearchBar.displayName = 'TableSearchBar'; - export default TableSearchBar; diff --git a/src/components/Table/TableSortButtons.tsx b/src/components/Table/TableSortButtons.tsx index f045db07374e..59c80aa7984f 100644 --- a/src/components/Table/TableSortButtons.tsx +++ b/src/components/Table/TableSortButtons.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; import Icon from '@components/Icon'; @@ -7,64 +7,80 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {useTableContext} from './TableContext'; -function TableSortButtons() { +type SortButtonProps = { + option: {label: string; value: string}; + isActive: boolean; + sortOrder: 'asc' | 'desc'; + onPress: (sortKey: string) => void; +}; + +function SortButton({option, isActive, sortOrder, onPress}: SortButtonProps) { const styles = useThemeStyles(); const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong'] as const); - const {sortByConfig, sortBy, sortOrder, setSortBy} = useTableContext(); + const sortIcon = sortOrder === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong; - const handleSortPress = useCallback( - (sortKey: string) => { - // If clicking the same sort key, toggle order; otherwise set new sort key with ascending order - if (sortBy === sortKey) { - const newOrder = sortOrder === 'asc' ? 'desc' : 'asc'; - setSortBy(sortKey, newOrder); - return; - } + const handlePress = () => { + onPress(option.value); + }; - setSortBy(sortKey, 'asc'); - }, - [sortBy, sortOrder, setSortBy], + return ( + ); +} - const sortButtons = useMemo(() => { - if (!sortByConfig) { - return []; - } +function TableSortButtons() { + const styles = useThemeStyles(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong'] as const); + const {sortByConfig, sortBy, sortOrder, setSortBy} = useTableContext(); - return sortByConfig.options.map((option) => { - const isActive = sortBy === option.value; - const sortIcon = sortOrder === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong; + const handleSortPress = (sortKey: string) => { + // If clicking the same sort key, toggle order; otherwise set new sort key with ascending order + if (sortBy === sortKey) { + const newOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + setSortBy(sortKey, newOrder); + return; + } - return ( - - ); - }); - }, [sortByConfig, sortBy, sortOrder, handleSortPress, expensifyIcons, styles, theme]); + setSortBy(sortKey, 'asc'); + }; - if (!sortByConfig || sortButtons.length === 0) { + if (!sortByConfig || sortByConfig.options.length === 0) { return null; } - return {sortButtons}; + return ( + + {sortByConfig.options.map((option) => { + const isActive = sortBy === option.value; + return ( + + ); + })} + + ); } -TableSortButtons.displayName = 'TableSortButtons'; - export default TableSortButtons; diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts index 6026607b3ff3..5a69d40e7474 100644 --- a/src/components/Table/index.ts +++ b/src/components/Table/index.ts @@ -1,5 +1,6 @@ import TableComponent from './Table'; import TableBody from './TableBody'; +import type TableContext from './TableContext'; import TableFilterButtons from './TableFilterButtons'; import TableHeader from './TableHeader'; import TableSearchBar from './TableSearchBar'; @@ -7,6 +8,7 @@ import TableSortButtons from './TableSortButtons'; // Define the compound component type type TableComponentType = typeof TableComponent & { + Context: typeof TableContext; Header: typeof TableHeader; Body: typeof TableBody; FilterButtons: typeof TableFilterButtons; @@ -23,10 +25,5 @@ Table.SearchBar = TableSearchBar; Table.SortButtons = TableSortButtons; export default Table; -export {TableContext, useTableContext} from './TableContext'; +export {useTableContext} from './TableContext'; export type {FilterConfig, SortByConfig, TableContextValue} from './TableContext'; -export {default as TableHeader} from './TableHeader'; -export {default as TableBody} from './TableBody'; -export {default as TableFilterButtons} from './TableFilterButtons'; -export {default as TableSearchBar} from './TableSearchBar'; -export {default as TableSortButtons} from './TableSortButtons'; From 5b40adfcde03ceca33f659062aee850e4340af64 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 13:03:39 -0500 Subject: [PATCH 05/53] refactor: improve generic table component --- src/components/Table/Table.tsx | 19 ++++------- src/components/Table/TableBody.tsx | 35 +++----------------- src/components/Table/TableContext.tsx | 24 +++++--------- src/components/Table/TableFilterButtons.tsx | 2 +- src/components/Table/TableSortButtons.tsx | 1 - src/components/Table/{index.ts => index.tsx} | 15 +++++---- src/components/Table/types.ts | 27 +++++++++++++++ 7 files changed, 55 insertions(+), 68 deletions(-) rename src/components/Table/{index.ts => index.tsx} (66%) create mode 100644 src/components/Table/types.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 5cc81a881840..f5d18ffd4b6e 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,17 +1,9 @@ import React, {useState} from 'react'; -import type {ReactNode} from 'react'; import TableContext from './TableContext'; -import type {FilterConfig, SortByConfig} from './TableContext'; +import type {TableContextValue} from './TableContext'; +import type {TableProps} from './types'; -type TableProps = { - data: T[]; - filters?: Record; - sortBy?: SortByConfig; - onSearch?: (items: T[], searchString: string) => T[]; - children: ReactNode; -}; - -function Table({data, filters, sortBy, onSearch, children}: TableProps) { +function Table({data = [], filters, sortBy, onSearch, children, ...flatListProps}: TableProps) { const [filterValues, setFilterValues] = useState>(() => { const initialFilters: Record = {}; if (filters) { @@ -88,7 +80,7 @@ function Table({data, filters, sortBy, onSearch, children}: TableProps) { } // eslint-disable-next-line react/jsx-no-constructed-context-values - const contextValue = { + const contextValue: TableContextValue = { filteredAndSortedData, filters: filterValues, sortBy: currentSortBy, @@ -99,9 +91,10 @@ function Table({data, filters, sortBy, onSearch, children}: TableProps) { setSearchString: setSearchStringHandler, filterConfigs: filters, sortByConfig: sortBy, + flatListProps, }; - return {children}; + return }>{children}; } Table.displayName = 'Table'; diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 55914a556d96..7227d4559a25 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -1,34 +1,12 @@ import React from 'react'; -import type {FlatListProps, StyleProp, ViewStyle} from 'react-native'; import {FlatList} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import {useTableContext} from './TableContext'; -type TableBodyProps = { - renderItem: (item: T, index: number) => React.ReactNode; - keyExtractor?: (item: T, index: number) => string; - ListEmptyComponent?: React.ComponentType | React.ReactElement | null; - contentContainerStyle?: StyleProp; - onScroll?: FlatListProps['onScroll']; - onEndReached?: FlatListProps['onEndReached']; - onEndReachedThreshold?: number; - // Allow other FlatList props to be passed through - [key: string]: unknown; -}; - -type RenderItemProps = { - item: T; - index: number; - renderItem: (item: T, index: number) => React.ReactNode; -}; - -function TableBodyRenderItem({item, index, renderItem}: RenderItemProps) { - return <>{renderItem(item, index)}; -} - -function TableBody({renderItem, keyExtractor, ListEmptyComponent, contentContainerStyle, onScroll, onEndReached, onEndReachedThreshold, ...flatListProps}: TableBodyProps) { +function TableBody() { const styles = useThemeStyles(); - const {filteredAndSortedData} = useTableContext(); + const {filteredAndSortedData, flatListProps} = useTableContext(); + const {keyExtractor, ListEmptyComponent, contentContainerStyle, onScroll, onEndReached, onEndReachedThreshold} = flatListProps ?? {}; const defaultKeyExtractor = (item: T, index: number): string => { if (keyExtractor) { @@ -49,14 +27,9 @@ function TableBody({renderItem, keyExtractor, ListEmptyComponent, contentCont return `item-${index}`; }; - const renderItemWithIndex = ({item: flatListItem, index: flatListIndex}: {item: T; index: number}) => { - return ; - }; - return ( - data={filteredAndSortedData} - renderItem={renderItemWithIndex} keyExtractor={defaultKeyExtractor} ListEmptyComponent={ListEmptyComponent} contentContainerStyle={[contentContainerStyle, filteredAndSortedData.length === 0 && styles.flex1]} diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 2b6625f3f50f..742d02fdef32 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -1,17 +1,5 @@ import {createContext, useContext} from 'react'; - -type FilterConfig = { - options: Array<{label: string; value: unknown}>; - filterType: 'multi-select' | 'single-select'; - default: unknown; - predicate: (item: T, filterValue: unknown) => boolean; -}; - -type SortByConfig = { - options: Array<{label: string; value: string}>; - default: string; - comparator: (a: T, b: T, sortKey: string, order: 'asc' | 'desc') => number; -}; +import type {FilterConfig, SharedFlatListProps, SortByConfig} from './types'; type TableContextValue = { filteredAndSortedData: T[]; @@ -24,6 +12,7 @@ type TableContextValue = { setSearchString: (value: string) => void; filterConfigs: Record | undefined; sortByConfig: SortByConfig | undefined; + flatListProps: SharedFlatListProps; }; const defaultTableContextValue: TableContextValue = { @@ -37,18 +26,23 @@ const defaultTableContextValue: TableContextValue = { setSearchString: () => {}, filterConfigs: undefined, sortByConfig: undefined, + flatListProps: {} as SharedFlatListProps, }; -const TableContext = createContext>(defaultTableContextValue); +type TableContextType = React.Context>; + +const TableContext = createContext(defaultTableContextValue); function useTableContext(): TableContextValue { const context = useContext(TableContext); + if (context === defaultTableContextValue && context.filterConfigs === undefined) { throw new Error('useTableContext must be used within a Table provider'); } + return context as TableContextValue; } export default TableContext; export {useTableContext}; -export type {FilterConfig, SortByConfig, TableContextValue}; +export type {TableContextType, TableContextValue}; diff --git a/src/components/Table/TableFilterButtons.tsx b/src/components/Table/TableFilterButtons.tsx index 08158a2d6f50..7871ff281829 100644 --- a/src/components/Table/TableFilterButtons.tsx +++ b/src/components/Table/TableFilterButtons.tsx @@ -8,7 +8,7 @@ import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPo import withViewportOffsetTop from '@components/withViewportOffsetTop'; import useThemeStyles from '@hooks/useThemeStyles'; import {useTableContext} from './TableContext'; -import type {FilterConfig} from './TableContext'; +import type {FilterConfig} from './types'; type FilterButtonItem = { key: string; diff --git a/src/components/Table/TableSortButtons.tsx b/src/components/Table/TableSortButtons.tsx index 59c80aa7984f..1ebb80be8f95 100644 --- a/src/components/Table/TableSortButtons.tsx +++ b/src/components/Table/TableSortButtons.tsx @@ -75,7 +75,6 @@ function TableSortButtons() { isActive={isActive} sortOrder={sortOrder} onPress={handleSortPress} - expensifyIcons={expensifyIcons} /> ); })} diff --git a/src/components/Table/index.ts b/src/components/Table/index.tsx similarity index 66% rename from src/components/Table/index.ts rename to src/components/Table/index.tsx index 5a69d40e7474..344801ce9939 100644 --- a/src/components/Table/index.ts +++ b/src/components/Table/index.tsx @@ -1,23 +1,23 @@ import TableComponent from './Table'; import TableBody from './TableBody'; -import type TableContext from './TableContext'; +import type {TableContextType} from './TableContext'; import TableFilterButtons from './TableFilterButtons'; import TableHeader from './TableHeader'; import TableSearchBar from './TableSearchBar'; import TableSortButtons from './TableSortButtons'; // Define the compound component type -type TableComponentType = typeof TableComponent & { - Context: typeof TableContext; +type TableComponentType = typeof TableComponent & { + Context: TableContextType; Header: typeof TableHeader; - Body: typeof TableBody; + Body: typeof TableBody; FilterButtons: typeof TableFilterButtons; SearchBar: typeof TableSearchBar; SortButtons: typeof TableSortButtons; }; -// Attach sub-components to Table for compositional API -const Table = TableComponent as TableComponentType; +const Table = TableComponent as TableComponentType; + Table.Header = TableHeader; Table.Body = TableBody; Table.FilterButtons = TableFilterButtons; @@ -26,4 +26,5 @@ Table.SortButtons = TableSortButtons; export default Table; export {useTableContext} from './TableContext'; -export type {FilterConfig, SortByConfig, TableContextValue} from './TableContext'; +export type {TableContextValue} from './TableContext'; +export type * from './types'; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts new file mode 100644 index 000000000000..9a8d0d2e44c4 --- /dev/null +++ b/src/components/Table/types.ts @@ -0,0 +1,27 @@ +import type {PropsWithChildren} from 'react'; +import type {FlatListProps} from 'react-native'; + +type FilterConfig = { + options: Array<{label: string; value: unknown}>; + filterType: 'multi-select' | 'single-select'; + default: unknown; + predicate: (item: T, filterValue: unknown) => boolean; +}; + +type SortByConfig = { + options: Array<{label: string; value: string}>; + default: string; + comparator: (a: T, b: T, sortKey: string, order: 'asc' | 'desc') => number; +}; + +type SharedFlatListProps = Omit, 'data'>; + +type TableProps = SharedFlatListProps & + PropsWithChildren<{ + data: T[] | undefined; + filters?: Record; + sortBy?: SortByConfig; + onSearch?: (items: T[], searchString: string) => T[]; + }>; + +export type {FilterConfig, SortByConfig, SharedFlatListProps, TableProps}; From 8b2e1d4ea4d9e81d2b8ecf6243c43a19b09f324e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 17:30:35 -0500 Subject: [PATCH 06/53] feat: implement sorting and `TableHeader` component --- src/components/Table/Table.tsx | 79 +++++++++-------- src/components/Table/TableContext.tsx | 42 +++++----- src/components/Table/TableFilterButtons.tsx | 2 +- src/components/Table/TableHeader.tsx | 84 +++++++++++++++++-- src/components/Table/TableHeaderContainer.tsx | 16 ++++ src/components/Table/TableSearchBar.tsx | 2 +- src/components/Table/TableSortButtons.tsx | 2 +- src/components/Table/index.tsx | 13 ++- src/components/Table/types.ts | 51 +++++++---- src/styles/index.ts | 7 ++ 10 files changed, 215 insertions(+), 83 deletions(-) create mode 100644 src/components/Table/TableHeaderContainer.tsx diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index f5d18ffd4b6e..f47c8f3947d8 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,37 +1,45 @@ import React, {useState} from 'react'; import TableContext from './TableContext'; -import type {TableContextValue} from './TableContext'; +import type {TableContextValue, UpdateFilterCallback, UpdateSortingCallback} from './TableContext'; import type {TableProps} from './types'; -function Table({data = [], filters, sortBy, onSearch, children, ...flatListProps}: TableProps) { - const [filterValues, setFilterValues] = useState>(() => { - const initialFilters: Record = {}; - if (filters) { - for (const key of Object.keys(filters)) { - initialFilters[key] = filters[key].default; - } - } - return initialFilters; +function Table({data = [], columns, filters, compareItems, isItemInFilter, isItemInSearch, children, ...flatListProps}: TableProps) { + if (!columns || columns.length === 0) { + throw new Error('Table columns must be provided'); + } + + const [currentFilters, setCurrentFilters] = useState>(() => { + return {}; + + // const initialFilters: Record = {}; + // if (filters) { + // for (const key of Object.keys(filters)) { + // initialFilters[key] = filters[key].default; + // } + // } + // return initialFilters; }); - const [currentSortBy, setCurrentSortBy] = useState(sortBy?.default); + const [sortColumn, setSortColumn] = useState(); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [searchString, setSearchString] = useState(''); - const setFilter = (key: string, value: unknown) => { - setFilterValues((prev) => ({ + const updateFilter: UpdateFilterCallback = ({key, value}) => { + setCurrentFilters((prev) => ({ ...prev, [key]: value, })); }; - const setSortByHandler = (key: string, order: 'asc' | 'desc') => { - setCurrentSortBy(key); - setSortOrder(order); - }; + const updateSorting: UpdateSortingCallback = ({columnKey, order}) => { + if (columnKey) { + setSortColumn(columnKey); + setSortOrder(order ?? 'asc'); + return; + } - const setSearchStringHandler = (value: string) => { - setSearchString(value); + setSortColumn(undefined); + setSortOrder('asc'); }; // Apply filters using predicate functions @@ -40,7 +48,8 @@ function Table({data = [], filters, sortBy, onSearch, children, ...flatListPr filteredData = data.filter((item) => { return Object.keys(filters).every((filterKey) => { const filterConfig = filters[filterKey]; - const filterValue = filterValues[filterKey]; + // const filterValue = filterValues[filterKey]; + const filterValue = undefined; // If filter value is empty/undefined, include the item if (filterValue === undefined || filterValue === null) { @@ -54,47 +63,47 @@ function Table({data = [], filters, sortBy, onSearch, children, ...flatListPr return true; } // For multi-select, item passes if it matches any selected value - return filterValueArray.some((value) => filterConfig.predicate(item, value)); + return filterValueArray.some((value) => isItemInFilter?.(item, value) ?? true); } // Handle single-select filters - return filterConfig.predicate(item, filterValue); + return isItemInFilter?.(item, filterValue) ?? true; }); }); } // Apply search using onSearch callback let searchedData = filteredData; - if (onSearch && searchString.trim()) { - searchedData = onSearch(filteredData, searchString); + if (isItemInSearch && searchString.trim()) { + searchedData = filteredData.filter((item) => isItemInSearch(item, searchString)); } // Apply sorting using comparator function let filteredAndSortedData = searchedData; - if (sortBy && currentSortBy) { + if (sortColumn) { const sortedData = [...searchedData]; sortedData.sort((a, b) => { - return sortBy.comparator(a, b, currentSortBy, sortOrder); + return compareItems?.(a, b, sortColumn, sortOrder) ?? 0; }); filteredAndSortedData = sortedData; } // eslint-disable-next-line react/jsx-no-constructed-context-values - const contextValue: TableContextValue = { + const contextValue: TableContextValue = { filteredAndSortedData, - filters: filterValues, - sortBy: currentSortBy, + columns, + currentFilters, + sortColumn, sortOrder, searchString, - setFilter, - setSortBy: setSortByHandler, - setSearchString: setSearchStringHandler, - filterConfigs: filters, - sortByConfig: sortBy, + updateFilter, + updateSorting, + updateSearchString: setSearchString, + filterConfig: filters, flatListProps, }; - return }>{children}; + return }>{children}; } Table.displayName = 'Table'; diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 742d02fdef32..d3ece64cd189 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -1,31 +1,35 @@ import {createContext, useContext} from 'react'; -import type {FilterConfig, SharedFlatListProps, SortByConfig} from './types'; +import type {FilterConfig, SharedFlatListProps, TableColumn} from './types'; -type TableContextValue = { +type UpdateSortingCallback = (params: {columnKey?: ColumnKey; order?: 'asc' | 'desc'}) => void; +type UpdateSearchStringCallback = (value: string) => void; +type UpdateFilterCallback = (params: {key: string; value: unknown}) => void; + +type TableContextValue = { filteredAndSortedData: T[]; - filters: Record; - sortBy: string | undefined; + columns: TableColumn[]; + currentFilters: Record; + sortColumn: ColumnKey | undefined; sortOrder: 'asc' | 'desc'; searchString: string; - setFilter: (key: string, value: unknown) => void; - setSortBy: (key: string, order: 'asc' | 'desc') => void; - setSearchString: (value: string) => void; - filterConfigs: Record | undefined; - sortByConfig: SortByConfig | undefined; + updateFilter: UpdateFilterCallback; + updateSorting: UpdateSortingCallback; + updateSearchString: UpdateSearchStringCallback; + filterConfig: FilterConfig | undefined; flatListProps: SharedFlatListProps; }; -const defaultTableContextValue: TableContextValue = { +const defaultTableContextValue: TableContextValue = { filteredAndSortedData: [], - filters: {}, - sortBy: undefined, + columns: [], + currentFilters: {}, + sortColumn: undefined, sortOrder: 'asc', searchString: '', - setFilter: () => {}, - setSortBy: () => {}, - setSearchString: () => {}, - filterConfigs: undefined, - sortByConfig: undefined, + updateFilter: () => {}, + updateSorting: () => {}, + updateSearchString: () => {}, + filterConfig: undefined, flatListProps: {} as SharedFlatListProps, }; @@ -36,7 +40,7 @@ const TableContext = createContext(defaultTableContextValue); function useTableContext(): TableContextValue { const context = useContext(TableContext); - if (context === defaultTableContextValue && context.filterConfigs === undefined) { + if (context === defaultTableContextValue && context.currentFilters === undefined) { throw new Error('useTableContext must be used within a Table provider'); } @@ -45,4 +49,4 @@ function useTableContext(): TableContextValue { export default TableContext; export {useTableContext}; -export type {TableContextType, TableContextValue}; +export type {TableContextType, TableContextValue, UpdateSortingCallback, UpdateSearchStringCallback, UpdateFilterCallback}; diff --git a/src/components/Table/TableFilterButtons.tsx b/src/components/Table/TableFilterButtons.tsx index 7871ff281829..5fd5462be621 100644 --- a/src/components/Table/TableFilterButtons.tsx +++ b/src/components/Table/TableFilterButtons.tsx @@ -162,7 +162,7 @@ function renderFilterItem({item}: {item: FilterButtonItem}) { function TableFilterButtons() { const styles = useThemeStyles(); - const {filterConfigs, filters, setFilter} = useTableContext(); + const {currentFilters: filterConfigs, currentFilters: filters, updateFilter: setFilter} = useTableContext(); const filterItems = buildFilterItems(filterConfigs, filters, setFilter); diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 6ef916a4515f..eb64822e9327 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -1,16 +1,86 @@ -import React from 'react'; -import type {ReactNode} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import {useTableContext} from './TableContext'; +import type {TableColumn} from './types'; -type TableHeaderProps = { - children: ReactNode; -}; +function TableHeader() { + const styles = useThemeStyles(); + const {columns} = useTableContext(); + + return ( + + {columns.map((column) => { + return ( + + ); + })} + + ); +} -function TableHeader({children}: TableHeaderProps) { +function TableHeaderColumn({column}: {column: TableColumn}) { + const theme = useTheme(); const styles = useThemeStyles(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong'] as const); + + const {sortColumn, sortOrder, updateSorting} = useTableContext(); + const isSortingByColumn = column.key === sortColumn; + const [sortToggleCount, setSortToggleCount] = useState(0); + const sortIcon = sortOrder === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong; + + const toggleSorting = () => { + if (sortToggleCount >= 2) { + updateSorting({columnKey: undefined}); + setSortToggleCount(0); + return; + } + + const newSortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + setSortToggleCount((prev) => prev + 1); + updateSorting({columnKey: column.key, order: newSortOrder}); + }; + + return ( + + + {column.label} + - return {children}; + {isSortingByColumn && ( + + )} + + ); } export default TableHeader; diff --git a/src/components/Table/TableHeaderContainer.tsx b/src/components/Table/TableHeaderContainer.tsx new file mode 100644 index 000000000000..8b9bb66c7cf8 --- /dev/null +++ b/src/components/Table/TableHeaderContainer.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type {ReactNode} from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type TableHeaderProps = { + children: ReactNode; +}; + +function TableHeaderContainer({children}: TableHeaderProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default TableHeaderContainer; diff --git a/src/components/Table/TableSearchBar.tsx b/src/components/Table/TableSearchBar.tsx index 2223e870651b..5cab02c1e1f6 100644 --- a/src/components/Table/TableSearchBar.tsx +++ b/src/components/Table/TableSearchBar.tsx @@ -10,7 +10,7 @@ function TableSearchBar() { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass'] as const); - const {searchString, setSearchString} = useTableContext(); + const {searchString, updateSearchString: setSearchString} = useTableContext(); const handleChangeText = (text: string) => { setSearchString(text); diff --git a/src/components/Table/TableSortButtons.tsx b/src/components/Table/TableSortButtons.tsx index 1ebb80be8f95..4cf9a839a889 100644 --- a/src/components/Table/TableSortButtons.tsx +++ b/src/components/Table/TableSortButtons.tsx @@ -47,7 +47,7 @@ function SortButton({option, isActive, sortOrder, onPress}: SortButtonProps) { function TableSortButtons() { const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong'] as const); - const {sortByConfig, sortBy, sortOrder, setSortBy} = useTableContext(); + const {sortByConfig, sortColumn: sortBy, sortOrder, updateSorting: setSortBy} = useTableContext(); const handleSortPress = (sortKey: string) => { // If clicking the same sort key, toggle order; otherwise set new sort key with ascending order diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 344801ce9939..bd5b83aa9d1b 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -1,24 +1,29 @@ import TableComponent from './Table'; import TableBody from './TableBody'; import type {TableContextType} from './TableContext'; +import TableContext from './TableContext'; import TableFilterButtons from './TableFilterButtons'; import TableHeader from './TableHeader'; +import TableHeaderContainer from './TableHeaderContainer'; import TableSearchBar from './TableSearchBar'; import TableSortButtons from './TableSortButtons'; // Define the compound component type -type TableComponentType = typeof TableComponent & { - Context: TableContextType; +type TableComponentType = typeof TableComponent & { + Context: TableContextType; Header: typeof TableHeader; - Body: typeof TableBody; + HeaderContainer: typeof TableHeaderContainer; + Body: typeof TableBody; FilterButtons: typeof TableFilterButtons; SearchBar: typeof TableSearchBar; SortButtons: typeof TableSortButtons; }; -const Table = TableComponent as TableComponentType; +const Table = TableComponent as TableComponentType; +Table.Context = TableContext; Table.Header = TableHeader; +Table.HeaderContainer = TableHeaderContainer; Table.Body = TableBody; Table.FilterButtons = TableFilterButtons; Table.SearchBar = TableSearchBar; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 9a8d0d2e44c4..ca824e68227e 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -1,27 +1,48 @@ import type {PropsWithChildren} from 'react'; -import type {FlatListProps} from 'react-native'; +import type {FlatListProps, StyleProp, TextStyle, ViewStyle} from 'react-native'; -type FilterConfig = { - options: Array<{label: string; value: unknown}>; - filterType: 'multi-select' | 'single-select'; - default: unknown; - predicate: (item: T, filterValue: unknown) => boolean; +type TableColumnStyling = { + flex?: number; + containerStyles?: StyleProp; + labelStyles?: StyleProp; }; -type SortByConfig = { - options: Array<{label: string; value: string}>; - default: string; - comparator: (a: T, b: T, sortKey: string, order: 'asc' | 'desc') => number; +type TableColumn = { + key: ColumnKey; + label: string; + styling?: TableColumnStyling; }; +type FilterConfig = Record< + string, + { + filterType?: 'multi-select' | 'single-select'; + options?: string[]; + default?: string; + } +>; + +type TableSortOrder = 'asc' | 'desc'; + +type CompareItemsCallback = (a: T, b: T, sortColumn: ColumnKey, order: TableSortOrder) => number; + +type IsItemInFilterCallback = (item: T, filters: string[]) => boolean; + +type IsItemInSearchCallback = (item: T, searchString: string) => boolean; + type SharedFlatListProps = Omit, 'data'>; -type TableProps = SharedFlatListProps & +type TableProps = SharedFlatListProps & PropsWithChildren<{ data: T[] | undefined; - filters?: Record; - sortBy?: SortByConfig; - onSearch?: (items: T[], searchString: string) => T[]; + columns: Array>; + filters?: FilterConfig; + initialFilters?: string[]; + initialSortColumn?: string; + initialSearchString?: string; + compareItems?: CompareItemsCallback; + isItemInFilter?: IsItemInFilterCallback; + isItemInSearch?: IsItemInSearchCallback; }>; -export type {FilterConfig, SortByConfig, SharedFlatListProps, TableProps}; +export type {TableColumn, FilterConfig, SharedFlatListProps, TableProps, TableSortOrder, CompareItemsCallback, IsItemInFilterCallback, IsItemInSearchCallback}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 815536b71f8e..beb1a75334c7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -509,6 +509,13 @@ const staticStyles = (theme: ThemeColors) => lineHeight: variables.lineHeightNormal, }, + textMicroBoldSupporting: { + color: theme.textSupporting, + ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontSize: variables.fontSizeSmall, + lineHeight: variables.lineHeightNormal, + }, + textMicroSupporting: { color: theme.textSupporting, ...FontUtils.fontFamily.platform.EXP_NEUE, From 7f4d04d906757b74d92331199e8327f77a4d0f16 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 17:33:05 -0500 Subject: [PATCH 07/53] feat: migrate `WorkspaceCompanyCardList` to generic table --- .../WorkspaceCompanyCardsList.tsx | 241 ------------------ .../WorkspaceCompanyCardsListRow.tsx | 180 ------------- .../WorkspaceCompanyCardsPage.tsx | 4 +- .../WorkspaceCompanyCardsTable.tsx | 226 ++++++++++++++++ .../WorkspaceCompanyCardsTableItem.tsx | 222 ++++++++++++++++ 5 files changed, 450 insertions(+), 423 deletions(-) delete mode 100644 src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx delete mode 100644 src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx create mode 100644 src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx create mode 100644 src/pages/workspace/companyCards/WorkspaceCompanyCardsTableItem.tsx diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx deleted file mode 100644 index f97c9f04d5e8..000000000000 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useMemo, useRef} from 'react'; -import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {PressableWithFeedback} from '@components/Pressable'; -import SearchBar from '@components/SearchBar'; -import Text from '@components/Text'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchResults from '@hooks/useSearchResults'; -import useThemeStyles from '@hooks/useThemeStyles'; -import { - filterCardsByPersonalDetails, - getCardsByCardholderName, - getCompanyCardFeedWithDomainID, - getCompanyFeeds, - getPlaidInstitutionIconUrl, - sortCardsByCardholderName, -} from '@libs/CardUtils'; -import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; -import Navigation from '@navigation/Navigation'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {Card, CompanyCardFeed, CompanyCardFeedWithDomainID, WorkspaceCardsList} from '@src/types/onyx'; -import WorkspaceCompanyCardsFeedAddedEmptyPage from './WorkspaceCompanyCardsFeedAddedEmptyPage'; -import WorkspaceCompanyCardsListRow from './WorkspaceCompanyCardsListRow'; - -type WorkspaceCompanyCardsListProps = { - /** Selected feed */ - selectedFeed: CompanyCardFeedWithDomainID; - - /** List of company cards */ - cardsList: OnyxEntry; - - /** Current policy id */ - policyID: string; - - /** On assign card callback */ - onAssignCard: () => void; - - /** Whether to disable assign card button */ - isAssigningCardDisabled?: boolean; - - /** Whether to show GB disclaimer */ - shouldShowGBDisclaimer?: boolean; -}; - -function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer}: WorkspaceCompanyCardsListProps) { - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const listRef = useRef>(null); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); - const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); - const policy = usePolicy(policyID); - - const {cardList, ...assignedCards} = cardsList ?? {}; - const [cardFeeds] = useCardFeeds(policyID); - - const companyFeeds = getCompanyFeeds(cardFeeds); - const cards = companyFeeds?.[selectedFeed]?.accountList; - - const plaidIconUrl = getPlaidInstitutionIconUrl(selectedFeed); - - // Get all cards sorted by cardholder name - const allCards = useMemo(() => { - const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); - return getCardsByCardholderName(cardsList, policyMembersAccountIDs); - }, [cardsList, policy?.employeeList]); - - // Filter and sort cards based on search input - const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); - const sortCards = useCallback((cardsToSort: Card[]) => sortCardsByCardholderName(cardsToSort, personalDetails, localeCompare), [personalDetails, localeCompare]); - const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); - - const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; - - // When we reach the medium screen width or the narrow layout is active, - // we want to hide the table header and the middle column of the card rows, so that the content is not overlapping. - const shouldUseNarrowTableRowLayout = isMediumScreenWidth || shouldUseNarrowLayout; - - const renderItem = useCallback( - ({item: cardName, index}: ListRenderItemInfo) => { - const assignedCard = Object.values(assignedCards ?? {}).find((card) => card.cardName === cardName); - - const customCardName = customCardNames?.[assignedCard?.cardID ?? CONST.DEFAULT_NUMBER_ID]; - - const isCardDeleted = assignedCard?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - return ( - - { - if (!assignedCard) { - onAssignCard(); - return; - } - - if (!assignedCard?.accountID || !assignedCard?.fundID) { - return; - } - - return Navigation.navigate( - ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute( - policyID, - assignedCard.cardID.toString(), - getCompanyCardFeedWithDomainID(assignedCard?.bank as CompanyCardFeed, assignedCard.fundID), - ), - ); - }} - > - {({hovered}) => ( - - )} - - - ); - }, - [ - assignedCards, - customCardNames, - isAssigningCardDisabled, - onAssignCard, - personalDetails, - plaidIconUrl, - policyID, - selectedFeed, - shouldUseNarrowTableRowLayout, - styles.br3, - styles.highlightBG, - styles.hoveredComponentBG, - styles.mb3, - styles.mh5, - styles.ph5, - ], - ); - - const keyExtractor = useCallback((item: string, index: number) => `${item}_${index}`, []); - - const ListHeaderComponent = shouldUseNarrowTableRowLayout ? ( - - ) : ( - <> - {(cards?.length ?? 0) > CONST.SEARCH_ITEM_LIMIT && ( - - )} - {!isSearchEmpty && ( - - - - {translate('common.member')} - - - - - {translate('workspace.companyCards.card')} - - - - - {translate('workspace.companyCards.cardName')} - - - - )} - - ); - - // Show empty state when there are no cards - if (!cards?.length) { - return ( - - ); - } - - return ( - - - - ); -} - -WorkspaceCompanyCardsList.displayName = 'WorkspaceCompanyCardsList'; - -export default WorkspaceCompanyCardsList; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx deleted file mode 100644 index e9a9a94c367b..000000000000 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Avatar from '@components/Avatar'; -import Button from '@components/Button'; -import Icon from '@components/Icon'; -import PlaidCardFeedIcon from '@components/PlaidCardFeedIcon'; -import Text from '@components/Text'; -import TextWithTooltip from '@components/TextWithTooltip'; -import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeIllustrations from '@hooks/useThemeIllustrations'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {getCardDefaultName} from '@libs/actions/Card'; -import {getCardFeedIcon, lastFourNumbersFromCardName} from '@libs/CardUtils'; -import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import type {CompanyCardFeed, CompanyCardFeedWithDomainID, PersonalDetails} from '@src/types/onyx'; - -type WorkspaceCompanyCardsListRowProps = { - /** Selected feed */ - selectedFeed: CompanyCardFeedWithDomainID; - - /** Card number */ - cardName: string; - - /** Card name */ - customCardName?: string; - - /** Plaid URL */ - plaidIconUrl?: string; - - /** Cardholder personal details */ - cardholder?: PersonalDetails | null; - - /** Whether the list item is hovered */ - isHovered?: boolean; - - /** Whether the card is assigned */ - isAssigned: boolean; - - /** Whether to disable assign card button */ - isAssigningCardDisabled?: boolean; - - /** Whether to use narrow table row layout */ - shouldUseNarrowTableRowLayout?: boolean; - - /** On assign card callback */ - onAssignCard: () => void; -}; - -function WorkspaceCompanyCardsListRow({ - selectedFeed, - cardholder, - customCardName, - cardName, - isHovered, - isAssigned, - onAssignCard, - plaidIconUrl, - isAssigningCardDisabled, - shouldUseNarrowTableRowLayout, -}: WorkspaceCompanyCardsListRowProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const illustrations = useThemeIllustrations(); - const companyCardFeedIcons = useCompanyCardFeedIcons(); - const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight'] as const); - - const customCardNameWithFallback = customCardName ?? getCardDefaultName(cardholder?.displayName); - - let cardFeedIcon = null; - if (!plaidIconUrl) { - cardFeedIcon = getCardFeedIcon(selectedFeed as CompanyCardFeed, illustrations, companyCardFeedIcons); - } - - const lastFourCardNameNumbers = lastFourNumbersFromCardName(cardName); - - const alternateLoginText = shouldUseNarrowTableRowLayout ? `${customCardNameWithFallback} - ${lastFourCardNameNumbers}` : (cardholder?.login ?? ''); - - return ( - - - {isAssigned ? ( - <> - - - - - - - - ) : ( - <> - {!!plaidIconUrl && } - - {!plaidIconUrl && !!cardFeedIcon && ( - - )} - - - Unassigned - - - )} - - - {!shouldUseNarrowTableRowLayout && ( - - - {cardName} - - - )} - - - {isAssigned && ( - - {!shouldUseNarrowTableRowLayout && ( - - {customCardNameWithFallback} - - )} - - - )} - {!isAssigned && ( - - ); -} - -export default TableSortButtons; diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index ef67600b3879..630b94041292 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -4,7 +4,6 @@ import TableContext from './TableContext'; import TableFilterButtons from './TableFilterButtons'; import TableHeader from './TableHeader'; import TableSearchBar from './TableSearchBar'; -import TableSortButtons from './TableSortButtons'; const Table = Object.assign(TableComponent, { Context: TableContext, @@ -12,7 +11,6 @@ const Table = Object.assign(TableComponent, { Body: TableBody, FilterButtons: TableFilterButtons, SearchBar: TableSearchBar, - SortButtons: TableSortButtons, }); export default Table; From da32c86c3a279c983308ba4095f158e2170e953f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 12:37:48 -0500 Subject: [PATCH 33/53] fix: Table test errors and refactor --- tests/{unit => ui}/TableTest.tsx | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) rename tests/{unit => ui}/TableTest.tsx (94%) diff --git a/tests/unit/TableTest.tsx b/tests/ui/TableTest.tsx similarity index 94% rename from tests/unit/TableTest.tsx rename to tests/ui/TableTest.tsx index a625eefcfbea..4fc817af4080 100644 --- a/tests/unit/TableTest.tsx +++ b/tests/ui/TableTest.tsx @@ -1,9 +1,10 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; import {fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; -import {Text, View} from 'react-native'; -import type {ListRenderItemInfo} from 'react-native'; +import {View} from 'react-native'; import Table from '@components/Table'; import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn} from '@components/Table'; +import Text from '@components/Text'; import type Navigation from '@libs/Navigation/Navigation'; // Mock navigation @@ -67,27 +68,33 @@ jest.mock('@hooks/useLazyAsset', () => ({ // Mock Icon component jest.mock('@components/Icon', () => { - const MockIcon = (): null => null; + function MockIcon(): null { + return null; + } return MockIcon; }); // Mock TextInput component jest.mock('@components/TextInput', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports const {TextInput: RNTextInput} = jest.requireActual('react-native'); - const MockTextInput = (props: {label: string; accessibilityLabel: string; value: string; onChangeText: (text: string) => void}) => ( - - ); + function MockTextInput(props: {accessibilityLabel: string; value: string; onChangeText: (text: string) => void}) { + return ( + + ); + } return MockTextInput; }); // Mock PressableWithFeedback jest.mock('@components/Pressable', () => ({ PressableWithFeedback: (props: {children: React.ReactNode; onPress: () => void; accessibilityLabel: string; accessibilityRole: 'button' | 'link' | 'none' | undefined}) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports const {Pressable} = jest.requireActual('react-native'); return ( = (item, searchString) => item.name.toLowerCase().includes(searchString.toLowerCase()); - const compareItems: CompareItemsCallback = (a, b, sortColumn, order) => { + const compareItems: CompareItemsCallback = (a, b, {columnKey, order}) => { const multiplier = order === 'asc' ? 1 : -1; - if (sortColumn === 'name') { + if (columnKey === 'name') { return a.name.localeCompare(b.name) * multiplier; } return a.category.localeCompare(b.category) * multiplier; @@ -163,7 +170,7 @@ describe('Table', () => { it('should render all data items', () => { const props = createDefaultProps(); render( - + { it('should render column headers when Header component is used', () => { const props = createDefaultProps(); render( - +
Date: Wed, 17 Dec 2025 12:43:49 -0500 Subject: [PATCH 34/53] fix: Expensicons error --- src/components/CaretWrapper.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/CaretWrapper.tsx b/src/components/CaretWrapper.tsx index eedaa79d0db0..ec718247840d 100644 --- a/src/components/CaretWrapper.tsx +++ b/src/components/CaretWrapper.tsx @@ -1,12 +1,12 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; type CaretWrapperProps = ChildrenProps & { style?: StyleProp; @@ -17,12 +17,13 @@ type CaretWrapperProps = ChildrenProps & { function CaretWrapper({children, style, carretWidth, carretHeight}: CaretWrapperProps) { const theme = useTheme(); const styles = useThemeStyles(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['DownArrow'] as const); return ( {children} Date: Wed, 17 Dec 2025 12:49:48 -0500 Subject: [PATCH 35/53] fix: remove manual memo --- src/components/Table/Table.tsx | 59 +++++++++---------- .../Table/TableFilterButtons/index.tsx | 13 ++-- .../WorkspaceCompanyCardsPage.tsx | 2 +- .../WorkspaceCompanyCardsTable.tsx | 37 ++++++------ 4 files changed, 51 insertions(+), 60 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 6e727791e279..dd425a8f7b16 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,5 +1,5 @@ import type {FlashListRef} from '@shopify/flash-list'; -import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; +import React, {useImperativeHandle, useRef, useState} from 'react'; import TableContext from './TableContext'; import type {TableContextValue, UpdateFilterCallback, UpdateSortingCallback} from './TableContext'; import type {ActiveSorting, GetActiveFiltersCallback, GetActiveSearchStringCallback, GetActiveSortingCallback, TableHandle, TableMethods, TableProps, ToggleSortingCallback} from './types'; @@ -33,12 +33,12 @@ function Table { + const updateFilter: UpdateFilterCallback = ({key, value}) => { setCurrentFilters((prev) => ({ ...prev, [key]: value, })); - }, []); + }; // Apply filters using predicate functions let filteredData = data; @@ -83,45 +83,42 @@ function Table>({columnKey: undefined, order: 'asc'}); - const updateSorting: UpdateSortingCallback = useCallback(({columnKey, order}) => { + const updateSorting: UpdateSortingCallback = ({columnKey, order}) => { if (columnKey) { setActiveSorting({columnKey, order: order ?? 'asc'}); return; } setActiveSorting({columnKey: undefined, order: 'asc'}); - }, []); + }; - const toggleSorting: ToggleSortingCallback = useCallback( - (columnKey) => { - if (!columnKey) { - updateSorting({columnKey: undefined}); + const toggleSorting: ToggleSortingCallback = (columnKey) => { + if (!columnKey) { + updateSorting({columnKey: undefined}); + sortToggleCountRef.current = 0; + return; + } + + setActiveSorting((currentSorting) => { + if (columnKey !== currentSorting.columnKey) { sortToggleCountRef.current = 0; - return; + return {columnKey, order: 'asc'}; } - setActiveSorting((currentSorting) => { - if (columnKey !== currentSorting.columnKey) { - sortToggleCountRef.current = 0; - return {columnKey, order: 'asc'}; - } - - // Check current toggle count to decide if we should reset - if (sortToggleCountRef.current >= MAX_SORT_TOGGLE_COUNT) { - // Reset sorting when max toggle count is reached - sortToggleCountRef.current = 0; - updateSorting({columnKey: undefined}); - return {columnKey: undefined, order: 'asc'}; - } + // Check current toggle count to decide if we should reset + if (sortToggleCountRef.current >= MAX_SORT_TOGGLE_COUNT) { + // Reset sorting when max toggle count is reached + sortToggleCountRef.current = 0; + updateSorting({columnKey: undefined}); + return {columnKey: undefined, order: 'asc'}; + } - // Toggle the sort order - sortToggleCountRef.current += 1; - const newSortOrder = currentSorting.order === 'asc' ? 'desc' : 'asc'; - return {columnKey: currentSorting.columnKey, order: newSortOrder}; - }); - }, - [updateSorting], - ); + // Toggle the sort order + sortToggleCountRef.current += 1; + const newSortOrder = currentSorting.order === 'asc' ? 'desc' : 'asc'; + return {columnKey: currentSorting.columnKey, order: newSortOrder}; + }); + }; // Apply sorting using comparator function let processedData = filteredAndSearchedData; diff --git a/src/components/Table/TableFilterButtons/index.tsx b/src/components/Table/TableFilterButtons/index.tsx index 77e6aa989da0..6bc502a7b6fd 100644 --- a/src/components/Table/TableFilterButtons/index.tsx +++ b/src/components/Table/TableFilterButtons/index.tsx @@ -1,12 +1,12 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import type {ReactNode} from 'react'; import {FlatList, View} from 'react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; +import {useTableContext} from '@components/Table/TableContext'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {useTableContext} from '..'; import buildFilterItems from './buildFilterItems'; import type {FilterButtonItem} from './buildFilterItems'; @@ -18,12 +18,9 @@ function TableFilterButtons(props: TableFilterButtonsProps) { const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const {filterConfig: filterConfigs, activeFilters: filters, updateFilter} = useTableContext(); - const setFilter = useCallback( - (key: string, value: unknown) => { - updateFilter({key, value}); - }, - [updateFilter], - ); + const setFilter = (key: string, value: unknown) => { + updateFilter({key, value}); + }; const filterItems = buildFilterItems(filterConfigs, filters, setFilter, translate('search.filtersHeader')); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index cf9df47ecb36..f43ec796618f 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -56,7 +56,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData); const fetchCompanyCards = useCallback(() => { openPolicyCompanyCardsPage(policyID, domainOrWorkspaceAccountID); - }, [policyID, domainOrWorkspaceAccountID]); + }, [domainOrWorkspaceAccountID, policyID]); const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); const isLoading = !isOffline && (!cardFeeds || (!!defaultFeed?.isLoading && isEmptyObject(cardsList))); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx index f559156e3b92..663809ff3e6f 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx @@ -1,5 +1,5 @@ import type {ListRenderItemInfo} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Table from '@components/Table'; @@ -138,7 +138,7 @@ function WorkspaceCompanyCardsTable({ return 0; }; - const isItemInSearch: IsItemInSearchCallback = useCallback((item, searchString) => { + const isItemInSearch: IsItemInSearchCallback = (item, searchString) => { const searchLower = searchString.toLowerCase(); return ( item.cardName.toLowerCase().includes(searchLower) || @@ -146,9 +146,9 @@ function WorkspaceCompanyCardsTable({ (item.cardholder?.displayName?.toLowerCase().includes(searchLower) ?? false) || (item.cardholder?.login?.toLowerCase().includes(searchLower) ?? false) ); - }, []); + }; - const isItemInFilter: IsItemInFilterCallback = useCallback((item, filterValues) => { + const isItemInFilter: IsItemInFilterCallback = (item, filterValues) => { if (!filterValues || filterValues.length === 0) { return true; } @@ -162,22 +162,19 @@ function WorkspaceCompanyCardsTable({ return true; } return false; - }, []); - - const filterConfig: FilterConfig = useMemo( - () => ({ - status: { - filterType: 'single-select', - options: [ - {label: translate('workspace.moreFeatures.companyCards.allCards'), value: 'all'}, - {label: translate('workspace.moreFeatures.companyCards.assignedCards'), value: 'assigned'}, - {label: translate('workspace.moreFeatures.companyCards.unassignedCards'), value: 'unassigned'}, - ], - default: 'all', - }, - }), - [translate], - ); + }; + + const filterConfig: FilterConfig = { + status: { + filterType: 'single-select', + options: [ + {label: translate('workspace.moreFeatures.companyCards.allCards'), value: 'all'}, + {label: translate('workspace.moreFeatures.companyCards.assignedCards'), value: 'assigned'}, + {label: translate('workspace.moreFeatures.companyCards.unassignedCards'), value: 'unassigned'}, + ], + default: 'all', + }, + }; const columns: Array> = [ { From 4ce427c4904b7af5689acd1a129fd68cf32d32c1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 12:49:56 -0500 Subject: [PATCH 36/53] fix: dependency cycle --- src/components/Table/TableBody.tsx | 2 +- src/components/Table/TableHeader.tsx | 2 +- src/components/Table/TableSearchBar.tsx | 2 +- src/components/Table/index.tsx | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index d9b8e7372af8..51914774f91a 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -5,7 +5,7 @@ import type {ViewProps} from 'react-native'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {useTableContext} from '.'; +import {useTableContext} from './TableContext'; type TableBodyProps = ViewProps; diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 3403377840de..0a6895a5082e 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -8,7 +8,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import {useTableContext} from '.'; +import {useTableContext} from './TableContext'; import type {TableColumn} from './types'; type TableHeaderProps = ViewProps; diff --git a/src/components/Table/TableSearchBar.tsx b/src/components/Table/TableSearchBar.tsx index e61474a5fb58..7120abad2f3b 100644 --- a/src/components/Table/TableSearchBar.tsx +++ b/src/components/Table/TableSearchBar.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native'; import TextInput from '@components/TextInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import {useTableContext} from '.'; +import {useTableContext} from './TableContext'; function TableSearchBar() { const {translate} = useLocalize(); diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 630b94041292..1fd72fdd70be 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -14,6 +14,5 @@ const Table = Object.assign(TableComponent, { }); export default Table; -export {useTableContext} from './TableContext'; export type {TableContextValue} from './TableContext'; export type * from './types'; From fe1a2a3bb5a959133762671527c26460d70553db Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 12:51:14 -0500 Subject: [PATCH 37/53] fix: Expensicons --- .../TextInput/BaseTextInput/implementation/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index c8c61e16003b..c20186de0ccc 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -8,7 +8,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import Checkbox from '@components/Checkbox'; import FormHelpMessage from '@components/FormHelpMessage'; import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; @@ -22,6 +21,7 @@ import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; import TextInputMeasurement from '@components/TextInput/TextInputMeasurement'; import useHtmlPaste from '@hooks/useHtmlPaste'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -95,6 +95,7 @@ function BaseTextInput({ const {hasError = false} = inputProps; const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye', 'EyeDisabled'] as const); // Disabling this line for safeness as nullish coalescing works only if value is undefined or null // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -496,7 +497,7 @@ function BaseTextInput({ accessibilityLabel={translate('common.visible')} > From 2477218f70aab6dd5cbe18cb6c1dfbba0b52d40f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 13:50:15 -0500 Subject: [PATCH 38/53] fix: rename list components --- .../workspace/companyCards/WorkspaceCompanyCardsPage.tsx | 6 +++--- ...tons.tsx => WorkspaceCompanyCardsTableHeaderButtons.tsx} | 6 +++--- .../companyCards/WorkspaceCompanyCardsTableItem.tsx | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/pages/workspace/companyCards/{WorkspaceCompanyCardsListHeaderButtons.tsx => WorkspaceCompanyCardsTableHeaderButtons.tsx} (97%) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index e6c03c11b669..5132624ecd18 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -23,7 +23,7 @@ import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import WorkspaceCompanyCardPageEmptyState from './WorkspaceCompanyCardPageEmptyState'; import WorkspaceCompanyCardsFeedPendingPage from './WorkspaceCompanyCardsFeedPendingPage'; -import WorkspaceCompanyCardsListHeaderButtons from './WorkspaceCompanyCardsListHeaderButtons'; +import WorkspaceCompanyCardsTableHeaderButtons from './WorkspaceCompanyCardsListHeaderButtons'; import WorkspaceCompanyCardsTable from './WorkspaceCompanyCardsTable'; type WorkspaceCompanyCardsPageProps = PlatformStackScreenProps; @@ -98,7 +98,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { addBottomSafeAreaPadding > {isPending && !!selectedFeed && ( - @@ -119,7 +119,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { onAssignCard={assignCard} isAssigningCardDisabled={isAssigningCardDisabled} renderHeaderButtons={(searchBar, filterButtons) => ( - Date: Wed, 17 Dec 2025 14:11:26 -0500 Subject: [PATCH 39/53] fix: `WorkspaceCompanyCardsTableHeaderButtons` import --- src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 5132624ecd18..8ea5bb9c1180 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -23,8 +23,8 @@ import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import WorkspaceCompanyCardPageEmptyState from './WorkspaceCompanyCardPageEmptyState'; import WorkspaceCompanyCardsFeedPendingPage from './WorkspaceCompanyCardsFeedPendingPage'; -import WorkspaceCompanyCardsTableHeaderButtons from './WorkspaceCompanyCardsListHeaderButtons'; import WorkspaceCompanyCardsTable from './WorkspaceCompanyCardsTable'; +import WorkspaceCompanyCardsTableHeaderButtons from './WorkspaceCompanyCardsTableHeaderButtons'; type WorkspaceCompanyCardsPageProps = PlatformStackScreenProps; From 7f6185c84dc8ea86572e57874b2115844e94760d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 14:12:54 -0500 Subject: [PATCH 40/53] Update WorkspaceMemberDetailsPage.tsx --- .../members/WorkspaceMemberDetailsPage.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 477ed732082d..2e2c1f9f9bba 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -422,13 +422,11 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM /> {shouldShowCardsSection && ( <> - {memberCards.length > 0 && ( - - - {translate('walletPage.assignedCards')} - - - )} + + + {translate('walletPage.assignedCards')} + + {memberCards.map((memberCard) => { const isCardDeleted = memberCard.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const plaidUrl = getPlaidInstitutionIconUrl(memberCard?.bank); @@ -465,6 +463,11 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM ); })} + )} From 7d03cc8d7775f2aa58db61038aa3ff62196f8766 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 14:14:14 -0500 Subject: [PATCH 41/53] Update WorkspaceMemberDetailsPage.tsx --- .../members/WorkspaceMemberDetailsPage.tsx | 42 ++++--------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 2e2c1f9f9bba..87a1524c4907 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -54,7 +54,6 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import variables from '@styles/variables'; -import {setIssueNewCardStepAndData} from '@userActions/Card'; import {clearWorkspaceOwnerChangeFlow, isApprover as isApproverUserAction, openPolicyMemberProfilePage, removeMembers} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -264,30 +263,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM [policyID], ); - const handleIssueNewCard = useCallback(() => { - if (isAccountLocked) { - showLockedAccountModal(); - return; - } - - if (hasMultipleFeeds) { - Navigation.navigate(ROUTES.WORKSPACE_MEMBER_NEW_CARD.getRoute(policyID, accountID)); - return; - } - const activeRoute = Navigation.getActiveRoute(); - - setIssueNewCardStepAndData({ - step: CONST.EXPENSIFY_CARD.STEP.CARD_TYPE, - data: { - assigneeEmail: memberLogin, - }, - isEditing: false, - isChangeAssigneeDisabled: true, - policyID, - }); - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID, activeRoute)); - }, [accountID, hasMultipleFeeds, memberLogin, policyID, isAccountLocked, showLockedAccountModal]); - const startChangeOwnershipFlow = useCallback(() => { clearWorkspaceOwnerChangeFlow(policyID); Navigation.navigate(ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute(policyID, accountID, 'amountOwed' as ValueOf)); @@ -422,11 +397,13 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM /> {shouldShowCardsSection && ( <> - - - {translate('walletPage.assignedCards')} - - + {memberCards.length > 0 && ( + + + {translate('walletPage.assignedCards')} + + + )} {memberCards.map((memberCard) => { const isCardDeleted = memberCard.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const plaidUrl = getPlaidInstitutionIconUrl(memberCard?.bank); @@ -463,11 +440,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM ); })} - )} From 5bbe140e1a67a60961797144807387dff64f9fc7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 15:39:00 -0500 Subject: [PATCH 42/53] refactor: inline methods --- .../TableFilterButtons/buildFilterItems.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/components/Table/TableFilterButtons/buildFilterItems.tsx b/src/components/Table/TableFilterButtons/buildFilterItems.tsx index ffaa6808d4da..2ce56b2b00ac 100644 --- a/src/components/Table/TableFilterButtons/buildFilterItems.tsx +++ b/src/components/Table/TableFilterButtons/buildFilterItems.tsx @@ -97,11 +97,6 @@ function createMultiSelectPopover({filterKey, filterConfig, currentFilterValue, value: option.value, })); - const handleChange = (items: Array<{text: string; value: string}>) => { - const values = items.map((item) => item.value); - setFilter(filterKey, values); - }; - return ( { + const values = items.map((item) => item.value); + setFilter(filterKey, values); + }} /> ); }; @@ -134,10 +132,6 @@ function createSingleSelectPopover({filterKey, filterConfig, currentFilterValue, } : null; - const handleChange = (item: {text: string; value: string} | null) => { - setFilter(filterKey, item?.value ?? null); - }; - return ( setFilter(filterKey, item?.value ?? null)} /> ); }; From 438b5bad90c4dda2d2cf5cd0738246c54e881de5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 16:22:47 -0500 Subject: [PATCH 43/53] refactor: Make Table.FilterButtons generic and refactor header buttons --- .../Table/TableFilterButtons/index.tsx | 12 +++-- .../WorkspaceCompanyCardsPage.tsx | 42 ++++++++--------- .../WorkspaceCompanyCardsTable.tsx | 42 ++++++++--------- ...orkspaceCompanyCardsTableHeaderButtons.tsx | 45 ++++++++----------- 4 files changed, 66 insertions(+), 75 deletions(-) diff --git a/src/components/Table/TableFilterButtons/index.tsx b/src/components/Table/TableFilterButtons/index.tsx index 6bc502a7b6fd..8b3d2faed21a 100644 --- a/src/components/Table/TableFilterButtons/index.tsx +++ b/src/components/Table/TableFilterButtons/index.tsx @@ -10,12 +10,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import buildFilterItems from './buildFilterItems'; import type {FilterButtonItem} from './buildFilterItems'; -type TableFilterButtonsProps = ViewProps; +type TableFilterButtonsProps = ViewProps & { + contentContainerStyle?: StyleProp; +}; -function TableFilterButtons(props: TableFilterButtonsProps) { +function TableFilterButtons({contentContainerStyle, ...props}: TableFilterButtonsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const {filterConfig: filterConfigs, activeFilters: filters, updateFilter} = useTableContext(); const setFilter = (key: string, value: unknown) => { @@ -28,8 +29,6 @@ function TableFilterButtons(props: TableFilterButtonsProps) { return null; } - const shouldShowResponsiveLayout = shouldUseNarrowLayout || isMediumScreenWidth; - return ( // eslint-disable-next-line react/jsx-props-no-spreading @@ -38,8 +37,7 @@ function TableFilterButtons(props: TableFilterButtonsProps) { data={filterItems} keyExtractor={(item) => item.key} renderItem={({item}) => } - style={shouldShowResponsiveLayout && [styles.flexGrow0, styles.flexShrink0]} - contentContainerStyle={[styles.flexRow, styles.gap2, styles.w100]} + contentContainerStyle={[styles.flexRow, styles.gap2, styles.w100, contentContainerStyle]} showsHorizontalScrollIndicator={false} CellRendererComponent={CellRendererComponent} /> diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 8ea5bb9c1180..7657c8ceeeaa 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -36,43 +36,48 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { const illustrations = useMemoizedLazyIllustrations(['CompanyCard']); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: false}); const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; + const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY, {canBeMissing: false}); const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, {canBeMissing: true}); + const [cardFeeds, , defaultFeed] = useCardFeeds(policyID); const selectedFeed = getSelectedFeed(lastSelectedFeed, cardFeeds); + const companyFeeds = getCompanyFeeds(cardFeeds); + const selectedFeedData = selectedFeed && companyFeeds[selectedFeed]; const feed = selectedFeed ? getCompanyCardFeed(selectedFeed) : undefined; const [cardsList] = useCardsList(selectedFeed); - const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY, {canBeMissing: false}); - const hasNoAssignedCard = Object.keys(cardsList ?? {}).length === 0; - const companyCards = getCompanyFeeds(cardFeeds); - const selectedFeedData = selectedFeed && companyCards[selectedFeed]; const isNoFeed = !selectedFeedData; - const isPending = !!selectedFeedData?.pending; - const isFeedAdded = !isPending && !isNoFeed; + const isFeedPending = !!selectedFeedData?.pending; + const isFeedAdded = !isFeedPending && !isNoFeed; + const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData); + + const isGB = countryByIp === CONST.COUNTRY.GB; + const hasNoAssignedCard = Object.keys(cardsList ?? {}).length === 0; + const shouldShowGBDisclaimer = isGB && isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && (isNoFeed || hasNoAssignedCard); + const fetchCompanyCards = useCallback(() => { openPolicyCompanyCardsPage(policyID, domainOrWorkspaceAccountID); }, [domainOrWorkspaceAccountID, policyID]); const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); const isLoading = !isOffline && (!cardFeeds || (!!defaultFeed?.isLoading && isEmptyObject(cardsList))); - const isGB = countryByIp === CONST.COUNTRY.GB; - const shouldShowGBDisclaimer = isGB && isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && (isNoFeed || hasNoAssignedCard); useEffect(() => { fetchCompanyCards(); }, [fetchCompanyCards]); useEffect(() => { - if (isLoading || !feed || isPending) { + if (isLoading || !feed || isFeedPending) { return; } openPolicyCompanyCardsFeed(domainOrWorkspaceAccountID, policyID, feed); - }, [feed, isLoading, policyID, isPending, domainOrWorkspaceAccountID]); + }, [feed, isLoading, policyID, isFeedPending, domainOrWorkspaceAccountID]); const {assignCard, isAssigningCardDisabled} = useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}); @@ -97,20 +102,23 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { showLoadingAsFirstRender={false} addBottomSafeAreaPadding > - {isPending && !!selectedFeed && ( + {isFeedPending && !!selectedFeed && ( )} + {isNoFeed && ( )} - {isPending && } - {isFeedAdded && !isPending && ( + + {isFeedPending && } + + {isFeedAdded && !isFeedPending && ( ( - - )} /> )} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx index 663809ff3e6f..766f4401dc12 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx @@ -15,6 +15,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CompanyCardFeedWithDomainID, WorkspaceCardsList} from '@src/types/onyx'; import WorkspaceCompanyCardsFeedAddedEmptyPage from './WorkspaceCompanyCardsFeedAddedEmptyPage'; +import WorkspaceCompanyCardsTableHeaderButtons from './WorkspaceCompanyCardsTableHeaderButtons'; import WorkspaceCompanyCardTableItem from './WorkspaceCompanyCardsTableItem'; import type {WorkspaceCompanyCardTableItemData} from './WorkspaceCompanyCardsTableItem'; @@ -38,20 +39,9 @@ type WorkspaceCompanyCardsTableProps = { /** Whether to show GB disclaimer */ shouldShowGBDisclaimer?: boolean; - - /** Render prop for header buttons - receives SearchBar and FilterButtons as children */ - renderHeaderButtons?: (searchBar: React.ReactNode, filterButtons: React.ReactNode) => React.ReactNode; }; -function WorkspaceCompanyCardsTable({ - selectedFeed, - cardsList, - policyID, - onAssignCard, - isAssigningCardDisabled, - shouldShowGBDisclaimer, - renderHeaderButtons, -}: WorkspaceCompanyCardsTableProps) { +function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer}: WorkspaceCompanyCardsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -65,8 +55,6 @@ function WorkspaceCompanyCardsTable({ const companyFeeds = getCompanyFeeds(cardFeeds); const cards = companyFeeds?.[selectedFeed]?.accountList; - const plaidIconUrl = getPlaidInstitutionIconUrl(selectedFeed); - // When we reach the medium screen width or the narrow layout is active, // we want to hide the table header and the middle column of the card rows, so that the content is not overlapping. const shouldShowNarrowLayout = shouldUseNarrowLayout || isMediumScreenWidth; @@ -94,7 +82,7 @@ function WorkspaceCompanyCardsTable({ item={item} policyID={policyID} selectedFeed={selectedFeed} - plaidIconUrl={plaidIconUrl} + plaidIconUrl={getPlaidInstitutionIconUrl(selectedFeed)} onAssignCard={onAssignCard} isAssigningCardDisabled={isAssigningCardDisabled} shouldUseNarrowTableRowLayout={shouldShowNarrowLayout} @@ -223,11 +211,17 @@ function WorkspaceCompanyCardsTable({ // Show empty state when there are no cards if (!data.length) { return ( - + + + + ); } @@ -243,7 +237,13 @@ function WorkspaceCompanyCardsTable({ isItemInFilter={isItemInFilter} filters={filterConfig} > - {renderHeaderButtons?.(, )} + + + {!shouldShowNarrowLayout && } diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx index 98ce9f3623aa..e5aa8e19a686 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import FeedSelector from '@components/FeedSelector'; @@ -7,6 +7,7 @@ import Icon from '@components/Icon'; // eslint-disable-next-line no-restricted-imports import * as Expensicons from '@components/Icon/Expensicons'; import RenderHTML from '@components/RenderHTML'; +import Table from '@components/Table'; import Text from '@components/Text'; import type {CompanyCardFeedWithDomainID} from '@hooks/useCardFeeds'; import useCardFeeds from '@hooks/useCardFeeds'; @@ -51,14 +52,11 @@ type WorkspaceCompanyCardsTableHeaderButtonsProps = { /** Currently selected feed */ selectedFeed: CompanyCardFeedWithDomainID; - /** Search bar component to render */ - searchBar?: React.ReactNode; - - /** Filter buttons component to render */ - filterButtons?: React.ReactNode; + /** Whether the feed is pending */ + shouldDisplayTableComponents?: boolean; }; -function WorkspaceCompanyCardsTableHeaderButtons({policyID, selectedFeed, searchBar, filterButtons}: WorkspaceCompanyCardsTableHeaderButtonsProps) { +function WorkspaceCompanyCardsTableHeaderButtons({policyID, selectedFeed, shouldDisplayTableComponents = false}: WorkspaceCompanyCardsTableHeaderButtonsProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); @@ -110,24 +108,19 @@ function WorkspaceCompanyCardsTableHeaderButtons({policyID, selectedFeed, search Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed))); }; - const secondaryActions = useMemo( - () => [ - { - icon: icons.Gear, - text: translate('common.settings'), - onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)), - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - }, - ], - [policyID, icons.Gear, translate], - ); + const secondaryActions = [ + { + icon: icons.Gear, + text: translate('common.settings'), + onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + }, + ]; - const supportingText = useMemo(() => { - const firstPart = translate(isCommercialFeed ? 'workspace.companyCards.commercialFeed' : 'workspace.companyCards.directFeed'); - const domainName = domain?.email ? Str.extractEmailDomain(domain.email) : undefined; - const secondPart = ` (${domainName ?? policy?.name})`; - return `${firstPart}${secondPart}`; - }, [domain?.email, isCommercialFeed, policy?.name, translate]); + const firstPart = translate(isCommercialFeed ? 'workspace.companyCards.commercialFeed' : 'workspace.companyCards.directFeed'); + const domainName = domain?.email ? Str.extractEmailDomain(domain.email) : undefined; + const secondPart = ` (${domainName ?? policy?.name})`; + const supportingText = `${firstPart}${secondPart}`; const shouldShowNarrowLayout = shouldUseNarrowLayout || isMediumScreenWidth; @@ -153,9 +146,9 @@ function WorkspaceCompanyCardsTableHeaderButtons({policyID, selectedFeed, search - {searchBar} + {shouldDisplayTableComponents && } - {filterButtons} + {shouldDisplayTableComponents && } {}} From 9bee8acad90ca1147d07645849a20372a6a89c14 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 16:32:24 -0500 Subject: [PATCH 44/53] fix: filter buttons in header bar --- .../companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx index e5aa8e19a686..9694d0516db8 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableHeaderButtons.tsx @@ -147,8 +147,8 @@ function WorkspaceCompanyCardsTableHeaderButtons({policyID, selectedFeed, should style={[styles.alignItemsCenter, styles.gap3, shouldShowNarrowLayout ? [styles.flexColumnReverse, styles.w100, styles.alignItemsStretch, styles.gap5] : styles.flexRow]} > {shouldDisplayTableComponents && } - - {shouldDisplayTableComponents && } + + {shouldDisplayTableComponents && } {}} From 3bc2932295bfdeeed650ad96ff56bad896ae26c7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 16:48:26 -0500 Subject: [PATCH 45/53] feat: allow searching for "unassigned"/"assigned" --- .../companyCards/WorkspaceCompanyCardsTable.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx index 766f4401dc12..fd6e8810d929 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx @@ -126,14 +126,23 @@ function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssign return 0; }; + const assignedKeyword = translate('workspace.moreFeatures.companyCards.assignedCards').toLowerCase(); + const unassignedKeyword = translate('workspace.moreFeatures.companyCards.unassignedCards').toLowerCase(); + const isItemInSearch: IsItemInSearchCallback = (item, searchString) => { const searchLower = searchString.toLowerCase(); - return ( + + // Include assigned/unassigned cards if the user is typing "Unassigned" or "Assigned" (localized) + const isAssignedCardMatch = assignedKeyword.startsWith(searchLower) && item.isAssigned; + const isUnassignedCardMatch = unassignedKeyword.startsWith(searchLower) && !item.isAssigned; + + const isMatch = item.cardName.toLowerCase().includes(searchLower) || (item.customCardName?.toLowerCase().includes(searchLower) ?? false) || (item.cardholder?.displayName?.toLowerCase().includes(searchLower) ?? false) || - (item.cardholder?.login?.toLowerCase().includes(searchLower) ?? false) - ); + (item.cardholder?.login?.toLowerCase().includes(searchLower) ?? false); + + return isMatch || isAssignedCardMatch || isUnassignedCardMatch; }; const isItemInFilter: IsItemInFilterCallback = (item, filterValues) => { From 76a8a90f5d707ea9128ab39fa87a626f65f8282d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 16:48:59 -0500 Subject: [PATCH 46/53] refactor: add `contentContainerStyle` prop to `TableBody` and remove extra keyExtractor --- src/components/Table/TableBody.tsx | 35 ++++++------------------------ 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 51914774f91a..0afc518bf80c 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -1,38 +1,21 @@ import {FlashList} from '@shopify/flash-list'; import React from 'react'; import {View} from 'react-native'; -import type {ViewProps} from 'react-native'; +import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {useTableContext} from './TableContext'; -type TableBodyProps = ViewProps; +type TableBodyProps = ViewProps & { + contentContainerStyle?: StyleProp; +}; -function TableBody(props: TableBodyProps) { +function TableBody({contentContainerStyle, ...props}: TableBodyProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {processedData: filteredAndSortedData, originalDataLength, activeSearchString, listProps} = useTableContext(); - const {keyExtractor, ListEmptyComponent, contentContainerStyle, onScroll, onEndReached, onEndReachedThreshold} = listProps ?? {}; - - const defaultKeyExtractor = (item: T, index: number): string => { - if (keyExtractor) { - return keyExtractor(item, index); - } - - // Try to extract a key from common object properties - if (typeof item === 'object' && item !== null) { - const obj = item as Record; - if ('id' in obj && typeof obj.id === 'string') { - return obj.id; - } - if ('key' in obj && typeof obj.key === 'string') { - return obj.key; - } - } - - return `item-${index}`; - }; + const {ListEmptyComponent, contentContainerStyle: listContentContainerStyle} = listProps ?? {}; // Show "no results found" when search returns empty but original data exists const isEmptySearchResult = filteredAndSortedData.length === 0 && activeSearchString.trim().length > 0 && originalDataLength > 0; @@ -48,12 +31,8 @@ function TableBody(props: TableBodyProps) { data={filteredAndSortedData} - keyExtractor={defaultKeyExtractor} ListEmptyComponent={isEmptySearchResult ? EmptySearchComponent : ListEmptyComponent} - contentContainerStyle={[contentContainerStyle, filteredAndSortedData.length === 0 && styles.flex1]} - onScroll={onScroll} - onEndReached={onEndReached} - onEndReachedThreshold={onEndReachedThreshold} + contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flex1, listContentContainerStyle, contentContainerStyle]} // eslint-disable-next-line react/jsx-props-no-spreading {...listProps} /> From 0c06cccc818231c353d27112c931ce3dbeed63a0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 17:31:35 -0500 Subject: [PATCH 47/53] refactor: improve sorting --- src/components/Table/Table.tsx | 52 +++---------------- src/components/Table/TableContext.tsx | 15 ++++-- src/components/Table/TableHeader.tsx | 26 ++++++++-- src/components/Table/types.ts | 6 +-- .../WorkspaceCompanyCardsTable.tsx | 2 +- 5 files changed, 43 insertions(+), 58 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index dd425a8f7b16..133b874d2e48 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,13 +1,9 @@ import type {FlashListRef} from '@shopify/flash-list'; import React, {useImperativeHandle, useRef, useState} from 'react'; import TableContext from './TableContext'; -import type {TableContextValue, UpdateFilterCallback, UpdateSortingCallback} from './TableContext'; +import type {TableContextValue, UpdateFilterCallback} from './TableContext'; import type {ActiveSorting, GetActiveFiltersCallback, GetActiveSearchStringCallback, GetActiveSortingCallback, TableHandle, TableMethods, TableProps, ToggleSortingCallback} from './types'; -// We want to allow the user to switch once between ascending and descending order. -// After that, sorting for a specific column will be reset. -const MAX_SORT_TOGGLE_COUNT = 1; - function Table({ ref, data = [], @@ -40,7 +36,6 @@ function Table { @@ -74,50 +69,15 @@ function Table isItemInSearch(item, activeSearchString)); } - const sortToggleCountRef = useRef(0); - const [activeSorting, setActiveSorting] = useState>({columnKey: undefined, order: 'asc'}); - - const updateSorting: UpdateSortingCallback = ({columnKey, order}) => { - if (columnKey) { - setActiveSorting({columnKey, order: order ?? 'asc'}); - return; - } - - setActiveSorting({columnKey: undefined, order: 'asc'}); - }; - - const toggleSorting: ToggleSortingCallback = (columnKey) => { - if (!columnKey) { - updateSorting({columnKey: undefined}); - sortToggleCountRef.current = 0; - return; - } - - setActiveSorting((currentSorting) => { - if (columnKey !== currentSorting.columnKey) { - sortToggleCountRef.current = 0; - return {columnKey, order: 'asc'}; - } + const [activeSorting, updateSorting] = useState>({columnKey: undefined, order: 'asc'}); - // Check current toggle count to decide if we should reset - if (sortToggleCountRef.current >= MAX_SORT_TOGGLE_COUNT) { - // Reset sorting when max toggle count is reached - sortToggleCountRef.current = 0; - updateSorting({columnKey: undefined}); - return {columnKey: undefined, order: 'asc'}; - } - - // Toggle the sort order - sortToggleCountRef.current += 1; - const newSortOrder = currentSorting.order === 'asc' ? 'desc' : 'asc'; - return {columnKey: currentSorting.columnKey, order: newSortOrder}; - }); + const toggleColumnSorting: ToggleSortingCallback = (columnKey) => { + updateSorting((prevSorting) => ({columnKey: columnKey ?? prevSorting.columnKey, order: prevSorting.order === 'asc' ? 'desc' : 'asc'})); }; // Apply sorting using comparator function @@ -144,7 +104,7 @@ function Table { const customMethods: TableMethods = { updateSorting, - toggleSorting, + toggleColumnSorting, updateFilter, updateSearchString, getActiveSorting, @@ -176,7 +136,7 @@ function Table = { listRef: React.RefObject | null>; @@ -15,7 +24,7 @@ type TableContextValue = { updateFilter: UpdateFilterCallback; updateSorting: UpdateSortingCallback; - toggleSorting: ToggleSortingCallback; + toggleColumnSorting: ToggleColumnSortingCallback; updateSearchString: UpdateSearchStringCallback; }; @@ -32,7 +41,7 @@ const defaultTableContextValue: TableContextValue = { activeSearchString: '', updateFilter: () => {}, updateSorting: () => {}, - toggleSorting: () => {}, + toggleColumnSorting: () => {}, updateSearchString: () => {}, filterConfig: undefined, listProps: {} as SharedListProps, diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 0a6895a5082e..ad3ba48c2f36 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import type {ViewProps} from 'react-native'; import Icon from '@components/Icon'; @@ -11,11 +11,15 @@ import variables from '@styles/variables'; import {useTableContext} from './TableContext'; import type {TableColumn} from './types'; +// We want to allow the user to switch once between ascending and descending order. +// After that, sorting for a specific column will be reset. +const NUMBER_OF_TOGGLES_BEFORE_RESET = 2; + type TableHeaderProps = ViewProps; -function TableHeader({style, ...props}: TableHeaderProps) { +function TableHeader({style, ...props}: TableHeaderProps) { const styles = useThemeStyles(); - const {columns} = useTableContext(); + const {columns} = useTableContext(); return ( ({column}: {column: TableColumn}) { const theme = useTheme(); const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong'] as const); - const {activeSorting, toggleSorting} = useTableContext(); + const {activeSorting, updateSorting, toggleColumnSorting} = useTableContext(); const isSortingByColumn = column.key === activeSorting.columnKey; const sortIcon = activeSorting.order === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong; + const toggleCount = useRef(0); + const toggleSorting = (columnKey: ColumnKey) => { + if (toggleCount.current >= NUMBER_OF_TOGGLES_BEFORE_RESET) { + toggleCount.current = 0; + updateSorting({columnKey: undefined, order: 'asc'}); + return; + } + + toggleCount.current++; + toggleColumnSorting(columnKey); + }; + return ( = (item: T, filters: string[]) => boolean; type IsItemInSearchCallback = (item: T, searchString: string) => boolean; -type UpdateSortingCallback = (params: {columnKey?: ColumnKey; order?: SortOrder}) => void; +type UpdateSortingCallback = (value: SetStateAction>) => void; type ToggleSortingCallback = (columnKey?: ColumnKey) => void; @@ -52,7 +52,7 @@ type GetActiveSearchStringCallback = () => string; type TableMethods = { updateSorting: UpdateSortingCallback; - toggleSorting: ToggleSortingCallback; + toggleColumnSorting: ToggleSortingCallback; updateFilter: UpdateFilterCallback; updateSearchString: UpdateSearchStringCallback; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx index fd6e8810d929..2718ffaca975 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTable.tsx @@ -205,7 +205,7 @@ function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssign isNarrowLayoutRef.current = true; const activeSorting = tableRef.current?.getActiveSorting(); setActiveSortingInWideLayout(activeSorting); - tableRef.current?.updateSorting({columnKey: 'member'}); + tableRef.current?.updateSorting({columnKey: 'member', order: 'asc'}); return; } From a7477385b9c0edb5854ef206978edbc4cc6d2741 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 18:31:11 -0500 Subject: [PATCH 48/53] refactor: extract middlewares --- src/components/Table/Table.tsx | 100 +++------------ src/components/Table/middlewares/filtering.ts | 120 ++++++++++++++++++ src/components/Table/middlewares/searching.ts | 57 +++++++++ src/components/Table/middlewares/sorting.ts | 90 +++++++++++++ src/components/Table/middlewares/types.ts | 8 ++ src/components/Table/types.ts | 74 +---------- 6 files changed, 297 insertions(+), 152 deletions(-) create mode 100644 src/components/Table/middlewares/filtering.ts create mode 100644 src/components/Table/middlewares/searching.ts create mode 100644 src/components/Table/middlewares/sorting.ts create mode 100644 src/components/Table/middlewares/types.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 133b874d2e48..d537a41a0910 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,8 +1,12 @@ import type {FlashListRef} from '@shopify/flash-list'; -import React, {useImperativeHandle, useRef, useState} from 'react'; +import React, {useImperativeHandle, useRef} from 'react'; +import type {Middleware} from './middlewares/types'; +import useFiltering from './middlewares/useFiltering'; +import useSearching from './middlewares/useSearching'; +import useSorting from './middlewares/useSorting'; import TableContext from './TableContext'; -import type {TableContextValue, UpdateFilterCallback} from './TableContext'; -import type {ActiveSorting, GetActiveFiltersCallback, GetActiveSearchStringCallback, GetActiveSortingCallback, TableHandle, TableMethods, TableProps, ToggleSortingCallback} from './types'; +import type {TableContextValue} from './TableContext'; +import type {GetActiveFiltersCallback, GetActiveSearchStringCallback, TableHandle, TableMethods, TableProps} from './types'; function Table({ ref, @@ -19,88 +23,16 @@ function Table>(() => { - const initialFilters: Record = {}; - if (filters) { - for (const key of Object.keys(filters) as FilterKey[]) { - initialFilters[key] = filters[key].default; - } - } - return initialFilters; - }); - - const updateFilter: UpdateFilterCallback = ({key, value}) => { - setCurrentFilters((prev) => ({ - ...prev, - [key]: value, - })); - }; + const {middleware: filterMiddleware, currentFilters, updateFilter} = useFiltering({filters, isItemInFilter}); - let filteredData = data; - if (filters) { - filteredData = data.filter((item) => { - const filterKeys = Object.keys(filters) as FilterKey[]; + const {middleware: searchMiddleware, activeSearchString, updateSearchString} = useSearching({isItemInSearch}); - return filterKeys.every((filterKey) => { - const filterConfig = filters[filterKey]; - const filterValue = currentFilters[filterKey]; + const {middleware: sortMiddleware, activeSorting, updateSorting, toggleColumnSorting, getActiveSorting} = useSorting({compareItems}); - // If filter value is empty/undefined, include the item - if (filterValue === undefined || filterValue === null) { - return true; - } - - // Handle multi-select filters (array values) - if (filterConfig.filterType === 'multi-select') { - const filterValueArray = Array.isArray(filterValue) ? filterValue.filter((v): v is string => typeof v === 'string') : []; - if (filterValueArray.length === 0) { - return true; - } - // For multi-select, pass the array of selected values - return isItemInFilter?.(item, filterValueArray) ?? true; - } - - // Handle single-select filters - const singleValue = typeof filterValue === 'string' ? filterValue : ''; - return singleValue === '' || (isItemInFilter?.(item, [singleValue]) ?? true); - }); - }); - } - - const [activeSearchString, updateSearchString] = useState(''); - - let filteredAndSearchedData = filteredData; - if (isItemInSearch && activeSearchString.trim()) { - filteredAndSearchedData = filteredData.filter((item) => isItemInSearch(item, activeSearchString)); - } - - const [activeSorting, updateSorting] = useState>({columnKey: undefined, order: 'asc'}); - - const toggleColumnSorting: ToggleSortingCallback = (columnKey) => { - updateSorting((prevSorting) => ({columnKey: columnKey ?? prevSorting.columnKey, order: prevSorting.order === 'asc' ? 'desc' : 'asc'})); - }; - - // Apply sorting using comparator function - let processedData = filteredAndSearchedData; - if (activeSorting.columnKey && compareItems) { - const sortedData = [...filteredAndSearchedData]; - sortedData.sort((a, b) => { - return compareItems?.(a, b, activeSorting) ?? 0; - }); - processedData = sortedData; - } - - const getActiveSorting: GetActiveSortingCallback = () => { - return activeSorting; - }; - const getActiveFilters: GetActiveFiltersCallback = () => { - return currentFilters; - }; - const getActiveSearchString: GetActiveSearchStringCallback = () => { - return activeSearchString; - }; + const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), data); const listRef = useRef>(null); + useImperativeHandle(ref, () => { const customMethods: TableMethods = { updateSorting, @@ -113,12 +45,12 @@ function Table { - if (prop in target) { - return target[prop as keyof typeof target]; + get: (target, property) => { + if (property in target) { + return target[property as keyof typeof target]; } - return listRef.current?.[prop as keyof FlashListRef]; + return listRef.current?.[property as keyof FlashListRef]; }, }) as TableHandle; }); diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts new file mode 100644 index 000000000000..e860cbc7e5b7 --- /dev/null +++ b/src/components/Table/middlewares/filtering.ts @@ -0,0 +1,120 @@ +import {useState} from 'react'; +import type {Middleware, MiddlewareHookResult} from './types'; + +type FilterConfigEntry = { + filterType?: 'multi-select' | 'single-select'; + options: Array<{label: string; value: string}>; + default?: string; +}; + +type FilterConfig = Record; + +type IsItemInFilterCallback = (item: T, filters: string[]) => boolean; + +type FilteringMethods = { + updateFilter: (params: {key: FilterKey; value: unknown}) => void; + getActiveFilters: () => Record; +}; + +type UseFilteringProps = { + filters?: FilterConfig; + isItemInFilter?: IsItemInFilterCallback; +}; + +type UseFilteringResult = MiddlewareHookResult & { + currentFilters: Record; + methods: FilteringMethods; +}; + +function useFiltering({filters, isItemInFilter}: UseFilteringProps): UseFilteringResult { + const [currentFilters, setCurrentFilters] = useState>(() => { + const initialFilters = {} as Record; + + if (filters) { + for (const key of Object.keys(filters) as FilterKey[]) { + initialFilters[key] = filters[key].default; + } + } + + return initialFilters; + }); + + const updateFilter: FilteringMethods['updateFilter'] = ({key, value}) => { + setCurrentFilters((previousFilters) => ({ + ...previousFilters, + [key]: value, + })); + }; + + const getActiveFilters: FilteringMethods['getActiveFilters'] = () => { + return currentFilters; + }; + + const middleware: Middleware = (data) => filter({data, filters, currentFilters, isItemInFilter}); + + const methods: FilteringMethods = { + updateFilter, + getActiveFilters, + }; + + return {middleware, currentFilters, methods}; +} + +type FilteringMiddlewareParams = { + data: T[]; + filters?: FilterConfig; + currentFilters: Record; + isItemInFilter?: IsItemInFilterCallback; +}; + +function filter({data, filters, currentFilters, isItemInFilter}: FilteringMiddlewareParams): T[] { + if (!filters) { + // No filters configured, return original data. + return data; + } + + const filterKeys = Object.keys(filters) as FilterKey[]; + + return data.filter((item) => { + return filterKeys.every((filterKey) => { + const filterConfig = filters[filterKey]; + const filterValue = currentFilters[filterKey]; + + // When no filter value is set, we keep the item. + if (filterValue === undefined || filterValue === null) { + return true; + } + + if (filterConfig.filterType === 'multi-select') { + const selectedValues = Array.isArray(filterValue) ? filterValue.filter((value): value is string => typeof value === 'string') : []; + + if (selectedValues.length === 0) { + return true; + } + + if (!isItemInFilter) { + // Without a filter callback, we do not exclude any items. + return true; + } + + return isItemInFilter(item, selectedValues); + } + + const singleValue = typeof filterValue === 'string' ? filterValue : ''; + + if (singleValue === '') { + return true; + } + + if (!isItemInFilter) { + // Without a filter callback, we do not exclude any items. + return true; + } + + return isItemInFilter(item, [singleValue]); + }); + }); +} + +export default useFiltering; +export type {FilteringMiddlewareParams, UseFilteringProps, FilteringMethods, FilterConfig, FilterConfigEntry, IsItemInFilterCallback}; diff --git a/src/components/Table/middlewares/searching.ts b/src/components/Table/middlewares/searching.ts new file mode 100644 index 000000000000..29f7e7594940 --- /dev/null +++ b/src/components/Table/middlewares/searching.ts @@ -0,0 +1,57 @@ +import {useState} from 'react'; +import type {GetActiveSearchStringCallback} from '@components/Table/types'; +import type {Middleware, MiddlewareHookResult} from './types'; + +type IsItemInSearchCallback = (item: T, searchString: string) => boolean; + +type UseSearchingProps = { + isItemInSearch?: IsItemInSearchCallback; +}; + +type SearchingMethods = { + updateSearchString: (value: string) => void; + getActiveSearchString: () => string; +}; + +type UseSearchingResult = MiddlewareHookResult & { + activeSearchString: string; + updateSearchString: (searchString: string) => void; + getActiveSearchString: GetActiveSearchStringCallback; +}; + +function useSearching({isItemInSearch}: UseSearchingProps): UseSearchingResult { + const [activeSearchString, updateSearchString] = useState(''); + + const middleware: Middleware = (data) => search({data, activeSearchString, isItemInSearch}); + + const getActiveSearchString: GetActiveSearchStringCallback = () => { + return activeSearchString; + }; + + return {middleware, activeSearchString, updateSearchString, getActiveSearchString}; +} + +type SearchingMiddlewareParams = { + data: T[]; + activeSearchString: string; + isItemInSearch?: IsItemInSearchCallback; +}; + +function search({data, activeSearchString, isItemInSearch}: SearchingMiddlewareParams): T[] { + const trimmedSearchString = activeSearchString.trim(); + + if (!isItemInSearch) { + // Without a search callback, we keep all items. + return data; + } + + if (trimmedSearchString === '') { + // Empty search string means no searching should be applied. + return data; + } + + return data.filter((item) => isItemInSearch(item, trimmedSearchString)); +} + +export default useSearching; +export type {UseSearchingProps, UseSearchingResult, SearchingMethods, IsItemInSearchCallback}; diff --git a/src/components/Table/middlewares/sorting.ts b/src/components/Table/middlewares/sorting.ts new file mode 100644 index 000000000000..0cf8e9a9178b --- /dev/null +++ b/src/components/Table/middlewares/sorting.ts @@ -0,0 +1,90 @@ +import {useState} from 'react'; +import type {SetStateAction} from 'react'; +import type {Middleware, MiddlewareHookResult} from './types'; + +type SortOrder = 'asc' | 'desc'; + +type ActiveSorting = { + columnKey: ColumnKey | undefined; + order: SortOrder; +}; + +type CompareItemsCallback = (a: T, b: T, sortingConfig: ActiveSorting) => number; + +type SortingMethods = { + updateSorting: (value: SetStateAction>) => void; + toggleColumnSorting: (columnKey?: ColumnKey) => void; + getActiveSorting: () => { + columnKey: ColumnKey | undefined; + order: SortOrder; + }; +}; + +type UseSortingProps = { + compareItems?: CompareItemsCallback; +}; + +type UseSortingResult = MiddlewareHookResult & { + activeSorting: ActiveSorting; + compareItems?: CompareItemsCallback; + methods: SortingMethods; +}; + +function useSorting({compareItems}: UseSortingProps): UseSortingResult { + const [activeSorting, updateSorting] = useState>({ + columnKey: undefined, + order: 'asc', + }); + + const toggleColumnSorting: SortingMethods['toggleColumnSorting'] = (columnKey) => { + updateSorting((previousSorting) => { + const columnKeyToUse = columnKey ?? previousSorting.columnKey; + const orderToUse = previousSorting.order === 'asc' ? 'desc' : 'asc'; + + return { + columnKey: columnKeyToUse, + order: orderToUse, + }; + }); + }; + + const getActiveSorting: SortingMethods['getActiveSorting'] = () => { + return activeSorting; + }; + + const middleware: Middleware = (data) => sort({data, activeSorting, compareItems}); + + const methods: SortingMethods = { + updateSorting, + toggleColumnSorting, + getActiveSorting, + }; + + return {middleware, activeSorting, methods}; +} + +type SortMiddlewareParams = { + data: T[]; + activeSorting: ActiveSorting; + compareItems?: CompareItemsCallback; +}; + +function sort({data, activeSorting, compareItems}: SortMiddlewareParams): T[] { + const hasSortingColumn = !!activeSorting.columnKey; + + if (!hasSortingColumn || !compareItems) { + // When no sorting is configured, return the data as is. + return data; + } + + const sortedData = [...data]; + + sortedData.sort((firstItem, secondItem) => { + return compareItems(firstItem, secondItem, activeSorting); + }); + + return sortedData; +} + +export default useSorting; +export type {UseSortingProps, UseSortingResult, CompareItemsCallback, SortOrder, ActiveSorting, SortingMethods}; diff --git a/src/components/Table/middlewares/types.ts b/src/components/Table/middlewares/types.ts new file mode 100644 index 000000000000..07033cf314c3 --- /dev/null +++ b/src/components/Table/middlewares/types.ts @@ -0,0 +1,8 @@ +type Middleware = (data: T[]) => T[]; + +type MiddlewareHookResult = { + middleware: Middleware; + methods?: Record; +}; + +export type {Middleware, MiddlewareHookResult}; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index c550cbdbd3a7..d8c3710a45e2 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -1,6 +1,9 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; -import type {PropsWithChildren, SetStateAction} from 'react'; +import type {PropsWithChildren} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {FilterConfig, FilteringMethods, IsItemInFilterCallback} from './middlewares/filtering'; +import type {IsItemInSearchCallback, SearchingMethods} from './middlewares/searching'; +import type {CompareItemsCallback, SortingMethods} from './middlewares/sorting'; type TableColumnStyling = { flex?: number; @@ -14,52 +17,7 @@ type TableColumn = { styling?: TableColumnStyling; }; -type FilterConfigEntry = { - filterType?: 'multi-select' | 'single-select'; - options: Array<{label: string; value: string}>; - default?: string; -}; - -type FilterConfig = Record; - -type SortOrder = 'asc' | 'desc'; - -type ActiveSorting = { - columnKey: ColumnKey | undefined; - order: SortOrder; -}; - -type CompareItemsCallback = (a: T, b: T, sortingConfig: ActiveSorting) => number; - -type IsItemInFilterCallback = (item: T, filters: string[]) => boolean; - -type IsItemInSearchCallback = (item: T, searchString: string) => boolean; - -type UpdateSortingCallback = (value: SetStateAction>) => void; - -type ToggleSortingCallback = (columnKey?: ColumnKey) => void; - -type UpdateFilterCallback = (params: {key: FilterKey; value: unknown}) => void; - -type UpdateSearchStringCallback = (value: string) => void; - -type GetActiveSortingCallback = () => { - columnKey: ColumnKey | undefined; - order: SortOrder; -}; -type GetActiveFiltersCallback = () => Record; -type GetActiveSearchStringCallback = () => string; - -type TableMethods = { - updateSorting: UpdateSortingCallback; - toggleColumnSorting: ToggleSortingCallback; - updateFilter: UpdateFilterCallback; - updateSearchString: UpdateSearchStringCallback; - - getActiveSorting: GetActiveSortingCallback; - getActiveFilters: GetActiveFiltersCallback; - getActiveSearchString: GetActiveSearchStringCallback; -}; +type TableMethods = SortingMethods & FilteringMethods & SearchingMethods; type TableHandle = FlashListRef & TableMethods; @@ -79,24 +37,4 @@ type TableProps>; }>; -export type { - TableColumn, - TableMethods, - TableHandle, - TableProps, - SharedListProps, - SortOrder, - ActiveSorting, - FilterConfig, - FilterConfigEntry, - CompareItemsCallback, - IsItemInFilterCallback, - IsItemInSearchCallback, - UpdateSortingCallback, - ToggleSortingCallback, - UpdateSearchStringCallback, - UpdateFilterCallback, - GetActiveSortingCallback, - GetActiveFiltersCallback, - GetActiveSearchStringCallback, -}; +export type {TableColumn, TableMethods, TableHandle, TableProps, SharedListProps, CompareItemsCallback, IsItemInFilterCallback}; From 98b2b88a1f90126a1ce0286e4f11993d639686c2 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 17 Dec 2025 17:07:29 -0800 Subject: [PATCH 49/53] feature: added documentation and fixed comments --- contributingGuides/TABLE.md | 249 ++++++++++ src/components/CaretWrapper.tsx | 10 +- .../Search/FilterDropdowns/DropdownButton.tsx | 8 +- src/components/Table/Table.tsx | 156 +++++- src/components/Table/TableBody.tsx | 32 ++ src/components/Table/TableContext.tsx | 48 ++ .../Table/TableFilterButtons/index.tsx | 62 +++ src/components/Table/TableHeader.tsx | 43 +- src/components/Table/TableSearchBar.tsx | 28 ++ src/components/Table/index.tsx | 51 ++ src/components/Table/middlewares/types.ts | 16 + src/components/Table/types.ts | 138 ++++- tests/ui/TableTest.tsx | 470 +++++++++++++++++- 13 files changed, 1270 insertions(+), 41 deletions(-) create mode 100644 contributingGuides/TABLE.md diff --git a/contributingGuides/TABLE.md b/contributingGuides/TABLE.md new file mode 100644 index 000000000000..d0bb6d938dc4 --- /dev/null +++ b/contributingGuides/TABLE.md @@ -0,0 +1,249 @@ +# Table Component + +A composable, generic table component with built-in filtering, search, and sorting capabilities. + +## Quick Start + +```tsx +import Table from '@components/Table'; +import type { TableColumn, CompareItemsCallback } from '@components/Table'; + +type Item = { id: string; name: string; status: string }; +type ColumnKey = 'name' | 'status'; + +const columns: Array> = [ + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, +]; + +function MyTable() { + return ( + + data={items} + columns={columns} + renderItem={({ item }) => } + keyExtractor={(item) => item.id} + > + + +
+ ); +} +``` + +## Compositional Pattern + +The Table uses a **compound component pattern** where the parent `` manages all state and child components render specific UI parts: + +| Component | Purpose | +|-----------|---------| +| `
` | Parent container that manages state and provides context | +| `` | Renders sortable column headers | +| `` | Renders data rows using FlashList | +| `` | Search input that filters data | +| `` | Dropdown filter buttons | + +### Flexible Composition + +You only include the components you need: + +```tsx +// Minimal: just data rows +
+ +
+ +// With search + + + +
+ +// Full featured + + + + + +
+``` + +## Features + +### Sorting + +Enable by providing `compareItems`: + +```tsx +const compareItems: CompareItemsCallback = (a, b, { columnKey, order }) => { + const multiplier = order === 'asc' ? 1 : -1; + return a[columnKey].localeCompare(b[columnKey]) * multiplier; +}; + + + {/* Clicking headers toggles sort */} + +
+``` + +Header click behavior: `ascending → descending → reset` + +### Searching + +Enable by providing `isItemInSearch`: + +```tsx +const isItemInSearch = (item: Item, searchString: string) => + item.name.toLowerCase().includes(searchString.toLowerCase()); + + + + +
+``` + +### Filtering + +Enable by providing `filters` config and `isItemInFilter`: + +```tsx +import type { FilterConfig, IsItemInFilterCallback } from '@components/Table'; + +const filterConfig: FilterConfig = { + status: { + filterType: 'single-select', // or 'multi-select' + options: [ + { label: 'All', value: 'all' }, + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ], + default: 'all', + }, +}; + +const isItemInFilter: IsItemInFilterCallback = (item, filterValues) => { + if (filterValues.includes('all')) return true; + return filterValues.includes(item.status); +}; + + + + +
+``` + +## Programmatic Control + +Access table methods via ref: + +```tsx +import type { TableHandle } from '@components/Table'; + +const tableRef = useRef>(null); + +// Update sorting programmatically +tableRef.current?.updateSorting({ columnKey: 'name', order: 'desc' }); + +// Update search +tableRef.current?.updateSearchString('query'); + +// Get current state +const sorting = tableRef.current?.getActiveSorting(); +const searchString = tableRef.current?.getActiveSearchString(); + +// FlashList methods also available +tableRef.current?.scrollToIndex({ index: 0 }); + + + +
+``` + +## Type Parameters + +| Parameter | Description | +|-----------|-------------| +| `T` | Type of items in the data array | +| `ColumnKey` | String literal union of column keys (e.g., `'name' \| 'status'`) | +| `FilterKey` | String literal union of filter keys | + +## Architecture + +### Middleware Pipeline + +Data processing flows through three middlewares: + +``` +data → [Filtering] → [Searching] → [Sorting] → processedData +``` + +Each middleware transforms the data array. The order is fixed: filters first, then search, then sort. + +### Context + +All sub-components access shared state via `TableContext`. You can create custom sub-components using `useTableContext`: + +```tsx +import { useTableContext } from '@components/Table/TableContext'; + +function CustomComponent() { + const { processedData, activeSorting, updateSorting } = useTableContext(); + // Build custom UI using context data... +} +``` + +## Column Configuration + +```tsx +type TableColumn = { + key: ColumnKey; // Unique identifier + label: string; // Display text + styling?: { + flex?: number; // Column width ratio + containerStyles?: StyleProp; + labelStyles?: StyleProp; + }; +}; +``` + +## Props Reference + +### Table Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `data` | `T[]` | Yes | Array of items to display | +| `columns` | `TableColumn[]` | Yes | Column configuration | +| `renderItem` | FlashList's `renderItem` | Yes | Row renderer | +| `keyExtractor` | FlashList's `keyExtractor` | Yes | Unique key generator | +| `compareItems` | `CompareItemsCallback` | No | Sorting comparator | +| `isItemInSearch` | `IsItemInSearchCallback` | No | Search predicate | +| `isItemInFilter` | `IsItemInFilterCallback` | No | Filter predicate | +| `filters` | `FilterConfig` | No | Filter dropdown config | +| `ref` | `Ref>` | No | Ref for programmatic control | + +Plus all FlashList props except `data`. diff --git a/src/components/CaretWrapper.tsx b/src/components/CaretWrapper.tsx index ec718247840d..6518f6a53449 100644 --- a/src/components/CaretWrapper.tsx +++ b/src/components/CaretWrapper.tsx @@ -10,11 +10,11 @@ import Icon from './Icon'; type CaretWrapperProps = ChildrenProps & { style?: StyleProp; - carretWidth?: number; - carretHeight?: number; + caretWidth?: number; + caretHeight?: number; }; -function CaretWrapper({children, style, carretWidth, carretHeight}: CaretWrapperProps) { +function CaretWrapper({children, style, caretWidth, caretHeight}: CaretWrapperProps) { const theme = useTheme(); const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['DownArrow'] as const); @@ -25,8 +25,8 @@ function CaretWrapper({children, style, carretWidth, carretHeight}: CaretWrapper
); diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index 38bb18fe211b..2b6286b20b79 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -131,16 +131,16 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi > {/* Dropdown Trigger */}