diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7c5e7eba54df..9906338d2c1c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9108,6 +9108,7 @@ const CONST = { MORE_DROPDOWN: 'WorkspaceMembers-MoreDropdown', }, CATEGORIES: { + ROW: 'WorkspaceCategories-Row', ADD_BUTTON: 'WorkspaceCategories-AddButton', MORE_DROPDOWN: 'WorkspaceCategories-MoreDropdown', BULK_ACTIONS_DROPDOWN: 'WorkspaceCategories-BulkActionsDropdown', diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 53d7b10e997d..89d86d71f8a8 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,12 +1,20 @@ import type {FlashListRef} from '@shopify/flash-list'; import React, {useImperativeHandle, useRef} from 'react'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import CONST from '@src/CONST'; import useFiltering from './middlewares/filtering'; import useSearching from './middlewares/searching'; +import useSelection from './middlewares/selection'; import useSorting from './middlewares/sorting'; import TableContext from './TableContext'; import type {TableContextValue} from './TableContext'; -import type {TableHandle, TableMethods, TableProps} from './types'; +import type {TableData, TableHandle, TableMethods, TableProps} from './types'; /** * A composable table component that provides filtering, search, and sorting functionality. @@ -32,12 +40,13 @@ import type {TableHandle, TableMethods, TableProps} from './types'; * 1. **Filtering** - Applies dropdown filter selections * 2. **Searching** - Applies search string filtering * 3. **Sorting** - Sorts data by the active column + * 4. **Selection** - Applies row selection state & provides helpers for selection * * Each middleware transforms the data array and passes it to the next. * * ## Generic Type Parameters * - * - `T` - The type of items in your data array + * - `DataType` - The type of items in your data array * - `ColumnKey` - String literal union of valid column keys (e.g., `'name' | 'date'`) * - `FilterKey` - String literal union of valid filter keys * @@ -129,39 +138,51 @@ import type {TableHandle, TableMethods, TableProps} from './types'; * * ``` */ -function Table({ +function Table({ ref, title, - data = [], columns, filters, + data = [], + selectedKeys = [], compareItems, isItemInFilter, isItemInSearch, initialSortColumn, children, + selectionEnabled, + onRowSelectionChange, ...listProps -}: TableProps) { +}: TableProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const isMobileSelectionEnabled = useMobileSelectionMode(); + if (!columns || columns.length === 0) { throw new Error('Table columns must be provided'); } const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const {middleware: filterMiddleware, currentFilters, methods: filterMethods} = useFiltering({filters, isItemInFilter}); + const {middleware: filterMiddleware, currentFilters, methods: filterMethods} = useFiltering({filters, isItemInFilter}); + const filteredData = filterMiddleware(data); - const {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching({isItemInSearch}); + const {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching({isItemInSearch}); + const searchedData = searchMiddleware(filteredData); - const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting({compareItems, initialSortColumn}); + const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting({compareItems, initialSortColumn}); + const sortedData = sortMiddleware(searchedData); - const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), data); + const {middleware: selectionMiddleware, methods: selectionMethods, mobileSelectionModalRowKey} = useSelection({data: sortedData, selectedKeys, onRowSelectionChange}); + const processedData = selectionMiddleware(sortedData); - const listRef = useRef>(null); + const listRef = useRef>(null); const tableMethods: TableMethods = { ...filterMethods, ...sortMethods, ...searchMethods, + ...selectionMethods, }; /** @@ -179,9 +200,9 @@ function Table processedData; } - return listRef.current?.[property as keyof FlashListRef]; + return listRef.current?.[property as keyof FlashListRef]; }, - }) as TableHandle; + }) as TableHandle; }); const originalDataLength = data?.length ?? 0; @@ -199,8 +220,17 @@ function Table 0; const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasSearchString || hasActiveFilters); + const handleMobileSelectionPress = () => { + turnOnMobileSelectionMode(); + + if (mobileSelectionModalRowKey) { + tableMethods.handleSingleRowSelection(mobileSelectionModalRowKey); + tableMethods.setMobileSelectionModalRowKey(null); + } + }; + // eslint-disable-next-line react/jsx-no-constructed-context-values - const contextValue: TableContextValue = { + const contextValue: TableContextValue = { title, listRef, listProps, @@ -216,9 +246,29 @@ function Table}>{children}; + return ( + }> + {children} + + tableMethods.setMobileSelectionModalRowKey(null)} + > + + + + ); } export default Table; diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index acca2f433a54..1ed2c932676d 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -7,6 +7,7 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddi import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {TableData} from '.'; import {useTableContext} from './TableContext'; /** @@ -45,10 +46,19 @@ type TableBodyProps = ViewProps & { * * ``` */ -function TableBody({contentContainerStyle, style, ...props}: TableBodyProps) { +function TableBody({contentContainerStyle, style, ...props}: TableBodyProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {processedData: filteredAndSortedData, activeSearchString, listProps, listRef, shouldUseNarrowTableLayout, hasActiveFilters, hasSearchString, isEmptyResult} = useTableContext(); + const { + processedData: filteredAndSortedData, + activeSearchString, + listProps, + listRef, + shouldUseNarrowTableLayout, + hasActiveFilters, + hasSearchString, + isEmptyResult, + } = useTableContext(); const {ListEmptyComponent, contentContainerStyle: listContentContainerStyle, ...restListProps} = listProps ?? {}; const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ @@ -88,7 +98,7 @@ function TableBody({contentContainerStyle, style, ...props}: TableBodyProps) style={[styles.flex1, styles.mnh0, style]} {...props} > - + ref={listRef} data={filteredAndSortedData} style={[styles.flex1, styles.mnh0]} diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 6d83062e2716..61ce34cbc338 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -2,27 +2,30 @@ import type {FlashListRef} from '@shopify/flash-list'; import React, {createContext, useContext} from 'react'; import type {FilterConfig} from './middlewares/filtering'; import type {ActiveSorting} from './middlewares/sorting'; -import type {SharedListProps, TableColumn, TableMethods} from './types'; +import type {SharedListProps, TableColumn, TableData, TableMethods, TableRow} from './types'; /** * The shape of the Table context value. * This context is provided by the `` component and consumed by its sub-components. * - * @template T - The type of items in the table's data array. + * @template DataType - The type of items in the table's data array. * @template ColumnKey - A string literal type representing the valid column keys. */ -type TableContextValue = { +type TableContextValue = { /** The title of the table when shown on smaller screens. */ title?: string; /** Reference to the underlying FlashList for programmatic control. */ - listRef: React.RefObject | null>; + listRef: React.RefObject | null>; /** FlashList props passed through from the Table component. */ - listProps: SharedListProps; + listProps: SharedListProps; + + /** Whether or not selection is enabled for the table */ + selectionEnabled?: boolean; /** The data array after filtering, searching, and sorting have been applied. */ - processedData: T[]; + processedData: Array>; /** The original length of the data array before any processing. */ originalDataLength: number; @@ -54,11 +57,14 @@ type TableContextValue = { +const defaultTableContextValue: TableContextValue = { listRef: React.createRef(), processedData: [], originalDataLength: 0, @@ -71,11 +77,12 @@ const defaultTableContextValue: TableContextValue = { activeSearchString: '', tableMethods: {} as TableMethods, filterConfig: undefined, - listProps: {} as SharedListProps, + listProps: {} as SharedListProps, hasActiveFilters: false, hasSearchString: false, isEmptyResult: false, shouldUseNarrowTableLayout: false, + isMobileSelectionEnabled: false, }; const TableContext = createContext(defaultTableContextValue); @@ -97,14 +104,14 @@ const TableContext = createContext(defaultTableContextValue); * } * ``` */ -function useTableContext() { +function useTableContext() { const context = useContext(TableContext); if (context === defaultTableContextValue && context.activeFilters === undefined) { throw new Error('useTableContext must be used within a Table provider'); } - return context as unknown as TableContextValue; + return context as unknown as TableContextValue; } export default TableContext; diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index dcaec3a1b032..1e8a54d5dac1 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -1,16 +1,19 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import type {ViewProps} from 'react-native'; +import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {useTableContext} from './TableContext'; -import type {TableColumn} from './types'; +import type {TableColumn, TableData} from './types'; /** * Number of times a column can be toggled before sorting is reset. @@ -30,7 +33,7 @@ type TableHeaderProps = ViewProps; * Clicking a column header toggles sorting: ascending -> descending -> reset. * The currently sorted column displays an arrow icon indicating sort direction. * - * @template T - The type of items in the table's data array. + * @template DataType - The type of items in the table's data array. * @template ColumnKey - A string literal type representing the valid column keys. * * @example @@ -46,10 +49,13 @@ type TableHeaderProps = ViewProps; *
* ``` */ -function TableHeader({style, ...props}: TableHeaderProps) { +function TableHeader({style, ...props}: TableHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {columns, isEmptyResult, title, processedData, shouldUseNarrowTableLayout} = useTableContext(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, processedData, isMobileSelectionEnabled} = useTableContext(); + const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout); if (shouldUseNarrowTableLayout && !title) { return null; @@ -59,7 +65,24 @@ function TableHeader({style, ...props}: Ta return null; } - const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')).join(' '); + const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); + + if (isSelectionCheckboxVisible) { + gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); + } + + const selectableRows = processedData.filter((row) => !row.disabled); + let isSelectionIndeterminate = false; + let isEverySelectableRowSelected = selectableRows.length > 0; + + // We exclude disabled rows from the 'select all' behavior, so if a disabled row is not selected, we still + // consider all active rows to be selected + if (isSelectionCheckboxVisible) { + for (const row of selectableRows) { + isSelectionIndeterminate = row.selected || isSelectionIndeterminate; + isEverySelectableRowSelected = row.selected && isEverySelectableRowSelected; + } + } return ( ({style, ...props}: Ta styles.gap3, // Use Grid on web when available (will override flex if supported) styles.dGrid, - !shouldUseNarrowTableLayout && {gridTemplateColumns}, + !shouldUseNarrowTableLayout && {gridTemplateColumns: gridTemplateColumns.join(' ')}, style, ]} {...props} > {shouldUseNarrowTableLayout && ( - + + {!!isSelectionCheckboxVisible && ( + + )} + ({style, ...props}: Ta )} - {!shouldUseNarrowTableLayout && - columns.map((column) => { - return ( - + {!!selectionEnabled && ( + - ); - })} + )} + + {columns.map((column) => { + return ( + + ); + })} + + )} ); } @@ -110,23 +156,23 @@ function TableHeader({style, ...props}: Ta /** * Renders a single sortable column header. * - * @template T - The type of items in the table's data array. + * @template DataType - The type of items in the table's data array. * @template ColumnKey - A string literal type representing the valid column keys. */ -function TableHeaderColumn({column}: {column: TableColumn}) { +function TableHeaderColumn({column}: {column: TableColumn}) { const theme = useTheme(); + const toggleCount = useRef(0); const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong']); const { activeSorting, tableMethods: {updateSorting, toggleColumnSorting}, - } = useTableContext(); + } = useTableContext(); + const isSortingByColumn = column.key === activeSorting.columnKey; const sortIcon = activeSorting.order === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong; - const toggleCount = useRef(0); - /** * Handles column header press for sorting. * Cycles through: first toggle (asc), second toggle (desc), third toggle (reset). @@ -156,15 +202,15 @@ function TableHeaderColumn({column}: {colu accessible accessibilityLabel={column.label} accessibilityRole="button" + disabled={!column.sortable} sentryLabel={CONST.SENTRY_LABEL.TABLE_HEADER.SORTABLE_COLUMN} style={tableHeaderStyles} - disabled={!column.sortable} onPress={() => toggleSorting(column.key)} > {column.label} diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 92c36aaea220..7421699b5fcb 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import type {PressableStateCallbackType} from 'react-native'; +import type {GestureResponderEvent, PressableStateCallbackType} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; +import Checkbox from '@components/Checkbox'; import ErrorMessageRow from '@components/ErrorMessageRow'; import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -9,6 +10,8 @@ import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWi import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -24,6 +27,9 @@ type TableRowProps = Omit & { /** Whether or not the table row is pressable or not */ interactive: boolean; + /** Whether or not the table row should be disabled */ + disabled?: boolean; + /** The index of the row in the table */ rowIndex: number; @@ -47,6 +53,7 @@ export default function TableRow({ children, accessible, rowIndex, + disabled, sentryLabel, interactive, isLoading, @@ -61,13 +68,21 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); - const {processedData, columns, shouldUseNarrowTableLayout} = useTableContext(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, isMobileSelectionEnabled} = useTableContext(); + const item = processedData.at(rowIndex); const rowCount = processedData.length; const isFirstRow = rowIndex === 0; const isLastRow = rowIndex === rowCount - 1; - const isInteractive = interactive && !isLoading; - const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')).join(' '); + const isDisabled = !!disabled || !!isLoading; + const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); + const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout); + + if (selectionEnabled && isSelectionCheckboxVisible) { + gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); + } const animatedHighlightStyle = useAnimatedHighlightStyle({ shouldHighlight: !!shouldAnimateInHighlight, @@ -75,12 +90,17 @@ export default function TableRow({ backgroundColor: theme.transparent, }); + if (!item) { + return null; + } + const tableRowPressableStyles = [ styles.mh5, styles.highlightBG, - isInteractive && styles.userSelectNone, + styles.userSelectNone, !isFirstRow && styles.borderTop, isLastRow && styles.tableBottomRadius, + item.selected && [styles.activeComponentBG, {borderColor: theme.buttonHoveredBG}], shouldUseNarrowTableLayout ? styles.tableRowHeightCompact : styles.tableRowHeight, ]; @@ -102,9 +122,19 @@ export default function TableRow({ styles.gap3, styles.dFlex, // Use Grid on web when available (will override flex if supported) - !shouldUseNarrowTableLayout && [styles.dGrid, {gridTemplateColumns}], + !shouldUseNarrowTableLayout && [styles.dGrid, {gridTemplateColumns: gridTemplateColumns.join(' ')}], ]; + const tableRowPressableHoverStyle = (() => { + if (isDisabled || !interactive) { + return undefined; + } + if (item.selected) { + return styles.activeComponentBG; + } + return styles.hoveredComponentBG; + })(); + const renderChildren = (state: PressableStateCallbackType) => { if (typeof children === 'function') { return children(state); @@ -113,6 +143,36 @@ export default function TableRow({ return children; }; + const handleCheckboxPress = (event?: GestureResponderEvent | KeyboardEvent | undefined) => { + if (event && 'shiftKey' in event && event.shiftKey) { + tableMethods.handleMultipleRowSelection(item.keyForList); + return; + } + + tableMethods.handleSingleRowSelection(item.keyForList); + }; + + const handleRowPress = (event?: GestureResponderEvent | KeyboardEvent | undefined) => { + if (isDisabled || !interactive) { + return; + } + + if (shouldUseNarrowLayout && isMobileSelectionEnabled) { + handleCheckboxPress(event); + return; + } + + onPress?.(); + }; + + const handleRowLongPress = () => { + if (isDisabled || !selectionEnabled || isMobileSelectionEnabled || !shouldUseNarrowLayout || !interactive) { + return; + } + + tableMethods.setMobileSelectionModalRowKey(item.keyForList); + }; + return ( handleRowPress(event)} + onLongPress={handleRowLongPress} {...props} > {(state) => ( @@ -144,7 +206,20 @@ export default function TableRow({ ) : ( - {renderChildren(state)} + + {!!isSelectionCheckboxVisible && ( + handleCheckboxPress(event)} + /> + )} + {renderChildren(state)} + )} {!!offlineWithFeedback?.errors && ( diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts index 84a01bbd77e4..aaefcb8d0f95 100644 --- a/src/components/Table/middlewares/filtering.ts +++ b/src/components/Table/middlewares/filtering.ts @@ -1,4 +1,5 @@ import {useState} from 'react'; +import type {TableData} from '@components/Table/types'; import type {Middleware, MiddlewareHookResult} from './types'; /** @@ -23,12 +24,12 @@ type FilterConfig = Record = (item: T, filters: string[]) => boolean; +type IsItemInFilterCallback = (item: DataType, filters: string[]) => boolean; /** * Methods exposed by the table to control filtering. @@ -46,34 +47,37 @@ type FilteringMethods = { /** * Props for the filtering middleware. * - * @template T - The type of items in the data array. + * @template DataType - The type of items in the data array. * @template FilterKey - The type of filter keys. */ -type UseFilteringProps = { +type UseFilteringProps = { filters?: FilterConfig; - isItemInFilter?: IsItemInFilterCallback; + isItemInFilter?: IsItemInFilterCallback; }; /** * Result returned by the filtering middleware. * - * @template T - The type of items in the data array. + * @template DataType - The type of items in the data array. * @template FilterKey - The type of filter keys. */ -type UseFilteringResult = MiddlewareHookResult> & { +type UseFilteringResult = MiddlewareHookResult> & { currentFilters: Record; }; /** * Provides functionality to filter table data. * - * @template T - The type of items in the data array. + * @template DataType - The type of items in the data array. * @template FilterKey - The type of filter keys. * @param filters - The filters to use. * @param isItemInFilter - The callback to check if an item matches a filter. * @returns The result of the filtering middleware. */ -function useFiltering({filters, isItemInFilter}: UseFilteringProps): UseFilteringResult { +function useFiltering({ + filters, + isItemInFilter, +}: UseFilteringProps): UseFilteringResult { const [currentFilters, setCurrentFilters] = useState>(() => { const initialFilters = {} as Record; @@ -97,7 +101,7 @@ function useFiltering({filters, isItemInFi return currentFilters; }; - const middleware: Middleware = (data) => filter({data, filters, currentFilters, isItemInFilter}); + const middleware: Middleware = (data) => filter({data, filters, currentFilters, isItemInFilter}); const methods: FilteringMethods = { updateFilter, @@ -110,20 +114,20 @@ function useFiltering({filters, isItemInFi /** * Parameters for the filtering middleware. * - * @template T - The type of items in the data array. + * @template DataType - The type of items in the data array. * @template FilterKey - The type of filter keys. */ -type FilteringMiddlewareParams = { - data: T[]; +type FilteringMiddlewareParams = { + data: DataType[]; filters?: FilterConfig; currentFilters: Record; - isItemInFilter?: IsItemInFilterCallback; + isItemInFilter?: IsItemInFilterCallback; }; /** * Filters table data based on the current filters. * - * @template T - The type of items in the data array. + * @template DataType - The type of items in the data array. * @template FilterKey - The type of filter keys. * @param data - The data to filter. * @param filters - The filters to use. @@ -131,7 +135,7 @@ type FilteringMiddlewareParams = { * @param isItemInFilter - The callback to check if an item matches a filter. * @returns The filtered data. */ -function filter({data, filters, currentFilters, isItemInFilter}: FilteringMiddlewareParams): T[] { +function filter({data, filters, currentFilters, isItemInFilter}: FilteringMiddlewareParams): DataType[] { if (!filters) { // No filters configured, return original data. return data; diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts new file mode 100644 index 000000000000..3bfefd6efff1 --- /dev/null +++ b/src/components/Table/middlewares/selection.ts @@ -0,0 +1,188 @@ +import type {Dispatch, SetStateAction} from 'react'; +import {useEffect, useRef, useState} from 'react'; +import type {TableData, TableRow} from '@components/Table/types'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import type {MiddlewareHookResult} from './types'; + +type UseSelectionProps = { + /** The data being used in the table */ + data: DataType[]; + + /** The list of selected keys */ + selectedKeys: string[]; + + /** Callback that is fired when the selection of rows in the table changes */ + onRowSelectionChange?: (selectedRowKeys: string[]) => void; +}; + +type SelectionMethods = { + /** Callback to either select or unselect all rows in the table */ + handleSelectAll: () => void; + + /** Callback to select multiple rows in the table, while holding shift and clicking on a row */ + handleMultipleRowSelection: (keyForList: string) => void; + + /** Callback to select a single row in the table */ + handleSingleRowSelection: (keyForList: string) => void; + + /** Clear all of the currently selected rows in the table */ + clearSelection: () => void; + + /** Set whether or not the mobile selection modal is visible */ + setMobileSelectionModalRowKey: Dispatch>; +}; + +type UseSelectionResult = MiddlewareHookResult> & { + /** Whether or not the mobile selection modal is visible */ + mobileSelectionModalRowKey: string | null; +}; + +export default function useSelection({data, selectedKeys, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSelectionModeEnabled = useMobileSelectionMode(); + const lastSelectedRowKeyRef = useRef(null); + const lastSelectedRowIsSelectedRef = useRef(false); + + // When a user long-presses a row on mobile, store the key of the row that will be selected if + // the user confirms the selection + const [mobileSelectionModalRowKey, setMobileSelectionModalRowKey] = useState(null); + + const selectableKeys = data.filter((item) => !item.disabled).map((item) => item.keyForList); + const tableRowData: Array> = data.map((item) => ({...item, selected: selectedKeys.includes(item.keyForList)})); + + // Automatically disable selection mode when switching to desktop, or enable it when switching to mobile if there are selected rows + useEffect(() => { + if (shouldUseNarrowLayout && !isSelectionModeEnabled && selectedKeys.length) { + turnOnMobileSelectionMode(); + } else if (!shouldUseNarrowLayout && isSelectionModeEnabled && !selectedKeys.length) { + turnOffMobileSelectionMode(); + } + }, [shouldUseNarrowLayout, isSelectionModeEnabled, selectedKeys.length]); + + // When there are no more items to be selected, turn off selection mode on mobile + useEffect(() => { + if (selectableKeys.length || !isSelectionModeEnabled) { + return; + } + + turnOffMobileSelectionMode(); + }, [selectableKeys.length, isSelectionModeEnabled]); + + /** + * Clear all of the currently selected keys + */ + const clearSelection = () => { + onRowSelectionChange?.([]); + }; + + /** + * When the select all checkbox is toggled, select or deselect all of the + * rows in the table + */ + const handleSelectAll = () => { + const areAllSelectableRowsSelected = selectableKeys.every((key) => selectedKeys.includes(key)); + + const isSelectionEmpty = selectedKeys.length === 0; + const isSelectionFull = areAllSelectableRowsSelected; + const isSelectionIndeterminate = selectedKeys.length > 0 && !areAllSelectableRowsSelected; + + if (isSelectionEmpty) { + onRowSelectionChange?.(selectableKeys); + } else if (isSelectionFull || isSelectionIndeterminate) { + onRowSelectionChange?.([]); + } + }; + + /** + * When a single row is selected in the table, update the selection state to toggle the selection either + * on or off + */ + const handleSingleRowSelection = (keyForList: string) => { + const keyIndex = selectedKeys.indexOf(keyForList); + const isCurrentlySelected = keyIndex !== -1; + + lastSelectedRowKeyRef.current = keyForList; + lastSelectedRowIsSelectedRef.current = !isCurrentlySelected; + + if (isCurrentlySelected) { + onRowSelectionChange?.([...selectedKeys.slice(0, keyIndex), ...selectedKeys.slice(keyIndex + 1)]); + return; + } + + onRowSelectionChange?.([...selectedKeys, keyForList]); + }; + + /** + * When a row is selected, while holding shift, select all of the rows in-between + * the last selected row and the current row + */ + const handleMultipleRowSelection = (keyForList: string) => { + const keyForListExists = selectableKeys.includes(keyForList); + + if (!keyForListExists) { + return; + } + + const lastSelectedRowKey = lastSelectedRowKeyRef.current; + const lastSelectedRowIsSelected = lastSelectedRowIsSelectedRef.current; + + if (!lastSelectedRowKey) { + handleSingleRowSelection(keyForList); + return; + } + + const currentSelectedRowIndex = selectableKeys.indexOf(keyForList); + const lastSelectedRowIndex = selectableKeys.indexOf(lastSelectedRowKey); + + if (currentSelectedRowIndex === -1 || lastSelectedRowIndex === -1) { + handleSingleRowSelection(keyForList); + return; + } + + const endIndex = Math.max(currentSelectedRowIndex, lastSelectedRowIndex); + const startIndex = Math.min(currentSelectedRowIndex, lastSelectedRowIndex); + + const newSelectedKeys = [...selectedKeys]; + + for (let i = startIndex; i <= endIndex; i++) { + const key = selectableKeys.at(i); + + if (!key) { + continue; + } + + if (lastSelectedRowIsSelected) { + if (!newSelectedKeys.includes(key)) { + newSelectedKeys.push(key); + } + } else { + const index = newSelectedKeys.indexOf(key); + if (index !== -1) { + newSelectedKeys.splice(index, 1); + } + } + } + + onRowSelectionChange?.(newSelectedKeys); + }; + + const middleware = () => { + return tableRowData; + }; + + return { + middleware, + mobileSelectionModalRowKey, + methods: { + handleSelectAll, + handleMultipleRowSelection, + handleSingleRowSelection, + clearSelection, + setMobileSelectionModalRowKey, + }, + }; +} + +export type {SelectionMethods, UseSelectionProps, UseSelectionResult}; diff --git a/src/components/Table/middlewares/types.ts b/src/components/Table/middlewares/types.ts index af4af5589929..80c0922936b9 100644 --- a/src/components/Table/middlewares/types.ts +++ b/src/components/Table/middlewares/types.ts @@ -3,19 +3,15 @@ * * Middlewares are pure functions that receive data and return transformed data. * They are chained together in a pipeline: filter → search → sort. - * - * @template T - The type of items in the data array. */ -type Middleware = (data: T[]) => T[]; +type Middleware = (data: InputDataType[]) => OutputDataType[]; /** * Result returned by a middleware hook. - * - * @template T - The type of items in the data array. */ -type MiddlewareHookResult = { +type MiddlewareHookResult = { /** The middleware function to apply to data. */ - middleware: Middleware; + middleware: Middleware; /** Optional methods exposed by the middleware for external control. */ methods: Methods; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 4944fc870bfc..834fad6a2ed9 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -3,8 +3,20 @@ 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 {SelectionMethods} from './middlewares/selection'; import type {ActiveSorting, CompareItemsCallback, SortingMethods} from './middlewares/sorting'; +/** + * Defines the required minimum shape for each row of data in the table + */ +type TableData = { + /** A unique identifier for the row */ + keyForList: string; + + /** Whether or not the row is disabled. Prevents row selection when the row is disabled */ + disabled?: boolean; +}; + /** * Styling options for a table column. */ @@ -31,46 +43,50 @@ type TableColumn = { /** Display label shown in the table header. */ label: string; - /** Optional fixed width in pixels. When set, the column uses this width instead of an equal-share `1fr` track. */ - width?: number; + /** Whether the column is sortable or not */ + sortable: boolean; + + /** Optional fixed width for the column */ + width?: number | string; /** Optional styling configuration for the column. */ styling?: TableColumnStyling; +}; - /** Whether or not the column is sortable */ - sortable: boolean; +type TableRow = DataType & { + /** Whether or not the row is selected or not */ + selected: boolean; }; /** * Methods exposed by the Table component for programmatic control. * Combines sorting, filtering, and searching capabilities. * - * @template T - The type of items in the table's data array (unused in methods, kept for consistency). * @template ColumnKey - A string literal type representing the valid column keys. * @template FilterKey - A string literal type representing the valid filter keys. */ -type TableMethods = SortingMethods & FilteringMethods & SearchingMethods; +type TableMethods = SortingMethods & FilteringMethods & SearchingMethods & SelectionMethods; /** * The ref handle type for the Table component. * Provides access to both FlashList methods and custom table control methods. * - * @template T - The type of items in the table's data array. + * @template DataType - The type of items in the table's data array. * @template ColumnKey - A string literal type representing the valid column keys. * @template FilterKey - A string literal type representing the valid filter keys. */ -type TableHandle = FlashListRef & +type TableHandle = FlashListRef & TableMethods & { /** Method to get all of the processed data after filtering, searching, and sorting have been applied. */ - getProcessedData: () => T[]; + getProcessedData: () => Array>; }; /** * FlashList props with the 'data' prop omitted, as the Table manages data internally. * - * @template T - The type of items in the table's data array. + * @template DataType - The type of items in the table's data array. */ -type SharedListProps = Omit, 'data'>; +type SharedListProps = Omit, 'data'>; /** * Props for the Table component. @@ -79,7 +95,7 @@ type SharedListProps = Omit, 'data'>; * state and provides context, while child components (``, ``, * ``, ``) consume that context to render UI. * - * @template T - The type of items in the table's data array. + * @template DataType - The type of items in the table's data array. * @template ColumnKey - A string literal type representing the valid column keys. * @template FilterKey - A string literal type representing the valid filter keys. * @@ -99,13 +115,16 @@ type SharedListProps = Omit, 'data'>; * * ``` */ -type TableProps = SharedListProps & +type TableProps = SharedListProps & PropsWithChildren<{ /** The title for the table when shown on smaller screens */ title?: string; /** Array of data items to display in the table. */ - data: T[] | undefined; + data: DataType[] | undefined; + + /** Whether multi selection is enabled */ + selectionEnabled?: boolean; /** Column configuration defining what columns to display and how. */ columns: Array>; @@ -122,27 +141,46 @@ type TableProps; + compareItems?: CompareItemsCallback; /** * Predicate function to determine if an item matches the active filters. * Receives an item and an array of active filter values. */ - isItemInFilter?: IsItemInFilterCallback; + isItemInFilter?: IsItemInFilterCallback; /** * Predicate function to determine if an item matches the search string. * Receives an item and the current search string. */ - isItemInSearch?: IsItemInSearchCallback; + isItemInSearch?: IsItemInSearchCallback; /** Ref to access table methods programmatically. */ - ref?: React.Ref>; + ref?: React.Ref>; + + /** Callback when an option is selected */ + onRowSelectionChange?: (selectedRowKeys: string[]) => void; }>; -export type {TableColumn, TableMethods, TableHandle, TableProps, SharedListProps, CompareItemsCallback, IsItemInFilterCallback, IsItemInSearchCallback, FilterConfig, ActiveSorting}; +export type { + TableData, + TableRow, + TableColumn, + TableMethods, + TableHandle, + TableProps, + SharedListProps, + CompareItemsCallback, + IsItemInFilterCallback, + IsItemInSearchCallback, + FilterConfig, + ActiveSorting, +}; diff --git a/src/components/Tables/DomainListTable/index.tsx b/src/components/Tables/DomainListTable/index.tsx index 4bc1f5c692a7..796a9686d5e9 100644 --- a/src/components/Tables/DomainListTable/index.tsx +++ b/src/components/Tables/DomainListTable/index.tsx @@ -15,6 +15,7 @@ import DomainListTableRow from './DomainListTableRow'; type DomainTableColumnKey = 'domains' | 'actions'; type DomainRowData = { + keyForList: string; domainAccountID: number; title: string; disabled: boolean; @@ -38,8 +39,18 @@ export default function DomainListTable({domains}: DomainListTableProps) { const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const domainTableColumns: Array> = [ - {key: 'domains', label: translate('common.domains'), sortable: true}, - {key: 'actions', width: variables.domainTableActionColumnWidth, label: '', styling: {containerStyles: [styles.justifyContentEnd, styles.pr3]}, sortable: false}, + { + sortable: true, + key: 'domains', + label: translate('common.domains'), + }, + { + sortable: false, + key: 'actions', + width: variables.domainTableActionColumnWidth, + label: '', + styling: {containerStyles: [styles.justifyContentEnd, styles.pr3]}, + }, ]; const compareTableItems: CompareItemsCallback = (item1, item2, activeSorting) => { diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx new file mode 100644 index 000000000000..e4e656a80eed --- /dev/null +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import {View} from 'react-native'; +import Avatar from '@components/Avatar'; +import Icon from '@components/Icon'; +import Switch from '@components/Switch'; +import Table from '@components/Table'; +import TextWithTooltip from '@components/TextWithTooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {WorkspaceCategoryTableRowData} from '.'; + +type WorkspaceCategoriesTableRowProps = { + /** Data about the category */ + item: WorkspaceCategoryTableRowData; + + /** The index of the row relative to all other rows */ + rowIndex: number; + + /** Whether to use narrow table row layout */ + shouldUseNarrowTableLayout: boolean; + + /** Whether the GL Code column is visible on web screens or not */ + shouldShowGLCodeColumn: boolean; + + /** Whether the approver column is visible on web screens or not */ + shouldShowApproverColumn: boolean; +}; + +export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTableLayout, shouldShowGLCodeColumn, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + + const accessibilityLabel = [ + item.name, + item.enabled ? translate('common.enabled') : translate('common.disabled'), + shouldShowGLCodeColumn && item.glCode ? `${translate('workspace.categories.glCode')}: ${item.glCode}` : null, + shouldShowApproverColumn && item.approverDisplayName ? `${translate('common.approver')}: ${item.approverDisplayName}` : null, + ] + .filter(Boolean) + .join(', '); + + return ( + + {({hovered}) => ( + <> + + + + + {!shouldUseNarrowTableLayout && shouldShowGLCodeColumn && ( + + + + )} + + {!shouldUseNarrowTableLayout && shouldShowApproverColumn && ( + + {!!item.approverDisplayName && !!item.approverAccountID && ( + <> + + + + )} + + )} + + + + + + + + )} + + ); +} diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx new file mode 100644 index 000000000000..8309fcda77f2 --- /dev/null +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -0,0 +1,168 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React from 'react'; +import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; +import Table from '@components/Table'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import tokenizedSearch from '@libs/tokenizedSearch'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; + +type WorkspaceCategoryTableColumnKey = 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; + +type WorkspaceCategoryTableRowData = TableData & { + name: string; + glCode?: string; + enabled: boolean; + approverAvatar?: AvatarSource; + approverAccountID?: number; + approverDisplayName?: string; + disabled: boolean; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; + isLocked: boolean; + action: () => void; + dismissError: () => void; + onToggleEnabled: (enabled: boolean) => void; +}; + +type WorkspaceCategoriesTableProps = { + ref?: React.Ref> | undefined; + categories: WorkspaceCategoryTableRowData[]; + selectionEnabled: boolean; + shouldShowGLCodeColumn: boolean; + shouldShowApproverColumn: boolean; + selectedKeys: string[]; + onRowSelectionChange: (selectedRowKeys: string[]) => void; + EmptyStateComponent: React.ReactElement; +}; + +export default function WorkspaceCategoriesTable({ + ref, + categories, + selectedKeys, + selectionEnabled, + shouldShowGLCodeColumn, + shouldShowApproverColumn, + onRowSelectionChange, + EmptyStateComponent, +}: WorkspaceCategoriesTableProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + + const categoryTableColumns: Array> = [ + { + key: 'name', + label: translate('common.name'), + sortable: true, + }, + ...(shouldShowGLCodeColumn + ? [ + { + key: 'glCode' as const, + label: translate('workspace.categories.glCode'), + sortable: true, + }, + ] + : []), + ...(shouldShowApproverColumn + ? [ + { + key: 'approver' as const, + label: translate('common.approver'), + sortable: true, + }, + ] + : []), + { + key: 'enabled', + label: translate('common.enabled'), + sortable: true, + width: variables.tableSwitchColumnWidth, + styling: { + containerStyles: [styles.justifyContentEnd], + }, + }, + { + key: 'actions', + label: '', + sortable: false, + width: variables.tableCaretColumnWidth, + }, + ]; + + const compareItems: CompareItemsCallback = (item1, item2, activeSorting) => { + const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; + + if (activeSorting.columnKey === 'approver') { + const approver1 = item1.approverDisplayName ?? ''; + const approver2 = item2.approverDisplayName ?? ''; + return localeCompare(approver1, approver2) * orderMultiplier; + } + + if (activeSorting.columnKey === 'enabled') { + const enabled1 = item1.enabled ? 1 : 0; + const enabled2 = item2.enabled ? 1 : 0; + return (enabled1 - enabled2) * orderMultiplier; + } + + if (activeSorting.columnKey === 'glCode') { + const glCode1 = item1.glCode ?? ''; + const glCode2 = item2.glCode ?? ''; + return localeCompare(glCode1, glCode2) * orderMultiplier; + } + + return localeCompare(item1.name, item2.name) * orderMultiplier; + }; + + const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + const searchLower = searchValue.toLowerCase(); + const results = tokenizedSearch([item], searchLower, (option) => [option.name, option.glCode ?? '']); + return results.length > 0; + }; + + const renderCategoryItem = ({item, index}: ListRenderItemInfo) => ( + + ); + + const isEmpty = categories.length === 0; + + return ( + category.keyForList} + onRowSelectionChange={onRowSelectionChange} + > + {isEmpty && EmptyStateComponent} + {!isEmpty && ( + <> + {categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } + + + + )} +
+ ); +} + +export type {WorkspaceCategoryTableRowData, WorkspaceCategoryTableColumnKey}; diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index 2f7c2bedf281..05ce37a59527 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import Button from '@components/Button'; import Icon from '@components/Icon'; import ReportActionAvatars from '@components/ReportActionAvatars'; +import type {TableData} from '@components/Table'; import Table from '@components/Table'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -22,19 +23,20 @@ import type {Card, CompanyCardFeed, CompanyCardFeedWithDomainID} from '@src/type import type {CardAssignmentData} from '@src/types/onyx/Card'; import WorkspaceCompanyCardsTableSkeleton from './WorkspaceCompanyCardsTableSkeleton'; -type WorkspaceCompanyCardTableRowData = CardAssignmentData & { - /** Whether the card is deleted */ - isCardDeleted: boolean; +type WorkspaceCompanyCardTableRowData = TableData & + CardAssignmentData & { + /** Whether the card is deleted */ + isCardDeleted: boolean; - /** Whether the card is assigned */ - isAssigned: boolean; + /** Whether the card is assigned */ + isAssigned: boolean; - /** Assigned card */ - assignedCard?: Card; + /** Assigned card */ + assignedCard?: Card; - /** On dismiss error callback */ - onDismissError?: () => void; -}; + /** On dismiss error callback */ + onDismissError?: () => void; + }; type WorkspaceCompanyCardTableRowProps = { /** The workspace company card table item */ diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index b56af8ba7bc5..f7cb8d672a3c 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -181,6 +181,7 @@ function WorkspaceCompanyCardsTable({ return { cardName, + keyForList: `${cardName}_${assignedCard?.cardID ?? 'unassigned'}_${encryptedCardNumber}`, encryptedCardNumber, customCardName: assignedCard?.cardID && customCardNames?.[assignedCard.cardID] ? customCardNames?.[assignedCard.cardID] : getDefaultCardName(cardholder?.displayName ?? ''), isCardDeleted: assignedCard?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 4345c94fe14c..d373a159359c 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -2,7 +2,7 @@ import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; import type {ValueOf} from 'type-fest'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableHandle} from '@components/Table'; +import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table from '@components/Table'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -18,10 +18,9 @@ import WorkspaceRow from './WorkspaceTableRow'; type WorkspaceTableColumnKey = 'workspaces' | 'owner' | 'type' | 'actions'; -type WorkspaceRowData = { +type WorkspaceRowData = TableData & { title: string; icon: AvatarSource; - disabled: boolean; isDefault: boolean; isDeleted: boolean; isLoadingBill: boolean; @@ -56,10 +55,28 @@ export default function WorkspaceListTable({ref, workspaces}: WorkspaceListTable const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const workspaceTableColumns: Array> = [ - {key: 'workspaces', label: translate('common.workspaces'), sortable: true}, - {key: 'owner', label: translate('common.owner'), sortable: true}, - {key: 'type', label: translate('workspace.common.workspaceType'), sortable: true}, - {key: 'actions', width: variables.workspaceTableActionColumnWidth, label: '', styling: {containerStyles: [styles.justifyContentEnd, styles.pr3]}, sortable: false}, + { + sortable: true, + key: 'workspaces', + label: translate('common.workspaces'), + }, + { + sortable: true, + key: 'owner', + label: translate('common.owner'), + }, + { + sortable: true, + key: 'type', + label: translate('workspace.common.workspaceType'), + }, + { + sortable: false, + key: 'actions', + width: variables.workspaceTableActionColumnWidth, + label: '', + styling: {containerStyles: [styles.justifyContentEnd, styles.pr3]}, + }, ]; const compareTableItems: CompareItemsCallback = (item1, item2, activeSorting) => { diff --git a/src/components/Tables/WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx b/src/components/Tables/WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx index 8015b76a04a3..aac049417db3 100644 --- a/src/components/Tables/WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx +++ b/src/components/Tables/WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import Avatar from '@components/Avatar'; import Icon from '@components/Icon'; import ReportActionAvatars from '@components/ReportActionAvatars'; +import type {TableData} from '@components/Table'; import Table from '@components/Table'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -14,7 +15,7 @@ import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -type WorkspaceRoomRowData = { +type WorkspaceRoomRowData = TableData & { /** The room reportID */ reportID: string; diff --git a/src/pages/domain/DomainsListPage.tsx b/src/pages/domain/DomainsListPage.tsx index e05a8c9cc67d..8277dc1963c4 100644 --- a/src/pages/domain/DomainsListPage.tsx +++ b/src/pages/domain/DomainsListPage.tsx @@ -57,6 +57,7 @@ function DomainsListPage() { const domainErrors = allDomainErrors?.[`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domain.accountID}`]; domainRows.push({ + keyForList: String(domain.accountID), isAdmin: isDomainAdmin, isValidated: domain.validated, domainAccountID: domain.accountID, diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 30565a36bd78..0d06bb10dabc 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -578,6 +578,7 @@ function WorkspacesListPage() { const ownerDetails = policyOwnerAccountID && getPersonalDetailsByIDs({accountIDs: [policyOwnerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); const pendingWorkspaceRow: WorkspaceRowData = { + keyForList: policyID, policyID, disabled: true, errors: undefined, @@ -607,6 +608,7 @@ function WorkspacesListPage() { const ownerDetails = policyOwnerAccountID && getPersonalDetailsByIDs({accountIDs: [policyOwnerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); const workspaceRow: WorkspaceRowData = { + keyForList: policy.id, policyID: policy.id, disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: policy.errors, diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 8eed58012ccc..3e5e4626d8a8 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import Avatar from '@components/Avatar'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; @@ -13,14 +12,9 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import SearchBar from '@components/SearchBar'; -import TableListItem from '@components/SelectionList/ListItem/TableListItem'; -import type {ListItem} from '@components/SelectionList/types'; -import SelectionListWithModal from '@components/SelectionListWithModal'; -import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; -import Switch from '@components/Switch'; +import type {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; +import WorkspaceCategoriesTable from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; -import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -36,15 +30,12 @@ import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; -import useSearchResults from '@hooks/useSearchResults'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {getCategoryApproverRule, getDecodedCategoryName} from '@libs/CategoryUtils'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -54,15 +45,14 @@ import {isDisablingOrDeletingLastEnabledCategory} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getConnectedIntegration, getCurrentConnectionName, hasAccountingConnections, hasTags, isControlPolicy, shouldShowSyncError} from '@libs/PolicyUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import variables from '@styles/variables'; import {close} from '@userActions/Modal'; import {clearCategoryErrors, deleteWorkspaceCategories, downloadCategoriesCSV, openPolicyCategoriesPage, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {PolicyCategories} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; type WorkspaceCategoriesPageProps = @@ -74,8 +64,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); const {environmentURL} = useEnvironment(); @@ -93,12 +82,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const currentConnectionName = getCurrentConnectionName(policy); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canWrite: canWriteCategories, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.CATEGORIES); + const {canWrite: canWriteCategories, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.CATEGORIES); - const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedCategoryKeys, setSelectedCategoryKeys] = useState([]); const canSelectMultiple = canWriteCategories && (isSmallScreenWidth ? isMobileSelectionModeEnabled : true); const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); - const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled; const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Download', 'Gear', 'Plus', 'Table', 'Trashcan']); const illustrations = useMemoizedLazyIllustrations(['FolderOpen']); const genericIllustration = useGenericEmptyStateIllustration(); @@ -134,15 +122,18 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const cleanupSelectedOption = useCallback(() => setSelectedCategories([]), []); - useCleanupSelectedOptions(cleanupSelectedOption); + const clearTableSelection = useCallback(() => { + setSelectedCategoryKeys((prevSelectedCategoryKeys) => (prevSelectedCategoryKeys.length > 0 ? [] : prevSelectedCategoryKeys)); + }, []); + + useCleanupSelectedOptions(clearTableSelection); useEffect(() => { - if (selectedCategories.length === 0 || !canSelectMultiple) { + if (selectedCategoryKeys.length === 0 || !canSelectMultiple) { return; } - setSelectedCategories((prevSelectedCategories) => { + setSelectedCategoryKeys((prevSelectedCategories) => { const newSelectedCategories = []; for (const categoryName of prevSelectedCategories) { @@ -166,7 +157,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [policyCategories]); useSearchBackPress({ - onClearSelection: () => setSelectedCategories([]), + onClearSelection: clearTableSelection, onNavigationCallBack: () => Navigation.goBack(backTo), }); @@ -222,204 +213,85 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ], ); - const glCodeContainerStyle = useMemo(() => [styles.flex1], [styles.flex1]); - const glCodeTextStyle = useMemo(() => [styles.alignSelfStart], [styles.alignSelfStart]); - const switchContainerStyle = useMemo(() => [StyleUtils.getMinimumWidth(variables.w72)], [StyleUtils]); + const navigateToCategory = useCallback( + (category: PolicyCategories[string]) => { + const path = isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, category.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(category.name)); - const categoryList = useMemo(() => { - const categories = Object.values(policyCategories ?? {}); - return categories.reduce((acc, value) => { - const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + Navigation.navigate(path); + }, + [backTo, isQuickSettingsFlow, policyId], + ); - if (!isOffline && isDisabled) { - return acc; + const handleCategoryToggle = useCallback( + (enabled: boolean, category: PolicyCategories[string]) => { + if (!canWriteCategories) { + showReadOnlyModal(); + return; } - const approverEmail = shouldShowApproverColumn ? (getCategoryApproverRule(policy?.rules?.approvalRules ?? [], value.name)?.approver ?? '') : ''; - const approverPersonalDetail = getPersonalDetailByEmail(approverEmail); - const {avatar, displayName = approverEmail, accountID} = approverPersonalDetail ?? {}; - const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; - - acc.push({ - text: getDecodedCategoryName(value.name), - keyForList: value.name, - isDisabled, - pendingAction: value.pendingAction, - errors: value.errors ?? undefined, - rightElement: isControlPolicyWithWideLayout ? ( - <> - - - {value['GL Code']} - - - {shouldShowApproverColumn && ( - - {approverDisplayName ? ( - <> - - - {approverDisplayName} - - - ) : null} - - )} - - { - if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } - updateWorkspaceCategoryEnabled(newValue, value.name); - }} - showLockIcon={!canWriteCategories || isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])} - /> - - - ) : ( - { - if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } - updateWorkspaceCategoryEnabled(newValue, value.name); - }} - showLockIcon={!canWriteCategories || isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])} - /> - ), - }); - - return acc; - }, []); - }, [ - showCannotDeleteOrDisableLastCategoryModal, - policyCategories, - isOffline, - translate, - updateWorkspaceCategoryEnabled, - policy, - isControlPolicyWithWideLayout, - glCodeContainerStyle, - glCodeTextStyle, - switchContainerStyle, - shouldShowApproverColumn, - canWriteCategories, - withReadOnlyFallback, - styles.alignItemsCenter, - styles.flexRow, - styles.mr3, - ]); + if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } - const filterCategory = useCallback((categoryOption: ListItem, searchInput: string) => { - const results = tokenizedSearch([categoryOption], searchInput, (option) => [option.text ?? '', option.alternateText ?? '']); - return results.length > 0; - }, []); - const sortCategories = useCallback( - (data: ListItem[]) => { - return data.sort((a, b) => localeCompare(a.text ?? '', b?.text ?? '')); + updateWorkspaceCategoryEnabled(enabled, category.name); }, - [localeCompare], + [canWriteCategories, policy, policyCategories, showCannotDeleteOrDisableLastCategoryModal, showReadOnlyModal, updateWorkspaceCategoryEnabled], ); - const [inputValue, setInputValue, filteredCategoryList] = useSearchResults(categoryList, filterCategory, sortCategories); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); + const categories = Object.values(policyCategories ?? {}); - const toggleCategory = useCallback( - (category: ListItem) => { - setSelectedCategories((prev) => { - if (prev.includes(category.keyForList)) { - return prev.filter((key) => key !== category.keyForList); - } - return [...prev, category.keyForList]; - }); - }, - [setSelectedCategories], - ); + const categoryApproverEmails = useMemo(() => { + const approverEmails: Record = {}; - const toggleAllCategories = () => { - const availableCategories = filteredCategoryList.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const someSelected = availableCategories.some((category) => selectedCategories.includes(category.keyForList)); - setSelectedCategories(someSelected ? [] : availableCategories.map((item) => item.keyForList)); - }; + for (const category of categories) { + const approverEmail = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category.name)?.approver; - const getCustomListHeader = () => { - if (filteredCategoryList.length === 0) { - return null; + if (approverEmail) { + approverEmails[category.name] = approverEmail; + } } + return approverEmails; + }, [categories, policy?.rules?.approvalRules]); - // Show GL Code column only on wide screens for control policies. Approver column additionally requires rules to be enabled - if (isControlPolicyWithWideLayout) { - const header = ( - - - {translate('common.name')} - - - {translate('workspace.categories.glCode')} - - {shouldShowApproverColumn && ( - - {translate('common.approver')} - - )} - - {translate('common.enabled')} - - - ); + const shouldShowGLCodeColumn = Object.values(policyCategories ?? {}).some((category) => !!category['GL Code']) && isControlPolicyWithWideLayout; + const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled && Object.keys(categoryApproverEmails).length > 0; + + const categoryRows = useMemo(() => { + return categories.reduce((acc, value) => { + const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - if (canSelectMultiple) { - return header; + if (!isOffline && isDisabled) { + return acc; } - return {header}; - } + const approverEmail = shouldShowApproverColumn ? categoryApproverEmails[value.name] : undefined; + const approverPersonalDetail = getPersonalDetailByEmail(approverEmail); + const {avatar: approverAvatar, displayName = approverEmail, accountID: approverAccountID} = approverPersonalDetail ?? {}; + const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; - return ( - - ); - }; + acc.push({ + keyForList: value.name, + name: getDecodedCategoryName(value.name), + glCode: value['GL Code'], + disabled: isDisabled, + approverAvatar, + approverAccountID, + approverDisplayName, + enabled: value.enabled, + errors: value.errors ?? undefined, + pendingAction: value.pendingAction, + isLocked: isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value]) || !canWriteCategories || isDisabled, + action: () => navigateToCategory(value), + onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), + dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), + }); - const navigateToCategorySettings = (category: ListItem) => { - if (canWriteCategories && isSmallScreenWidth && isMobileSelectionModeEnabled) { - toggleCategory(category); - return; - } - Navigation.navigate( - isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, category.keyForList, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(category.keyForList)), - ); - }; + return acc; + }, []); + }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, canWriteCategories, policy, policyCategories, navigateToCategory, handleCategoryToggle, policyId]); const navigateToCategoriesSettings = useCallback(() => { Navigation.navigate(createDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_SETTINGS.path)); @@ -429,15 +301,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { Navigation.navigate(createDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORY_CREATE.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORY_CREATE.path)); }; - const dismissError = (item: ListItem) => { - clearCategoryErrors(policyId, item.keyForList, policyCategories); - }; - const handleDeleteCategories = () => { - if (selectedCategories.length > 0) { + if (selectedCategoryKeys.length > 0) { deleteWorkspaceCategories( policyData, - selectedCategories, + selectedCategoryKeys, isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, @@ -447,9 +315,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); } - setSelectedCategories([]); + clearTableSelection(); }; - const hasVisibleCategories = categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); + + const hasVisibleCategories = categoryRows.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const policyHasAccountingConnections = hasAccountingConnections(policy); @@ -542,13 +411,13 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; - const selectedCategoriesObject = selectedCategories.map((key) => policyCategories?.[key]); + const selectedCategoriesObject = selectedCategoryKeys.map((key) => policyCategories?.[key]); - if (canWriteCategories && (isSmallScreenWidth ? canSelectMultiple : selectedCategories.length > 0)) { + if (canWriteCategories && (isSmallScreenWidth ? canSelectMultiple : selectedCategoryKeys.length > 0)) { if (!isThereAnyAccountingConnection) { options.push({ icon: icons.Trashcan, - text: translate(selectedCategories.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), + text: translate(selectedCategoryKeys.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, onSelected: async () => { if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, selectedCategoriesObject)) { @@ -557,8 +426,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { } const {action} = await showConfirmModal({ - title: translate(selectedCategories.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), - prompt: translate(selectedCategories.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt'), + title: translate(selectedCategoryKeys.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), + prompt: translate(selectedCategoryKeys.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt'), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, @@ -570,9 +439,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); } - const enabledCategories = selectedCategories.filter((categoryName) => policyCategories?.[categoryName]?.enabled); + const enabledCategories = selectedCategoryKeys.filter((categoryName) => policyCategories?.[categoryName]?.enabled); if (enabledCategories.length > 0) { - const categoriesToDisable = selectedCategories + const categoriesToDisable = selectedCategoryKeys .filter((categoryName) => policyCategories?.[categoryName]?.enabled) .reduce>((acc, categoryName) => { acc[categoryName] = { @@ -590,7 +459,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { showCannotDeleteOrDisableLastCategoryModal(); return; } - setSelectedCategories([]); + clearTableSelection(); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToDisable, @@ -611,9 +480,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); } - const disabledCategories = selectedCategories.filter((categoryName) => !policyCategories?.[categoryName]?.enabled); + const disabledCategories = selectedCategoryKeys.filter((categoryName) => !policyCategories?.[categoryName]?.enabled); if (disabledCategories.length > 0) { - const categoriesToEnable = selectedCategories + const categoriesToEnable = selectedCategoryKeys .filter((categoryName) => !policyCategories?.[categoryName]?.enabled) .reduce>((acc, categoryName) => { acc[categoryName] = { @@ -627,7 +496,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { text: translate(disabledCategories.length === 1 ? 'workspace.categories.enableCategory' : 'workspace.categories.enableCategories'), value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, onSelected: () => { - setSelectedCategories([]); + clearTableSelection(); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToEnable, @@ -653,11 +522,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { onPress={() => null} shouldAlwaysShowDropdownMenu buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {count: selectedCategories.length})} + customText={translate('workspace.common.selected', {count: selectedCategoryKeys.length})} options={options} isSplitButton={false} style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} - isDisabled={!selectedCategories.length} + isDisabled={!selectedCategoryKeys.length} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CATEGORIES.BULK_ACTIONS_DROPDOWN} testID="WorkspaceCategoriesPage-header-dropdown-menu-button" /> @@ -700,35 +569,26 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return; } - setSelectedCategories([]); - }, [setSelectedCategories, isMobileSelectionModeEnabled]); + clearTableSelection(); + }, [clearTableSelection, isMobileSelectionModeEnabled]); const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; const headerContent = ( - <> - - {!hasSyncError && isConnectionVerified && currentConnectionName ? ( - - ) : ( - {translate('workspace.categories.subtitle')} - )} - - {categoryList.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - + {!hasSyncError && isConnectionVerified && currentConnectionName ? ( + + ) : ( + {translate('workspace.categories.subtitle')} )} - + ); + const subtitleText = useMemo(() => { if (!policyHasAccountingConnections) { return {translate('workspace.categories.emptyCategories.subtitle')}; @@ -752,6 +612,35 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { policyId, canWriteCategories, ]); + + const emptyStateContent = ( + + + + ); + return ( { if (isMobileSelectionModeEnabled) { - setSelectedCategories([]); + clearTableSelection(); turnOffMobileSelectionMode(); return; } @@ -791,7 +680,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { {!shouldDisplayButtonsInSeparateLine && getHeaderButtons()} {shouldDisplayButtonsInSeparateLine && !!getHeaderButtons() && {getHeaderButtons()}} + {(!hasVisibleCategories || isLoading) && headerContent} + {isLoading && ( )} - {hasVisibleCategories && !isLoading && ( - item && canWriteCategories && toggleCategory(item)} - onSelectAll={canWriteCategories && filteredCategoryList.length > 0 ? toggleAllCategories : undefined} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - turnOnSelectionModeOnLongPress={canWriteCategories && isSmallScreenWidth} - customListHeader={getCustomListHeader()} - customListHeaderContent={headerContent} - canSelectMultiple={canSelectMultiple} - selectAllAccessibilityLabel={translate('accessibilityHints.selectAllCategories')} - shouldShowListEmptyContent={false} - onDismissError={dismissError} - showScrollIndicator={false} - shouldHeaderBeInsideList - shouldShowRightCaret - /> - )} - {!hasVisibleCategories && !isLoading && inputValue.length === 0 && ( - - + {hasVisibleCategories && ( + + {!hasSyncError && isConnectionVerified && currentConnectionName ? ( + + ) : ( + {translate('workspace.categories.subtitle')} + )} + + )} + + setSelectedCategoryKeys(selectedRowKeys)} + EmptyStateComponent={emptyStateContent} /> - + )} { const ownerDetails = report.ownerAccountID ? personalDetails?.[report.ownerAccountID] : undefined; return { + keyForList: report.reportID, reportID: report.reportID, name: getReportName(report, reportAttributes), ownerAccountID: report.ownerAccountID, diff --git a/src/styles/index.ts b/src/styles/index.ts index aef9a7f57128..2d434a7a6f2c 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3142,7 +3142,6 @@ const staticStyles = (theme: ThemeColors) => height: 28, justifyContent: 'center', borderRadius: 20, - padding: 15, }, switchThumb: { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index f78b02fa7ded..0e9eaee8ec44 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -128,12 +128,15 @@ export default { optionRowHeightCompact: 52, tableHeaderContentHeight: 20, tableRowHeight: 56, - tableRowHeightCompact: 72, + tableRowHeightCompact: 60, tableRowPaddingVertical: 8, tableRowPaddingHorizontal: 12, tableGroupRowPaddingVertical: 4, tableGroupRowHeight: 36, tableSkeletonHeight: 32, + tableCheckboxColumnWidth: 20, + tableSwitchColumnWidth: 58, + tableCaretColumnWidth: 20, domainTableActionColumnWidth: 64, workspaceTableActionColumnWidth: 64, sectionMenuItemHeight: 52, diff --git a/tests/ui/TableTest.tsx b/tests/ui/TableTest.tsx index 9781fcb50f57..903982d44407 100644 --- a/tests/ui/TableTest.tsx +++ b/tests/ui/TableTest.tsx @@ -135,6 +135,7 @@ jest.mock('@components/Pressable', () => ({ // Sample data types for testing type TestItem = { + keyForList: string; id: string; name: string; category: string; @@ -145,11 +146,11 @@ type TestColumnKey = 'name' | 'category' | 'value'; // Sample test data const mockData: TestItem[] = [ - {id: '1', name: 'Apple', category: 'fruit', value: 100}, - {id: '2', name: 'Banana', category: 'fruit', value: 200}, - {id: '3', name: 'Carrot', category: 'vegetable', value: 50}, - {id: '4', name: 'Date', category: 'fruit', value: 150}, - {id: '5', name: 'Eggplant', category: 'vegetable', value: 75}, + {keyForList: '1', id: '1', name: 'Apple', category: 'fruit', value: 100}, + {keyForList: '2', id: '2', name: 'Banana', category: 'fruit', value: 200}, + {keyForList: '3', id: '3', name: 'Carrot', category: 'vegetable', value: 50}, + {keyForList: '4', id: '4', name: 'Date', category: 'fruit', value: 150}, + {keyForList: '5', id: '5', name: 'Eggplant', category: 'vegetable', value: 75}, ]; const mockColumns: Array> = [ @@ -824,6 +825,7 @@ describe('Table', () => { describe('performance and edge cases', () => { it('should handle large datasets', () => { const largeData: TestItem[] = Array.from({length: 100}, (_, i) => ({ + keyForList: String(i + 1), id: String(i + 1), name: `Item ${i + 1}`, category: i % 2 === 0 ? 'fruit' : 'vegetable', diff --git a/tests/ui/WorkspaceCategoriesTest.tsx b/tests/ui/WorkspaceCategoriesTest.tsx index fc1b6566d71f..d89b6d00c69d 100644 --- a/tests/ui/WorkspaceCategoriesTest.tsx +++ b/tests/ui/WorkspaceCategoriesTest.tsx @@ -138,9 +138,8 @@ describe('WorkspaceCategories', () => { expect(screen.getByText(SECOND_CATEGORY)).toBeOnTheScreen(); }); - // Select categories to delete by clicking their checkboxes - fireEvent.press(screen.getByTestId(`${CONST.SELECTION_BUTTON_TEST_ID}${FIRST_CATEGORY}`)); - fireEvent.press(screen.getByTestId(`${CONST.SELECTION_BUTTON_TEST_ID}${SECOND_CATEGORY}`)); + // Select categories to delete + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('workspace.common.selectAll'))); const dropdownMenuButtonTestID = 'WorkspaceCategoriesPage-header-dropdown-menu-button'; @@ -244,9 +243,8 @@ describe('WorkspaceCategories', () => { expect(screen.getByText(SECOND_CATEGORY)).toBeOnTheScreen(); }); - // Select categories to disable by clicking their checkboxes - fireEvent.press(screen.getByTestId(`${CONST.SELECTION_BUTTON_TEST_ID}${FIRST_CATEGORY}`)); - fireEvent.press(screen.getByTestId(`${CONST.SELECTION_BUTTON_TEST_ID}${SECOND_CATEGORY}`)); + // Select categories to disable + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('workspace.common.selectAll'))); const dropdownMenuButtonTestID = 'WorkspaceCategoriesPage-header-dropdown-menu-button';