From 95c04c359e5f8310103f7ab209c656c198589188 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 15 Dec 2025 12:37:32 -0500 Subject: [PATCH 001/323] feat: implement initial company cards table --- .../WorkspaceCompanyCardsPage.tsx | 5 +- .../WorkspaceCompanyCardTableRow.tsx | 105 +++++++++ .../WorkspaceCompanyCardsTable/index.tsx | 204 ++++++++++++++++++ 3 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 src/pages/workspace/companyCards/components/WorkspaceCompanyCardsTable/WorkspaceCompanyCardTableRow.tsx create mode 100644 src/pages/workspace/companyCards/components/WorkspaceCompanyCardsTable/index.tsx diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 86dc3915da99b..3c81bf0980760 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -41,9 +41,9 @@ import type SCREENS from '@src/SCREENS'; import type {CurrencyList} from '@src/types/onyx'; import type {AssignCardData, AssignCardStep} from '@src/types/onyx/AssignCard'; import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; +import WorkspaceCompanyCardsTable from './components/WorkspaceCompanyCardsTable'; import WorkspaceCompanyCardPageEmptyState from './WorkspaceCompanyCardPageEmptyState'; import WorkspaceCompanyCardsFeedPendingPage from './WorkspaceCompanyCardsFeedPendingPage'; -import WorkspaceCompanyCardsList from './WorkspaceCompanyCardsList'; import WorkspaceCompanyCardsListHeaderButtons from './WorkspaceCompanyCardsListHeaderButtons'; type WorkspaceCompanyCardsPageProps = PlatformStackScreenProps; @@ -206,7 +206,8 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { )} {isPending && } {isFeedAdded && !isPending && ( - void; +}; + +function WorkspaceCompanyCardsTableRow({cardholder, cardName: name, cardNumber, isHovered, onAssignCard}: WorkspaceCompanyCardsTableRowProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + const isAssigned = false; + const cardName = name ?? getDisplayNameOrDefault(cardholder); + + return ( + + + {isAssigned ? ( + + ) : ( + + Unassigned + + )} + + + + + {cardNumber} + + + + + {isAssigned ? ( + + + {cardName} + + + + ) : ( + + ); + }); + }, [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 0000000000000..205c544de18db --- /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 042/323] 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 523dbeff73117..4532e89e386b2 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 ac3072ed5da5a..d06d72490529f 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 1033539ce2d9f..53627e49f75d8 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 46cca8495e641..4c74e51ca677d 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 f4ec33ed4e0f9..916ed145a8f7e 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 8efc17ee84787..90fde7e17ff1c 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 4136eb6ed44b7..f045db07374ee 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 043/323] 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 205c544de18db..6026607b3ff3a 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 044/323] 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 4532e89e386b2..5cc81a881840b 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 d06d72490529f..55914a556d960 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 53627e49f75d8..2b6625f3f50fa 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 4c74e51ca677d..08158a2d6f501 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 916ed145a8f7e..6ef916a4515fb 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 90fde7e17ff1c..2223e870651b0 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 f045db07374ee..59c80aa7984f3 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 6026607b3ff3a..5a69d40e74740 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 d5f3a35d934eaff484eb956b2dec2308400eca0f Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 12:23:40 -0500 Subject: [PATCH 045/323] remove assign card menu item --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index a10d4e41233b4..81257f03ed882 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -464,11 +464,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM ); })} - )} From f4cc4409b8b015fb87f2bf9099cd7bd2b4e39bae Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 12:27:46 -0500 Subject: [PATCH 046/323] conditionally show assigned cards header --- .../workspace/members/WorkspaceMemberDetailsPage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 81257f03ed882..5994523331b7a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -423,11 +423,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); From 2a6efbe4eafaecfbdf582cf13438a90f1ce5367c Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 12:45:51 -0500 Subject: [PATCH 047/323] import isLoadingOnyxValue and cardListMetada --- src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index b07564cea2eeb..839a4642bcca3 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -21,6 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import WorkspaceCompanyCardPageEmptyState from './WorkspaceCompanyCardPageEmptyState'; import WorkspaceCompanyCardsFeedPendingPage from './WorkspaceCompanyCardsFeedPendingPage'; import WorkspaceCompanyCardsList from './WorkspaceCompanyCardsList'; @@ -43,7 +44,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { const [cardFeeds, , defaultFeed] = useCardFeeds(policyID); const selectedFeed = getSelectedFeed(lastSelectedFeed, cardFeeds); const feed = selectedFeed ? getCompanyCardFeed(selectedFeed) : undefined; - const [cardsList] = useCardsList(selectedFeed); + const [cardsList, cardsListMetadata] = useCardsList(selectedFeed); const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY, {canBeMissing: false}); const hasNoAssignedCard = Object.keys(cardsList ?? {}).length === 0; From 5b40adfcde03ceca33f659062aee850e4340af64 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 13:03:39 -0500 Subject: [PATCH 048/323] 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 5cc81a881840b..f5d18ffd4b6e3 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 55914a556d960..7227d4559a25d 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 2b6625f3f50fa..742d02fdef326 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 08158a2d6f501..7871ff2818297 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 59c80aa7984f3..1ebb80be8f95c 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 5a69d40e74740..344801ce99393 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 0000000000000..9a8d0d2e44c4c --- /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 92bd4cb898f2aaa6ea0472cc8bfaf9abf37cc22b Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 14:59:45 -0500 Subject: [PATCH 049/323] check card feeds --- 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 839a4642bcca3..b0293fe79ffa4 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -60,7 +60,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { }, [policyID, domainOrWorkspaceAccountID]); const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); - const isLoading = !isOffline && (!cardFeeds || (!!defaultFeed?.isLoading && isEmptyObject(cardsList))); + const isLoading = !isOffline && !cardFeeds; const isGB = countryByIp === CONST.COUNTRY.GB; const shouldShowGBDisclaimer = isGB && isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && (isNoFeed || hasNoAssignedCard); From f85990b271941371ce46912521f69576f514e270 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 15:13:07 -0500 Subject: [PATCH 050/323] add isloadingCardsList --- .../workspace/companyCards/WorkspaceCompanyCardsList.tsx | 5 ++++- .../workspace/companyCards/WorkspaceCompanyCardsPage.tsx | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index f97c9f04d5e8e..2e703390a84a8 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -49,9 +49,12 @@ type WorkspaceCompanyCardsListProps = { /** Whether to show GB disclaimer */ shouldShowGBDisclaimer?: boolean; + + /** Whether the cards list is loading */ + isLoadingCardsList?: boolean; }; -function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer}: WorkspaceCompanyCardsListProps) { +function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer, isLoadingCardsList = false}: WorkspaceCompanyCardsListProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const listRef = useRef>(null); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index b0293fe79ffa4..faf177a18d12d 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -61,6 +61,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); const isLoading = !isOffline && !cardFeeds; + const isLoadingCardsList = (!isOffline && isLoadingOnyxValue(cardsListMetadata)) || testLoadingDelay; const isGB = countryByIp === CONST.COUNTRY.GB; const shouldShowGBDisclaimer = isGB && isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && (isNoFeed || hasNoAssignedCard); @@ -120,6 +121,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { policyID={policyID} onAssignCard={assignCard} isAssigningCardDisabled={isAssigningCardDisabled} + isLoadingCardsList={isLoadingCardsList} /> )} From c254b6bebf1c602136d00ee6cf6ca259016946be Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 15:15:36 -0500 Subject: [PATCH 051/323] import skeleton elements --- src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 2e703390a84a8..baf23a1440cb5 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -7,6 +7,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; import SearchBar from '@components/SearchBar'; import Text from '@components/Text'; +import TableRowSkeleton from '@components/Skeletons/TableRowSkeleton'; import useCardFeeds from '@hooks/useCardFeeds'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -14,6 +15,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; +import useNetwork from '@hooks/useNetwork'; import { filterCardsByPersonalDetails, getCardsByCardholderName, @@ -56,6 +58,7 @@ type WorkspaceCompanyCardsListProps = { function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer, isLoadingCardsList = false}: WorkspaceCompanyCardsListProps) { const styles = useThemeStyles(); + const {isOffline} = useNetwork(); const {translate, localeCompare} = useLocalize(); const listRef = useRef>(null); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); From fafdcb4f32e7e22cd03eced17b8826803b439df1 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 15:18:37 -0500 Subject: [PATCH 052/323] implement skeleton when loading --- .../workspace/companyCards/WorkspaceCompanyCardsList.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index baf23a1440cb5..63a0131526785 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -215,8 +215,8 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC ); - // Show empty state when there are no cards - if (!cards?.length) { + // Show empty state when there are no cards (but not when loading) + if (!cards?.length && !isLoadingCardsList) { return ( : undefined} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" contentContainerStyle={styles.flexGrow1} From 2562d109b18b06b4e68601ad5aa126db0539a80a Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Tue, 16 Dec 2025 16:28:47 -0500 Subject: [PATCH 053/323] polish skeleton loading --- .../companyCards/WorkspaceCompanyCardsList.tsx | 11 +++++++---- .../companyCards/WorkspaceCompanyCardsPage.tsx | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 63a0131526785..50aacc681319c 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -15,6 +15,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import useNetwork from '@hooks/useNetwork'; import { filterCardsByPersonalDetails, @@ -63,7 +64,9 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC const listRef = useRef>(null); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const [personalDetails, personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const isLoadingPersonalDetails = !isOffline && isLoadingOnyxValue(personalDetailsMetadata); + const isLoadingCardsTableData = isLoadingCardsList || isLoadingPersonalDetails; const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); const policy = usePolicy(policyID); @@ -216,7 +219,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC ); // Show empty state when there are no cards (but not when loading) - if (!cards?.length && !isLoadingCardsList) { + if (!cards?.length && !isLoadingCardsTableData) { return ( : undefined} + ListEmptyComponent={!isOffline && isLoadingCardsTableData ? : undefined} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" contentContainerStyle={styles.flexGrow1} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index faf177a18d12d..0dca766b7cba2 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -61,7 +61,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); const isLoading = !isOffline && !cardFeeds; - const isLoadingCardsList = (!isOffline && isLoadingOnyxValue(cardsListMetadata)) || testLoadingDelay; + const isLoadingCardsList = (!isOffline && isLoadingOnyxValue(cardsListMetadata)); const isGB = countryByIp === CONST.COUNTRY.GB; const shouldShowGBDisclaimer = isGB && isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && (isNoFeed || hasNoAssignedCard); From 8b2e1d4ea4d9e81d2b8ecf6243c43a19b09f324e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 16 Dec 2025 17:30:35 -0500 Subject: [PATCH 054/323] 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 f5d18ffd4b6e3..f47c8f3947d81 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 742d02fdef326..d3ece64cd1892 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 7871ff2818297..5fd5462be621d 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 6ef916a4515fb..eb64822e93276 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 0000000000000..8b9bb66c7cf80 --- /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 2223e870651b0..5cab02c1e1f61 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 1ebb80be8f95c..4cf9a839a889e 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 344801ce99393..bd5b83aa9d1b8 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 9a8d0d2e44c4c..ca824e68227e1 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 815536b71f8ef..beb1a75334c77 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 055/323] 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 f97c9f04d5e8e..0000000000000 --- 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 e9a9a94c367b1..0000000000000 --- 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 ef67600b38794..630b940412927 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 121d160dc5f46c951e094cd8c96e52419cf00cd3 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 12:37:11 -0500 Subject: [PATCH 090/323] remove unused variables --- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 036df93e6ca12..1d0fc9848d905 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -7,8 +7,6 @@ import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useCardsList from '@hooks/useCardsList'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -48,10 +46,7 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD, {canBeMissing: true}); - const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: false}); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - const [list] = useCardsList(feed); - const [cardFeeds] = useCardFeeds(policyID); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); From da32c86c3a279c983308ba4095f158e2170e953f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 12:37:48 -0500 Subject: [PATCH 091/323] 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 a625eefcfbea2..4fc817af4080b 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 092/323] 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 eedaa79d0db0c..ec718247840d2 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:45:28 -0500 Subject: [PATCH 093/323] update card identifier menu item in confirmation screen --- .../companyCards/assignCard/ConfirmationStep.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index e5bad5176e61b..3ff872136ec99 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -12,7 +12,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getCompanyCardFeed, getPlaidCountry, getPlaidInstitutionId, isSelectedFeedExpired, lastFourNumbersFromCardName, maskCardNumber} from '@libs/CardUtils'; +import {getCompanyCardFeed, getPlaidCountry, getPlaidInstitutionId, isSelectedFeedExpired, maskCardNumber} from '@libs/CardUtils'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import {getDefaultAvatarURL} from '@libs/UserUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; @@ -121,10 +121,8 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { {translate('workspace.companyCards.confirmationDescription')} editStep(CONST.COMPANY_CARD.STEP.CARD)} + title={data?.encryptedCardNumber ?? maskCardNumber(data?.cardNumber ?? '', data?.bankName)} + interactive={false} /> Date: Wed, 17 Dec 2025 12:49:48 -0500 Subject: [PATCH 094/323] 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 6e727791e279b..dd425a8f7b16a 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 77e6aa989da04..6bc502a7b6fd8 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 cf9df47ecb36c..f43ec796618fa 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 f559156e3b92e..663809ff3e6f5 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 095/323] 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 d9b8e7372af81..51914774f91a3 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 3403377840de7..0a6895a5082e0 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 e61474a5fb584..7120abad2f3b2 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 630b940412927..1fd72fdd70be7 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 096/323] 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 c8c61e16003bf..c20186de0ccc1 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 9aa99afabb4f4bbe1f2dbebe5395ac9ffa86d9c3 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 12:52:33 -0500 Subject: [PATCH 097/323] add default date to confirmation screen --- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 1d0fc9848d905..852b6405b9231 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useMemo, useState} from 'react'; +import {format} from 'date-fns'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -106,6 +107,8 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { nextStep = CONST.COMPANY_CARD.STEP.CONFIRMATION; data.encryptedCardNumber = assignCard.data.encryptedCardNumber; data.cardNumber = assignCard.data.cardNumber; + data.startDate = data.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + data.dateOption = data.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; } setAssignCardStepAndData({ From 2477218f70aab6dd5cbe18cb6c1dfbba0b52d40f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 13:50:15 -0500 Subject: [PATCH 098/323] 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 e6c03c11b669c..5132624ecd18f 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 099/323] 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 5132624ecd18f..8ea5bb9c11806 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 100/323] 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 477ed732082dc..2e2c1f9f9bba8 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 101/323] 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 2e2c1f9f9bba8..87a1524c49070 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 a7da239b21b1637c8d3a9f03eb6bf69bc3a9ee47 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 14:36:56 -0500 Subject: [PATCH 102/323] update cardholder menu item --- .../companyCards/assignCard/ConfirmationStep.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index 3ff872136ec99..b467b3c86a878 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -2,6 +2,7 @@ import React, {useEffect} from 'react'; import {InteractionManager, View} from 'react-native'; import Button from '@components/Button'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScrollView from '@components/ScrollView'; @@ -124,14 +125,11 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { title={data?.encryptedCardNumber ?? maskCardNumber(data?.cardNumber ?? '', data?.bankName)} interactive={false} /> - - {cardholderName} - {cardholderEmail} - - } + Date: Wed, 17 Dec 2025 14:48:36 -0500 Subject: [PATCH 103/323] add back navigation logic to start date --- .../companyCards/assignCard/AssignCardFeedPage.tsx | 2 +- .../assignCard/TransactionStartDateStep.tsx | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index 72a9cde384a0d..b3218aea6d4ee 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -101,7 +101,7 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { /> ); case CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE: - return ; + return ; case CONST.COMPANY_CARD.STEP.CARD_NAME: return ; case CONST.COMPANY_CARD.STEP.CONFIRMATION: diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index 59142d0f16fc6..465f2e1f186fe 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -1,4 +1,7 @@ import {format, subDays} from 'date-fns'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import type SCREENS from '@src/SCREENS'; import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; @@ -12,11 +15,12 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isRequiredFulfilled} from '@libs/ValidationUtils'; +import Navigation from '@navigation/Navigation'; import {setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -function TransactionStartDateStep() { +function TransactionStartDateStep({route}: {route: PlatformStackRouteProp}) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -37,7 +41,13 @@ function TransactionStartDateStep() { }); return; } - setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.CARD}); + const backTo = route?.params?.backTo; + if (backTo) { + Navigation.goBack(backTo); + return; + } + const nextStep = data?.encryptedCardNumber ? CONST.COMPANY_CARD.STEP.ASSIGNEE : CONST.COMPANY_CARD.STEP.CARD; + setAssignCardStepAndData({currentStep: nextStep}); }; const handleSelectDateOption = (dateOption: string) => { From 45ca1dafd4ac0b22a91e3af10edf992a7f51ba48 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 14:49:22 -0500 Subject: [PATCH 104/323] feat: integrate commercial cards --- .../WorkspaceCompanyCardsList.tsx | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index f97c9f04d5e8e..3a8f48f50e043 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -22,6 +22,7 @@ import { getPlaidInstitutionIconUrl, sortCardsByCardholderName, } from '@libs/CardUtils'; +import Log from '@libs/Log'; import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -31,6 +32,25 @@ import type {Card, CompanyCardFeed, CompanyCardFeedWithDomainID, WorkspaceCardsL import WorkspaceCompanyCardsFeedAddedEmptyPage from './WorkspaceCompanyCardsFeedAddedEmptyPage'; import WorkspaceCompanyCardsListRow from './WorkspaceCompanyCardsListRow'; +function isSameCommercialCompanyCardName(aMaskedPan: string | undefined, bMaskedPan: string | undefined) { + if (!aMaskedPan || !bMaskedPan) { + return false; + } + + const aFirstDigits = aMaskedPan.split('X').at(0); + const bFirstDigits = bMaskedPan.split('X').at(0); + const aLastDigits = aMaskedPan.split('X').at(-1); + const bLastDigits = bMaskedPan.split('X').at(-1); + + const firstDigitsCount = Math.min(aFirstDigits?.length ?? 0, bFirstDigits?.length ?? 0); + const lastDigitsCount = Math.min(aLastDigits?.length ?? 0, bLastDigits?.length ?? 0); + + const areFirstDigitsEqual = aFirstDigits?.slice(0, firstDigitsCount) === bFirstDigits?.slice(0, firstDigitsCount); + const areLastDigitsEqual = aLastDigits?.slice(-lastDigitsCount) === bLastDigits?.slice(-lastDigitsCount); + + return areFirstDigitsEqual && areLastDigitsEqual; +} + type WorkspaceCompanyCardsListProps = { /** Selected feed */ selectedFeed: CompanyCardFeedWithDomainID; @@ -63,9 +83,21 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC const {cardList, ...assignedCards} = cardsList ?? {}; const [cardFeeds] = useCardFeeds(policyID); - const companyFeeds = getCompanyFeeds(cardFeeds); - const cards = companyFeeds?.[selectedFeed]?.accountList; + + const hasCommercialCompanyCards = !!cardList; + const hasPlaidCompanyCards = !!companyFeeds?.[selectedFeed]?.accountList; + + if (hasCommercialCompanyCards && hasPlaidCompanyCards) { + Log.warn('Both commercial and company cards found'); + } + + if (!hasCommercialCompanyCards && !hasPlaidCompanyCards) { + Log.warn('No commercial nor Plaid cards found'); + } + + const isPlaidCardFeed = hasPlaidCompanyCards; + const cards = isPlaidCardFeed ? (companyFeeds?.[selectedFeed]?.accountList ?? []) : Object.keys(cardList ?? {}); const plaidIconUrl = getPlaidInstitutionIconUrl(selectedFeed); @@ -88,7 +120,11 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC const renderItem = useCallback( ({item: cardName, index}: ListRenderItemInfo) => { - const assignedCard = Object.values(assignedCards ?? {}).find((card) => card.cardName === cardName); + const assignedCardPredicate = (card: Card) => (isPlaidCardFeed ? card.cardName === cardName : isSameCommercialCompanyCardName(card.cardName, cardName)); + + const assignedCard = Object.values(assignedCards ?? {}).find(assignedCardPredicate); + + console.log('assignedCard', assignedCard); const customCardName = customCardNames?.[assignedCard?.cardID ?? CONST.DEFAULT_NUMBER_ID]; @@ -148,6 +184,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC assignedCards, customCardNames, isAssigningCardDisabled, + isPlaidCardFeed, onAssignCard, personalDetails, plaidIconUrl, From 04e686a27dcd0fa545db584761af93e17643ab89 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 14:58:19 -0500 Subject: [PATCH 105/323] fix assignee step navigation when reselecting same member --- .../workspace/companyCards/assignCard/AssigneeStep.tsx | 9 +++++++++ .../companyCards/assignCard/ConfirmationStep.tsx | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 852b6405b9231..d11ca5b29d137 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -83,9 +83,18 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { }; Keyboard.dismiss(); + if (assignee?.login === assignCard?.data?.email) { + if (assignCard?.data?.encryptedCardNumber) { + nextStep = CONST.COMPANY_CARD.STEP.CONFIRMATION; + data.encryptedCardNumber = assignCard.data.encryptedCardNumber; + data.cardNumber = assignCard.data.cardNumber; + data.startDate = data.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + data.dateOption = data.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; + } setAssignCardStepAndData({ currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep, + data, isEditing: false, }); return; diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index b467b3c86a878..7340db8f1e719 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -128,8 +128,8 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { Date: Wed, 17 Dec 2025 15:04:32 -0500 Subject: [PATCH 106/323] refactor: extract `isMaskedCardNumberEqual` into `CardUtils` --- src/CONST/index.ts | 1 + src/libs/CardUtils.ts | 41 +++++++++++++++++++ .../WorkspaceCompanyCardsList.tsx | 24 +---------- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4daf3831e0865..1d31a0491ddbc 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3403,6 +3403,7 @@ const CONST = { CSV: 'ccupload', }, FEED_KEY_SEPARATOR: '#', + CARD_NUMBER_MASK_CHAR: 'X', STEP_NAMES: ['1', '2', '3', '4'], STEP: { BANK_CONNECTION: 'BankConnection', diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 468910e6b492c..2591720e029eb 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -814,6 +814,46 @@ function getCompanyCardFeed(feedWithDomainID: string): CompanyCardFeed { return feed as CompanyCardFeed; } +/** + * Check if two masked card numbers (PAN) are equal. + * This function compares the first and last digits of the masked card numbers. + * If the number of revealed digits do not match, it will compare the the number of revealed digits. + * + * @param a the first masked card number + * @param b the second masked card number + * @param maskChar the character used to mask the card number + * @returns true if the two masked card numbers are equal, false otherwise + */ +function isMaskedCardNumberEqual(a: string | undefined, b: string | undefined, maskChar: string = CONST.COMPANY_CARD.CARD_NUMBER_MASK_CHAR, compareIfPatternDoesNotMatch = true) { + if (!a || !b) { + return false; + } + + const aParts = a.split(maskChar); + const bParts = b.split(maskChar); + + const aFirstDigits = aParts.at(0); + const bFirstDigits = bParts.at(0); + const aLastDigits = aParts.at(-1); + const bLastDigits = bParts.at(-1); + + const aFirstDigitsCount = aFirstDigits?.length ?? 0; + const bFirstDigitsCount = bFirstDigits?.length ?? 0; + const aLastDigitsCount = aLastDigits?.length ?? 0; + const bLastDigitsCount = bLastDigits?.length ?? 0; + + if (!compareIfPatternDoesNotMatch) { + return aFirstDigitsCount === bFirstDigitsCount && aLastDigitsCount === bLastDigitsCount; + } + + const firstDigitsCount = Math.min(aFirstDigitsCount, bFirstDigitsCount); + const lastDigitsCount = Math.min(aLastDigitsCount, bLastDigitsCount); + + const areFirstDigitsEqual = aFirstDigits?.slice(0, firstDigitsCount) === bFirstDigits?.slice(0, firstDigitsCount); + const areLastDigitsEqual = aLastDigits?.slice(-lastDigitsCount) === bLastDigits?.slice(-lastDigitsCount); + return areFirstDigitsEqual && areLastDigitsEqual; +} + export { getAssignedCardSortKey, isExpensifyCard, @@ -878,6 +918,7 @@ export { getEligibleBankAccountsForUkEuCard, COMPANY_CARD_FEED_ICON_NAMES, COMPANY_CARD_BANK_ICON_NAMES, + isMaskedCardNumberEqual, }; export type {CompanyCardFeedIcons, CompanyCardBankIcons}; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 3a8f48f50e043..411b317cceda7 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -20,6 +20,7 @@ import { getCompanyCardFeedWithDomainID, getCompanyFeeds, getPlaidInstitutionIconUrl, + isMaskedCardNumberEqual, sortCardsByCardholderName, } from '@libs/CardUtils'; import Log from '@libs/Log'; @@ -32,25 +33,6 @@ import type {Card, CompanyCardFeed, CompanyCardFeedWithDomainID, WorkspaceCardsL import WorkspaceCompanyCardsFeedAddedEmptyPage from './WorkspaceCompanyCardsFeedAddedEmptyPage'; import WorkspaceCompanyCardsListRow from './WorkspaceCompanyCardsListRow'; -function isSameCommercialCompanyCardName(aMaskedPan: string | undefined, bMaskedPan: string | undefined) { - if (!aMaskedPan || !bMaskedPan) { - return false; - } - - const aFirstDigits = aMaskedPan.split('X').at(0); - const bFirstDigits = bMaskedPan.split('X').at(0); - const aLastDigits = aMaskedPan.split('X').at(-1); - const bLastDigits = bMaskedPan.split('X').at(-1); - - const firstDigitsCount = Math.min(aFirstDigits?.length ?? 0, bFirstDigits?.length ?? 0); - const lastDigitsCount = Math.min(aLastDigits?.length ?? 0, bLastDigits?.length ?? 0); - - const areFirstDigitsEqual = aFirstDigits?.slice(0, firstDigitsCount) === bFirstDigits?.slice(0, firstDigitsCount); - const areLastDigitsEqual = aLastDigits?.slice(-lastDigitsCount) === bLastDigits?.slice(-lastDigitsCount); - - return areFirstDigitsEqual && areLastDigitsEqual; -} - type WorkspaceCompanyCardsListProps = { /** Selected feed */ selectedFeed: CompanyCardFeedWithDomainID; @@ -120,12 +102,10 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC const renderItem = useCallback( ({item: cardName, index}: ListRenderItemInfo) => { - const assignedCardPredicate = (card: Card) => (isPlaidCardFeed ? card.cardName === cardName : isSameCommercialCompanyCardName(card.cardName, cardName)); + const assignedCardPredicate = (card: Card) => (isPlaidCardFeed ? card.cardName === cardName : isMaskedCardNumberEqual(card.cardName, cardName)); const assignedCard = Object.values(assignedCards ?? {}).find(assignedCardPredicate); - console.log('assignedCard', assignedCard); - const customCardName = customCardNames?.[assignedCard?.cardID ?? CONST.DEFAULT_NUMBER_ID]; const isCardDeleted = assignedCard?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; From b6a00e89f5100fc4567aec23ef82ae65fedc3fc6 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 15:13:23 -0500 Subject: [PATCH 107/323] react compiler fixes --- .../WorkspaceCompanyCardsList.tsx | 18 ++++------ .../assignCard/TransactionStartDateStep.tsx | 33 +++++++++---------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 455cc5188a9db..099b6207078af 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -1,6 +1,6 @@ import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -72,14 +72,12 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC 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]); + const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); + const allCards = getCardsByCardholderName(cardsList, policyMembersAccountIDs); // 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 filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails); + const sortCards = (cardsToSort: Card[]) => sortCardsByCardholderName(cardsToSort, personalDetails, localeCompare); const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; @@ -88,8 +86,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC // 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 renderItem = ({item: cardName, index}: ListRenderItemInfo) => { const assignedCard = Object.values(assignedCards ?? {}).find((card) => card.cardName === cardName); const customCardName = customCardNames?.[assignedCard?.cardID ?? CONST.DEFAULT_NUMBER_ID]; @@ -185,10 +182,9 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC styles.mb3, styles.mh5, styles.ph5, - ], ); - const keyExtractor = useCallback((item: string, index: number) => `${item}_${index}`, []); + const keyExtractor = (item: string, index: number) => `${item}_${index}`; const ListHeaderComponent = shouldUseNarrowTableRowLayout ? ( diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index 465f2e1f186fe..957ef633da0e1 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -2,7 +2,7 @@ import {format, subDays} from 'date-fns'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; import DatePicker from '@components/DatePicker'; @@ -77,23 +77,20 @@ function TransactionStartDateStep({route}: {route: PlatformStackRouteProp [ - { - value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, - text: translate('workspace.companyCards.fromTheBeginning'), - keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, - isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, - }, - { - value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, - text: translate('workspace.companyCards.customStartDate'), - keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, - isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, - }, - ], - [dateOptionSelected, translate], - ); + const dateOptions = [ + { + value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + text: translate('workspace.companyCards.fromTheBeginning'), + keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + }, + { + value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, + text: translate('workspace.companyCards.customStartDate'), + keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, + isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, + }, + ]; return ( Date: Wed, 17 Dec 2025 15:15:24 -0500 Subject: [PATCH 108/323] feat: show last card numbers for commercial feeds --- src/libs/CardUtils.ts | 50 +++++++++++++------ .../WorkspaceCompanyCardsList.tsx | 1 + .../WorkspaceCompanyCardsListRow.tsx | 12 +++-- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 2591720e029eb..cc02164164dc0 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -255,7 +255,7 @@ function maskCardNumber(cardName?: string, feed?: string, showOriginalName?: boo /** * Returns last 4 number from company card name * - * @param cardName - card name with dash in the middle and 4 numbers in the end. + * @param cardName - card name with dash (or other separator) in the middle and 4 numbers in the end. * @returns - Last 4 numbers */ function lastFourNumbersFromCardName(cardName: string | undefined): string { @@ -814,6 +814,32 @@ function getCompanyCardFeed(feedWithDomainID: string): CompanyCardFeed { return feed as CompanyCardFeed; } +type SplitMaskedCardNumberResult = { + firstDigits?: string; + lastDigits?: string; +}; + +/** + * Split masked card number into first and last digits + * + * @param cardNumber the card number to split + * @param maskChar the character used to mask the card number + * @returns the first and last digits of the card number + */ +function splitMaskedCardNumber(cardNumber: string | undefined, maskChar: string = CONST.COMPANY_CARD.CARD_NUMBER_MASK_CHAR): SplitMaskedCardNumberResult { + if (!cardNumber) { + return { + firstDigits: undefined, + lastDigits: undefined, + }; + } + const parts = cardNumber.split(maskChar); + return { + firstDigits: parts.at(0), + lastDigits: parts.at(-1), + }; +} + /** * Check if two masked card numbers (PAN) are equal. * This function compares the first and last digits of the masked card numbers. @@ -829,18 +855,13 @@ function isMaskedCardNumberEqual(a: string | undefined, b: string | undefined, m return false; } - const aParts = a.split(maskChar); - const bParts = b.split(maskChar); - - const aFirstDigits = aParts.at(0); - const bFirstDigits = bParts.at(0); - const aLastDigits = aParts.at(-1); - const bLastDigits = bParts.at(-1); + const aParts = splitMaskedCardNumber(a, maskChar); + const bParts = splitMaskedCardNumber(b, maskChar); - const aFirstDigitsCount = aFirstDigits?.length ?? 0; - const bFirstDigitsCount = bFirstDigits?.length ?? 0; - const aLastDigitsCount = aLastDigits?.length ?? 0; - const bLastDigitsCount = bLastDigits?.length ?? 0; + const aFirstDigitsCount = aParts.firstDigits?.length ?? 0; + const bFirstDigitsCount = bParts.firstDigits?.length ?? 0; + const aLastDigitsCount = aParts.lastDigits?.length ?? 0; + const bLastDigitsCount = bParts.lastDigits?.length ?? 0; if (!compareIfPatternDoesNotMatch) { return aFirstDigitsCount === bFirstDigitsCount && aLastDigitsCount === bLastDigitsCount; @@ -849,8 +870,8 @@ function isMaskedCardNumberEqual(a: string | undefined, b: string | undefined, m const firstDigitsCount = Math.min(aFirstDigitsCount, bFirstDigitsCount); const lastDigitsCount = Math.min(aLastDigitsCount, bLastDigitsCount); - const areFirstDigitsEqual = aFirstDigits?.slice(0, firstDigitsCount) === bFirstDigits?.slice(0, firstDigitsCount); - const areLastDigitsEqual = aLastDigits?.slice(-lastDigitsCount) === bLastDigits?.slice(-lastDigitsCount); + const areFirstDigitsEqual = aParts.firstDigits?.slice(0, firstDigitsCount) === bParts.firstDigits?.slice(0, firstDigitsCount); + const areLastDigitsEqual = aParts.lastDigits?.slice(-lastDigitsCount) === bParts.lastDigits?.slice(-lastDigitsCount); return areFirstDigitsEqual && areLastDigitsEqual; } @@ -919,6 +940,7 @@ export { COMPANY_CARD_FEED_ICON_NAMES, COMPANY_CARD_BANK_ICON_NAMES, isMaskedCardNumberEqual, + splitMaskedCardNumber, }; export type {CompanyCardFeedIcons, CompanyCardBankIcons}; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 411b317cceda7..1c861c5d71604 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -151,6 +151,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC customCardName={customCardName} isHovered={hovered} isAssigned={!!assignedCard} + isPlaidCardFeed={isPlaidCardFeed} onAssignCard={onAssignCard} isAssigningCardDisabled={isAssigningCardDisabled} shouldUseNarrowTableRowLayout={shouldUseNarrowTableRowLayout} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx index 90e6899a5c8f9..05617e1825519 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx @@ -13,7 +13,7 @@ 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 {getCardFeedIcon, lastFourNumbersFromCardName, splitMaskedCardNumber} from '@libs/CardUtils'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -47,6 +47,9 @@ type WorkspaceCompanyCardsListRowProps = { /** Whether to use narrow table row layout */ shouldUseNarrowTableRowLayout?: boolean; + /** Whether the card is a Plaid card feed */ + isPlaidCardFeed: boolean; + /** On assign card callback */ onAssignCard: () => void; }; @@ -58,6 +61,7 @@ function WorkspaceCompanyCardsListRow({ cardName, isHovered, isAssigned, + isPlaidCardFeed, onAssignCard, plaidIconUrl, isAssigningCardDisabled, @@ -77,9 +81,11 @@ function WorkspaceCompanyCardsListRow({ cardFeedIcon = getCardFeedIcon(selectedFeed as CompanyCardFeed, illustrations, companyCardFeedIcons); } - const lastFourCardNameNumbers = lastFourNumbersFromCardName(cardName); + const lastCardNumbers = isPlaidCardFeed ? lastFourNumbersFromCardName(cardName) : splitMaskedCardNumber(cardName)?.lastDigits; + + console.log('lastCardNumbers', lastCardNumbers); - const alternateLoginText = shouldUseNarrowTableRowLayout ? `${customCardNameWithFallback} - ${lastFourCardNameNumbers}` : (cardholder?.login ?? ''); + const alternateLoginText = shouldUseNarrowTableRowLayout ? `${customCardNameWithFallback}${lastCardNumbers ? ` - ${lastCardNumbers}` : ''}` : (cardholder?.login ?? ''); return ( From 60b009eaf3f7bb8959057651ca5515efc10c0860 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 15:20:07 -0500 Subject: [PATCH 109/323] remove log --- .../workspace/companyCards/WorkspaceCompanyCardsListRow.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx index 05617e1825519..196d7098c98fc 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListRow.tsx @@ -83,8 +83,6 @@ function WorkspaceCompanyCardsListRow({ const lastCardNumbers = isPlaidCardFeed ? lastFourNumbersFromCardName(cardName) : splitMaskedCardNumber(cardName)?.lastDigits; - console.log('lastCardNumbers', lastCardNumbers); - const alternateLoginText = shouldUseNarrowTableRowLayout ? `${customCardNameWithFallback}${lastCardNumbers ? ` - ${lastCardNumbers}` : ''}` : (cardholder?.login ?? ''); return ( From 3b246779dfccc5d3cd96388cf128cc1a4f8888b5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 15:22:05 -0500 Subject: [PATCH 110/323] revert: JSDoc comment change --- src/libs/CardUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index cc02164164dc0..eae5a1afb3fb5 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -255,7 +255,7 @@ function maskCardNumber(cardName?: string, feed?: string, showOriginalName?: boo /** * Returns last 4 number from company card name * - * @param cardName - card name with dash (or other separator) in the middle and 4 numbers in the end. + * @param cardName - card name with dash in the middle and 4 numbers in the end. * @returns - Last 4 numbers */ function lastFourNumbersFromCardName(cardName: string | undefined): string { From 55e782b55ffe2bf03ac99a11ac1f81105a759524 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 15:23:02 -0500 Subject: [PATCH 111/323] fix: update warning message --- src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 1c861c5d71604..f85fff2414b9a 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -71,7 +71,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC const hasPlaidCompanyCards = !!companyFeeds?.[selectedFeed]?.accountList; if (hasCommercialCompanyCards && hasPlaidCompanyCards) { - Log.warn('Both commercial and company cards found'); + Log.warn('Both commercial and Plaidcards found'); } if (!hasCommercialCompanyCards && !hasPlaidCompanyCards) { From 4d5cf53842799908fc1bf65b5ede31b75686dee4 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 15:36:59 -0500 Subject: [PATCH 112/323] react compiler fixes --- .../WorkspaceCompanyCardsList.tsx | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 099b6207078af..3f6da7be04e14 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -164,25 +164,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC ); - }, - [ - assignedCards, - cardList, - customCardNames, - isAssigningCardDisabled, - onAssignCard, - personalDetails, - plaidIconUrl, - policyID, - selectedFeed, - shouldUseNarrowTableRowLayout, - styles.br3, - styles.highlightBG, - styles.hoveredComponentBG, - styles.mb3, - styles.mh5, - styles.ph5, - ); + }; const keyExtractor = (item: string, index: number) => `${item}_${index}`; From 5bbe140e1a67a60961797144807387dff64f9fc7 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 15:39:00 -0500 Subject: [PATCH 113/323] 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 ffaa6808d4da6..2ce56b2b00ac3 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 10962b1c3daec0d2ec9de93def2b4d98320b06d3 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 15:44:19 -0500 Subject: [PATCH 114/323] lint errors --- .../WorkspaceCompanyCardsList.tsx | 2 +- .../assignCard/AssignCardFeedPage.tsx | 21 ++++++++++--------- .../companyCards/assignCard/AssigneeStep.tsx | 2 +- .../assignCard/ConfirmationStep.tsx | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 3f6da7be04e14..42ddd84135389 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -102,7 +102,7 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC cardIdentifier = cardName; } else if (isCommercial) { const cardValue = cardList?.[cardName] ?? cardName; - const digitsOnly = cardValue.replace(/\D/g, ''); + const digitsOnly = cardValue.replaceAll(/\D/g, ''); if (digitsOnly.length >= 10) { const first6 = digitsOnly.substring(0, 6); const last4 = digitsOnly.substring(digitsOnly.length - 4); diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index b3218aea6d4ee..ffa836448612b 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -45,17 +45,18 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { }, []); useEffect(() => { - if (cardID && !currentStep) { - const companyCardFeed = getCompanyCardFeed(feed); - - setAssignCardStepAndData({ - currentStep: CONST.COMPANY_CARD.STEP.ASSIGNEE, - data: { - bankName: companyCardFeed, - encryptedCardNumber: cardID, - }, - }); + if (!cardID || currentStep) { + return; } + const companyCardFeed = getCompanyCardFeed(feed); + + setAssignCardStepAndData({ + currentStep: CONST.COMPANY_CARD.STEP.ASSIGNEE, + data: { + bankName: companyCardFeed, + encryptedCardNumber: cardID, + }, + }); }, [cardID, currentStep, feed]); if (isActingAsDelegate) { diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index d11ca5b29d137..959d9bd410e38 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -41,7 +41,7 @@ type AssigneeStepProps = { route: PlatformStackRouteProp; }; -function AssigneeStep({policy, feed, route}: AssigneeStepProps) { +function AssigneeStep({policy, feed: _feed, route}: AssigneeStepProps) { const policyID = route.params.policyID; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index 7340db8f1e719..f5725a689d454 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -129,7 +129,7 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { label={translate('workspace.companyCards.cardholder')} labelStyle={styles.mb3} title={cardholderName && cardholderName !== cardholderEmail ? cardholderName : cardholderEmail} - {...(cardholderName && cardholderName !== cardholderEmail ? {description: cardholderEmail} : {})} + description={cardholderName && cardholderName !== cardholderEmail ? cardholderEmail : undefined} icon={cardholderDetails?.avatar ?? getDefaultAvatarURL(cardholderAccountID)} iconType={CONST.ICON_TYPE_AVATAR} shouldShowRightIcon From eeb347bf2883992df1ec832ca44477125efe5299 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 16:05:52 -0500 Subject: [PATCH 115/323] lint errors --- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 2 +- .../workspace/companyCards/assignCard/ConfirmationStep.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 959d9bd410e38..18d7454edfc08 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -41,7 +41,7 @@ type AssigneeStepProps = { route: PlatformStackRouteProp; }; -function AssigneeStep({policy, feed: _feed, route}: AssigneeStepProps) { +function AssigneeStep({policy, route}: AssigneeStepProps) { const policyID = route.params.policyID; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index f5725a689d454..876b09447c692 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -15,7 +15,7 @@ import useRootNavigationState from '@hooks/useRootNavigationState'; import useThemeStyles from '@hooks/useThemeStyles'; import {getCompanyCardFeed, getPlaidCountry, getPlaidInstitutionId, isSelectedFeedExpired, maskCardNumber} from '@libs/CardUtils'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; -import {getDefaultAvatarURL} from '@libs/UserUtils'; +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import Navigation from '@navigation/Navigation'; import {assignWorkspaceCompanyCard, clearAssignCardStepAndData, setAddNewCompanyCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards'; @@ -130,7 +130,7 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { labelStyle={styles.mb3} title={cardholderName && cardholderName !== cardholderEmail ? cardholderName : cardholderEmail} description={cardholderName && cardholderName !== cardholderEmail ? cardholderEmail : undefined} - icon={cardholderDetails?.avatar ?? getDefaultAvatarURL(cardholderAccountID)} + icon={cardholderDetails?.avatar ?? getDefaultAvatarURL({accountID: cardholderAccountID ?? CONST.DEFAULT_NUMBER_ID})} iconType={CONST.ICON_TYPE_AVATAR} shouldShowRightIcon onPress={() => editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} From 8efe9125ad82a02e3e770b6fb733b7507a3d68b5 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 16:12:47 -0500 Subject: [PATCH 116/323] remove unusued feed prop --- .../workspace/companyCards/assignCard/AssignCardFeedPage.tsx | 2 -- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 3 --- 2 files changed, 5 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index ffa836448612b..1346d6af9fad8 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -90,7 +90,6 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { return ( ); @@ -124,7 +123,6 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { return ( ); diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 18d7454edfc08..ba8903180f364 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -34,9 +34,6 @@ type AssigneeStepProps = { /** The policy that the card will be issued under */ policy: OnyxEntry; - /** Selected feed */ - feed: OnyxTypes.CompanyCardFeedWithDomainID; - /** Route params */ route: PlatformStackRouteProp; }; From 9d9b7d46a4cb87c1122e9a176e5e6dd0c554d2ec Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 16:15:23 -0500 Subject: [PATCH 117/323] prettier --- .../WorkspaceCompanyCardsList.tsx | 146 +++++++++--------- .../assignCard/AssignCardFeedPage.tsx | 2 +- .../companyCards/assignCard/AssigneeStep.tsx | 2 +- .../assignCard/ConfirmationStep.tsx | 2 +- .../assignCard/TransactionStartDateStep.tsx | 6 +- .../members/WorkspaceMemberNewCardPage.tsx | 4 +- 6 files changed, 82 insertions(+), 80 deletions(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 42ddd84135389..a4e6d9e1c09d4 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -19,8 +19,8 @@ import { getCardsByCardholderName, getCompanyCardFeedWithDomainID, getCompanyFeeds, - getPlaidInstitutionId, getPlaidInstitutionIconUrl, + getPlaidInstitutionId, isCustomFeed, sortCardsByCardholderName, } from '@libs/CardUtils'; @@ -87,83 +87,83 @@ function WorkspaceCompanyCardsList({selectedFeed, cardsList, policyID, onAssignC const shouldUseNarrowTableRowLayout = isMediumScreenWidth || shouldUseNarrowLayout; const renderItem = ({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; - - let cardIdentifier: string | undefined; - if (!assignedCard) { - const isPlaid = !!getPlaidInstitutionId(selectedFeed); - const isCommercial = isCustomFeed(selectedFeed); - - if (isPlaid) { - cardIdentifier = cardName; - } else if (isCommercial) { - const cardValue = cardList?.[cardName] ?? cardName; - const digitsOnly = cardValue.replaceAll(/\D/g, ''); - if (digitsOnly.length >= 10) { - const first6 = digitsOnly.substring(0, 6); - const last4 = digitsOnly.substring(digitsOnly.length - 4); - cardIdentifier = `${first6}${last4}`; - } else { - cardIdentifier = cardValue; - } + 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; + + let cardIdentifier: string | undefined; + if (!assignedCard) { + const isPlaid = !!getPlaidInstitutionId(selectedFeed); + const isCommercial = isCustomFeed(selectedFeed); + + if (isPlaid) { + cardIdentifier = cardName; + } else if (isCommercial) { + const cardValue = cardList?.[cardName] ?? cardName; + const digitsOnly = cardValue.replaceAll(/\D/g, ''); + if (digitsOnly.length >= 10) { + const first6 = digitsOnly.substring(0, 6); + const last4 = digitsOnly.substring(digitsOnly.length - 4); + cardIdentifier = `${first6}${last4}`; } else { - cardIdentifier = cardList?.[cardName] ?? cardName; + cardIdentifier = cardValue; } + } else { + cardIdentifier = cardList?.[cardName] ?? cardName; } + } - return ( - + { + if (!assignedCard) { + onAssignCard(cardIdentifier); + 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), + ), + ); + }} > - { - if (!assignedCard) { - onAssignCard(cardIdentifier); - 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}) => ( - onAssignCard(cardIdentifier)} - isAssigningCardDisabled={isAssigningCardDisabled} - shouldUseNarrowTableRowLayout={shouldUseNarrowTableRowLayout} - /> - )} - - - ); + {({hovered}) => ( + onAssignCard(cardIdentifier)} + isAssigningCardDisabled={isAssigningCardDisabled} + shouldUseNarrowTableRowLayout={shouldUseNarrowTableRowLayout} + /> + )} + + + ); }; const keyExtractor = (item: string, index: number) => `${item}_${index}`; diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index 1346d6af9fad8..b042270fe8f1d 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -4,13 +4,13 @@ import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useInitial from '@hooks/useInitial'; import useOnyx from '@hooks/useOnyx'; +import {getCompanyCardFeed} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import PlaidConnectionStep from '@pages/workspace/companyCards/addNew/PlaidConnectionStep'; import BankConnection from '@pages/workspace/companyCards/BankConnection'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; -import {getCompanyCardFeed} from '@libs/CardUtils'; import {clearAssignCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index ba8903180f364..e3c159fd37031 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -1,5 +1,5 @@ -import React, {useEffect, useMemo, useState} from 'react'; import {format} from 'date-fns'; +import React, {useEffect, useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index 876b09447c692..ee2f35d487441 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -15,8 +15,8 @@ import useRootNavigationState from '@hooks/useRootNavigationState'; import useThemeStyles from '@hooks/useThemeStyles'; import {getCompanyCardFeed, getPlaidCountry, getPlaidInstitutionId, isSelectedFeedExpired, maskCardNumber} from '@libs/CardUtils'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; -import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import Navigation from '@navigation/Navigation'; import {assignWorkspaceCompanyCard, clearAssignCardStepAndData, setAddNewCompanyCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index 957ef633da0e1..e31d76c4fd141 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -1,7 +1,4 @@ import {format, subDays} from 'date-fns'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import type SCREENS from '@src/SCREENS'; import React, {useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; @@ -13,12 +10,15 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isRequiredFulfilled} from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; import {setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; function TransactionStartDateStep({route}: {route: PlatformStackRouteProp}) { const {translate} = useLocalize(); diff --git a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx index 6446a2610ed2a..e3d01105ec676 100644 --- a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx @@ -125,7 +125,9 @@ function WorkspaceMemberNewCardPage({route, personalDetails}: WorkspaceMemberNew isEditing: false, }); Navigation.setNavigationActionToMicrotaskQueue(() => - Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute({policyID, feed: selectedFeed, cardID: undefined}, ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID))), + Navigation.navigate( + ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute({policyID, feed: selectedFeed, cardID: undefined}, ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)), + ), ); } }; From 438b5bad90c4dda2d2cf5cd0738246c54e881de5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 16:22:47 -0500 Subject: [PATCH 118/323] 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 6bc502a7b6fd8..8b3d2faed21a2 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 8ea5bb9c11806..7657c8ceeeaac 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 663809ff3e6f5..766f4401dc127 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 98ce9f3623aa3..e5aa8e19a6861 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 5ea1f497f61faa2dfd8c5eba336e335ef1d9dca5 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 16:30:33 -0500 Subject: [PATCH 119/323] translations and useMemoizedLazyExpensifyIcons --- src/languages/de.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 5 +++-- 10 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index d564baa8e8032..cfb78e5034f15 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4891,6 +4891,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU feedName: ({feedName}: CompanyCardFeedNameParams) => `${feedName}-Karten`, directFeed: 'Direkt-Feed', whoNeedsCardAssigned: 'Wer braucht eine zugewiesene Karte?', + chooseTheCardholder: 'Wähle den Karteninhaber', chooseCard: 'Wähle eine Karte', chooseCardFor: ({assignee}: AssigneeParams) => `Wähle eine Karte für ${assignee}. Du findest die Karte, die du suchst, nicht? Teile es uns mit.`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 4869b8669263a..09ae6feac3b0b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4409,6 +4409,7 @@ ${amount} para ${merchant} - ${date}`, feedName: ({feedName}) => `Tarjetas ${feedName}`, directFeed: 'Fuente directa', whoNeedsCardAssigned: '¿Quién necesita una tarjeta?', + chooseTheCardholder: 'Elige el titular de la tarjeta', chooseCard: 'Elige una tarjeta', chooseCardFor: ({assignee}) => `Elige una tarjeta para ${assignee}. ¿No encuentras la tarjeta que buscas? Avísanos.`, noActiveCards: 'No hay tarjetas activas en este feed', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 348c03b779b3c..b3ff0b0a840f9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4894,6 +4894,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. feedName: ({feedName}: CompanyCardFeedNameParams) => `Cartes ${feedName}`, directFeed: 'Flux direct', whoNeedsCardAssigned: 'Qui a besoin d’une carte attribuée ?', + chooseTheCardholder: 'Choisissez le titulaire de la carte', chooseCard: 'Choisissez une carte', chooseCardFor: ({assignee}: AssigneeParams) => `Choisissez une carte pour ${assignee}. Vous ne trouvez pas la carte que vous recherchez ? Faites-le-nous savoir.`, diff --git a/src/languages/it.ts b/src/languages/it.ts index fdc51026f132c..dce714c2f8b7d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4874,6 +4874,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. feedName: ({feedName}: CompanyCardFeedNameParams) => `Carte ${feedName}`, directFeed: 'Feed diretto', whoNeedsCardAssigned: 'Chi ha bisogno di una carta assegnata?', + chooseTheCardholder: 'Scegli il titolare della carta', chooseCard: 'Scegli una carta', chooseCardFor: ({assignee}: AssigneeParams) => `Scegli una carta per ${assignee}. Non riesci a trovare la carta che stai cercando? Facci sapere.`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 64307358155de..778cb40aa817f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4849,6 +4849,7 @@ _より詳しい手順については、[ヘルプサイトをご覧ください feedName: ({feedName}: CompanyCardFeedNameParams) => `${feedName} カード`, directFeed: 'ダイレクトフィード', whoNeedsCardAssigned: '誰にカードを割り当てる必要がありますか?', + chooseTheCardholder: 'カード所有者を選択', chooseCard: 'カードを選択', chooseCardFor: ({assignee}: AssigneeParams) => `${assignee} に使うカードを選択してください。お探しのカードが見つかりませんか?お知らせください。`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7c6b77da032a3..eb6a3b7a7b53f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4868,6 +4868,7 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO feedName: ({feedName}: CompanyCardFeedNameParams) => `${feedName}-kaarten`, directFeed: 'Directe feed', whoNeedsCardAssigned: 'Wie heeft een kaart toegewezen nodig?', + chooseTheCardholder: 'Kies de kaarthouder', chooseCard: 'Kies een kaart', chooseCardFor: ({assignee}: AssigneeParams) => `Kies een kaart voor ${assignee}. Kun je de kaart die je zoekt niet vinden? Laat het ons weten.`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fbde33fa9dbf0..e914d0991f06c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4861,6 +4861,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy feedName: ({feedName}: CompanyCardFeedNameParams) => `Karty ${feedName}`, directFeed: 'Bezpośredni kanał', whoNeedsCardAssigned: 'Kto potrzebuje przypisanej karty?', + chooseTheCardholder: 'Wybierz posiadacza karty', chooseCard: 'Wybierz kartę', chooseCardFor: ({assignee}: AssigneeParams) => `Wybierz kartę dla ${assignee}. Nie możesz znaleźć karty, której szukasz? Daj nam znać.`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 99cf6e164665e..9d45f29005dc1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4860,6 +4860,7 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT feedName: ({feedName}: CompanyCardFeedNameParams) => `Cartões ${feedName}`, directFeed: 'Conexão direta', whoNeedsCardAssigned: 'Quem precisa de um cartão atribuído?', + chooseTheCardholder: 'Escolha o portador do cartão', chooseCard: 'Escolha um cartão', chooseCardFor: ({assignee}: AssigneeParams) => `Escolha um cartão para ${assignee}. Não encontra o cartão que está procurando? Avise-nos.`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 518aa19bdd03c..6728dac96de83 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4781,6 +4781,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM feedName: ({feedName}: CompanyCardFeedNameParams) => `${feedName} 卡片`, directFeed: '直接数据馈送', whoNeedsCardAssigned: '谁需要被分配一张卡?', + chooseTheCardholder: '选择持卡人', chooseCard: '选择一张卡片', chooseCardFor: ({assignee}: AssigneeParams) => `为 ${assignee} 选择一张卡片。找不到您要找的卡片?请告诉我们。`, noActiveCards: '此信息流中没有有效的卡片', diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index e3c159fd37031..98fb1acec84c9 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -2,12 +2,12 @@ import {format} from 'date-fns'; import React, {useEffect, useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -43,6 +43,7 @@ function AssigneeStep({policy, route}: AssigneeStepProps) { const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD, {canBeMissing: true}); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); @@ -156,7 +157,7 @@ function AssigneeStep({policy, route}: AssigneeStepProps) { isSelected: assignCard?.data?.email === email, icons: [ { - source: personalDetail?.avatar ?? Expensicons.FallbackAvatar, + source: personalDetail?.avatar ?? icons.FallbackAvatar, name: formatPhoneNumber(email), type: CONST.ICON_TYPE_AVATAR, id: personalDetail?.accountID, From 9bee8acad90ca1147d07645849a20372a6a89c14 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 16:32:24 -0500 Subject: [PATCH 120/323] 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 e5aa8e19a6861..9694d0516db8b 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 121/323] 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 766f4401dc127..fd6e8810d9299 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 122/323] 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 51914774f91a3..0afc518bf80c7 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 ec9378b03eb3662e64fd4b17e73f12162fa7e4f9 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 17:13:58 -0500 Subject: [PATCH 123/323] fix warning --- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 2 +- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 98fb1acec84c9..ed1c7565a72fd 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -169,7 +169,7 @@ function AssigneeStep({policy, route}: AssigneeStepProps) { membersList = sortAlphabetically(membersList, 'text', localeCompare); return membersList; - }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare]); + }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare, icons.FallbackAvatar]); const assignees = useMemo(() => { if (!debouncedSearchTerm) { diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index a10d4e41233b4..2be256202aea9 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -274,6 +274,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_MEMBER_NEW_CARD.getRoute(policyID, accountID)); return; } + const activeRoute = Navigation.getActiveRoute(); setIssueNewCardStepAndData({ From 0c06cccc818231c353d27112c931ce3dbeed63a0 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 17:31:35 -0500 Subject: [PATCH 124/323] 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 dd425a8f7b16a..133b874d2e48d 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 0a6895a5082e0..ad3ba48c2f369 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 fd6e8810d9299..2718ffaca975b 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 c22604bfb52be6f74960c2b6bbaad0fb7d6a5c3e Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 18:08:37 -0500 Subject: [PATCH 125/323] remove unused handleIssueNewCard --- .../members/WorkspaceMemberDetailsPage.tsx | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index cd9cd22eed4f4..0870a2fd06b1e 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -264,31 +264,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)); From cb8f62f4e58c121dde53b92c9690643d06ebe751 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 17 Dec 2025 18:17:55 -0500 Subject: [PATCH 126/323] remove unused setIssueNewCardStepAndData --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 0870a2fd06b1e..87a1524c49070 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'; From a7477385b9c0edb5854ef206978edbc4cc6d2741 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 17 Dec 2025 18:31:11 -0500 Subject: [PATCH 127/323] 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 133b874d2e48d..d537a41a09103 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 0000000000000..e860cbc7e5b7b --- /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 0000000000000..29f7e7594940e --- /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 0000000000000..0cf8e9a9178b3 --- /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 0000000000000..07033cf314c3d --- /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 c550cbdbd3a74..d8c3710a45e24 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 128/323] 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 0000000000000..d0bb6d938dc4a --- /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 ec718247840d2..6518f6a53449f 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 38bb18fe211b1..2b6286b20b79b 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 */}